feat(guides) : add selectable target and team conditions
CI / Build immutable images and deploy (push) Has been cancelled
CI / Verify (push) Has been cancelled

This commit is contained in:
2026-09-06 17:40:06 +07:00 Unverified
parent 6dc46ca2a7
commit fcf6a2c187
13 changed files with 4207 additions and 63 deletions
+19
View File
@@ -133,6 +133,25 @@ export default async function StructuredEditorPage({
damage: member.damage,
})),
})),
teamGroups: content.teamGroups.map((group) => ({
id: group.id,
note: group.note,
teams: group.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.filter((section) => !extraSection || section.id === extraSection.id).map((section) => ({
id: section.id,
title: section.title,
+116 -30
View File
@@ -22,7 +22,7 @@ import {
UserRoundIcon,
XIcon,
} from "lucide-react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useEffect, useMemo, useState, type ReactNode, type SetStateAction } from "react";
import { toast } from "sonner";
import {
closestCenter,
@@ -232,6 +232,10 @@ type ArtifactProfile = {
circlet: string[];
substats: string[];
targets: Array<{ stat: string; value: string }>;
targetConditions: Array<{
note: string | null;
rows: Array<{ stat: string; value: string }>;
}>;
talents: TalentPriority[];
note: string | null;
} | null;
@@ -272,6 +276,11 @@ type TeamValue = {
damage: string;
}>;
};
type TeamGroupValue = {
id: string;
note: string | null;
teams: TeamValue[];
};
let clientIdCounter = 0;
const DEFAULT_EXTRA_BACKGROUND_COLOR = "#24232C";
@@ -333,6 +342,7 @@ export interface GuideSectionEditorProps {
artifactProfile: ArtifactProfile;
constellationGroups: ConstellationGroupValue[];
teams: TeamValue[];
teamGroups: TeamGroupValue[];
extras: Array<{
id: string;
title: string;
@@ -1345,18 +1355,25 @@ function ArtifactEditor({
const [goblet, setGoblet] = useState(profile?.goblet ?? []);
const [circlet, setCirclet] = useState(profile?.circlet ?? []);
const [substats, setSubstats] = useState(profile?.substats ?? []);
const [targets, setTargets] = useState(() => {
const existing = new Map(
(profile?.targets ?? []).flatMap((target) => {
const [targetGroups, setTargetGroups] = useState(() => {
const source = profile?.targetConditions.length
? profile.targetConditions
: [{ note: null, rows: profile?.targets ?? [] }];
return source.map((group, index) => {
const existing = new Map(group.rows.flatMap((target) => {
const stat = normalizeTargetStat(target.stat);
return stat ? [[stat, target.value] as const] : [];
}),
);
return targetStatDefaults.map((target) => ({
stat: target.stat,
value: existing.get(target.stat) ?? target.value,
}));
}));
return {
clientId: `target-condition-${index}`,
note: group.note ?? "",
rows: targetStatDefaults.map((target) => ({ stat: target.stat, value: existing.get(target.stat) ?? target.value })),
};
});
});
const [activeTargetGroupIndex, setActiveTargetGroupIndex] = useState(0);
const targets = targetGroups[activeTargetGroupIndex]?.rows ?? [];
const setTargets = (rows: typeof targets) => setTargetGroups((current) => current.map((group, index) => index === activeTargetGroupIndex ? { ...group, rows } : group));
const [talents, setTalents] = useState<TalentPriority[]>(() => {
const source: TalentPriority[] = profile?.talents.length
? profile.talents
@@ -1392,9 +1409,10 @@ function ArtifactEditor({
goblet,
circlet,
substats,
targets: targets
.filter((target) => target.value.trim())
.map((target) => ({ stat: target.stat, value: target.value.trim() })),
targetGroups: targetGroups.map((group) => ({
note: group.note.trim() || null,
rows: group.rows.filter((target) => target.value.trim()).map((target) => ({ stat: target.stat, value: target.value.trim() })),
})),
talents,
note: note || null,
};
@@ -1410,7 +1428,8 @@ function ArtifactEditor({
goblet,
circlet,
substats,
targets,
targetGroups,
activeTargetGroupIndex,
talents,
note,
};
@@ -1431,7 +1450,7 @@ function ArtifactEditor({
"goblet",
"circlet",
"substats",
"targets",
"targetGroups",
"talents",
],
["note"],
@@ -1442,7 +1461,8 @@ function ArtifactEditor({
setGoblet(draft.goblet);
setCirclet(draft.circlet);
setSubstats(draft.substats);
setTargets(draft.targets);
setTargetGroups(draft.targetGroups);
setActiveTargetGroupIndex(draft.activeTargetGroupIndex ?? 0);
setTalents(draft.talents);
setNote(draft.note);
},
@@ -1794,6 +1814,27 @@ function ArtifactEditor({
/>
<FieldSet>
<FieldLegend>Target Stats</FieldLegend>
<div className="flex flex-wrap gap-2">
{targetGroups.map((group, index) => (
<Button key={group.clientId} type="button" variant={index === activeTargetGroupIndex ? "default" : "outline"} onClick={() => setActiveTargetGroupIndex(index)}>
Condition {index + 1}
</Button>
))}
<Button type="button" variant="outline" disabled={targetGroups.length >= 20} onClick={() => {
setTargetGroups([...targetGroups, { clientId: createClientId("target-condition"), note: "", rows: targetStatDefaults.map((target) => ({ ...target })) }]);
setActiveTargetGroupIndex(targetGroups.length);
}}><PlusIcon data-icon="inline-start" />เพิ่ม Condition</Button>
<Button type="button" variant="outline" disabled={targetGroups.length >= 20} onClick={() => {
const source = targetGroups[activeTargetGroupIndex];
if (!source) return;
setTargetGroups([...targetGroups, { clientId: createClientId("target-condition"), note: source.note, rows: source.rows.map((row) => ({ ...row })) }]);
setActiveTargetGroupIndex(targetGroups.length);
}}><CopyIcon data-icon="inline-start" />Duplicate Condition</Button>
</div>
<Field>
<FieldLabel htmlFor={`target-condition-note-${targetGroups[activeTargetGroupIndex]?.clientId}`}>Condition note (optional)</FieldLabel>
<Textarea id={`target-condition-note-${targetGroups[activeTargetGroupIndex]?.clientId}`} value={targetGroups[activeTargetGroupIndex]?.note ?? ""} onChange={(event) => setTargetGroups(targetGroups.map((group, index) => index === activeTargetGroupIndex ? { ...group, note: event.target.value } : group))} />
</Field>
<div className="grid gap-5 sm:grid-cols-2">
{[
targetStatOptions.slice(0, 4),
@@ -1836,6 +1877,12 @@ function ArtifactEditor({
กรอกเฉพาะค่าเป็นตัวเลข เปอร์เซ็นต์ หรือข้อความ เช่น -%
หากไม่กรอกค่า ระบบจะไม่แสดงส่วนค่าสถานะเป้าหมาย
</FieldDescription>
{targetGroups.length > 1 ? (
<Button type="button" variant="ghost" onClick={() => {
setTargetGroups(targetGroups.filter((_, index) => index !== activeTargetGroupIndex));
setActiveTargetGroupIndex(Math.max(0, activeTargetGroupIndex - 1));
}}><Trash2Icon data-icon="inline-start" />ลบ Condition นี้</Button>
) : null}
</FieldSet>
<FieldSet>
<FieldLegend>Talent Priority</FieldLegend>
@@ -2261,9 +2308,12 @@ function TeamEditor({
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const [teams, setTeams] = useState<TeamToolValue[]>(
initial.teams.map(({ id, ...team }) => ({ clientId: id, ...team })),
);
const [teamGroups, setTeamGroups] = useState(() => initial.teamGroups.length
? initial.teamGroups.map((group) => ({ clientId: group.id, note: group.note ?? "", teams: group.teams.map(({ id, ...team }) => ({ clientId: id, ...team })) }))
: [{ clientId: "team-condition-default", note: "", teams: [] as TeamToolValue[] }]);
const [activeTeamGroupIndex, setActiveTeamGroupIndex] = useState(0);
const teams = useMemo(() => teamGroups[activeTeamGroupIndex]?.teams ?? [], [activeTeamGroupIndex, teamGroups]);
const setTeams = (update: SetStateAction<TeamToolValue[]>) => setTeamGroups((current) => current.map((group, index) => index === activeTeamGroupIndex ? { ...group, teams: typeof update === "function" ? update(group.teams) : update } : group));
const [selectedTeamIds, setSelectedTeamIds] = useState<Set<string>>(
() => new Set(),
);
@@ -2309,7 +2359,7 @@ function TeamEditor({
}, searchQuery, searchTarget));
}, [artifacts, characters, searchQuery, searchTarget, teams, weapons]);
const value = {
teams: teams.map((team) => ({
groups: teamGroups.map((group) => ({ note: group.note.trim() || null, teams: group.teams.map((team) => ({
title: team.title,
rotationSeconds: Number(team.rotationSeconds),
combo: team.combo,
@@ -2317,7 +2367,7 @@ function TeamEditor({
...member,
damage: Number(member.damage),
})),
})),
})) })),
};
const validation = teamSectionSchema.safeParse({
...value,
@@ -2325,14 +2375,14 @@ function TeamEditor({
});
const teamFieldInvalid = (teamIndex: number, field: string) =>
!validation.success && validation.error.issues.some((issue) =>
issue.path[0] === "teams" && issue.path[1] === teamIndex && issue.path[2] === field,
issue.path[0] === "groups" && issue.path[1] === activeTeamGroupIndex && issue.path[2] === "teams" && issue.path[3] === teamIndex && issue.path[4] === field,
);
const memberFieldInvalid = (teamIndex: number, memberIndex: number, field: string) =>
!validation.success && validation.error.issues.some((issue) =>
issue.path[0] === "teams" && issue.path[1] === teamIndex && issue.path[2] === "members" &&
issue.path[3] === memberIndex && issue.path[4] === field,
issue.path[0] === "groups" && issue.path[1] === activeTeamGroupIndex && issue.path[2] === "teams" && issue.path[3] === teamIndex && issue.path[4] === "members" &&
issue.path[5] === memberIndex && issue.path[6] === field,
);
const localDraft = { teams };
const localDraft = { teamGroups, activeTeamGroupIndex };
useEffect(() => {
const timer = window.setTimeout(() => {
try {
@@ -2351,7 +2401,7 @@ function TeamEditor({
const stored = JSON.parse(
window.localStorage.getItem(multipleApplyStorageKey(guide.id)) ?? "null",
) as { selectedTeamIds?: unknown; applyFields?: unknown } | null;
const teamIds = new Set(initial.teams.map((team) => team.id));
const teamIds = new Set(initial.teamGroups.flatMap((group) => group.teams.map((team) => team.id)));
setSelectedTeamIds(new Set(
Array.isArray(stored?.selectedTeamIds)
? stored.selectedTeamIds.filter(
@@ -2373,7 +2423,7 @@ function TeamEditor({
setMultipleApplyGuideId(guide.id);
}, 0);
return () => window.clearTimeout(timer);
}, [guide.id, initial.teams]);
}, [guide.id, initial.teamGroups]);
useEffect(() => {
if (multipleApplyGuideId !== guide.id) return;
try {
@@ -2406,8 +2456,11 @@ function TeamEditor({
storageKey: adminDraftKey(guide.id, "team"),
localDraft,
parseLocalDraft: (candidate) =>
parseEditorDraft<typeof localDraft>(candidate, ["teams"]),
restoreLocalDraft: (draft) => setTeams(draft.teams),
parseEditorDraft<typeof localDraft>(candidate, ["teamGroups"]),
restoreLocalDraft: (draft) => {
setTeamGroups(draft.teamGroups);
setActiveTeamGroupIndex(draft.activeTeamGroupIndex ?? 0);
},
});
const handleTeamDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return;
@@ -2588,7 +2641,40 @@ function TeamEditor({
ระบบจะแสดงสัดส่วนและ Total DPS และบันทึกอัตโนมัติ
</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap gap-2">
{teamGroups.map((group, index) => (
<Button key={group.clientId} type="button" variant={index === activeTeamGroupIndex ? "default" : "outline"} onClick={() => {
setActiveTeamGroupIndex(index);
setSelectedTeamIds(new Set());
setSourceTeamId(group.teams[0]?.clientId ?? "");
}}>Condition {index + 1}</Button>
))}
<Button type="button" variant="outline" disabled={teamGroups.length >= 20} onClick={() => {
setTeamGroups([...teamGroups, { clientId: createClientId("team-condition"), note: "", teams: [] }]);
setActiveTeamGroupIndex(teamGroups.length);
setSelectedTeamIds(new Set());
setSourceTeamId("");
}}><PlusIcon data-icon="inline-start" />เพิ่ม Condition</Button>
<Button type="button" variant="outline" disabled={teamGroups.length >= 20} onClick={() => {
const source = teamGroups[activeTeamGroupIndex];
if (!source) return;
const copiedTeams = source.teams.map((team) => ({ ...team, clientId: createClientId("team"), members: team.members.map((member) => ({ ...member })) }));
setTeamGroups([...teamGroups, { clientId: createClientId("team-condition"), note: source.note, teams: copiedTeams }]);
setActiveTeamGroupIndex(teamGroups.length);
setSelectedTeamIds(new Set());
setSourceTeamId(copiedTeams[0]?.clientId ?? "");
}}><CopyIcon data-icon="inline-start" />Duplicate Condition</Button>
</div>
<Field>
<FieldLabel htmlFor={`team-condition-note-${teamGroups[activeTeamGroupIndex]?.clientId}`}>Condition note (optional)</FieldLabel>
<Textarea id={`team-condition-note-${teamGroups[activeTeamGroupIndex]?.clientId}`} value={teamGroups[activeTeamGroupIndex]?.note ?? ""} onChange={(event) => setTeamGroups(teamGroups.map((group, index) => index === activeTeamGroupIndex ? { ...group, note: event.target.value } : group))} />
</Field>
{teamGroups.length > 1 ? <Button className="self-start" type="button" variant="ghost" onClick={() => {
setTeamGroups(teamGroups.filter((_, index) => index !== activeTeamGroupIndex));
setActiveTeamGroupIndex(Math.max(0, activeTeamGroupIndex - 1));
setSelectedTeamIds(new Set());
}}><Trash2Icon data-icon="inline-start" />ลบ Condition นี้</Button> : null}
<div className="flex min-w-0 flex-wrap items-center gap-2 rounded-lg border bg-muted/30 p-3">
<Checkbox
checked={selectedTeamIds.size === teams.length && teams.length > 0}
+23
View File
@@ -0,0 +1,23 @@
"use client";
import { Children, type ReactNode, useState } from "react";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
export function ConditionSwitcher({ children, label = "เลือก Condition" }: { children: ReactNode; label?: string }) {
const items = Children.toArray(children);
const [active, setActive] = useState("0");
if (items.length <= 1) return items[0] ?? null;
return (
<div className="flex flex-col gap-4">
<ToggleGroup aria-label={label} variant="outline" value={[active]} onValueChange={(values) => {
if (values[0]) setActive(values[0]);
}}>
{items.map((_, index) => <ToggleGroupItem key={index} value={String(index)}>Condition {index + 1}</ToggleGroupItem>)}
</ToggleGroup>
{items[Number(active)]}
</div>
);
}
+16 -5
View File
@@ -29,6 +29,7 @@ import { TeamArtifactIcon, TeamSearch } from "@/components/public/team-search";
import { GuideImageExport } from "@/components/public/guide-image-export";
import { GuideExportBranding } from "@/components/public/guide-export-branding";
import { ConstellationImage } from "@/components/public/constellation-image";
import { ConditionSwitcher } from "@/components/public/condition-switcher";
import { MaterialPoster } from "@/components/public/material-poster";
import { RichTextContent } from "@/components/rich-text-content";
import { LiveRefresh } from "@/components/live-refresh";
@@ -62,7 +63,10 @@ function hasSection(guide: PublicGuide, slug: string): boolean {
guide.artifactProfile &&
(guide.artifactProfile.sands.length ||
guide.artifactProfile.goblet.length ||
guide.artifactProfile.circlet.length),
guide.artifactProfile.circlet.length ||
guide.artifactProfile.substats.length ||
guide.artifactProfile.targetConditions.some((group) => group.rows.length) ||
guide.artifactProfile.talents.length),
)
);
if (slug === "constellations") return guide.constellationGroups.some((group) => group.rows.length > 0);
@@ -432,6 +436,7 @@ function WeaponSectionContent({
) : null}
</div>
</header>
<ConditionSwitcher label="เลือกเงื่อนไขอาวุธ">
{weaponGroups.map((group, groupIndex, groups) => (
<div key={group.id} className="relative z-10 flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
@@ -596,6 +601,7 @@ function WeaponSectionContent({
</div>
</div>
))}
</ConditionSwitcher>
</section>
);
}
@@ -778,11 +784,14 @@ function ArtifactSectionContent({
<CardHeader>
</CardHeader>
<CardContent className="flex flex-col gap-5">
{profile.targets.length ? (
<div>
{profile.targetConditions.some((group) => group.rows.length) ? (
<ConditionSwitcher label="เลือกเงื่อนไขค่าสถานะเป้าหมาย">
{profile.targetConditions.filter((group) => group.rows.length).map((group, groupIndex) => (
<div key={groupIndex}>
<p className="mb-2 text-sm font-medium">ค่าสถานะต่ำๆที่ควรจะปั้นไว้</p>
{group.note ? <p className="mb-2 whitespace-pre-wrap text-sm text-muted-foreground">{group.note}</p> : null}
<div className="flex flex-col gap-1">
{profile.targets.map((target, index) => (
{group.rows.map((target, index) => (
<div
key={`${target.stat}-${target.value}`}
className={cn(
@@ -812,6 +821,8 @@ function ArtifactSectionContent({
))}
</div>
</div>
))}
</ConditionSwitcher>
) : null}
{profile.talents.length ? (
<div>
@@ -1137,7 +1148,7 @@ function TeamSection({ guide }: { guide: PublicGuide }) {
<p className="text-sm font-medium text-primary">คำนวณความเสียหาย</p>
<h1 className="font-heading text-4xl font-semibold">คำนวณทีม</h1>
</header>
<TeamSearch teams={guide.teams} />
<TeamSearch groups={guide.teamGroups} />
</section>
);
}
+12 -1
View File
@@ -360,7 +360,10 @@ function TeamCard({
);
}
export function TeamSearch({ teams }: { teams: PublicGuide["teams"] }) {
export function TeamSearch({ groups }: { groups: PublicGuide["teamGroups"] }) {
const [activeCondition, setActiveCondition] = useState("0");
const activeGroup = groups[Number(activeCondition)] ?? groups[0];
const teams = useMemo(() => activeGroup?.teams ?? [], [activeGroup]);
const [query, setQuery] = useState("");
const [searchTarget, setSearchTarget] = useState<TeamSearchTarget>("all");
const [dpsOrder, setDpsOrder] = useState<DpsOrder>(null);
@@ -376,6 +379,14 @@ export function TeamSearch({ teams }: { teams: PublicGuide["teams"] }) {
return (
<div className="flex flex-col gap-4">
{groups.length > 1 ? (
<ToggleGroup aria-label="เลือกเงื่อนไขทีม" variant="outline" value={[activeCondition]} onValueChange={(values) => {
if (values[0]) setActiveCondition(values[0]);
}}>
{groups.map((group, index) => <ToggleGroupItem key={group.id} value={String(index)}>Condition {index + 1}</ToggleGroupItem>)}
</ToggleGroup>
) : null}
{activeGroup?.note ? <p className="whitespace-pre-wrap text-muted-foreground">{activeGroup.note}</p> : null}
<div className="relative">
<SearchIcon aria-hidden="true" className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
+16
View File
@@ -660,11 +660,26 @@ export const artifactProfiles = guidesSchema.table("artifact_profile", {
circlet: jsonb("circlet").$type<string[]>().default([]).notNull(),
substats: jsonb("substats").$type<string[]>().default([]).notNull(),
targets: jsonb("targets").$type<Array<{ stat: string; value: string }>>().default([]).notNull(),
targetConditions: jsonb("target_conditions")
.$type<Array<{ note: string | null; rows: Array<{ stat: string; value: string }> }>>()
.default([])
.notNull(),
talents: jsonb("talents").$type<Array<{ type: "NA" | "E" | "Q"; priority: number; level?: string; relationToNext?: "gt" | "gte" | "eq" }>>().default([]).notNull(),
note: text("note"),
...timestamps,
});
export const teamConditions = guidesSchema.table(
"team_condition",
{
id: uuid("id").defaultRandom().primaryKey(),
guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }),
note: text("note"),
sortOrder: integer("sort_order").default(0).notNull(),
},
(table) => [index("team_condition_order_idx").on(table.guideId, table.sortOrder)],
);
export const constellationConditions = guidesSchema.table(
"constellation_condition",
{
@@ -693,6 +708,7 @@ export const teamCompositions = guidesSchema.table(
{
id: uuid("id").defaultRandom().primaryKey(),
guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }),
conditionId: uuid("condition_id").references(() => teamConditions.id, { onDelete: "cascade" }),
title: text("title").notNull(),
rotationSeconds: numeric("rotation_seconds", { precision: 8, scale: 2 }).notNull(),
combo: text("combo").notNull(),
+15
View File
@@ -0,0 +1,15 @@
CREATE TABLE "guides"."team_condition" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"guide_id" uuid NOT NULL,
"note" text,
"sort_order" integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
ALTER TABLE "guides"."artifact_profile" ADD COLUMN "target_conditions" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
ALTER TABLE "guides"."team_composition" ADD COLUMN "condition_id" uuid;--> statement-breakpoint
UPDATE "guides"."artifact_profile" SET "target_conditions" = jsonb_build_array(jsonb_build_object('note', NULL, 'rows', "targets"));--> statement-breakpoint
INSERT INTO "guides"."team_condition" ("guide_id", "sort_order") SELECT DISTINCT "guide_id", 0 FROM "guides"."team_composition";--> statement-breakpoint
UPDATE "guides"."team_composition" AS team SET "condition_id" = condition."id" FROM "guides"."team_condition" AS condition WHERE condition."guide_id" = team."guide_id";--> statement-breakpoint
ALTER TABLE "guides"."team_condition" ADD CONSTRAINT "team_condition_guide_id_guide_id_fk" FOREIGN KEY ("guide_id") REFERENCES "guides"."guide"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "team_condition_order_idx" ON "guides"."team_condition" USING btree ("guide_id","sort_order");--> statement-breakpoint
ALTER TABLE "guides"."team_composition" ADD CONSTRAINT "team_composition_condition_id_team_condition_id_fk" FOREIGN KEY ("condition_id") REFERENCES "guides"."team_condition"("id") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -260,6 +260,13 @@
"when": 1788689979183,
"tag": "0036_third_warbound",
"breakpoints": true
},
{
"idx": 37,
"version": "7",
"when": 1788690776279,
"tag": "0037_right_rockslide",
"breakpoints": true
}
]
}
+10 -6
View File
@@ -20,6 +20,7 @@ import {
media,
outboxEvents,
teamCompositions,
teamConditions,
teamMembers,
weaponRecommendations,
weaponConditions,
@@ -204,7 +205,7 @@ export async function saveArtifacts(guideId: string, input: unknown) {
const [created] = await tx.insert(artifactGroups).values({ guideId, name: group.name, sortOrder }).returning();
if (group.rows.length) await tx.insert(artifactRecommendations).values(group.rows.map((row, rowOrder) => ({ ...row, percent: row.percent.toString(), groupId: created.id, sortOrder: rowOrder })));
}
await tx.insert(artifactProfiles).values({ guideId, sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats, targets: data.targets, talents: data.talents, note: data.note }).onConflictDoUpdate({ target: artifactProfiles.guideId, set: { sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats, targets: data.targets, talents: data.talents, note: data.note } });
await tx.insert(artifactProfiles).values({ guideId, sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats, targets: data.targetGroups[0]?.rows ?? [], targetConditions: data.targetGroups, talents: data.talents, note: data.note }).onConflictDoUpdate({ target: artifactProfiles.guideId, set: { sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats, targets: data.targetGroups[0]?.rows ?? [], targetConditions: data.targetGroups, talents: data.talents, note: data.note } });
return updated;
});
}
@@ -227,7 +228,7 @@ export async function saveTeams(guideId: string, input: unknown) {
const data = teamSectionSchema.parse(input);
const [guide] = await getDb().select().from(guides).where(eq(guides.id, guideId)).limit(1);
if (!guide) throw new Error("ไม่พบ Guide");
const members = data.teams.flatMap((team) => team.members);
const members = data.groups.flatMap((group) => group.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.flatMap((member) => [member.artifactKey, member.secondArtifactKey]).filter((key): key is string => Boolean(key)))];
@@ -248,10 +249,13 @@ export async function saveTeams(guideId: string, input: unknown) {
}
return getDb().transaction(async (tx) => {
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
await tx.delete(teamCompositions).where(eq(teamCompositions.guideId, guideId));
for (const [sortOrder, team] of data.teams.entries()) {
const [created] = await tx.insert(teamCompositions).values({ guideId, title: team.title, rotationSeconds: team.rotationSeconds.toString(), combo: team.combo, sortOrder }).returning();
await tx.insert(teamMembers).values(team.members.map((member, position) => ({ ...member, teamId: created.id, position, damage: member.damage.toString() })));
await tx.delete(teamConditions).where(eq(teamConditions.guideId, guideId));
for (const [groupOrder, group] of data.groups.entries()) {
const [condition] = await tx.insert(teamConditions).values({ guideId, note: group.note, sortOrder: groupOrder }).returning();
for (const [sortOrder, team] of group.teams.entries()) {
const [created] = await tx.insert(teamCompositions).values({ guideId, conditionId: condition.id, title: team.title, rotationSeconds: team.rotationSeconds.toString(), combo: team.combo, sortOrder }).returning();
await tx.insert(teamMembers).values(team.members.map((member, position) => ({ ...member, teamId: created.id, position, damage: member.damage.toString() })));
}
}
return updated;
});
+7 -3
View File
@@ -21,6 +21,7 @@ import {
glossaryAliases,
media,
teamCompositions,
teamConditions,
teamMembers,
weaponRecommendations,
weaponConditions,
@@ -180,12 +181,13 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
const materialKeys = [...new Set(
[...row.character.ascension, ...row.character.leveling].flatMap((group) => group.items.map((item) => item.key)),
)];
const [sections, weaponConditionsRows, groups, profile, constellationGroups, teams, aliases, materials, poster, bleeding] = await Promise.all([
const [sections, weaponConditionsRows, groups, profile, constellationGroups, teamConditionRows, teams, aliases, materials, poster, bleeding] = await Promise.all([
getDb().select().from(guideSections).where(and(eq(guideSections.guideId, row.guide.id), eq(guideSections.enabled, true))).orderBy(asc(guideSections.sortOrder)),
getDb().select().from(weaponConditions).where(eq(weaponConditions.guideId, row.guide.id)).orderBy(asc(weaponConditions.sortOrder)),
getDb().select().from(artifactGroups).where(eq(artifactGroups.guideId, row.guide.id)).orderBy(asc(artifactGroups.sortOrder)),
getDb().select().from(artifactProfiles).where(eq(artifactProfiles.guideId, row.guide.id)).limit(1),
getDb().select().from(constellationConditions).where(eq(constellationConditions.guideId, row.guide.id)).orderBy(asc(constellationConditions.sortOrder)),
getDb().select().from(teamConditions).where(eq(teamConditions.guideId, row.guide.id)).orderBy(asc(teamConditions.sortOrder)),
getDb().select().from(teamCompositions).where(eq(teamCompositions.guideId, row.guide.id)).orderBy(asc(teamCompositions.sortOrder)),
getDb().select().from(glossaryAliases),
materialKeys.length
@@ -238,6 +240,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
extraArtifactKeys.length ? getDb().select().from(catalogArtifacts).where(inArray(catalogArtifacts.key, extraArtifactKeys)) : [],
]);
const extraCatalogs = { character: extraCharacters, weapon: extraWeapons, artifact: extraArtifacts };
const hydratedTeams = 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), aliases: groupedAliases.character.get(character.key) ?? [] }, weapon: weapon ? { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } : null, artifact: artifact ? { ...artifact, aliases: groupedAliases.artifact.get(artifact.key) ?? [], imageUrl: publicMediaUrl(artifact.imageKey) } : null, secondArtifact: secondArtifact ? { ...secondArtifact, aliases: groupedAliases.artifact.get(secondArtifact.key) ?? [], imageUrl: publicMediaUrl(secondArtifact.imageKey) } : null }; }) }));
return {
...row.guide,
@@ -250,7 +253,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
weapons: weaponRows.map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } })),
weaponGroups: weaponConditionsRows.map((condition) => ({ ...condition, rows: weaponRows.filter(({ recommendation }) => recommendation.conditionId === condition.id).map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } })) })),
artifactGroups: groups.map((group) => ({ ...group, rows: artifactRows.filter(({ recommendation }) => recommendation.groupId === group.id).map(({ recommendation, artifact }) => { const second = secondArtifacts.find((item) => item.key === recommendation.secondArtifactKey); return { ...recommendation, first: { ...artifact, aliases: groupedAliases.artifact.get(artifact.key) ?? [], imageUrl: publicMediaUrl(artifact.imageKey) }, second: second ? { ...second, aliases: groupedAliases.artifact.get(second.key) ?? [], imageUrl: publicMediaUrl(second.imageKey) } : null }; }) })),
artifactProfile: profile[0] ?? null,
artifactProfile: profile[0] ? { ...profile[0], targetConditions: profile[0].targetConditions.length ? profile[0].targetConditions : [{ note: null, rows: profile[0].targets }] } : null,
constellationGroups: constellationGroups.map((group) => ({
...group,
rows: constellations.filter((recommendation) => recommendation.conditionId === group.id).map((recommendation) => {
@@ -263,7 +266,8 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
};
}),
})),
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), aliases: groupedAliases.character.get(character.key) ?? [] }, weapon: weapon ? { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } : null, artifact: artifact ? { ...artifact, aliases: groupedAliases.artifact.get(artifact.key) ?? [], imageUrl: publicMediaUrl(artifact.imageKey) } : null, secondArtifact: secondArtifact ? { ...secondArtifact, aliases: groupedAliases.artifact.get(secondArtifact.key) ?? [], imageUrl: publicMediaUrl(secondArtifact.imageKey) } : null }; }) })),
teams: hydratedTeams,
teamGroups: teamConditionRows.length ? teamConditionRows.map((condition) => ({ ...condition, teams: hydratedTeams.filter((team) => team.conditionId === condition.id) })) : [{ id: "team-condition-default", guideId: row.guide.id, note: null, sortOrder: 0, teams: hydratedTeams }],
extras: sections.filter((section) => section.kind === "extra").map((section) => ({ ...section, rows: extraRows.filter(({ row: extraRow }) => extraRow.sectionId === section.id).map(({ row: extraRow, asset }) => ({
...extraRow,
catalogItems: extraRow.catalogItems.map((reference) => {
+13 -12
View File
@@ -74,13 +74,13 @@ describe("structured guide validation", () => {
});
it("limits artifact main stats to three per slot", () => {
const base = { expectedVersion: 1, groups: artifactGroups, goblet: [], circlet: [], substats: [], targets: [], talents: [], note: null };
const base = { expectedVersion: 1, groups: artifactGroups, goblet: [], circlet: [], substats: [], targetGroups: [], talents: [], note: null };
expect(artifactSectionSchema.safeParse({ ...base, sands: ["EM", "ER", "ATK%"] }).success).toBe(true);
expect(artifactSectionSchema.safeParse({ ...base, sands: ["EM", "ER", "ATK%", "HP%"] }).success).toBe(false);
});
it("accepts flat artifact substats with the same icons as their percentage variants", () => {
const result = artifactSectionSchema.safeParse({ expectedVersion: 1, groups: artifactGroups, sands: [], goblet: [], circlet: [], substats: ["ATK", "HP", "DEF"], targets: [], talents: [], note: null });
const result = artifactSectionSchema.safeParse({ expectedVersion: 1, groups: artifactGroups, sands: [], goblet: [], circlet: [], substats: ["ATK", "HP", "DEF"], targetGroups: [], talents: [], note: null });
expect(result.success).toBe(true);
expect(targetStatIconName("ATK")).toBe(recommendedStatIconName("ATK%"));
expect(targetStatIconName("HP")).toBe(recommendedStatIconName("HP%"));
@@ -95,7 +95,7 @@ describe("structured guide validation", () => {
it("limits an optional note on each artifact recommendation", () => {
const row = { firstArtifactKey: "1", secondArtifactKey: null, percent: 100 };
const base = { expectedVersion: 1, sands: [], goblet: [], circlet: [], substats: [], targets: [], talents: [], note: null };
const base = { expectedVersion: 1, sands: [], goblet: [], circlet: [], substats: [], targetGroups: [], talents: [], note: null };
expect(artifactSectionSchema.safeParse({ ...base, groups: [{ name: "Late Game", rows: [{ ...row, note: null }] }, artifactGroups[1]] }).success).toBe(true);
expect(artifactSectionSchema.safeParse({ ...base, groups: [{ name: "Late Game", rows: [{ ...row, note: "x".repeat(2_001) }] }, artifactGroups[1]] }).success).toBe(false);
});
@@ -104,30 +104,30 @@ describe("structured guide validation", () => {
const base = { expectedVersion: 1, groups: artifactGroups, sands: [], goblet: [], circlet: [], substats: [], talents: [], note: null };
expect(artifactSectionSchema.safeParse({
...base,
targets: [
targetGroups: [{ note: "ER build", rows: [
{ stat: "ER", value: "100%" },
{ stat: "CRIT RATE", value: "5%" },
{ stat: "CRIT DAMAGE", value: "-%" },
],
] }],
}).success).toBe(true);
expect(artifactSectionSchema.safeParse({ ...base, targets: [{ stat: "Energy Recharge", value: "100%" }] }).success).toBe(false);
expect(artifactSectionSchema.safeParse({ ...base, targetGroups: [{ note: null, rows: [{ stat: "Energy Recharge", value: "100%" }] }] }).success).toBe(false);
});
it("requires the fixed Late Game and Early Game artifact groups", () => {
const base = { expectedVersion: 1, sands: [], goblet: [], circlet: [], substats: [], targets: [], talents: [], note: null };
const base = { expectedVersion: 1, sands: [], goblet: [], circlet: [], substats: [], targetGroups: [], talents: [], note: null };
expect(artifactSectionSchema.safeParse({ ...base, groups: artifactGroups }).success).toBe(true);
expect(artifactSectionSchema.safeParse({ ...base, groups: [...artifactGroups].reverse() }).success).toBe(false);
expect(artifactSectionSchema.safeParse({ ...base, groups: [...artifactGroups, { name: "Other", rows: [] }] }).success).toBe(false);
});
it("accepts only talent target levels from 1 through 10", () => {
const base = { expectedVersion: 1, groups: artifactGroups, sands: [], goblet: [], circlet: [], substats: [], targets: [], note: null };
const base = { expectedVersion: 1, groups: artifactGroups, sands: [], goblet: [], circlet: [], substats: [], targetGroups: [], note: null };
expect(artifactSectionSchema.safeParse({ ...base, talents: [{ type: "NA", priority: 1, level: "10" }] }).success).toBe(true);
expect(artifactSectionSchema.safeParse({ ...base, talents: [{ type: "NA", priority: 1, level: "11" }] }).success).toBe(false);
});
it("accepts talent comparison relations while keeping legacy talents valid", () => {
const base = { expectedVersion: 1, groups: artifactGroups, sands: [], goblet: [], circlet: [], substats: [], targets: [], note: null };
const base = { expectedVersion: 1, groups: artifactGroups, sands: [], goblet: [], circlet: [], substats: [], targetGroups: [], note: null };
expect(artifactSectionSchema.safeParse({ ...base, talents: [{ type: "E", priority: 1, relationToNext: "gte" }] }).success).toBe(true);
expect(artifactSectionSchema.safeParse({ ...base, talents: [{ type: "E", priority: 1, relationToNext: "eq" }] }).success).toBe(true);
expect(artifactSectionSchema.safeParse({ ...base, talents: [{ type: "E", priority: 1 }] }).success).toBe(true);
@@ -148,14 +148,15 @@ describe("structured guide validation", () => {
it("requires four unique team members and non-zero damage", () => {
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) }] };
const teams = [{ title: "Test", rotationSeconds: 20, combo: "E > Q", members: ["1", "2", "3", "4"].map(member) }];
const base = { expectedVersion: 1, groups: [{ note: null, teams }] };
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);
expect(teamSectionSchema.safeParse({ ...base, groups: [{ note: null, teams: [{ ...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) }] };
const input = { expectedVersion: 1, groups: [{ note: null, teams: [{ title: "Test", rotationSeconds: 20, combo: "E > Q", members: ["1", "2", "3", "4"].map(member) }] }] };
expect(teamSectionSchema.safeParse(input).success).toBe(true);
});
+12 -6
View File
@@ -95,10 +95,13 @@ export const artifactSectionSchema = z.strictObject({
goblet: z.array(z.enum(mainStatOptions.goblet)).max(3),
circlet: z.array(z.enum(mainStatOptions.circlet)).max(3),
substats: z.array(z.enum(substatOptions)).max(substatOptions.length),
targets: z.array(z.strictObject({
stat: z.enum(targetStatOptions),
value: z.string().trim().min(1).max(80),
})).max(targetStatOptions.length),
targetGroups: z.array(z.strictObject({
note: z.string().trim().max(2_000).nullable(),
rows: z.array(z.strictObject({
stat: z.enum(targetStatOptions),
value: z.string().trim().min(1).max(80),
})).max(targetStatOptions.length),
})).max(20),
talents: z.array(z.strictObject({
type: z.enum(["NA", "E", "Q"]),
priority: z.number().int().min(1).max(3),
@@ -128,7 +131,9 @@ export const constellationSectionSchema = z.strictObject({
export const teamSectionSchema = z.strictObject({
expectedVersion: z.number().int().positive(),
teams: z.array(z.strictObject({
groups: z.array(z.strictObject({
note: z.string().trim().max(2_000).nullable(),
teams: z.array(z.strictObject({
title: z.string().trim().min(1).max(160),
rotationSeconds: z.coerce.number().finite().positive().max(9999),
combo: z.string().trim().min(1).max(2_000),
@@ -145,7 +150,8 @@ export const teamSectionSchema = z.strictObject({
}).refine((members) => members.some((member) => member.damage > 0), {
message: "ความเสียหายรวมต้องมากกว่า 0",
}),
})).max(30),
})).max(30),
})).max(20),
});
const hexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/, "กรุณาเลือกสีที่ถูกต้อง");