feat(artifact) : add tier-list conditions
CI / Build immutable images and deploy (push) Successful in 6m36s

This commit is contained in:
2026-09-17 17:02:46 +07:00 Unverified
parent 884c659517
commit 13b6c646fe
12 changed files with 3015 additions and 196 deletions
+6 -1
View File
@@ -109,13 +109,18 @@ export default async function StructuredEditorPage({
artifactGroups: content.artifactGroups.map((group) => ({
id: group.id,
name: group.name,
rows: group.rows.map((row) => ({
note: group.note,
tiers: group.tiers.map((tier) => ({
id: tier.id,
name: tier.name,
rows: tier.rows.map((row) => ({
firstArtifactKey: row.firstArtifactKey,
secondArtifactKey: row.secondArtifactKey,
percent: row.percent,
note: row.note,
})),
})),
})),
artifactProfile: content.artifactProfile,
constellationGroups: content.constellationGroups.map((group) => ({
id: group.id,
+92 -156
View File
@@ -237,7 +237,7 @@ type WeaponGroupValue = {
note: string | null;
rows: WeaponValue[];
};
type ArtifactValue = {
type ArtifactTierValue = {
id: string;
name: string;
rows: Array<{
@@ -247,6 +247,12 @@ type ArtifactValue = {
note: string | null;
}>;
};
type ArtifactValue = {
id: string;
name: string | null;
note: string | null;
tiers: ArtifactTierValue[];
};
type ArtifactProfile = {
sands: string[];
goblet: string[];
@@ -1773,16 +1779,27 @@ function ArtifactEditor({
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const [groups, setGroups] = useState(() =>
(["Late Game", "Early Game"] as const).map((name, index) => ({
const [groups, setGroups] = useState(() => {
const source = initial.artifactGroups.length ? initial.artifactGroups : [{ id: "artifact-condition-default", name: null, note: null, tiers: [] }];
return source.map((condition, conditionIndex) => ({
clientId: condition.id,
name: condition.name ?? "",
note: condition.note ?? "",
tiers: (["Late Game", "Early Game"] as const).map((name, tierIndex) => ({
name,
rows:
initial.artifactGroups[index]?.rows.map((row, rowIndex) => ({
...row,
clientId: `${name}-${rowIndex}`,
})) ?? [],
rows: condition.tiers[tierIndex]?.rows.map((row, rowIndex) => ({ ...row, clientId: `${conditionIndex}-${name}-${rowIndex}` })) ?? [],
})),
);
}));
});
const [activeArtifactGroupIndex, setActiveArtifactGroupIndex] = useState(0);
const [renamingArtifactGroupIndex, setRenamingArtifactGroupIndex] = useState<number | null>(null);
const artifactTiers = groups[activeArtifactGroupIndex]?.tiers ?? [];
const setArtifactTierRows = (
tierIndex: number,
update: (rows: (typeof artifactTiers)[number]["rows"]) => (typeof artifactTiers)[number]["rows"],
) => setGroups((current) => current.map((condition, conditionIndex) => conditionIndex === activeArtifactGroupIndex
? { ...condition, tiers: condition.tiers.map((tier, index) => index === tierIndex ? { ...tier, rows: update(tier.rows) } : tier) }
: condition));
const [recommendedGroups, setRecommendedGroups] = useState(() => {
const source = profile?.recommendedConditions.length
? profile.recommendedConditions
@@ -1839,14 +1856,18 @@ function ArtifactEditor({
const [note, setNote] = useState(profile?.note ?? "");
const value = {
groups: groups.map((group) => ({
name: group.name,
rows: group.rows.map((row) => ({
name: group.name.trim() || null,
note: group.note.trim() || null,
tiers: group.tiers.map((tier) => ({
name: tier.name,
rows: tier.rows.map((row) => ({
firstArtifactKey: row.firstArtifactKey,
secondArtifactKey: row.secondArtifactKey,
percent: Number(row.percent),
note: row.note?.trim() || null,
})),
})),
})),
sands: recommendedGroups[0].sands,
goblet: recommendedGroups[0].goblet,
circlet: recommendedGroups[0].circlet,
@@ -1868,6 +1889,7 @@ function ArtifactEditor({
!validation.success && hasIssuePrefix(validation.error.issues, ...path);
const localDraft = {
groups,
activeArtifactGroupIndex,
sands,
goblet,
circlet,
@@ -1905,6 +1927,7 @@ function ArtifactEditor({
),
restoreLocalDraft: (draft) => {
setGroups(draft.groups);
setActiveArtifactGroupIndex(draft.activeArtifactGroupIndex ?? 0);
setRecommendedGroups(draft.recommendedGroups?.length ? draft.recommendedGroups : [{ clientId: createClientId("recommended-condition"), name: "", note: "", sands: draft.sands, goblet: draft.goblet, circlet: draft.circlet, substats: draft.substats }]);
setActiveRecommendedGroupIndex(Math.min(draft.activeRecommendedGroupIndex ?? 0, Math.max(0, (draft.recommendedGroups?.length ?? 1) - 1)));
setTargetGroups(draft.targetGroups.map((group) => ({ ...group, name: group.name ?? "" })));
@@ -1916,21 +1939,22 @@ function ArtifactEditor({
const orderedTalents = normalizeTalentPriorities(talents);
const handleArtifactDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return;
const activeGroupIndex = groups.findIndex((group) =>
const activeTierIndex = artifactTiers.findIndex((group) =>
group.rows.some((row) => row.clientId === active.id),
);
const overGroupIndex = groups.findIndex((group) =>
const overTierIndex = artifactTiers.findIndex((group) =>
group.rows.some((row) => row.clientId === over.id),
);
if (activeGroupIndex === -1 || activeGroupIndex !== overGroupIndex) return;
if (activeTierIndex === -1 || activeTierIndex !== overTierIndex) return;
setGroups((current) =>
current.map((group, groupIndex) => {
if (groupIndex !== activeGroupIndex) return group;
current.map((condition, conditionIndex) => {
if (conditionIndex !== activeArtifactGroupIndex) return condition;
return { ...condition, tiers: condition.tiers.map((group, groupIndex) => {
if (groupIndex !== activeTierIndex) return group;
const from = group.rows.findIndex((row) => row.clientId === active.id);
const to = group.rows.findIndex((row) => row.clientId === over.id);
return from === -1 || to === -1
? group
: { ...group, rows: arrayMove(group.rows, from, to) };
return from === -1 || to === -1 ? group : { ...group, rows: arrayMove(group.rows, from, to) };
}) };
}),
);
};
@@ -1966,7 +1990,45 @@ function ArtifactEditor({
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-5">
{groups.map((group, groupIndex) => (
<ConditionControls
ariaLabel="เลือกเงื่อนไขอาร์ติแฟกต์"
items={groups}
activeIndex={activeArtifactGroupIndex}
onActiveIndexChange={setActiveArtifactGroupIndex}
onRename={setRenamingArtifactGroupIndex}
onReorder={(from, to) => {
const reordered = reorderConditions(groups, activeArtifactGroupIndex, from, to);
setGroups(reordered.items);
setActiveArtifactGroupIndex(reordered.activeIndex);
}}
onAdd={() => {
setGroups([...groups, { clientId: createClientId("artifact-condition"), name: "", note: "", tiers: [{ name: "Late Game", rows: [] }, { name: "Early Game", rows: [] }] }]);
setActiveArtifactGroupIndex(groups.length);
}}
onDuplicate={() => {
const source = groups[activeArtifactGroupIndex];
if (!source) return;
setGroups([...groups, { ...source, clientId: createClientId("artifact-condition"), tiers: source.tiers.map((tier) => ({ ...tier, rows: tier.rows.map((row) => ({ ...row, clientId: createClientId("artifact") })) })) }]);
setActiveArtifactGroupIndex(groups.length);
}}
onDelete={() => {
const next = groups.filter((_, index) => index !== activeArtifactGroupIndex);
setGroups(next);
setActiveArtifactGroupIndex(Math.min(activeArtifactGroupIndex, next.length - 1));
}}
/>
<ConditionRenameDialog
open={renamingArtifactGroupIndex !== null}
fallback={`Condition ${(renamingArtifactGroupIndex ?? 0) + 1}`}
value={renamingArtifactGroupIndex === null ? "" : groups[renamingArtifactGroupIndex]?.name ?? ""}
onOpenChange={(open) => { if (!open) setRenamingArtifactGroupIndex(null); }}
onChange={(name) => setGroups(groups.map((group, index) => index === renamingArtifactGroupIndex ? { ...group, name } : group))}
/>
<Field>
<FieldLabel htmlFor={`artifact-condition-note-${groups[activeArtifactGroupIndex]?.clientId}`}>Condition note (optional)</FieldLabel>
<Textarea id={`artifact-condition-note-${groups[activeArtifactGroupIndex]?.clientId}`} value={groups[activeArtifactGroupIndex]?.note ?? ""} onChange={(event) => setGroups(groups.map((group, index) => index === activeArtifactGroupIndex ? { ...group, note: event.target.value } : group))} />
</Field>
{artifactTiers.map((group, groupIndex) => (
<Card key={group.name} size="sm">
<CardHeader>
<CardTitle>{group.name}</CardTitle>
@@ -1994,7 +2056,7 @@ function ArtifactEditor({
rowIndex % 2 === 0
? "bg-muted/60"
: "bg-muted/20",
artifactIssue("groups", groupIndex, "rows", rowIndex) && "border-destructive",
artifactIssue("groups", activeArtifactGroupIndex, "tiers", groupIndex, "rows", rowIndex) && "border-destructive",
)}
>
{dragHandle}
@@ -2003,29 +2065,12 @@ function ArtifactEditor({
<CatalogPicker
items={artifacts}
value={row.firstArtifactKey}
invalid={artifactIssue("groups", groupIndex, "rows", rowIndex, "firstArtifactKey")}
invalid={artifactIssue("groups", activeArtifactGroupIndex, "tiers", groupIndex, "rows", rowIndex, "firstArtifactKey")}
placeholder="ค้นหาชุด Artifact แรก…"
rarityBackground
showSelectedImage
onChange={(firstArtifactKey) =>
setGroups(
groups.map((item, index) =>
index === groupIndex
? {
...item,
rows: item.rows.map(
(candidate, inner) =>
inner === rowIndex
? {
...candidate,
firstArtifactKey,
}
: candidate,
),
}
: item,
),
)
setArtifactTierRows(groupIndex, (rows) => rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, firstArtifactKey } : candidate))
}
/>
<CatalogPicker
@@ -2035,26 +2080,7 @@ function ArtifactEditor({
rarityBackground
showSelectedImage
onChange={(secondArtifactKey) =>
setGroups(
groups.map((item, index) =>
index === groupIndex
? {
...item,
rows: item.rows.map(
(candidate, inner) =>
inner === rowIndex
? {
...candidate,
secondArtifactKey:
secondArtifactKey ||
null,
}
: candidate,
),
}
: item,
),
)
setArtifactTierRows(groupIndex, (rows) => rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, secondArtifactKey: secondArtifactKey || null } : candidate))
}
/>
<Input
@@ -2062,54 +2088,12 @@ function ArtifactEditor({
inputMode="decimal"
aria-label="Artifact percent"
value={row.percent}
aria-invalid={artifactIssue("groups", groupIndex, "rows", rowIndex)}
aria-invalid={artifactIssue("groups", activeArtifactGroupIndex, "tiers", groupIndex, "rows", rowIndex)}
onChange={(event) =>
setGroups(
groups.map((item, index) =>
index === groupIndex
? {
...item,
rows: item.rows.map(
(candidate, inner) =>
inner === rowIndex
? {
...candidate,
percent:
normalizePercentInput(
event.target
.value,
candidate.percent,
),
}
: candidate,
),
}
: item,
),
)
setArtifactTierRows(groupIndex, (rows) => rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: normalizePercentInput(event.target.value, candidate.percent) } : candidate))
}
onBlur={() =>
setGroups(
groups.map((item, index) =>
index === groupIndex
? {
...item,
rows: item.rows.map(
(candidate, inner) =>
inner === rowIndex
? {
...candidate,
percent:
finalizePercentInput(
candidate.percent,
),
}
: candidate,
),
}
: item,
),
)
setArtifactTierRows(groupIndex, (rows) => rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: finalizePercentInput(candidate.percent) } : candidate))
}
/>
<DeleteConfirmationDialog
@@ -2123,19 +2107,7 @@ function ArtifactEditor({
title={`ลบ Artifact อันดับ ${rowIndex + 1}?`}
description={`รายการ Artifact ในกลุ่ม ${group.name} และเปอร์เซ็นต์เปรียบเทียบจะถูกลบออกจาก Guide`}
onConfirm={() =>
setGroups(
groups.map((item, index) =>
index === groupIndex
? {
...item,
rows: item.rows.filter(
(_, inner) =>
inner !== rowIndex,
),
}
: item,
),
)
setArtifactTierRows(groupIndex, (rows) => rows.filter((_, inner) => inner !== rowIndex))
}
>
<Trash2Icon />
@@ -2152,25 +2124,7 @@ function ArtifactEditor({
id={`artifact-note-${groupIndex}-${rowIndex}`}
value={row.note ?? ""}
onChange={(event) =>
setGroups(
groups.map((item, index) =>
index === groupIndex
? {
...item,
rows: item.rows.map(
(candidate, inner) =>
inner === rowIndex
? {
...candidate,
note: event.target
.value,
}
: candidate,
),
}
: item,
),
)
setArtifactTierRows(groupIndex, (rows) => rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, note: event.target.value } : candidate))
}
/>
</Field>
@@ -2186,25 +2140,7 @@ function ArtifactEditor({
type="button"
variant="outline"
onClick={() =>
setGroups(
groups.map((item, index) =>
index === groupIndex
? {
...item,
rows: [
...item.rows,
{
clientId: createClientId("artifact"),
firstArtifactKey: "",
secondArtifactKey: null,
percent: "100",
note: "",
},
],
}
: item,
),
)
setArtifactTierRows(groupIndex, (rows) => [...rows, { clientId: createClientId("artifact"), firstArtifactKey: "", secondArtifactKey: null, percent: "100", note: "" }])
}
>
<PlusIcon data-icon="inline-start" />
+13 -1
View File
@@ -32,18 +32,21 @@ export function ArtifactImageExport({
hasNote,
targetConditionLabels,
recommendedConditionLabels,
tierConditionLabels,
}: {
children: ReactNode;
fileName: string;
hasNote: boolean;
targetConditionLabels: readonly string[];
recommendedConditionLabels: readonly string[];
tierConditionLabels: readonly string[];
}) {
const [sections, setSections] = useState<Set<ArtifactExportSection>>(
() => new Set(sectionOptions.map(([value]) => value)),
);
const [recommendedCondition, setRecommendedCondition] = useState("0");
const [targetCondition, setTargetCondition] = useState("0");
const [tierCondition, setTierCondition] = useState("0");
const entireSection = sections.size === sectionOptions.length;
const hiddenSections = sectionOptions
.filter(([value]) => !sections.has(value))
@@ -51,6 +54,7 @@ export function ArtifactImageExport({
.join("");
const targetRule = `[data-target-condition]:not([data-target-condition="${targetCondition}"]){display:none!important}`;
const recommendedRule = `[data-recommended-condition]:not([data-recommended-condition="${recommendedCondition}"]){display:none!important}`;
const tierRule = `[data-tier-condition]:not([data-tier-condition="${tierCondition}"]){display:none!important}`;
const onlySection = sections.size === 1 ? [...sections][0] : null;
const exportWidth = onlySection === "best-stats"
? 700
@@ -133,6 +137,14 @@ export function ArtifactImageExport({
</ToggleGroup>
</div>
) : null}
{sections.has("tierlist") && tierConditionLabels.length > 1 ? (
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">เงื่อนไขอันดับอาร์ติแฟกต์</p>
<ToggleGroup variant="outline" value={[tierCondition]} onValueChange={(values) => { if (values[0]) setTierCondition(values[0]); }}>
{tierConditionLabels.map((label, index) => <ToggleGroupItem key={index} value={String(index)} className="aria-pressed:bg-primary aria-pressed:text-primary-foreground">{label}</ToggleGroupItem>)}
</ToggleGroup>
</div>
) : null}
{sections.has("target-stats") && targetConditionLabels.length > 1 ? (
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">เงื่อนไขค่าสถานะเป้าหมาย</p>
@@ -162,7 +174,7 @@ export function ArtifactImageExport({
disabled={sections.size === 0}
captureClassName="p-0"
>
<style>{hiddenSections + targetRule + recommendedRule + hideEmptyRightColumn + layoutRules}</style>
<style>{hiddenSections + targetRule + recommendedRule + tierRule + hideEmptyRightColumn + layoutRules}</style>
{children}
</GuideImageExport>
</DialogContent>
+14 -6
View File
@@ -61,7 +61,7 @@ function hasSection(guide: PublicGuide, slug: string): boolean {
if (slug === "weapon") return guide.weapons.length > 0;
if (slug === "artifact")
return (
guide.artifactGroups.some((group) => group.rows.length > 0) ||
guide.artifactGroups.some((condition) => condition.tiers.some((group) => group.rows.length > 0)) ||
Boolean(
guide.artifactProfile &&
(guide.artifactProfile.recommendedConditions.some((condition) => condition.sands.length || condition.goblet.length || condition.circlet.length || condition.substats.length) ||
@@ -626,6 +626,7 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) {
hasNote={Boolean(guide.artifactProfile?.note)}
recommendedConditionLabels={(guide.artifactProfile?.recommendedConditions ?? []).map((group, index) => group.name || `เงื่อนไข ${index + 1}`)}
targetConditionLabels={targetConditions.map((group, index) => group.name || `เงื่อนไข ${index + 1}`)}
tierConditionLabels={guide.artifactGroups.map((group, index) => group.name || `เงื่อนไข ${index + 1}`)}
>
<ArtifactSectionContent guide={guide} exporting />
</ArtifactImageExport>
@@ -752,9 +753,13 @@ function ArtifactSectionContent({
{recommendedCards}
</ConditionSwitcher>
)}
<div data-artifact-export-section="tierlist" className="artifact-export-tierlist order-first flex flex-col gap-5">
{guide.artifactGroups.map((group) => (
<Card key={group.id}>
<div data-artifact-export-section="tierlist" className="order-first">
{(() => {
const tierCards = guide.artifactGroups.map((condition, conditionIndex) => (
<div key={condition.id} data-tier-condition={conditionIndex} className="artifact-export-tierlist flex flex-col gap-5">
{exporting && guide.artifactGroups.length > 1 ? <p className="text-sm font-medium">{condition.name || <ExportText th={`เงื่อนไข ${conditionIndex + 1}`} en={`Condition ${conditionIndex + 1}`} />}</p> : null}
{condition.note ? <p data-export-note className="whitespace-pre-wrap text-sm text-muted-foreground">{condition.note}</p> : null}
{condition.tiers.map((group) => <Card key={group.id}>
<CardHeader>
<CardTitle>
{group.name === "Late Game"
@@ -799,8 +804,11 @@ function ArtifactSectionContent({
</div>
))}
</CardContent>
</Card>
))}
</Card>)}
</div>
));
return exporting ? tierCards : <ConditionSwitcher label="เลือกเงื่อนไขอันดับอาร์ติแฟกต์" labels={guide.artifactGroups.map((condition, index) => condition.name || `เงื่อนไข ${index + 1}`)}>{tierCards}</ConditionSwitcher>;
})()}
</div>
</div>
{profile ? (
+14 -2
View File
@@ -366,15 +366,27 @@ export const weaponRecommendations = guidesSchema.table(
(table) => [index("weapon_recommendation_order_idx").on(table.conditionId, table.sortOrder)],
);
export const artifactConditions = guidesSchema.table(
"artifact_condition",
{
id: uuid("id").defaultRandom().primaryKey(),
guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }),
name: varchar("name", { length: 120 }),
note: text("note"),
sortOrder: integer("sort_order").default(0).notNull(),
},
(table) => [index("artifact_condition_order_idx").on(table.guideId, table.sortOrder)],
);
export const artifactGroups = guidesSchema.table(
"artifact_group",
{
id: uuid("id").defaultRandom().primaryKey(),
guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }),
conditionId: uuid("condition_id").notNull().references(() => artifactConditions.id, { onDelete: "cascade" }),
name: text("name").notNull(),
sortOrder: integer("sort_order").default(0).notNull(),
},
(table) => [index("artifact_group_order_idx").on(table.guideId, table.sortOrder)],
(table) => [index("artifact_group_order_idx").on(table.conditionId, table.sortOrder)],
);
export const artifactRecommendations = guidesSchema.table(
+26
View File
@@ -0,0 +1,26 @@
CREATE TABLE "guides"."artifact_condition" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"guide_id" uuid NOT NULL,
"name" varchar(120),
"note" text,
"sort_order" integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
INSERT INTO "guides"."artifact_condition" ("guide_id", "sort_order")
SELECT DISTINCT "guide_id", 0 FROM "guides"."artifact_group";
--> statement-breakpoint
ALTER TABLE "guides"."artifact_group" DROP CONSTRAINT "artifact_group_guide_id_guide_id_fk";
--> statement-breakpoint
DROP INDEX "guides"."artifact_group_order_idx";--> statement-breakpoint
ALTER TABLE "guides"."artifact_group" ADD COLUMN "condition_id" uuid;--> statement-breakpoint
UPDATE "guides"."artifact_group" AS "artifact_group"
SET "condition_id" = "artifact_condition"."id"
FROM "guides"."artifact_condition" AS "artifact_condition"
WHERE "artifact_condition"."guide_id" = "artifact_group"."guide_id";
--> statement-breakpoint
ALTER TABLE "guides"."artifact_group" ALTER COLUMN "condition_id" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "guides"."artifact_condition" ADD CONSTRAINT "artifact_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 "artifact_condition_order_idx" ON "guides"."artifact_condition" USING btree ("guide_id","sort_order");--> statement-breakpoint
ALTER TABLE "guides"."artifact_group" ADD CONSTRAINT "artifact_group_condition_id_artifact_condition_id_fk" FOREIGN KEY ("condition_id") REFERENCES "guides"."artifact_condition"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "artifact_group_order_idx" ON "guides"."artifact_group" USING btree ("condition_id","sort_order");--> statement-breakpoint
ALTER TABLE "guides"."artifact_group" DROP COLUMN "guide_id";
File diff suppressed because it is too large Load Diff
+7
View File
@@ -316,6 +316,13 @@
"when": 1789283972474,
"tag": "0044_plain_mystique",
"breakpoints": true
},
{
"idx": 45,
"version": "7",
"when": 1789637567192,
"tag": "0045_simple_argent",
"breakpoints": true
}
]
}
+10 -5
View File
@@ -5,6 +5,7 @@ import { and, asc, eq, inArray, sql } from "drizzle-orm";
import { getDb } from "@/db";
import {
artifactGroups,
artifactConditions,
artifactProfiles,
artifactRecommendations,
catalogArtifacts,
@@ -96,9 +97,10 @@ export async function createGuide(input: unknown) {
sortOrder: order.maximum + 10,
}).returning();
await tx.insert(guideSections).values(DEFAULT_SECTIONS.map((section) => ({ ...section, guideId: guide.id })));
const [condition] = await tx.insert(artifactConditions).values({ guideId: guide.id, sortOrder: 0 }).returning();
await tx.insert(artifactGroups).values([
{ guideId: guide.id, name: "Late Game", sortOrder: 0 },
{ guideId: guide.id, name: "Early Game", sortOrder: 1 },
{ conditionId: condition.id, name: "Late Game", sortOrder: 0 },
{ conditionId: condition.id, name: "Early Game", sortOrder: 1 },
]);
await tx.insert(artifactProfiles).values({ guideId: guide.id });
await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, parsed.coverMediaId));
@@ -213,11 +215,14 @@ export async function saveArtifacts(guideId: string, input: unknown, details?: G
if (!guide) throw new Error("ไม่พบ Guide");
return getDb().transaction(async (tx) => {
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "artifact", details);
await tx.delete(artifactGroups).where(eq(artifactGroups.guideId, guideId));
for (const [sortOrder, group] of data.groups.entries()) {
const [created] = await tx.insert(artifactGroups).values({ guideId, name: group.name, sortOrder }).returning();
await tx.delete(artifactConditions).where(eq(artifactConditions.guideId, guideId));
for (const [conditionOrder, condition] of data.groups.entries()) {
const [createdCondition] = await tx.insert(artifactConditions).values({ guideId, name: condition.name, note: condition.note, sortOrder: conditionOrder }).returning();
for (const [sortOrder, group] of condition.tiers.entries()) {
const [created] = await tx.insert(artifactGroups).values({ conditionId: createdCondition.id, 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, recommendedConditions: data.recommendedGroups ?? [{ name: null, note: null, 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, recommendedConditions: data.recommendedGroups ?? [{ name: null, note: null, 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;
});
+5 -3
View File
@@ -6,6 +6,7 @@ import * as z from "zod";
import { getDb } from "@/db";
import {
artifactGroups,
artifactConditions,
artifactProfiles,
artifactRecommendations,
catalogArtifacts,
@@ -329,10 +330,10 @@ 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, teamConditionRows, teams, aliases, materials, poster, bleeding] = await runWithConcurrency([
const [sections, weaponConditionsRows, artifactConditionRows, profile, constellationGroups, teamConditionRows, teams, aliases, materials, poster, bleeding] = await runWithConcurrency([
() => 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(artifactConditions).where(eq(artifactConditions.guideId, row.guide.id)).orderBy(asc(artifactConditions.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)),
@@ -350,6 +351,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
: Promise.resolve([]),
() => getDb().select({ layer: guidePosterBleeding, asset: media }).from(guidePosterBleeding).leftJoin(media, eq(media.id, guidePosterBleeding.mediaId)).where(eq(guidePosterBleeding.guideId, row.guide.id)).orderBy(asc(guidePosterBleeding.sortOrder)),
]);
const groups = artifactConditionRows.length ? await getDb().select().from(artifactGroups).where(inArray(artifactGroups.conditionId, artifactConditionRows.map((condition) => condition.id))).orderBy(asc(artifactGroups.sortOrder)) : [];
const groupedAliases = aliasesByItem(aliases);
const materialByKey = new Map(materials.map((item) => [item.key, item]));
@@ -404,7 +406,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
sections,
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 }; }) })),
artifactGroups: artifactConditionRows.map((condition) => ({ ...condition, tiers: groups.filter((group) => group.conditionId === condition.id).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] ? { ...profile[0], recommendedConditions: profile[0].recommendedConditions.length ? profile[0].recommendedConditions : [{ name: null, note: null, sands: profile[0].sands, goblet: profile[0].goblet, circlet: profile[0].circlet, substats: profile[0].substats }], targetConditions: profile[0].targetConditions.length ? profile[0].targetConditions : [{ name: null, note: null, rows: profile[0].targets }] } : null,
constellationGroups: constellationGroups.map((group) => ({
...group,
+18 -5
View File
@@ -21,10 +21,11 @@ import {
} from "@/lib/guides/stats";
describe("structured guide validation", () => {
const artifactGroups = [
const artifactTiers = [
{ name: "Late Game", rows: [] },
{ name: "Early Game", rows: [] },
] as const;
const artifactGroups = [{ name: null, note: null, tiers: artifactTiers }] as const;
it("allows creating a guide without an overview", () => {
expect(createGuideSchema.safeParse({ characterKey: "1", coverMediaId: "550e8400-e29b-41d4-a716-446655440000", overview: "" }).success).toBe(true);
});
@@ -120,8 +121,8 @@ 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: [], 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);
expect(artifactSectionSchema.safeParse({ ...base, groups: [{ name: null, note: null, tiers: [{ name: "Late Game", rows: [{ ...row, note: null }] }, artifactTiers[1]] }] }).success).toBe(true);
expect(artifactSectionSchema.safeParse({ ...base, groups: [{ name: null, note: null, tiers: [{ name: "Late Game", rows: [{ ...row, note: "x".repeat(2_001) }] }, artifactTiers[1]] }] }).success).toBe(false);
});
it("accepts fixed target stats with text values", () => {
@@ -140,8 +141,20 @@ describe("structured guide validation", () => {
it("requires the fixed Late Game and Early Game artifact groups", () => {
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);
expect(artifactSectionSchema.safeParse({ ...base, groups: [{ name: null, note: null, tiers: [...artifactTiers].reverse() }] }).success).toBe(false);
expect(artifactSectionSchema.safeParse({ ...base, groups: [{ name: null, note: null, tiers: [...artifactTiers, { name: "Other", rows: [] }] }] }).success).toBe(false);
});
it("supports named artifact tier-list conditions", () => {
const base = { expectedVersion: 1, sands: [], goblet: [], circlet: [], substats: [], targetGroups: [], talents: [], note: null };
const result = artifactSectionSchema.parse({ ...base, groups: [
{ name: " On field ", note: "Best damage", tiers: artifactTiers },
{ name: "", note: null, tiers: artifactTiers },
] });
expect(result.groups.map((group) => group.name)).toEqual(["On field", null]);
expect(artifactSectionSchema.safeParse({ ...base, groups: [] }).success).toBe(false);
expect(artifactSectionSchema.safeParse({ ...base, groups: [{ name: "x".repeat(121), note: null, tiers: artifactTiers }] }).success).toBe(false);
});
it("accepts only talent target levels from 1 through 10", () => {
+5 -1
View File
@@ -89,10 +89,14 @@ const artifactGroupSchema = (name: "Late Game" | "Early Game") => z.strictObject
export const artifactSectionSchema = z.strictObject({
expectedVersion: z.number().int().positive(),
groups: z.tuple([
groups: z.array(z.strictObject({
name: conditionName,
note: z.string().trim().max(2_000).nullable(),
tiers: z.tuple([
artifactGroupSchema("Late Game"),
artifactGroupSchema("Early Game"),
]),
})).min(1).max(20),
sands: z.array(z.enum(mainStatOptions.sands)).max(3),
goblet: z.array(z.enum(mainStatOptions.goblet)).max(3),
circlet: z.array(z.enum(mainStatOptions.circlet)).max(3),