feat : update
This commit is contained in:
@@ -6,6 +6,9 @@
|
||||
"name": "buzz-sheet",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.7.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"better-auth": "^1.7.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -134,6 +137,14 @@
|
||||
|
||||
"@better-fetch/fetch": ["@better-fetch/[email protected]", "", {}, "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g=="],
|
||||
|
||||
"@dnd-kit/accessibility": ["@dnd-kit/[email protected]", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
|
||||
|
||||
"@dnd-kit/core": ["@dnd-kit/[email protected]", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
|
||||
|
||||
"@dnd-kit/sortable": ["@dnd-kit/[email protected]", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="],
|
||||
|
||||
"@dnd-kit/utilities": ["@dnd-kit/[email protected]", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
|
||||
|
||||
"@dotenvx/dotenvx": ["@dotenvx/[email protected]", "", { "dependencies": { "@dotenvx/primitives": "^0.8.0", "commander": "^11.1.0", "conf": "^10.2.0", "dotenv": "^17.2.1", "enquirer": "^2.4.1", "env-paths": "^2.2.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "open": "^8.4.2", "picomatch": "^4.0.4", "systeminformation": "^5.22.11", "undici": "^7.11.0", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ=="],
|
||||
|
||||
"@dotenvx/primitives": ["@dotenvx/[email protected]", "", {}, "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg=="],
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { ArrowDownIcon, ArrowUpIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { ArrowDownIcon, ArrowUpIcon, GripVerticalIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import {
|
||||
saveArtifactSection,
|
||||
@@ -43,13 +59,19 @@ import {
|
||||
} from "@/lib/guides/schemas";
|
||||
import { supportsWeaponType } from "@/lib/catalog/lunaris";
|
||||
import { rarityGradientClass } from "@/lib/catalog/rarity";
|
||||
import {
|
||||
normalizeTalentPriorities,
|
||||
reorderTalentPriorities,
|
||||
type TalentPriority,
|
||||
type TalentRelation,
|
||||
} from "@/lib/guides/talents";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CatalogItem = { key: string; name: string; imageUrl: string; rarity?: number | null; weaponType?: string | null };
|
||||
type Guide = { id: string; characterKey: string; version: number; name: string };
|
||||
type WeaponValue = { weaponKey: string; refinement: number; overallPercent: string | null; personalPercent: string | null; weapon: CatalogItem };
|
||||
type ArtifactValue = { id: string; name: string; rows: Array<{ firstArtifactKey: string; secondArtifactKey: string | null; percent: string }> };
|
||||
type ArtifactProfile = { sands: string[]; goblet: string[]; circlet: string[]; substats: string[]; targets: Array<{ stat: string; value: string }>; talents: Array<{ type: "NA" | "E" | "Q"; priority: number; level?: string }>; note: string | null } | null;
|
||||
type ArtifactProfile = { sands: string[]; goblet: string[]; circlet: string[]; substats: string[]; targets: Array<{ stat: string; value: string }>; talents: TalentPriority[]; note: string | null } | null;
|
||||
type ConstellationValue = { level: number; detail: string; cumulativePercent: string };
|
||||
type CatalogConstellation = { level: number; name: string; description: string; iconUrl: string | null };
|
||||
type CatalogTalent = { type: "NA" | "E" | "Q"; name: string; iconUrl: string | null };
|
||||
@@ -119,6 +141,46 @@ function TalentLevelPicker({ value, onChange }: { value?: string; onChange: (val
|
||||
);
|
||||
}
|
||||
|
||||
function SortableTalentCard({
|
||||
talent,
|
||||
index,
|
||||
catalogTalent,
|
||||
onLevelChange,
|
||||
}: {
|
||||
talent: TalentPriority;
|
||||
index: number;
|
||||
catalogTalent?: CatalogTalent;
|
||||
onLevelChange: (level?: string) => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: talent.type });
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||
className={cn(isDragging && "opacity-60")}
|
||||
>
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
{catalogTalent?.iconUrl ? <Image src={catalogTalent.iconUrl} alt="" width={48} height={48} className="rounded-lg bg-muted" /> : null}
|
||||
<CardTitle>{index + 1}. {catalogTalent?.name ?? talent.type}</CardTitle>
|
||||
<CardAction>
|
||||
<Button type="button" variant="ghost" size="icon-sm" className="touch-none" {...attributes} {...listeners}>
|
||||
<GripVerticalIcon />
|
||||
<span className="sr-only">ลากเพื่อจัดลำดับ {catalogTalent?.name ?? talent.type}</span>
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Field>
|
||||
<FieldLabel>ระดับเป้าหมาย</FieldLabel>
|
||||
<TalentLevelPicker value={talent.level} onChange={onLevelChange} />
|
||||
</Field>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowActions({ index, length, move, remove }: { index: number; length: number; move: (from: number, to: number) => void; remove?: () => void }) {
|
||||
return <div className="flex gap-1"><Button type="button" variant="ghost" size="icon-sm" disabled={index === 0} onClick={() => move(index, index - 1)}><ArrowUpIcon /><span className="sr-only">เลื่อนขึ้น</span></Button><Button type="button" variant="ghost" size="icon-sm" disabled={index === length - 1} onClick={() => move(index, index + 1)}><ArrowDownIcon /><span className="sr-only">เลื่อนลง</span></Button>{remove ? <Button type="button" variant="ghost" size="icon-sm" onClick={remove}><Trash2Icon /><span className="sr-only">ลบ</span></Button> : null}</div>;
|
||||
}
|
||||
@@ -163,6 +225,10 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
|
||||
|
||||
function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBase) {
|
||||
const profile = initial.artifactProfile;
|
||||
const talentSensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
const [groups, setGroups] = useState(() => (["Late Game", "Early Game"] as const).map((name, index) => ({
|
||||
name,
|
||||
rows: initial.artifactGroups[index]?.rows.map((row) => ({ ...row })) ?? [],
|
||||
@@ -176,19 +242,26 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
|
||||
const defaults = DEFAULT_TARGET_STATS.map((stat) => existing.find((target) => target.stat === stat) ?? { stat, value: "" });
|
||||
return [...defaults, ...existing.filter((target) => !DEFAULT_TARGET_STATS.includes(target.stat as (typeof DEFAULT_TARGET_STATS)[number]))];
|
||||
});
|
||||
const [talents, setTalents] = useState<Array<{ type: "NA" | "E" | "Q"; priority: number; level?: string }>>(() => {
|
||||
const source: Array<{ type: "NA" | "E" | "Q"; priority: number; level?: string }> = profile?.talents.length
|
||||
const [talents, setTalents] = useState<TalentPriority[]>(() => {
|
||||
const source: TalentPriority[] = profile?.talents.length
|
||||
? profile.talents
|
||||
: (["NA", "E", "Q"] as const).map((type, priority) => ({ type, priority: priority + 1 }));
|
||||
return source.map((talent) => ({
|
||||
return normalizeTalentPriorities(source.map((talent) => ({
|
||||
...talent,
|
||||
level: talent.level && talentLevelOptions.includes(talent.level as (typeof talentLevelOptions)[number]) ? talent.level : undefined,
|
||||
}));
|
||||
})));
|
||||
});
|
||||
const [note, setNote] = useState(profile?.note ?? "");
|
||||
const value = { groups: groups.map((group) => ({ ...group, rows: group.rows.map((row) => ({ ...row, percent: Number(row.percent) })) })), sands, goblet, circlet, substats, targets: targets.filter((target) => target.stat.trim() && target.value.trim()), talents, note: note || null };
|
||||
const autosave = useAutosave({ value, valid: artifactSectionSchema.safeParse({ ...value, expectedVersion: guide.version }).success, initialVersion: guide.version, persist: (next, expectedVersion) => saveArtifactSection(guide.id, { ...next, expectedVersion }) });
|
||||
const orderedTalents = [...talents].sort((a, b) => a.priority - b.priority);
|
||||
const orderedTalents = normalizeTalentPriorities(talents);
|
||||
const handleTalentDragEnd = ({ active, over }: DragEndEvent) => {
|
||||
if (!over) return;
|
||||
setTalents(reorderTalentPriorities(orderedTalents, active.id as TalentPriority["type"], over.id as TalentPriority["type"]));
|
||||
};
|
||||
const setTalentRelation = (type: TalentPriority["type"], relationToNext: TalentRelation) => {
|
||||
setTalents(orderedTalents.map((talent) => talent.type === type ? { ...talent, relationToNext } : talent));
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card>
|
||||
@@ -199,7 +272,61 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Recommended Stats</CardTitle><CardDescription>เลือกค่าจากรายการแทนปุ่ม และเลือก Main Stat ได้สูงสุด 2 ค่าต่อชิ้น</CardDescription></CardHeader>
|
||||
<CardContent><FieldGroup><StatSelect label="Sands / นาฬิกา" options={mainStatOptions.sands} value={sands} max={2} onChange={setSands} /><StatSelect label="Goblet / ถ้วย" options={mainStatOptions.goblet} value={goblet} max={2} onChange={setGoblet} /><StatSelect label="Circlet / หมวก" options={mainStatOptions.circlet} value={circlet} max={2} onChange={setCirclet} /><StatSelect label="Substats" options={substatOptions} value={substats} max={substatOptions.length} onChange={setSubstats} /><FieldSet><FieldLegend>Target Stats</FieldLegend>{targets.map((target, index) => <div key={`${target.stat}-${index}`} className="grid gap-3 sm:grid-cols-[1fr_1fr_auto]"><Input value={target.stat} placeholder="ATK" aria-label={`Target stat ${index + 1}`} onChange={(event) => setTargets(targets.map((item, itemIndex) => itemIndex === index ? { ...item, stat: event.target.value } : item))} /><Input value={target.value} placeholder={target.stat === "ATK" ? "2200+" : "Recommended value"} aria-label={`${target.stat || "Target"} value`} onChange={(event) => setTargets(targets.map((item, itemIndex) => itemIndex === index ? { ...item, value: event.target.value } : item))} /><Button variant="ghost" size="icon" onClick={() => setTargets(targets.filter((_, itemIndex) => itemIndex !== index))}><Trash2Icon /><span className="sr-only">ลบเป้าหมาย</span></Button></div>)}<Button variant="outline" onClick={() => setTargets([...targets, { stat: "", value: "" }])}><PlusIcon data-icon="inline-start" />เพิ่ม Target</Button></FieldSet><FieldSet><FieldLegend>Talent Priority</FieldLegend>{orderedTalents.map((talent, index) => { const catalogTalent = catalogTalents.find((item) => item.type === talent.type); return <Card key={talent.type} size="sm"><CardHeader>{catalogTalent?.iconUrl ? <Image src={catalogTalent.iconUrl} alt="" width={48} height={48} className="rounded-lg bg-muted" /> : null}<CardTitle>{index + 1}. {catalogTalent?.name ?? talent.type}</CardTitle><CardAction><RowActions index={index} length={orderedTalents.length} move={(from, to) => setTalents(moveItem(orderedTalents, from, to).map((item, priority) => ({ ...item, priority: priority + 1 })))} /></CardAction></CardHeader><CardContent><Field><FieldLabel>ระดับเป้าหมาย</FieldLabel><TalentLevelPicker value={talent.level} onChange={(level) => setTalents(talents.map((item) => item.type === talent.type ? { ...item, level } : item))} /></Field></CardContent></Card>; })}</FieldSet><Field><FieldLabel htmlFor="artifact-note">Note (optional)</FieldLabel><Textarea id="artifact-note" value={note} onChange={(event) => setNote(event.target.value)} /></Field></FieldGroup></CardContent>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<StatSelect label="Sands / นาฬิกา" options={mainStatOptions.sands} value={sands} max={2} onChange={setSands} />
|
||||
<StatSelect label="Goblet / ถ้วย" options={mainStatOptions.goblet} value={goblet} max={2} onChange={setGoblet} />
|
||||
<StatSelect label="Circlet / หมวก" options={mainStatOptions.circlet} value={circlet} max={2} onChange={setCirclet} />
|
||||
<StatSelect label="Substats" options={substatOptions} value={substats} max={substatOptions.length} onChange={setSubstats} />
|
||||
<FieldSet>
|
||||
<FieldLegend>Target Stats</FieldLegend>
|
||||
{targets.map((target, index) => <div key={`${target.stat}-${index}`} className="grid gap-3 sm:grid-cols-[1fr_1fr_auto]"><Input value={target.stat} placeholder="ATK" aria-label={`Target stat ${index + 1}`} onChange={(event) => setTargets(targets.map((item, itemIndex) => itemIndex === index ? { ...item, stat: event.target.value } : item))} /><Input value={target.value} placeholder={target.stat === "ATK" ? "2200+" : "Recommended value"} aria-label={`${target.stat || "Target"} value`} onChange={(event) => setTargets(targets.map((item, itemIndex) => itemIndex === index ? { ...item, value: event.target.value } : item))} /><Button variant="ghost" size="icon" onClick={() => setTargets(targets.filter((_, itemIndex) => itemIndex !== index))}><Trash2Icon /><span className="sr-only">ลบเป้าหมาย</span></Button></div>)}
|
||||
<Button variant="outline" onClick={() => setTargets([...targets, { stat: "", value: "" }])}><PlusIcon data-icon="inline-start" />เพิ่ม Target</Button>
|
||||
</FieldSet>
|
||||
<FieldSet>
|
||||
<FieldLegend>Talent Priority</FieldLegend>
|
||||
<DndContext sensors={talentSensors} collisionDetection={closestCenter} onDragEnd={handleTalentDragEnd}>
|
||||
<SortableContext items={orderedTalents.map((talent) => talent.type)} strategy={verticalListSortingStrategy}>
|
||||
<div className="flex flex-col gap-3">
|
||||
{orderedTalents.map((talent, index) => {
|
||||
const catalogTalent = catalogTalents.find((item) => item.type === talent.type);
|
||||
return (
|
||||
<div key={talent.type} className="flex flex-col gap-2">
|
||||
<SortableTalentCard
|
||||
talent={talent}
|
||||
index={index}
|
||||
catalogTalent={catalogTalent}
|
||||
onLevelChange={(level) => setTalents(orderedTalents.map((item) => item.type === talent.type ? { ...item, level } : item))}
|
||||
/>
|
||||
{index < orderedTalents.length - 1 ? (
|
||||
<ToggleGroup
|
||||
variant="outline"
|
||||
spacing={0}
|
||||
value={[talent.relationToNext ?? "gt"]}
|
||||
onValueChange={(selected) => {
|
||||
const relation = selected[0] as TalentRelation | undefined;
|
||||
if (relation) setTalentRelation(talent.type, relation);
|
||||
}}
|
||||
aria-label={`ตัวเปรียบเทียบหลัง ${catalogTalent?.name ?? talent.type}`}
|
||||
className="self-center"
|
||||
>
|
||||
<ToggleGroupItem className={SELECTED_TOGGLE_CLASS} value="gt" aria-label="มากกว่า">></ToggleGroupItem>
|
||||
<ToggleGroupItem className={SELECTED_TOGGLE_CLASS} value="gte" aria-label="มากกว่าหรือเท่ากับ">≥</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</FieldSet>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="artifact-note">Note (optional)</FieldLabel>
|
||||
<Textarea id="artifact-note" value={note} onChange={(event) => setNote(event.target.value)} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end"><AutosaveStatus state={autosave} /></CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ElementIcon } from "@/components/public/element-icon";
|
||||
import { LiveRefresh } from "@/components/live-refresh";
|
||||
import { rarityGradientClass, rarityTextClass } from "@/lib/catalog/rarity";
|
||||
import type { PublicGuide } from "@/lib/guides/queries";
|
||||
import { normalizeTalentPriorities, talentRelationSymbol } from "@/lib/guides/talents";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function hasSection(guide: PublicGuide, slug: string): boolean {
|
||||
@@ -374,17 +375,27 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) {
|
||||
{profile.talents.length ? (
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium">ลำดับการอัพพรสวรรค์</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{[...profile.talents]
|
||||
.sort((a, b) => a.priority - b.priority)
|
||||
.map((talent) => {
|
||||
<div className="flex flex-wrap items-start justify-center gap-3">
|
||||
{normalizeTalentPriorities(profile.talents)
|
||||
.map((talent, index, talents) => {
|
||||
const catalogTalent = guide.character.talents.find((item) => item.type === talent.type);
|
||||
return catalogTalent?.iconUrl ? (
|
||||
<div key={talent.type} className="relative size-14 overflow-hidden rounded-lg border bg-muted">
|
||||
<Image src={catalogTalent.iconUrl} alt="" fill className="object-contain" />
|
||||
<Kbd className="absolute top-0 right-0">{talent.priority}</Kbd>
|
||||
const talentName = catalogTalent?.name ?? talent.type;
|
||||
return (
|
||||
<div key={talent.type} className="flex items-start gap-3">
|
||||
<div className="flex w-28 flex-col items-center gap-2 text-center">
|
||||
<div className="relative size-20 overflow-hidden rounded-xl border bg-muted">
|
||||
{catalogTalent?.iconUrl ? <Image src={catalogTalent.iconUrl} alt={talentName} fill className="object-contain" /> : <span className="grid size-full place-items-center font-semibold">{talent.type}</span>}
|
||||
</div>
|
||||
<Badge variant="secondary">{talent.level ? `Lv.${talent.level}+` : "—"}</Badge>
|
||||
<p className="text-sm font-medium">{talentName}</p>
|
||||
</div>
|
||||
{index < talents.length - 1 ? (
|
||||
<span className="mt-6 text-3xl font-semibold" aria-label={talent.relationToNext === "gte" ? "มากกว่าหรือเท่ากับ" : "มากกว่า"}>
|
||||
{talentRelationSymbol(talent.relationToNext)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : <Badge key={talent.type} variant="secondary">{talent.priority}. {talent.type}</Badge>;
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -606,7 +606,7 @@ 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(),
|
||||
talents: jsonb("talents").$type<Array<{ type: "NA" | "E" | "Q"; priority: number; level?: string }>>().default([]).notNull(),
|
||||
talents: jsonb("talents").$type<Array<{ type: "NA" | "E" | "Q"; priority: number; level?: string; relationToNext?: "gt" | "gte" }>>().default([]).notNull(),
|
||||
note: text("note"),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
@@ -35,6 +35,13 @@ describe("structured guide validation", () => {
|
||||
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 };
|
||||
expect(artifactSectionSchema.safeParse({ ...base, talents: [{ type: "E", priority: 1, relationToNext: "gte" }] }).success).toBe(true);
|
||||
expect(artifactSectionSchema.safeParse({ ...base, talents: [{ type: "E", priority: 1 }] }).success).toBe(true);
|
||||
expect(artifactSectionSchema.safeParse({ ...base, talents: [{ type: "E", priority: 1, relationToNext: "equal" }] }).success).toBe(false);
|
||||
});
|
||||
|
||||
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 base = { expectedVersion: 1, teams: [{ title: "Test", rotationSeconds: 20, combo: "E > Q", members: ["1", "2", "3", "4"].map(member) }] };
|
||||
|
||||
@@ -38,6 +38,7 @@ export const mainStatOptions = {
|
||||
|
||||
export const substatOptions = ["CRIT Rate%/CRIT DMG%", "HP%", "ATK%", "DEF%", "EM", "ER"] as const;
|
||||
export const talentLevelOptions = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"] as const;
|
||||
export const talentRelationOptions = ["gt", "gte"] as const;
|
||||
|
||||
const artifactGroupSchema = (name: "Late Game" | "Early Game") => z.strictObject({
|
||||
name: z.literal(name),
|
||||
@@ -66,6 +67,7 @@ export const artifactSectionSchema = z.strictObject({
|
||||
type: z.enum(["NA", "E", "Q"]),
|
||||
priority: z.number().int().min(1).max(3),
|
||||
level: z.enum(talentLevelOptions).optional(),
|
||||
relationToNext: z.enum(talentRelationOptions).optional(),
|
||||
})).max(3),
|
||||
note: z.string().trim().max(2_000).nullable(),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { normalizeTalentPriorities, reorderTalentPriorities, talentRelationSymbol } from "@/lib/guides/talents";
|
||||
|
||||
describe("talent priorities", () => {
|
||||
it("normalizes legacy talents to strict comparisons", () => {
|
||||
expect(normalizeTalentPriorities([
|
||||
{ type: "Q", priority: 2 },
|
||||
{ type: "E", priority: 1 },
|
||||
{ type: "NA", priority: 3 },
|
||||
])).toEqual([
|
||||
{ type: "E", priority: 1, relationToNext: "gt" },
|
||||
{ type: "Q", priority: 2, relationToNext: "gt" },
|
||||
{ type: "NA", priority: 3, relationToNext: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reorders talents while preserving relations between positions", () => {
|
||||
expect(reorderTalentPriorities([
|
||||
{ type: "E", priority: 1, relationToNext: "gte" },
|
||||
{ type: "Q", priority: 2, relationToNext: "gt" },
|
||||
{ type: "NA", priority: 3 },
|
||||
], "NA", "E")).toEqual([
|
||||
{ type: "NA", priority: 1, relationToNext: "gte" },
|
||||
{ type: "E", priority: 2, relationToNext: "gt" },
|
||||
{ type: "Q", priority: 3, relationToNext: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it("formats strict and greater-than-or-equal relations", () => {
|
||||
expect(talentRelationSymbol("gt")).toBe(">");
|
||||
expect(talentRelationSymbol("gte")).toBe("≥");
|
||||
expect(talentRelationSymbol()).toBe(">");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
export type TalentRelation = "gt" | "gte";
|
||||
|
||||
export interface TalentPriority {
|
||||
type: "NA" | "E" | "Q";
|
||||
priority: number;
|
||||
level?: string;
|
||||
relationToNext?: TalentRelation;
|
||||
}
|
||||
|
||||
export function normalizeTalentPriorities(talents: readonly TalentPriority[]): TalentPriority[] {
|
||||
const ordered = [...talents].sort((a, b) => a.priority - b.priority);
|
||||
return ordered.map((talent, index) => ({
|
||||
...talent,
|
||||
priority: index + 1,
|
||||
relationToNext: index < ordered.length - 1 ? talent.relationToNext ?? "gt" : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export function reorderTalentPriorities(
|
||||
talents: readonly TalentPriority[],
|
||||
activeType: TalentPriority["type"],
|
||||
overType: TalentPriority["type"],
|
||||
): TalentPriority[] {
|
||||
const ordered = normalizeTalentPriorities(talents);
|
||||
const from = ordered.findIndex((talent) => talent.type === activeType);
|
||||
const to = ordered.findIndex((talent) => talent.type === overType);
|
||||
if (from < 0 || to < 0 || from === to) return ordered;
|
||||
|
||||
const relations = ordered.slice(0, -1).map((talent) => talent.relationToNext ?? "gt");
|
||||
const next = [...ordered];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(to, 0, moved);
|
||||
|
||||
return next.map((talent, index) => ({
|
||||
...talent,
|
||||
priority: index + 1,
|
||||
relationToNext: index < next.length - 1 ? relations[index] : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export function talentRelationSymbol(relation?: TalentRelation): ">" | "≥" {
|
||||
return relation === "gte" ? "≥" : ">";
|
||||
}
|
||||
@@ -20,6 +20,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.7.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"better-auth": "^1.7.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
Reference in New Issue
Block a user