feat(team-calc) : add second artifact selection
CI / Verify (push) Successful in 58s
CI / Build immutable images and deploy (push) Successful in 1m59s

This commit is contained in:
2026-09-02 07:34:50 +07:00 Unverified
parent 2c069bf5e9
commit cab7a36f2a
12 changed files with 3308 additions and 12 deletions
+1 -1
View File
@@ -22,5 +22,5 @@ export default async function StructuredEditorPage({ params }: { params: Promise
const [catalog, content] = await Promise.all([getActiveCatalog(), getPublicGuide(guide.slug)]);
if (!catalog || !content) notFound();
const guideCharacter = catalog.characters.find((item) => item.key === guide.characterKey);
return <div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 sm:p-6 lg:p-8"><header className="flex items-center gap-4"><Button variant="outline" size="icon" render={<Link href={`/admin/${character}`} />} nativeButton={false} aria-label="กลับหน้า Guide"><ArrowLeftIcon /></Button><div className="min-w-0"><p className="text-sm text-muted-foreground">{guide.name}</p><h1 className="whitespace-nowrap font-heading text-3xl font-semibold">{guide.sections.find((section) => section.slug === page)?.title ?? page}</h1></div></header><GuideSectionNav characterKey={guide.characterKey} current={page as (typeof sections)[number]} /><GuideSectionEditor section={page as (typeof sections)[number]} guide={guide} weapons={catalog.weapons} artifacts={catalog.artifacts} characters={catalog.characters} catalogConstellations={guideCharacter?.constellations ?? []} catalogTalents={guideCharacter?.talents ?? []} initial={{ sectionNote: guide.sections.find((section) => section.slug === page)?.note ?? null, weapons: content.weapons, artifactGroups: content.artifactGroups.map((group) => ({ id: group.id, name: group.name, rows: group.rows.map((row) => ({ firstArtifactKey: row.firstArtifactKey, secondArtifactKey: row.secondArtifactKey, percent: row.percent, note: row.note })) })), artifactProfile: content.artifactProfile, constellations: content.constellations, teams: content.teams.map((team) => ({ id: team.id, title: team.title, rotationSeconds: team.rotationSeconds, combo: team.combo, members: team.members.map((member) => ({ characterKey: member.characterKey, weaponKey: member.weaponKey, artifactKey: member.artifactKey, constellation: member.constellation, refinement: member.refinement, damage: member.damage })) })), extras: content.extras.map((section) => ({ title: section.title, rows: section.rows.map((row) => ({ mediaId: row.mediaId, ratio: row.ratio === "1:1" ? "1:1" : "16:9", heading: row.heading, body: row.body, percent: row.percent, imageUrl: row.imageUrl })) })) }} /></main></div>;
return <div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 sm:p-6 lg:p-8"><header className="flex items-center gap-4"><Button variant="outline" size="icon" render={<Link href={`/admin/${character}`} />} nativeButton={false} aria-label="กลับหน้า Guide"><ArrowLeftIcon /></Button><div className="min-w-0"><p className="text-sm text-muted-foreground">{guide.name}</p><h1 className="whitespace-nowrap font-heading text-3xl font-semibold">{guide.sections.find((section) => section.slug === page)?.title ?? page}</h1></div></header><GuideSectionNav characterKey={guide.characterKey} current={page as (typeof sections)[number]} /><GuideSectionEditor section={page as (typeof sections)[number]} guide={guide} weapons={catalog.weapons} artifacts={catalog.artifacts} characters={catalog.characters} catalogConstellations={guideCharacter?.constellations ?? []} catalogTalents={guideCharacter?.talents ?? []} initial={{ sectionNote: guide.sections.find((section) => section.slug === page)?.note ?? null, weapons: content.weapons, artifactGroups: content.artifactGroups.map((group) => ({ id: group.id, name: group.name, rows: group.rows.map((row) => ({ firstArtifactKey: row.firstArtifactKey, secondArtifactKey: row.secondArtifactKey, percent: row.percent, note: row.note })) })), artifactProfile: content.artifactProfile, constellations: content.constellations, teams: content.teams.map((team) => ({ id: team.id, title: team.title, rotationSeconds: team.rotationSeconds, combo: team.combo, members: team.members.map((member) => ({ characterKey: member.characterKey, weaponKey: member.weaponKey, artifactKey: member.artifactKey, secondArtifactKey: member.secondArtifactKey, constellation: member.constellation, refinement: member.refinement, damage: member.damage })) })), extras: content.extras.map((section) => ({ title: section.title, rows: section.rows.map((row) => ({ mediaId: row.mediaId, ratio: row.ratio === "1:1" ? "1:1" : "16:9", heading: row.heading, body: row.body, percent: row.percent, imageUrl: row.imageUrl })) })) }} /></main></div>;
}
+23 -2
View File
@@ -67,12 +67,13 @@ export async function GET(
anuphanFonts,
Promise.all(
team.members.map(async (member) => {
const [character, weapon, artifact] = await Promise.all([
const [character, weapon, artifact, secondArtifact] = await Promise.all([
pngDataUrl(member.character.imageUrl),
member.weapon ? pngDataUrl(member.weapon.imageUrl) : null,
member.artifact ? pngDataUrl(member.artifact.imageUrl) : null,
member.secondArtifact ? pngDataUrl(member.secondArtifact.imageUrl) : null,
]);
return { character, weapon, artifact };
return { character, weapon, artifact, secondArtifact };
}),
),
]);
@@ -269,6 +270,26 @@ export async function GET(
/>
</div>
) : null}
{member.secondArtifact && memberAssets[index].secondArtifact ? (
<div
style={{
display: "flex",
width: 96,
height: 96,
overflow: "hidden",
borderRadius: 16,
background: rarityGradient(member.secondArtifact.rarity),
}}
>
<img
src={memberAssets[index].secondArtifact}
alt=""
width={96}
height={96}
style={{ objectFit: "contain" }}
/>
</div>
) : null}
</div>
<div
style={{
+7 -4
View File
@@ -111,7 +111,7 @@ type ArtifactProfile = { sands: string[]; goblet: string[]; circlet: string[]; s
type ConstellationValue = { level: number; detail: string; cumulativePercent: string; isGreat: boolean };
type CatalogConstellation = { level: number; name: string; description: string; iconUrl: string | null };
type CatalogTalent = { type: "NA" | "E" | "Q"; name: string; iconUrl: string | null };
type TeamValue = { id: string; title: string; rotationSeconds: string; combo: string; members: Array<{ characterKey: string; weaponKey: string | null; artifactKey: string | null; constellation: number; refinement: number; damage: string }> };
type TeamValue = { id: string; title: string; rotationSeconds: string; combo: string; members: Array<{ characterKey: string; weaponKey: string | null; artifactKey: string | null; secondArtifactKey: string | null; constellation: number; refinement: number; damage: string }> };
let clientIdCounter = 0;
@@ -564,7 +564,7 @@ function ConstellationEditor({ guide, initial, catalogConstellations }: EditorBa
}
function TeamEditor({ guide, characters, weapons, artifacts, initial }: EditorBase) {
const fallbackMembers = Array.from({ length: 4 }, () => ({ characterKey: "", weaponKey: null, artifactKey: null, constellation: 0, refinement: 1, damage: "0" }));
const fallbackMembers = Array.from({ length: 4 }, () => ({ characterKey: "", weaponKey: null, artifactKey: null, secondArtifactKey: null, constellation: 0, refinement: 1, damage: "0" }));
const teamSensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
@@ -635,10 +635,13 @@ function TeamEditor({ guide, characters, weapons, artifacts, initial }: EditorBa
</CardHeader>
<CardContent>
<FieldGroup>
<Field><FieldLabel className="flex items-center gap-2"><UserRoundIcon />ตัวละคร</FieldLabel><CatalogPicker items={characters} value={member.characterKey} placeholder="ค้นหาชื่อตัวละคร…" onChange={(characterKey) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, characterKey, weaponKey: null, artifactKey: null, constellation: 0, refinement: 1 } : candidate) } : item))} /></Field>
<Field><FieldLabel className="flex items-center gap-2"><UserRoundIcon />ตัวละคร</FieldLabel><CatalogPicker items={characters} value={member.characterKey} placeholder="ค้นหาชื่อตัวละคร…" onChange={(characterKey) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, characterKey, weaponKey: null, artifactKey: null, secondArtifactKey: null, constellation: 0, refinement: 1 } : candidate) } : item))} /></Field>
<Field><FieldLabel className="flex items-center gap-2"><ChartNoAxesCombinedIcon />Damage</FieldLabel><Input type="number" min="0" step="0.01" value={member.damage} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, damage: event.target.value } : candidate) } : item))} /></Field>
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-end gap-4"><Field className="min-w-0"><FieldLabel className="flex items-center gap-2"><SwordIcon />Weapon</FieldLabel><CatalogPicker items={compatibleWeapons} value={member.weaponKey ?? ""} placeholder="ค้นหาอาวุธที่รองรับ…" rarityBackground onChange={(weaponKey) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, weaponKey: weaponKey || null } : candidate) } : item))} /></Field><Field className="shrink-0"><FieldLabel className="flex items-center gap-2"><RefreshCcwIcon />Refinement</FieldLabel><ToggleGroup className="flex-nowrap" variant="outline" value={[`R${member.refinement}`]} onValueChange={(selected) => { const refinement = selected[0]; if (refinement) setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, refinement: Number(refinement.slice(1)) } : candidate) } : item)); }}>{[1, 2, 3, 4, 5].map((refinement) => <ToggleGroupItem className={SELECTED_TOGGLE_CLASS} key={refinement} value={`R${refinement}`}>R{refinement}</ToggleGroupItem>)}</ToggleGroup></Field></div>
<Field><FieldLabel className="flex items-center gap-2"><GemIcon />Artifact set</FieldLabel><CatalogPicker items={artifacts} value={member.artifactKey ?? ""} placeholder="ค้นหาชุด Artifact…" rarityBackground onChange={(artifactKey) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, artifactKey: artifactKey || null } : candidate) } : item))} /></Field>
<div className="grid grid-cols-2 gap-4">
<Field><FieldLabel className="flex items-center gap-2"><GemIcon />Artifact set 1</FieldLabel><CatalogPicker items={artifacts} value={member.artifactKey ?? ""} placeholder="ค้นหาชุด Artifact…" rarityBackground onChange={(artifactKey) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, artifactKey: artifactKey || null } : candidate) } : item))} /></Field>
<Field><FieldLabel className="flex items-center gap-2"><GemIcon />Artifact set 2</FieldLabel><CatalogPicker items={artifacts} value={member.secondArtifactKey ?? ""} placeholder="ค้นหาชุด Artifact…" rarityBackground onChange={(secondArtifactKey) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, secondArtifactKey: secondArtifactKey || null } : candidate) } : item))} /></Field>
</div>
<Field><FieldLabel className="flex items-center gap-2"><SparklesIcon />Constellation</FieldLabel><ToggleGroup className="flex-wrap justify-start" variant="outline" value={[`C${member.constellation}`]} onValueChange={(selected) => { const constellation = selected[0]; if (constellation) setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, constellation: Number(constellation.slice(1)) } : candidate) } : item)); }}>{[0, 1, 2, 3, 4, 5, 6].map((constellation) => <ToggleGroupItem className={SELECTED_TOGGLE_CLASS} key={constellation} value={`C${constellation}`}>C{constellation}</ToggleGroupItem>)}</ToggleGroup></Field>
</FieldGroup>
</CardContent>
+20
View File
@@ -28,6 +28,7 @@ export function TeamSearch({ teams }: { teams: PublicGuide["teams"] }) {
member.character.name,
member.weapon?.name,
member.artifact?.name,
member.secondArtifact?.name,
]),
].filter((name): name is string => Boolean(name));
return searchableNames.some((name) => name.toLocaleLowerCase().includes(normalizedQuery));
@@ -145,6 +146,25 @@ export function TeamSearch({ teams }: { teams: PublicGuide["teams"] }) {
) : (
<div className="aspect-square rounded-md border bg-muted" />
)}
{member.secondArtifact ? (
<Link
href={`https://lunaris.moe/artifact/${encodeURIComponent(member.secondArtifact.key)}`}
target="_blank"
rel="noreferrer"
aria-label={`เปิดข้อมูลอาร์ติแฟกต์ ${member.secondArtifact.name} บน Lunaris`}
className={cn(
"relative aspect-square overflow-hidden rounded-md bg-muted",
rarityGradientClass(member.secondArtifact.rarity),
)}
>
<Image
src={member.secondArtifact.imageUrl}
alt={member.secondArtifact.name}
fill
className="object-contain p-1"
/>
</Link>
) : null}
</div>
</div>
<p className="mt-2 truncate text-sm font-medium">
+1
View File
@@ -607,6 +607,7 @@ export const teamMembers = guidesSchema.table(
characterKey: varchar("character_key", { length: 64 }).notNull(),
weaponKey: varchar("weapon_key", { length: 64 }),
artifactKey: varchar("artifact_key", { length: 64 }),
secondArtifactKey: varchar("second_artifact_key", { length: 64 }),
constellation: integer("constellation").default(0).notNull(),
refinement: integer("refinement").default(1).notNull(),
damage: numeric("damage", { precision: 18, scale: 2 }).notNull(),
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "guides"."team_member" ADD COLUMN "second_artifact_key" varchar(64);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -127,6 +127,13 @@
"when": 1788245483347,
"tag": "0017_needy_hulk",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1788309185497,
"tag": "0018_blushing_blacklash",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -150,7 +150,7 @@ export async function saveTeams(guideId: string, input: unknown) {
const members = data.teams.flatMap((team) => team.members);
const characterKeys = [...new Set(members.map((member) => member.characterKey))];
const weaponKeys = [...new Set(members.map((member) => member.weaponKey).filter((key): key is string => Boolean(key)))];
const artifactKeys = [...new Set(members.map((member) => member.artifactKey).filter((key): key is string => Boolean(key)))];
const artifactKeys = [...new Set(members.flatMap((member) => [member.artifactKey, member.secondArtifactKey]).filter((key): key is string => Boolean(key)))];
const [characters, weapons, artifacts] = await Promise.all([
characterKeys.length ? getDb().select().from(catalogCharacters).where(inArray(catalogCharacters.key, characterKeys)) : [],
weaponKeys.length ? getDb().select().from(catalogWeapons).where(inArray(catalogWeapons.key, weaponKeys)) : [],
+5 -3
View File
@@ -99,7 +99,7 @@ export async function getPublicGuide(slug: string) {
const secondArtifacts = secondKeys.length ? await getDb().select().from(catalogArtifacts).where(inArray(catalogArtifacts.key, secondKeys)) : [];
const members = teams.length ? await getDb().select({ member: teamMembers, character: catalogCharacters }).from(teamMembers).innerJoin(catalogCharacters, eq(catalogCharacters.key, teamMembers.characterKey)).where(inArray(teamMembers.teamId, teams.map((team) => team.id))).orderBy(asc(teamMembers.position)) : [];
const memberWeaponKeys = members.map(({ member }) => member.weaponKey).filter((key): key is string => Boolean(key));
const memberArtifactKeys = members.map(({ member }) => member.artifactKey).filter((key): key is string => Boolean(key));
const memberArtifactKeys = members.flatMap(({ member }) => [member.artifactKey, member.secondArtifactKey]).filter((key): key is string => Boolean(key));
const [memberWeapons, memberArtifacts] = await Promise.all([
memberWeaponKeys.length ? getDb().select().from(catalogWeapons).where(inArray(catalogWeapons.key, memberWeaponKeys)) : [],
memberArtifactKeys.length ? getDb().select().from(catalogArtifacts).where(inArray(catalogArtifacts.key, memberArtifactKeys)) : [],
@@ -124,7 +124,7 @@ export async function getPublicGuide(slug: string) {
iconUrl: catalog?.iconKey ? publicMediaUrl(catalog.iconKey) : null,
};
}),
teams: teams.map((team) => ({ ...team, members: members.filter(({ member }) => member.teamId === team.id).map(({ member, character }) => { const weapon = memberWeapons.find((item) => item.key === member.weaponKey); const artifact = memberArtifacts.find((item) => item.key === member.artifactKey); return { ...member, character: catalogCharacterWithUrls(character), weapon: weapon ? { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } : null, artifact: artifact ? { ...artifact, imageUrl: publicMediaUrl(artifact.imageKey) } : null }; }) })),
teams: teams.map((team) => ({ ...team, members: members.filter(({ member }) => member.teamId === team.id).map(({ member, character }) => { const weapon = memberWeapons.find((item) => item.key === member.weaponKey); const artifact = memberArtifacts.find((item) => item.key === member.artifactKey); const secondArtifact = memberArtifacts.find((item) => item.key === member.secondArtifactKey); return { ...member, character: catalogCharacterWithUrls(character), weapon: weapon ? { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } : null, artifact: artifact ? { ...artifact, imageUrl: publicMediaUrl(artifact.imageKey) } : null, secondArtifact: secondArtifact ? { ...secondArtifact, imageUrl: publicMediaUrl(secondArtifact.imageKey) } : null }; }) })),
extras: sections.filter((section) => section.kind === "extra").map((section) => ({ ...section, rows: extraRows.filter(({ row: extraRow }) => extraRow.sectionId === section.id).map(({ row: extraRow, asset }) => ({ ...extraRow, imageUrl: asset ? publicMediaUrl(asset.objectKey) : null })) })),
};
}
@@ -157,7 +157,7 @@ export async function getPublicTeamExport(teamId: string) {
.orderBy(asc(teamMembers.position));
const weaponKeys = members.map(({ member }) => member.weaponKey).filter((key): key is string => Boolean(key));
const artifactKeys = members.map(({ member }) => member.artifactKey).filter((key): key is string => Boolean(key));
const artifactKeys = members.flatMap(({ member }) => [member.artifactKey, member.secondArtifactKey]).filter((key): key is string => Boolean(key));
const [weapons, artifacts] = await Promise.all([
weaponKeys.length ? getDb().select().from(catalogWeapons).where(inArray(catalogWeapons.key, weaponKeys)) : [],
artifactKeys.length ? getDb().select().from(catalogArtifacts).where(inArray(catalogArtifacts.key, artifactKeys)) : [],
@@ -170,11 +170,13 @@ export async function getPublicTeamExport(teamId: string) {
members: members.map(({ member, character }) => {
const weapon = weapons.find((item) => item.key === member.weaponKey);
const artifact = artifacts.find((item) => item.key === member.artifactKey);
const secondArtifact = artifacts.find((item) => item.key === member.secondArtifactKey);
return {
...member,
character: catalogCharacterWithUrls(character),
weapon: weapon ? { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } : null,
artifact: artifact ? { ...artifact, imageUrl: publicMediaUrl(artifact.imageKey) } : null,
secondArtifact: secondArtifact ? { ...secondArtifact, imageUrl: publicMediaUrl(secondArtifact.imageKey) } : null,
};
}),
};
+7 -1
View File
@@ -93,11 +93,17 @@ describe("structured guide validation", () => {
});
it("requires four unique team members and non-zero damage", () => {
const member = (characterKey: string) => ({ characterKey, weaponKey: null, artifactKey: null, constellation: 0, refinement: 1, damage: 100 });
const member = (characterKey: string) => ({ characterKey, weaponKey: null, artifactKey: null, secondArtifactKey: null, constellation: 0, refinement: 1, damage: 100 });
const base = { expectedVersion: 1, teams: [{ title: "Test", rotationSeconds: 20, combo: "E > Q", members: ["1", "2", "3", "4"].map(member) }] };
expect(teamSectionSchema.safeParse(base).success).toBe(true);
expect(teamSectionSchema.safeParse({ ...base, teams: [{ ...base.teams[0], members: ["1", "1", "3", "4"].map(member) }] }).success).toBe(false);
});
it("accepts a second artifact set for a team member", () => {
const member = (characterKey: string) => ({ characterKey, weaponKey: null, artifactKey: "artifact-1", secondArtifactKey: characterKey === "1" ? "artifact-2" : null, constellation: 0, refinement: 1, damage: 100 });
const input = { expectedVersion: 1, teams: [{ title: "Test", rotationSeconds: 20, combo: "E > Q", members: ["1", "2", "3", "4"].map(member) }] };
expect(teamSectionSchema.safeParse(input).success).toBe(true);
});
});
describe("Team Calc", () => {
+1
View File
@@ -98,6 +98,7 @@ export const teamSectionSchema = z.strictObject({
characterKey: catalogKey,
weaponKey: catalogKey.nullable(),
artifactKey: catalogKey.nullable(),
secondArtifactKey: catalogKey.nullable(),
constellation: z.number().int().min(0).max(6),
refinement: z.number().int().min(1).max(5),
damage: z.coerce.number().finite().nonnegative().max(999_999_999_999),