feat : add stygian guide
This commit is contained in:
@@ -0,0 +1,73 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { updateTag } from "next/cache";
|
||||||
|
import * as z from "zod";
|
||||||
|
|
||||||
|
import { requireAdmin } from "@/lib/auth/server";
|
||||||
|
import { auditActor, writeAuditLogBestEffort } from "@/lib/audit-log";
|
||||||
|
import { STYGIAN_CACHE_TAG } from "@/lib/cache/stygian";
|
||||||
|
import {
|
||||||
|
activateStygianSchedule,
|
||||||
|
saveStygianRecommendations,
|
||||||
|
syncStygianArchive,
|
||||||
|
} from "@/lib/stygian/repository";
|
||||||
|
|
||||||
|
function message(cause: unknown) {
|
||||||
|
return cause instanceof z.ZodError
|
||||||
|
? cause.issues.map((issue) => issue.message).join(" • ")
|
||||||
|
: cause instanceof Error ? cause.message : "ดำเนินการไม่สำเร็จ";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncStygianArchiveAction() {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
try {
|
||||||
|
const result = await syncStygianArchive();
|
||||||
|
await writeAuditLogBestEffort(auditActor(admin.user), {
|
||||||
|
action: "stygian.synced",
|
||||||
|
targetType: "stygian_schedule",
|
||||||
|
targetId: String(result.imported.at(-1) ?? result.stoppedAt),
|
||||||
|
metadata: { imported: result.imported, stoppedAt: result.stoppedAt },
|
||||||
|
});
|
||||||
|
updateTag(STYGIAN_CACHE_TAG);
|
||||||
|
return { status: "saved" as const, ...result };
|
||||||
|
} catch (cause) {
|
||||||
|
return { status: "error" as const, message: message(cause) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveStygianRecommendationsAction(scheduleId: number, input: unknown) {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
try {
|
||||||
|
const result = await saveStygianRecommendations(scheduleId, input);
|
||||||
|
await writeAuditLogBestEffort(auditActor(admin.user), {
|
||||||
|
action: "stygian.recommendations.saved",
|
||||||
|
targetType: "stygian_schedule",
|
||||||
|
targetId: String(scheduleId),
|
||||||
|
resultingVersion: result.version,
|
||||||
|
});
|
||||||
|
updateTag(STYGIAN_CACHE_TAG);
|
||||||
|
return { status: "saved" as const, version: result.version };
|
||||||
|
} catch (cause) {
|
||||||
|
return { status: "error" as const, message: message(cause) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activateStygianScheduleAction(scheduleId: number, expectedVersion: number) {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
try {
|
||||||
|
const result = await activateStygianSchedule(
|
||||||
|
z.number().int().positive().parse(scheduleId),
|
||||||
|
z.number().int().positive().parse(expectedVersion),
|
||||||
|
);
|
||||||
|
await writeAuditLogBestEffort(auditActor(admin.user), {
|
||||||
|
action: "stygian.activated",
|
||||||
|
targetType: "stygian_schedule",
|
||||||
|
targetId: String(scheduleId),
|
||||||
|
resultingVersion: result.version,
|
||||||
|
});
|
||||||
|
updateTag(STYGIAN_CACHE_TAG);
|
||||||
|
return { status: "saved" as const, version: result.version };
|
||||||
|
} catch (cause) {
|
||||||
|
return { status: "error" as const, message: message(cause) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { connection } from "next/server";
|
||||||
|
|
||||||
|
import { AdminHeader } from "@/components/admin/admin-header";
|
||||||
|
import { StygianEditor } from "@/components/admin/stygian-editor";
|
||||||
|
import { StygianSyncButton } from "@/components/admin/stygian-sync-button";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { getAdminSession } from "@/lib/auth/server";
|
||||||
|
import { getActiveCatalog } from "@/lib/guides/queries";
|
||||||
|
import { getStygianSchedule, listStygianSchedules } from "@/lib/stygian/repository";
|
||||||
|
|
||||||
|
export default async function AdminStygianPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||||
|
await connection();
|
||||||
|
if (!(await getAdminSession())) redirect("/admin/login");
|
||||||
|
const query = await searchParams;
|
||||||
|
const schedules = await listStygianSchedules();
|
||||||
|
const raw = Array.isArray(query.schedule) ? query.schedule[0] : query.schedule;
|
||||||
|
const selectedId = Number(raw) || schedules.find((entry) => entry.isActive)?.scheduleId || schedules[0]?.scheduleId;
|
||||||
|
const [schedule, catalog] = await Promise.all([selectedId ? getStygianSchedule(selectedId) : null, getActiveCatalog()]);
|
||||||
|
return (
|
||||||
|
<div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-7xl flex-col gap-8 p-4 sm:p-6 lg:p-8">
|
||||||
|
<header><p className="text-sm font-medium text-primary">Stygian Onslaught</p><h1 className="font-heading text-3xl font-semibold">Stygian Guide</h1><p className="mt-2 text-muted-foreground">Sync Lunaris schedules, prepare recommendations, and choose the public version.</p></header>
|
||||||
|
{!schedule || !catalog ? <Alert><AlertTitle>No Stygian schedules yet</AlertTitle><AlertDescription>Use the sync action below after the catalog has been synchronized.</AlertDescription></Alert> : null}
|
||||||
|
{schedule && catalog ? <StygianEditor
|
||||||
|
key={`${schedule.scheduleId}-${schedule.version}`}
|
||||||
|
schedules={schedules.map(({ scheduleId, challengeName, version, isActive }) => ({ scheduleId, challengeName, version, isActive }))}
|
||||||
|
schedule={{ scheduleId: schedule.scheduleId, challengeName: schedule.challengeName, version: schedule.version, isActive: schedule.isActive }}
|
||||||
|
bosses={schedule.bosses.map(({ level, position, name, imageUrl }) => ({ level, position, name, imageUrl }))}
|
||||||
|
recommendations={schedule.recommendations}
|
||||||
|
characters={catalog.characters.map(({ key, name, aliases, imageUrl, rarity, constellations }) => ({ key, name, aliases, imageUrl, rarity, constellations: constellations.map(({ level, name: constellationName, iconUrl }) => ({ level, name: constellationName, iconUrl })) }))}
|
||||||
|
weapons={catalog.weapons.map(({ key, name, aliases, imageUrl, rarity }) => ({ key, name, aliases, imageUrl, rarity }))}
|
||||||
|
artifacts={catalog.artifacts.map(({ key, name, aliases, imageUrl, rarity }) => ({ key, name, aliases, imageUrl, rarity }))}
|
||||||
|
/> : schedules.length === 0 && catalog ? <StygianSyncButton /> : null}
|
||||||
|
</main></div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
|
|
||||||
|
import { createRedisEventResponse } from "@/lib/events/redis-stream";
|
||||||
|
import { errorResponse } from "@/lib/security/http";
|
||||||
|
import { limitRequest, trustedClientAddress } from "@/lib/security/rate-limit";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
await connection();
|
||||||
|
try {
|
||||||
|
await limitRequest("stream-open", trustedClientAddress(request.headers), 60);
|
||||||
|
return await createRedisEventResponse("stygian", request.signal);
|
||||||
|
} catch (cause) {
|
||||||
|
return errorResponse(cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
{ url: siteUrl },
|
{ url: siteUrl },
|
||||||
|
{ url: `${siteUrl}/stygian` },
|
||||||
...guides.map((guide) => ({
|
...guides.map((guide) => ({
|
||||||
url: `${siteUrl}/${encodeURIComponent(guide.slug)}`,
|
url: `${siteUrl}/${encodeURIComponent(guide.slug)}`,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
|
|
||||||
|
import { SiteFooter } from "@/components/public/site-footer";
|
||||||
|
import { SiteHeader } from "@/components/public/site-header";
|
||||||
|
import { StygianGuide } from "@/components/public/stygian-guide";
|
||||||
|
import { LiveRefresh } from "@/components/live-refresh";
|
||||||
|
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
||||||
|
import { getCachedActiveStygianSchedule } from "@/lib/cache/stygian";
|
||||||
|
import { getActiveCatalog } from "@/lib/guides/queries";
|
||||||
|
|
||||||
|
export const instant = false;
|
||||||
|
|
||||||
|
export default async function StygianPage() {
|
||||||
|
await connection();
|
||||||
|
const [schedule, catalog] = await Promise.all([getCachedActiveStygianSchedule(), getActiveCatalog()]);
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh flex-col bg-background">
|
||||||
|
<LiveRefresh endpoint="/api/events/stygian" />
|
||||||
|
<SiteHeader editHref="/admin/stygian" />
|
||||||
|
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-8 px-4 py-10 sm:px-6 sm:py-14">
|
||||||
|
<header>
|
||||||
|
<p className="text-sm font-medium text-primary">Stygian Onslaught</p>
|
||||||
|
<h1 className="font-heading text-4xl font-semibold">Stygian Guide</h1>
|
||||||
|
{schedule ? <p className="mt-2 text-muted-foreground">Version {schedule.challengeName} · Schedule {schedule.scheduleId}</p> : null}
|
||||||
|
</header>
|
||||||
|
{schedule && catalog ? <StygianGuide schedule={schedule} catalog={catalog} /> : (
|
||||||
|
<Empty className="border"><EmptyHeader><EmptyTitle>Stygian Guide is not published yet</EmptyTitle><EmptyDescription>An administrator must sync and activate a schedule first.</EmptyDescription></EmptyHeader></Empty>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
<SiteFooter />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
UserPlusIcon,
|
UserPlusIcon,
|
||||||
|
SwordsIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -32,6 +33,15 @@ export function AdminHeader() {
|
|||||||
<span className="hidden sm:inline">ไกด์เกนชินไม่ใช่เกมมือถือ</span>
|
<span className="hidden sm:inline">ไกด์เกนชินไม่ใช่เกมมือถือ</span>
|
||||||
</Link>
|
</Link>
|
||||||
<nav aria-label="Admin navigation" className="flex items-center gap-1">
|
<nav aria-label="Admin navigation" className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant={pathname === "/admin/stygian" ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
nativeButton={false}
|
||||||
|
render={<Link href="/admin/stygian" />}
|
||||||
|
>
|
||||||
|
<SwordsIcon data-icon="inline-start" />
|
||||||
|
<span className="hidden sm:inline">Stygian</span>
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={pathname === "/admin/activity" ? "secondary" : "ghost"}
|
variant={pathname === "/admin/activity" ? "secondary" : "ghost"}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState, useTransition } from "react";
|
||||||
|
import { CheckIcon, PlusIcon, RefreshCwIcon, Trash2Icon } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import {
|
||||||
|
activateStygianScheduleAction,
|
||||||
|
saveStygianRecommendationsAction,
|
||||||
|
syncStygianArchiveAction,
|
||||||
|
} from "@/app/admin/stygian/actions";
|
||||||
|
import { AutosaveStatus } from "@/components/admin/autosave-status";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
ComboboxContent,
|
||||||
|
ComboboxEmpty,
|
||||||
|
ComboboxInput,
|
||||||
|
ComboboxItem,
|
||||||
|
ComboboxList,
|
||||||
|
ComboboxTrigger,
|
||||||
|
FuzzyCombobox,
|
||||||
|
} from "@/components/ui/combobox";
|
||||||
|
import { Field, FieldGroup, FieldLabel, FieldSet, FieldLegend } from "@/components/ui/field";
|
||||||
|
import { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { rarityGradientClass } from "@/lib/catalog/rarity";
|
||||||
|
import { stygianElements, type StygianRecommendationItemInput } from "@/lib/stygian/schema";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type CatalogItem = { key: string; name: string; aliases: string[]; imageUrl: string; rarity: number | null };
|
||||||
|
type Character = CatalogItem & { constellations: Array<{ level: number; name: string; iconUrl: string | null }> };
|
||||||
|
type ScheduleSummary = { scheduleId: number; challengeName: string; version: number; isActive: boolean };
|
||||||
|
type Boss = { level: number; position: number; name: string; imageUrl: string };
|
||||||
|
type StoredItem = {
|
||||||
|
kind: string;
|
||||||
|
catalogKey: string | null;
|
||||||
|
weaponKey: string | null;
|
||||||
|
artifactKey: string | null;
|
||||||
|
characterKey: string | null;
|
||||||
|
constellationLevel: number | null;
|
||||||
|
element: string | null;
|
||||||
|
};
|
||||||
|
type Recommendation = { id: string; level: number; bossPosition: number; kind: string; items: StoredItem[] };
|
||||||
|
|
||||||
|
function toInput(item: StoredItem): StygianRecommendationItemInput | null {
|
||||||
|
if ((item.kind === "character" || item.kind === "weapon" || item.kind === "artifact") && item.catalogKey) {
|
||||||
|
return item.kind === "character"
|
||||||
|
? { kind: "character", catalogKey: item.catalogKey, weaponKey: item.weaponKey, artifactKey: item.artifactKey, constellationLevel: item.constellationLevel }
|
||||||
|
: { kind: item.kind, catalogKey: item.catalogKey };
|
||||||
|
}
|
||||||
|
if (item.kind === "element" && item.element && stygianElements.includes(item.element as typeof stygianElements[number])) {
|
||||||
|
return { kind: "element", element: item.element as typeof stygianElements[number] };
|
||||||
|
}
|
||||||
|
if (item.kind === "constellation" && item.characterKey && item.constellationLevel) {
|
||||||
|
return { kind: "constellation", characterKey: item.characterKey, constellationLevel: item.constellationLevel };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function label(item: StygianRecommendationItemInput, characters: Character[], weapons: CatalogItem[], artifacts: CatalogItem[]) {
|
||||||
|
if (item.kind === "element") return item.element;
|
||||||
|
if (item.kind === "constellation") return `${characters.find((entry) => entry.key === item.characterKey)?.name ?? item.characterKey} C${item.constellationLevel}`;
|
||||||
|
const values = item.kind === "character" ? characters : item.kind === "weapon" ? weapons : artifacts;
|
||||||
|
return values.find((entry) => entry.key === item.catalogKey)?.name ?? item.catalogKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CatalogPicker({ items, value, onChange, placeholder }: { items: CatalogItem[]; value: string; onChange: (value: string) => void; placeholder: string }) {
|
||||||
|
const selected = items.find((item) => item.key === value) ?? null;
|
||||||
|
return (
|
||||||
|
<FuzzyCombobox
|
||||||
|
items={items}
|
||||||
|
value={selected}
|
||||||
|
onValueChange={(next) => onChange(next?.key ?? "")}
|
||||||
|
itemToStringLabel={(item) => item.name}
|
||||||
|
itemToStringValue={(item) => item.key}
|
||||||
|
searchNames={(item) => [item.name, ...item.aliases]}
|
||||||
|
autoHighlight
|
||||||
|
>
|
||||||
|
<ComboboxInput className="w-full" placeholder={placeholder} showClear />
|
||||||
|
<ComboboxContent className="min-w-80">
|
||||||
|
<ComboboxEmpty>No matches</ComboboxEmpty>
|
||||||
|
<ComboboxList>{(item) => <ComboboxItem key={item.key} value={item}><Image src={item.imageUrl} alt="" width={32} height={32} className="size-8 object-contain" /><span>{item.name}</span></ComboboxItem>}</ComboboxList>
|
||||||
|
</ComboboxContent>
|
||||||
|
</FuzzyCombobox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CatalogSlotPicker({ items, value, onChange, label, placeholder, className }: { items: CatalogItem[]; value: string; onChange: (value: string) => void; label: string; placeholder: string; className?: string }) {
|
||||||
|
const selected = items.find((item) => item.key === value) ?? null;
|
||||||
|
return (
|
||||||
|
<FuzzyCombobox items={items} value={selected} onValueChange={(next) => onChange(next?.key ?? "")} itemToStringLabel={(item) => item.name} itemToStringValue={(item) => item.key} searchNames={(item) => [item.name, ...item.aliases]} autoHighlight>
|
||||||
|
<ComboboxTrigger aria-label={selected ? `${label}: ${selected.name}` : label} className={cn("relative flex aspect-square w-full items-center justify-center overflow-hidden rounded-md border bg-muted p-0 outline-none focus-visible:ring-2 focus-visible:ring-ring [&>svg]:hidden", selected && rarityGradientClass(selected.rarity), className)}>
|
||||||
|
{selected ? <Image src={selected.imageUrl} alt="" fill sizes="96px" className="object-contain p-0.5" /> : <PlusIcon aria-hidden="true" />}
|
||||||
|
</ComboboxTrigger>
|
||||||
|
<ComboboxContent className="min-w-72"><ComboboxInput placeholder={placeholder} showClear showTrigger={false} /><ComboboxEmpty>No matches</ComboboxEmpty><ComboboxList>{(item) => <ComboboxItem key={item.key} value={item}><div className={cn("relative size-8 shrink-0 overflow-hidden rounded-md", rarityGradientClass(item.rarity))}><Image src={item.imageUrl} alt="" fill sizes="32px" className="object-contain" /></div><span>{item.name}</span></ComboboxItem>}</ComboboxList></ComboboxContent>
|
||||||
|
</FuzzyCombobox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemPicker({ characters, weapons, artifacts, onAdd }: { characters: Character[]; weapons: CatalogItem[]; artifacts: CatalogItem[]; onAdd: (item: StygianRecommendationItemInput) => void }) {
|
||||||
|
const [kind, setKind] = useState<StygianRecommendationItemInput["kind"]>("character");
|
||||||
|
const [key, setKey] = useState("");
|
||||||
|
const [weaponKey, setWeaponKey] = useState("");
|
||||||
|
const [artifactKey, setArtifactKey] = useState("");
|
||||||
|
const [element, setElement] = useState<typeof stygianElements[number]>("Cryo");
|
||||||
|
const [constellationLevel, setConstellationLevel] = useState(0);
|
||||||
|
const catalog = kind === "character" || kind === "constellation" ? characters : kind === "weapon" ? weapons : artifacts;
|
||||||
|
const selected = catalog.find((item) => item.key === key) ?? null;
|
||||||
|
const selectedWeapon = weapons.find((item) => item.key === weaponKey) ?? null;
|
||||||
|
const selectedArtifact = artifacts.find((item) => item.key === artifactKey) ?? null;
|
||||||
|
const value: StygianRecommendationItemInput | null = kind === "element"
|
||||||
|
? { kind, element }
|
||||||
|
: kind === "constellation"
|
||||||
|
? key ? { kind, characterKey: key, constellationLevel } : null
|
||||||
|
: key ? kind === "character"
|
||||||
|
? { kind, catalogKey: key, weaponKey: weaponKey || null, artifactKey: artifactKey || null, constellationLevel: constellationLevel || null }
|
||||||
|
: { kind, catalogKey: key }
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-[7rem_minmax(0,1fr)]">
|
||||||
|
<div className="self-start rounded-xl border bg-muted/30 p-1.5">
|
||||||
|
<div className={`relative aspect-square overflow-hidden rounded-lg ${selected ? rarityGradientClass(selected.rarity) : "bg-muted"}`}>
|
||||||
|
{selected ? <Image src={selected.imageUrl} alt={selected.name} fill sizes="112px" className="object-contain" /> : <span className="absolute inset-0 grid place-items-center px-2 text-center text-xs text-muted-foreground">{kind === "element" ? element : "Choose an item"}</span>}
|
||||||
|
</div>
|
||||||
|
{kind === "character" ? <div className="mt-1.5 grid grid-cols-2 gap-1.5">
|
||||||
|
<div className={`relative aspect-square overflow-hidden rounded-md border ${selectedArtifact ? rarityGradientClass(selectedArtifact.rarity) : "bg-muted"}`}>{selectedArtifact ? <Image src={selectedArtifact.imageUrl} alt={selectedArtifact.name} fill sizes="48px" className="object-contain p-1" /> : null}</div>
|
||||||
|
<div className={`relative aspect-square overflow-hidden rounded-md border ${selectedWeapon ? rarityGradientClass(selectedWeapon.rarity) : "bg-muted"}`}>{selectedWeapon ? <Image src={selectedWeapon.imageUrl} alt={selectedWeapon.name} fill sizes="48px" className="object-contain p-1" /> : null}</div>
|
||||||
|
</div> : null}
|
||||||
|
</div>
|
||||||
|
<FieldGroup className="gap-3">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-[9rem_minmax(0,1fr)]">
|
||||||
|
<Field><FieldLabel>Type</FieldLabel><Select value={kind} onValueChange={(next) => { if (next) { setKind(next as typeof kind); setKey(""); setConstellationLevel(next === "constellation" ? 1 : 0); } }}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="character">Character</SelectItem><SelectItem value="element">Element</SelectItem><SelectItem value="weapon">Weapon</SelectItem><SelectItem value="artifact">Artifact</SelectItem><SelectItem value="constellation">Constellation</SelectItem></SelectGroup></SelectContent></Select></Field>
|
||||||
|
<Field><FieldLabel>Recommendation</FieldLabel>{kind === "element" ? <Select value={element} onValueChange={(next) => { if (next) setElement(next as typeof element); }}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{stygianElements.map((entry) => <SelectItem key={entry} value={entry}>{entry}</SelectItem>)}</SelectGroup></SelectContent></Select> : <CatalogPicker items={catalog} value={key} onChange={setKey} placeholder={`Search ${kind}…`} />}</Field>
|
||||||
|
</div>
|
||||||
|
{kind === "character" ? <div className="grid gap-3 sm:grid-cols-2"><Field><FieldLabel>Weapon (optional)</FieldLabel><CatalogPicker items={weapons} value={weaponKey} onChange={setWeaponKey} placeholder="Search weapon…" /></Field><Field><FieldLabel>Artifact (optional)</FieldLabel><CatalogPicker items={artifacts} value={artifactKey} onChange={setArtifactKey} placeholder="Search artifact…" /></Field></div> : null}
|
||||||
|
<div className="flex items-end justify-end gap-3">
|
||||||
|
{kind === "character" || kind === "constellation" ? <Field className="max-w-32"><FieldLabel>{kind === "character" ? "Constellation" : "Level"}</FieldLabel><Select value={String(constellationLevel)} onValueChange={(next) => { if (next) setConstellationLevel(Number(next)); }}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{(kind === "character" ? [0, 1, 2, 3, 4, 5, 6] : [1, 2, 3, 4, 5, 6]).map((entry) => <SelectItem key={entry} value={String(entry)}>C{entry}</SelectItem>)}</SelectGroup></SelectContent></Select></Field> : null}
|
||||||
|
<Button type="button" disabled={!value} onClick={() => { if (value) { onAdd(value); setKey(""); } }}><PlusIcon data-icon="inline-start" />Add</Button>
|
||||||
|
</div>
|
||||||
|
</FieldGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddRecommendationSlot({ characters, weapons, artifacts, onAdd }: { characters: Character[]; weapons: CatalogItem[]; artifacts: CatalogItem[]; onAdd: (item: StygianRecommendationItemInput) => void }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger render={<Button variant="outline" className="aspect-[3/4] h-auto min-h-28 w-full border-dashed" />}><PlusIcon /><span className="sr-only">Add recommendation</span></PopoverTrigger>
|
||||||
|
<PopoverContent align="start" className="w-[min(44rem,calc(100vw-2rem))] p-4">
|
||||||
|
<PopoverHeader><PopoverTitle>Add recommendation</PopoverTitle><PopoverDescription>Choose a character or flexible recommendation. Weapon and artifact are optional.</PopoverDescription></PopoverHeader>
|
||||||
|
<ItemPicker characters={characters} weapons={weapons} artifacts={artifacts} onAdd={(item) => { onAdd(item); setOpen(false); }} />
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddCharacterSlot({ characters, onAdd }: { characters: Character[]; onAdd: (item: StygianRecommendationItemInput) => void }) {
|
||||||
|
return <CatalogSlotPicker items={characters} value="" label="Choose character" placeholder="Search character…" className="rounded-lg border-dashed" onChange={(catalogKey) => { if (catalogKey) onAdd({ kind: "character", catalogKey, weaponKey: null, artifactKey: null, constellationLevel: null }); }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VisualRecommendation({ item, onChange, onRemove, characters, weapons, artifacts, teamLayout = false }: { item: StygianRecommendationItemInput; onChange: (item: StygianRecommendationItemInput) => void; onRemove: () => void; characters: Character[]; weapons: CatalogItem[]; artifacts: CatalogItem[]; teamLayout?: boolean }) {
|
||||||
|
const character = item.kind === "character" ? characters.find((entry) => entry.key === item.catalogKey) : null;
|
||||||
|
const weapon = item.kind === "character" ? weapons.find((entry) => entry.key === item.weaponKey) : null;
|
||||||
|
const artifact = item.kind === "character" ? artifacts.find((entry) => entry.key === item.artifactKey) : null;
|
||||||
|
const standalone = item.kind === "weapon"
|
||||||
|
? weapons.find((entry) => entry.key === item.catalogKey)
|
||||||
|
: item.kind === "artifact"
|
||||||
|
? artifacts.find((entry) => entry.key === item.catalogKey)
|
||||||
|
: item.kind === "constellation"
|
||||||
|
? characters.find((entry) => entry.key === item.characterKey)
|
||||||
|
: null;
|
||||||
|
const updateCharacter = (patch: Partial<Extract<StygianRecommendationItemInput, { kind: "character" }>>) => {
|
||||||
|
if (item.kind === "character") onChange({ ...item, ...patch });
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="group relative min-w-0 overflow-hidden rounded-xl border bg-card p-1.5">
|
||||||
|
{character ? <>
|
||||||
|
<div className={cn("grid gap-1", teamLayout && "grid-cols-3")}>
|
||||||
|
<CatalogSlotPicker items={characters} value={character.key} label="Choose character" placeholder="Search character…" className={cn("rounded-lg border-0 [&>img]:p-0", teamLayout && "col-span-2 row-span-2")} onChange={(catalogKey) => { if (catalogKey) updateCharacter({ catalogKey, weaponKey: null, artifactKey: null, constellationLevel: null }); }} />
|
||||||
|
{teamLayout ? <div className="grid min-w-0 grid-cols-1 gap-1">
|
||||||
|
<CatalogSlotPicker items={weapons} value={weapon?.key ?? ""} label={`Choose weapon for ${character.name}`} placeholder="Search weapon…" onChange={(weaponKey) => updateCharacter({ weaponKey: weaponKey || null })} />
|
||||||
|
<CatalogSlotPicker items={artifacts} value={artifact?.key ?? ""} label={`Choose artifact for ${character.name}`} placeholder="Search artifact…" onChange={(artifactKey) => updateCharacter({ artifactKey: artifactKey || null })} />
|
||||||
|
</div> : <div className="col-span-full grid grid-cols-2 gap-1"><CatalogSlotPicker items={artifacts} value={artifact?.key ?? ""} label={`Choose artifact for ${character.name}`} placeholder="Search artifact…" onChange={(artifactKey) => updateCharacter({ artifactKey: artifactKey || null })} /><CatalogSlotPicker items={weapons} value={weapon?.key ?? ""} label={`Choose weapon for ${character.name}`} placeholder="Search weapon…" onChange={(weaponKey) => updateCharacter({ weaponKey: weaponKey || null })} /></div>}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex min-w-0 items-center justify-between gap-1">
|
||||||
|
<span className="truncate text-xs font-medium">{character.name}</span>
|
||||||
|
{teamLayout && item.kind === "character" ? <Select value={item.constellationLevel === null ? "none" : String(item.constellationLevel)} onValueChange={(next) => { if (next) updateCharacter({ constellationLevel: next === "none" ? null : Number(next) }); }}><SelectTrigger size="sm" className="h-6 w-auto min-w-12 px-1 text-xs"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="none">—</SelectItem>{[0, 1, 2, 3, 4, 5, 6].map((entry) => <SelectItem key={entry} value={String(entry)}>C{entry}</SelectItem>)}</SelectGroup></SelectContent></Select> : item.kind === "character" && item.constellationLevel !== null ? <Badge variant="secondary">C{item.constellationLevel}</Badge> : null}
|
||||||
|
</div>
|
||||||
|
</> : <div className="flex aspect-[3/4] flex-col items-center justify-center gap-2 rounded-lg bg-muted p-2 text-center">
|
||||||
|
{standalone ? <div className={`relative aspect-square w-full overflow-hidden rounded-lg ${rarityGradientClass(standalone.rarity)}`}><Image src={standalone.imageUrl} alt={standalone.name} fill sizes="128px" className="object-contain p-1" /></div> : null}
|
||||||
|
<span className="text-xs font-medium">{label(item, characters, weapons, artifacts)}</span>
|
||||||
|
</div>}
|
||||||
|
<Button variant="destructive" size="icon-xs" className="absolute top-2 left-2 opacity-0 group-hover:opacity-100 focus-visible:opacity-100" aria-label="Remove recommendation" onClick={onRemove}><Trash2Icon /></Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VisualRecommendationGrid({ items, limit, onChange, characters, weapons, artifacts, teamLayout = false, characterOnly = false }: { items: StygianRecommendationItemInput[]; limit?: number; onChange: (items: StygianRecommendationItemInput[]) => void; characters: Character[]; weapons: CatalogItem[]; artifacts: CatalogItem[]; teamLayout?: boolean; characterOnly?: boolean }) {
|
||||||
|
const emptyCount = limit ? Math.max(0, limit - items.length) : 1;
|
||||||
|
return <div className={cn("grid gap-2", teamLayout ? "grid-cols-[repeat(4,minmax(0,7.5rem))]" : "grid-cols-[repeat(auto-fill,minmax(6rem,7.5rem))]")}>{items.map((item, index) => <VisualRecommendation key={`${label(item, characters, weapons, artifacts)}-${index}`} item={item} teamLayout={teamLayout || characterOnly} characters={characters} weapons={weapons} artifacts={artifacts} onChange={(next) => onChange(items.map((entry, candidate) => candidate === index ? next : entry))} onRemove={() => onChange(items.filter((_, candidate) => candidate !== index))} />)}{Array.from({ length: emptyCount }, (_, index) => teamLayout || characterOnly ? <AddCharacterSlot key={`empty-${index}`} characters={characters} onAdd={(item) => onChange([...items, item])} /> : <AddRecommendationSlot key={`empty-${index}`} characters={characters} weapons={weapons} artifacts={artifacts} onAdd={(item) => onChange([...items, item])} />)}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BossRecommendationEditor({ scheduleId, version, level, boss, recommendations, characters, weapons, artifacts, onVersionChange }: {
|
||||||
|
scheduleId: number;
|
||||||
|
version: number;
|
||||||
|
level: 105 | 110;
|
||||||
|
boss: Boss;
|
||||||
|
recommendations: Recommendation[];
|
||||||
|
characters: Character[];
|
||||||
|
weapons: CatalogItem[];
|
||||||
|
artifacts: CatalogItem[];
|
||||||
|
onVersionChange: (version: number) => void;
|
||||||
|
}) {
|
||||||
|
const groups = recommendations.filter((group) => group.level === level && group.bossPosition === boss.position);
|
||||||
|
const [teams, setTeams] = useState<StygianRecommendationItemInput[][]>(() => groups.filter((group) => group.kind === "team").map((group) => group.items.flatMap((item) => toInput(item) ?? [])));
|
||||||
|
const [units, setUnits] = useState<StygianRecommendationItemInput[]>(() => groups.filter((group) => group.kind === "unit").flatMap((group) => group.items.flatMap((item) => toInput(item) ?? [])));
|
||||||
|
const fingerprint = JSON.stringify({ teams, units });
|
||||||
|
const valid = teams.every((team) => team.length >= 1 && team.length <= 4 && team.every((item) => item.kind === "character"));
|
||||||
|
const [savedFingerprint, setSavedFingerprint] = useState(fingerprint);
|
||||||
|
const [failedFingerprint, setFailedFingerprint] = useState<string | null>(null);
|
||||||
|
const [savingFingerprint, setSavingFingerprint] = useState<string | null>(null);
|
||||||
|
const saveState = !valid
|
||||||
|
? "incomplete"
|
||||||
|
: failedFingerprint === fingerprint
|
||||||
|
? "error"
|
||||||
|
: savingFingerprint !== null
|
||||||
|
? "saving"
|
||||||
|
: fingerprint === savedFingerprint
|
||||||
|
? "saved"
|
||||||
|
: "unsaved";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fingerprint === savedFingerprint || !valid || fingerprint === failedFingerprint || savingFingerprint !== null) return;
|
||||||
|
const timer = window.setTimeout(async () => {
|
||||||
|
setSavingFingerprint(fingerprint);
|
||||||
|
const snapshot = JSON.parse(fingerprint) as { teams: StygianRecommendationItemInput[][]; units: StygianRecommendationItemInput[] };
|
||||||
|
const result = await saveStygianRecommendationsAction(scheduleId, { expectedVersion: version, level, bossPosition: boss.position, ...snapshot });
|
||||||
|
if (result.status === "error") {
|
||||||
|
setFailedFingerprint(fingerprint);
|
||||||
|
setSavingFingerprint(null);
|
||||||
|
toast.error(result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSavedFingerprint(fingerprint);
|
||||||
|
setFailedFingerprint(null);
|
||||||
|
setSavingFingerprint(null);
|
||||||
|
onVersionChange(result.version);
|
||||||
|
}, 700);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [boss.position, failedFingerprint, fingerprint, level, onVersionChange, savedFingerprint, savingFingerprint, scheduleId, valid, version]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="grid gap-6 lg:grid-cols-[12rem_minmax(0,1fr)]">
|
||||||
|
<aside className="self-start">
|
||||||
|
<div className="relative aspect-square overflow-hidden rounded-xl bg-muted/30"><Image src={boss.imageUrl} alt={boss.name} fill sizes="192px" className="object-contain p-3" /></div>
|
||||||
|
<h3 className="mt-3 font-heading font-medium leading-snug">{boss.name}</h3>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">Level {level} recommendations</p>
|
||||||
|
</aside>
|
||||||
|
<div className="flex min-w-0 flex-col gap-6">
|
||||||
|
<FieldSet className="gap-3"><FieldLegend>REC TEAM</FieldLegend>
|
||||||
|
{teams.map((team, teamIndex) => <Card key={teamIndex} size="sm"><CardContent className="flex flex-col gap-2">
|
||||||
|
<VisualRecommendationGrid items={team} limit={4} teamLayout characters={characters} weapons={weapons} artifacts={artifacts} onChange={(items) => setTeams(teams.map((entry, index) => index === teamIndex ? items : entry).filter((entry) => entry.length))} />
|
||||||
|
<Button variant="outline" size="sm" className="self-start" onClick={() => setTeams(teams.filter((_, index) => index !== teamIndex))}><Trash2Icon data-icon="inline-start" />Remove team</Button>
|
||||||
|
</CardContent></Card>)}
|
||||||
|
<Button variant="outline" size="sm" className="self-start" onClick={() => setTeams([...teams, []])}><PlusIcon data-icon="inline-start" />Add team</Button>
|
||||||
|
</FieldSet>
|
||||||
|
<FieldSet className="gap-3"><FieldLegend>REC UNIT</FieldLegend><VisualRecommendationGrid items={units} characters={characters} weapons={weapons} artifacts={artifacts} onChange={setUnits} /></FieldSet>
|
||||||
|
<div className="flex justify-end"><AutosaveStatus state={saveState} issues={valid ? [] : ["Add at least 1 character to each team before autosave."]} onSave={() => setFailedFingerprint(null)} /></div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StygianEditor({ schedules, schedule, bosses, recommendations, characters, weapons, artifacts }: { schedules: ScheduleSummary[]; schedule: ScheduleSummary; bosses: Boss[]; recommendations: Recommendation[]; characters: Character[]; weapons: CatalogItem[]; artifacts: CatalogItem[] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [version, setVersion] = useState(schedule.version);
|
||||||
|
const [level, setLevel] = useState<105 | 110>(110);
|
||||||
|
const [bossPosition, setBossPosition] = useState(0);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<Card><CardHeader><CardTitle>Schedules</CardTitle><CardDescription>Sync imports every valid schedule from 5269001. Publishing remains manual.</CardDescription></CardHeader><CardContent className="flex flex-wrap gap-2">
|
||||||
|
{schedules.map((entry) => <Button key={entry.scheduleId} variant={entry.scheduleId === schedule.scheduleId ? "secondary" : "outline"} nativeButton={false} render={<Link href={`/admin/stygian?schedule=${entry.scheduleId}`} />}>{entry.challengeName}{entry.isActive ? " · Active" : ""}</Button>)}
|
||||||
|
<Button disabled={pending} onClick={() => startTransition(async () => { const result = await syncStygianArchiveAction(); if (result.status === "error") toast.error(result.message); else { toast.success(`Imported through ${result.imported.at(-1) ?? "none"}; stopped at ${result.stoppedAt}`); router.refresh(); } })}>{pending ? <Spinner data-icon="inline-start" /> : <RefreshCwIcon data-icon="inline-start" />}Sync archive</Button>
|
||||||
|
<Button disabled={pending || schedule.isActive} onClick={() => startTransition(async () => { const result = await activateStygianScheduleAction(schedule.scheduleId, version); if (result.status === "error") toast.error(result.message); else { setVersion(result.version); toast.success("Schedule activated"); router.refresh(); } })}><CheckIcon data-icon="inline-start" />{schedule.isActive ? "Active" : "Activate"}</Button>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
<Tabs value={String(level)} onValueChange={(next) => setLevel(Number(next) as 105 | 110)}>
|
||||||
|
<TabsList><TabsTrigger value="105">Level 105</TabsTrigger><TabsTrigger value="110">Level 110</TabsTrigger></TabsList>
|
||||||
|
{([105, 110] as const).map((entry) => <TabsContent key={entry} value={String(entry)}>
|
||||||
|
<Tabs value={String(bossPosition)} onValueChange={(next) => setBossPosition(Number(next))}>
|
||||||
|
<TabsList className="grid h-auto w-full grid-cols-3">
|
||||||
|
{bosses.filter((boss) => boss.level === entry).map((boss) => <TabsTrigger key={boss.position} value={String(boss.position)} className="min-w-0 py-2"><span className="truncate">{boss.name}</span></TabsTrigger>)}
|
||||||
|
</TabsList>
|
||||||
|
{bosses.filter((boss) => boss.level === entry).map((boss) => <TabsContent key={boss.position} value={String(boss.position)} className="pt-4">
|
||||||
|
<BossRecommendationEditor
|
||||||
|
scheduleId={schedule.scheduleId}
|
||||||
|
version={version}
|
||||||
|
level={entry}
|
||||||
|
boss={boss}
|
||||||
|
recommendations={recommendations}
|
||||||
|
characters={characters}
|
||||||
|
weapons={weapons}
|
||||||
|
artifacts={artifacts}
|
||||||
|
onVersionChange={setVersion}
|
||||||
|
/>
|
||||||
|
</TabsContent>)}
|
||||||
|
</Tabs>
|
||||||
|
</TabsContent>)}
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTransition } from "react";
|
||||||
|
import { RefreshCwIcon } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { syncStygianArchiveAction } from "@/app/admin/stygian/actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
|
||||||
|
export function StygianSyncButton() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
return (
|
||||||
|
<Button disabled={pending} onClick={() => startTransition(async () => {
|
||||||
|
const result = await syncStygianArchiveAction();
|
||||||
|
if (result.status === "error") toast.error(result.message);
|
||||||
|
else {
|
||||||
|
toast.success(`Imported through ${result.imported.at(-1) ?? "none"}; stopped at ${result.stoppedAt}`);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
})}>
|
||||||
|
{pending ? <Spinner data-icon="inline-start" /> : <RefreshCwIcon data-icon="inline-start" />}
|
||||||
|
Sync archive
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,6 +33,14 @@ export function SiteHeader({ editHref = "/admin", guideId }: { editHref?: string
|
|||||||
<span className="hidden sm:inline">ไกด์เกนชินไม่ใช่เกมมือถือ</span>
|
<span className="hidden sm:inline">ไกด์เกนชินไม่ใช่เกมมือถือ</span>
|
||||||
</Link>
|
</Link>
|
||||||
<nav aria-label="Public navigation" className="flex items-center gap-1">
|
<nav aria-label="Public navigation" className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
nativeButton={false}
|
||||||
|
render={<Link href="/stygian" />}
|
||||||
|
>
|
||||||
|
Stygian Guide
|
||||||
|
</Button>
|
||||||
<VisitorPresence guideId={guideId} />
|
<VisitorPresence guideId={guideId} />
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<AdminEditLink editHref={editHref} />
|
<AdminEditLink editHref={editHref} />
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { LunarisText } from "@/components/public/lunaris-text";
|
||||||
|
import { parseLunarisText } from "@/lib/catalog/lunaris";
|
||||||
|
import { rarityGradientClass } from "@/lib/catalog/rarity";
|
||||||
|
import type { getActiveCatalog } from "@/lib/guides/queries";
|
||||||
|
import type { getActiveStygianSchedule } from "@/lib/stygian/repository";
|
||||||
|
|
||||||
|
type Schedule = NonNullable<Awaited<ReturnType<typeof getActiveStygianSchedule>>>;
|
||||||
|
type Catalog = NonNullable<Awaited<ReturnType<typeof getActiveCatalog>>>;
|
||||||
|
type Item = Schedule["recommendations"][number]["items"][number];
|
||||||
|
|
||||||
|
function recommendation(item: Item, catalog: Catalog) {
|
||||||
|
if (item.kind === "element" && item.element) {
|
||||||
|
const source = catalog.characters.find((character) => character.element === item.element && character.elementIconUrl);
|
||||||
|
return { name: item.element, imageUrl: source?.elementIconUrl ?? null, badge: "Element" };
|
||||||
|
}
|
||||||
|
if (item.kind === "constellation" && item.characterKey && item.constellationLevel) {
|
||||||
|
const character = catalog.characters.find((candidate) => candidate.key === item.characterKey);
|
||||||
|
const constellation = character?.constellations.find((candidate) => candidate.level === item.constellationLevel);
|
||||||
|
return { name: `${character?.name ?? item.characterKey} C${item.constellationLevel}`, imageUrl: constellation?.iconUrl ?? character?.imageUrl ?? null, badge: "Constellation" };
|
||||||
|
}
|
||||||
|
if (item.kind === "character") {
|
||||||
|
const character = catalog.characters.find((candidate) => candidate.key === item.catalogKey);
|
||||||
|
const weapon = catalog.weapons.find((candidate) => candidate.key === item.weaponKey);
|
||||||
|
const artifact = catalog.artifacts.find((candidate) => candidate.key === item.artifactKey);
|
||||||
|
return {
|
||||||
|
name: character?.name ?? item.catalogKey ?? "Unknown",
|
||||||
|
imageUrl: character?.imageUrl ?? null,
|
||||||
|
badge: item.constellationLevel === null ? "Character" : `C${item.constellationLevel}`,
|
||||||
|
rarity: character?.rarity ?? null,
|
||||||
|
weapon,
|
||||||
|
artifact,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const collection = item.kind === "weapon" ? catalog.weapons : catalog.artifacts;
|
||||||
|
const value = collection.find((candidate) => candidate.key === item.catalogKey);
|
||||||
|
return { name: value?.name ?? item.catalogKey ?? "Unknown", imageUrl: value?.imageUrl ?? null, badge: item.kind, rarity: value?.rarity ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecommendationIcon({ item, catalog, teamLayout = false }: { item: Item; catalog: Catalog; teamLayout?: boolean }) {
|
||||||
|
const value = recommendation(item, catalog);
|
||||||
|
if (item.kind === "character") {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-1 rounded-lg border bg-card p-1">
|
||||||
|
<div className={teamLayout ? "grid grid-cols-3 gap-1" : "flex flex-col gap-1"}>
|
||||||
|
<div className={`${teamLayout ? "col-span-2 row-span-2" : ""} relative aspect-square overflow-hidden rounded-lg ${rarityGradientClass(value.rarity)}`}>
|
||||||
|
{value.imageUrl ? <Image src={value.imageUrl} alt={value.name} fill sizes="128px" className="object-contain" /> : null}
|
||||||
|
</div>
|
||||||
|
<div className={teamLayout ? "grid grid-cols-1 gap-1" : "grid grid-cols-2 gap-1"}>
|
||||||
|
<div className="relative aspect-square overflow-hidden rounded-md border bg-muted">
|
||||||
|
{value.weapon ? <Image src={value.weapon.imageUrl} alt={value.weapon.name} fill sizes="64px" className="object-contain p-1" /> : null}
|
||||||
|
</div>
|
||||||
|
<div className="relative aspect-square overflow-hidden rounded-md border bg-muted">
|
||||||
|
{value.artifact ? <Image src={value.artifact.imageUrl} alt={value.artifact.name} fill sizes="64px" className="object-contain p-1" /> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 items-center justify-between gap-1"><span className="truncate text-xs font-medium" title={value.name}>{value.name}</span><Badge variant="secondary">{value.badge}</Badge></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col items-center gap-1 text-center">
|
||||||
|
<div className="relative size-16 overflow-hidden rounded-lg border bg-muted">
|
||||||
|
{value.imageUrl ? <Image src={value.imageUrl} alt={value.name} fill sizes="64px" className="object-contain" /> : null}
|
||||||
|
</div>
|
||||||
|
<span className="max-w-20 truncate text-xs font-medium" title={value.name}>{value.name}</span>
|
||||||
|
<Badge variant="outline" className="capitalize">{value.badge}</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BossCard({ schedule, level, position, catalog }: { schedule: Schedule; level: 105 | 110; position: number; catalog: Catalog }) {
|
||||||
|
const boss = schedule.bosses.find((candidate) => candidate.level === level && candidate.position === position);
|
||||||
|
if (!boss) return null;
|
||||||
|
const groups = schedule.recommendations.filter((group) => group.level === level && group.bossPosition === position);
|
||||||
|
const teams = groups.filter((group) => group.kind === "team");
|
||||||
|
const units = groups.filter((group) => group.kind === "unit").flatMap((group) => group.items);
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="grid gap-6 md:grid-cols-[14rem_minmax(0,1fr)]">
|
||||||
|
<aside className="self-start">
|
||||||
|
<div className="relative aspect-square overflow-hidden rounded-xl bg-muted/30">
|
||||||
|
<Image src={boss.imageUrl} alt={boss.name} fill sizes="224px" className="object-contain p-3" priority={level === 110} />
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-3 font-heading text-lg font-semibold">{boss.name}</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">Level {level} · HP {Math.round(boss.hp).toLocaleString("en-US")}</p>
|
||||||
|
</aside>
|
||||||
|
<div className="flex min-w-0 flex-col gap-5">
|
||||||
|
<Accordion>
|
||||||
|
<AccordionItem value="overview">
|
||||||
|
<AccordionTrigger>Boss overview</AccordionTrigger>
|
||||||
|
<AccordionContent className="whitespace-pre-line text-muted-foreground">
|
||||||
|
<LunarisText nodes={parseLunarisText(boss.description)} fallback={boss.description} />
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
{boss.mechanics.map((mechanic, index) => (
|
||||||
|
<AccordionItem key={`${mechanic.name}-${index}`} value={`mechanic-${index}`}>
|
||||||
|
<AccordionTrigger>{mechanic.name}</AccordionTrigger>
|
||||||
|
<AccordionContent className="text-muted-foreground">
|
||||||
|
<LunarisText nodes={parseLunarisText(mechanic.description)} fallback={mechanic.description} />
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
<section className="flex flex-col gap-3" aria-label={`Recommended teams for ${boss.name}`}>
|
||||||
|
<h3 className="font-heading text-lg font-semibold">REC TEAM</h3>
|
||||||
|
{teams.length ? teams.map((team) => (
|
||||||
|
<div key={team.id} className="grid grid-cols-[repeat(4,minmax(0,7.5rem))] gap-2 rounded-lg border bg-muted/20 p-2">
|
||||||
|
{team.items.slice(0, 4).map((item) => <RecommendationIcon key={`${item.groupId}-${item.position}`} item={item} catalog={catalog} teamLayout />)}
|
||||||
|
{Array.from({ length: Math.max(0, 4 - team.items.length) }, (_, index) => <div key={`empty-${index}`} className="aspect-[3/4] rounded-lg border border-dashed bg-muted/30" aria-hidden="true" />)}
|
||||||
|
</div>
|
||||||
|
)) : <p className="text-sm text-muted-foreground">No team recommendations yet.</p>}
|
||||||
|
</section>
|
||||||
|
<section className="flex flex-col gap-3" aria-label={`Recommended units for ${boss.name}`}>
|
||||||
|
<h3 className="font-heading text-lg font-semibold">REC UNIT</h3>
|
||||||
|
{units.length ? <div className="grid grid-cols-[repeat(auto-fill,minmax(6rem,7.5rem))] gap-2">{units.map((item) => <RecommendationIcon key={`${item.groupId}-${item.position}`} item={item} catalog={catalog} />)}</div> : <p className="text-sm text-muted-foreground">No unit recommendations yet.</p>}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StygianGuide({ schedule, catalog }: { schedule: Schedule; catalog: Catalog }) {
|
||||||
|
return (
|
||||||
|
<Tabs defaultValue="110">
|
||||||
|
<TabsList aria-label="Stygian difficulty">
|
||||||
|
<TabsTrigger value="105">Level 105</TabsTrigger>
|
||||||
|
<TabsTrigger value="110">Level 110</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
{([105, 110] as const).map((level) => (
|
||||||
|
<TabsContent key={level} value={String(level)}>
|
||||||
|
<Tabs defaultValue="0">
|
||||||
|
<TabsList className="grid h-auto w-full grid-cols-3" aria-label={`Level ${level} bosses`}>
|
||||||
|
{[0, 1, 2].map((position) => {
|
||||||
|
const boss = schedule.bosses.find((candidate) => candidate.level === level && candidate.position === position);
|
||||||
|
return <TabsTrigger key={position} value={String(position)} className="min-w-0 py-2"><span className="truncate">{boss?.name ?? `Boss ${position + 1}`}</span></TabsTrigger>;
|
||||||
|
})}
|
||||||
|
</TabsList>
|
||||||
|
{[0, 1, 2].map((position) => <TabsContent key={position} value={String(position)} className="pt-4"><BossCard schedule={schedule} level={level} position={position} catalog={catalog} /></TabsContent>)}
|
||||||
|
</Tabs>
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
uuid,
|
uuid,
|
||||||
varchar,
|
varchar,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
CatalogAscensionGroup,
|
CatalogAscensionGroup,
|
||||||
@@ -274,6 +275,77 @@ export const catalogArtifacts = catalogSchema.table(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export interface StygianMechanic {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stygianSchedules = guidesSchema.table(
|
||||||
|
"stygian_schedule",
|
||||||
|
{
|
||||||
|
scheduleId: integer("schedule_id").primaryKey(),
|
||||||
|
challengeName: varchar("challenge_name", { length: 32 }).notNull(),
|
||||||
|
scheduleStartTime: timestamp("schedule_start_time", { withTimezone: true }).notNull(),
|
||||||
|
scheduleEndTime: timestamp("schedule_end_time", { withTimezone: true }).notNull(),
|
||||||
|
version: integer("version").default(1).notNull(),
|
||||||
|
isActive: boolean("is_active").default(false).notNull(),
|
||||||
|
activatedAt: timestamp("activated_at", { withTimezone: true }),
|
||||||
|
...timestamps,
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("stygian_schedule_active_idx").on(table.isActive),
|
||||||
|
uniqueIndex("stygian_schedule_single_active_idx").on(table.isActive).where(sql`${table.isActive} = true`),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const stygianBosses = guidesSchema.table(
|
||||||
|
"stygian_boss",
|
||||||
|
{
|
||||||
|
scheduleId: integer("schedule_id").notNull().references(() => stygianSchedules.scheduleId, { onDelete: "cascade" }),
|
||||||
|
level: integer("level").notNull(),
|
||||||
|
position: integer("position").notNull(),
|
||||||
|
configId: integer("config_id").notNull(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
hp: numeric("hp", { precision: 18, scale: 2 }).notNull(),
|
||||||
|
description: text("description").notNull(),
|
||||||
|
mechanics: jsonb("mechanics").$type<StygianMechanic[]>().default([]).notNull(),
|
||||||
|
imageKey: text("image_key").notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
primaryKey({ columns: [table.scheduleId, table.level, table.position] }),
|
||||||
|
index("stygian_boss_schedule_idx").on(table.scheduleId, table.level),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const stygianRecommendationGroups = guidesSchema.table(
|
||||||
|
"stygian_recommendation_group",
|
||||||
|
{
|
||||||
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
|
scheduleId: integer("schedule_id").notNull().references(() => stygianSchedules.scheduleId, { onDelete: "cascade" }),
|
||||||
|
level: integer("level").notNull(),
|
||||||
|
bossPosition: integer("boss_position").notNull(),
|
||||||
|
kind: varchar("kind", { length: 16 }).notNull(),
|
||||||
|
sortOrder: integer("sort_order").default(0).notNull(),
|
||||||
|
},
|
||||||
|
(table) => [index("stygian_recommendation_group_order_idx").on(table.scheduleId, table.level, table.bossPosition, table.kind, table.sortOrder)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const stygianRecommendationItems = guidesSchema.table(
|
||||||
|
"stygian_recommendation_item",
|
||||||
|
{
|
||||||
|
groupId: uuid("group_id").notNull().references(() => stygianRecommendationGroups.id, { onDelete: "cascade" }),
|
||||||
|
position: integer("position").notNull(),
|
||||||
|
kind: varchar("kind", { length: 24 }).notNull(),
|
||||||
|
catalogKey: varchar("catalog_key", { length: 64 }),
|
||||||
|
weaponKey: varchar("weapon_key", { length: 64 }),
|
||||||
|
artifactKey: varchar("artifact_key", { length: 64 }),
|
||||||
|
characterKey: varchar("character_key", { length: 64 }),
|
||||||
|
constellationLevel: integer("constellation_level"),
|
||||||
|
element: varchar("element", { length: 24 }),
|
||||||
|
},
|
||||||
|
(table) => [primaryKey({ columns: [table.groupId, table.position] })],
|
||||||
|
);
|
||||||
|
|
||||||
export const glossaryAliases = guidesSchema.table(
|
export const glossaryAliases = guidesSchema.table(
|
||||||
"glossary_alias",
|
"glossary_alias",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
CREATE TABLE "guides"."stygian_boss" (
|
||||||
|
"schedule_id" integer NOT NULL,
|
||||||
|
"level" integer NOT NULL,
|
||||||
|
"position" integer NOT NULL,
|
||||||
|
"config_id" integer NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"hp" numeric(18, 2) NOT NULL,
|
||||||
|
"description" text NOT NULL,
|
||||||
|
"mechanics" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
"image_key" text NOT NULL,
|
||||||
|
CONSTRAINT "stygian_boss_schedule_id_level_position_pk" PRIMARY KEY("schedule_id","level","position")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "guides"."stygian_recommendation_group" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"schedule_id" integer NOT NULL,
|
||||||
|
"level" integer NOT NULL,
|
||||||
|
"boss_position" integer NOT NULL,
|
||||||
|
"kind" varchar(16) NOT NULL,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "guides"."stygian_recommendation_item" (
|
||||||
|
"group_id" uuid NOT NULL,
|
||||||
|
"position" integer NOT NULL,
|
||||||
|
"kind" varchar(24) NOT NULL,
|
||||||
|
"catalog_key" varchar(64),
|
||||||
|
"character_key" varchar(64),
|
||||||
|
"constellation_level" integer,
|
||||||
|
"element" varchar(24),
|
||||||
|
CONSTRAINT "stygian_recommendation_item_group_id_position_pk" PRIMARY KEY("group_id","position")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "guides"."stygian_schedule" (
|
||||||
|
"schedule_id" integer PRIMARY KEY NOT NULL,
|
||||||
|
"challenge_name" varchar(32) NOT NULL,
|
||||||
|
"schedule_start_time" timestamp with time zone NOT NULL,
|
||||||
|
"schedule_end_time" timestamp with time zone NOT NULL,
|
||||||
|
"version" integer DEFAULT 1 NOT NULL,
|
||||||
|
"is_active" boolean DEFAULT false NOT NULL,
|
||||||
|
"activated_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "guides"."stygian_boss" ADD CONSTRAINT "stygian_boss_schedule_id_stygian_schedule_schedule_id_fk" FOREIGN KEY ("schedule_id") REFERENCES "guides"."stygian_schedule"("schedule_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "guides"."stygian_recommendation_group" ADD CONSTRAINT "stygian_recommendation_group_schedule_id_stygian_schedule_schedule_id_fk" FOREIGN KEY ("schedule_id") REFERENCES "guides"."stygian_schedule"("schedule_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "guides"."stygian_recommendation_item" ADD CONSTRAINT "stygian_recommendation_item_group_id_stygian_recommendation_group_id_fk" FOREIGN KEY ("group_id") REFERENCES "guides"."stygian_recommendation_group"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "stygian_boss_schedule_idx" ON "guides"."stygian_boss" USING btree ("schedule_id","level");--> statement-breakpoint
|
||||||
|
CREATE INDEX "stygian_recommendation_group_order_idx" ON "guides"."stygian_recommendation_group" USING btree ("schedule_id","level","boss_position","kind","sort_order");--> statement-breakpoint
|
||||||
|
CREATE INDEX "stygian_schedule_active_idx" ON "guides"."stygian_schedule" USING btree ("is_active");
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "guides"."stygian_recommendation_item" ADD COLUMN "weapon_key" varchar(64);--> statement-breakpoint
|
||||||
|
ALTER TABLE "guides"."stygian_recommendation_item" ADD COLUMN "artifact_key" varchar(64);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
CREATE UNIQUE INDEX "stygian_schedule_single_active_idx" ON "guides"."stygian_schedule" USING btree ("is_active") WHERE "guides"."stygian_schedule"."is_active" = true;
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -386,6 +386,27 @@
|
|||||||
"when": 1790503406247,
|
"when": 1790503406247,
|
||||||
"tag": "0054_open_changeling",
|
"tag": "0054_open_changeling",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 55,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1790688109314,
|
||||||
|
"tag": "0055_sour_micromax",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 56,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1790688285949,
|
||||||
|
"tag": "0056_burly_trish_tilby",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 57,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1790688461701,
|
||||||
|
"tag": "0057_dashing_zaran",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -34,6 +34,9 @@ export const AUDIT_ACTIONS = [
|
|||||||
"catalog_sync.unchanged",
|
"catalog_sync.unchanged",
|
||||||
"catalog_sync.cancelled",
|
"catalog_sync.cancelled",
|
||||||
"catalog_sync.failed",
|
"catalog_sync.failed",
|
||||||
|
"stygian.synced",
|
||||||
|
"stygian.recommendations.saved",
|
||||||
|
"stygian.activated",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const AUDIT_TARGET_TYPES = [
|
export const AUDIT_TARGET_TYPES = [
|
||||||
@@ -43,6 +46,7 @@ export const AUDIT_TARGET_TYPES = [
|
|||||||
"media",
|
"media",
|
||||||
"admin_account",
|
"admin_account",
|
||||||
"catalog_sync",
|
"catalog_sync",
|
||||||
|
"stygian_schedule",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
||||||
@@ -175,6 +179,7 @@ export const AUDIT_TARGET_LABELS: Record<
|
|||||||
media: "Media",
|
media: "Media",
|
||||||
admin_account: "Admin account",
|
admin_account: "Admin account",
|
||||||
catalog_sync: "Catalog sync",
|
catalog_sync: "Catalog sync",
|
||||||
|
stygian_schedule: "Stygian schedule",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AUDIT_ACTION_LABELS: Record<AuditAction, string> = {
|
export const AUDIT_ACTION_LABELS: Record<AuditAction, string> = {
|
||||||
@@ -205,6 +210,9 @@ export const AUDIT_ACTION_LABELS: Record<AuditAction, string> = {
|
|||||||
"catalog_sync.unchanged": "Catalog เป็นเวอร์ชันล่าสุด",
|
"catalog_sync.unchanged": "Catalog เป็นเวอร์ชันล่าสุด",
|
||||||
"catalog_sync.cancelled": "ยกเลิก Catalog sync แล้ว",
|
"catalog_sync.cancelled": "ยกเลิก Catalog sync แล้ว",
|
||||||
"catalog_sync.failed": "Catalog sync ล้มเหลว",
|
"catalog_sync.failed": "Catalog sync ล้มเหลว",
|
||||||
|
"stygian.synced": "Sync Stygian archive",
|
||||||
|
"stygian.recommendations.saved": "บันทึกคำแนะนำ Stygian",
|
||||||
|
"stygian.activated": "เผยแพร่ Stygian schedule",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isAuditTargetType(
|
export function isAuditTargetType(
|
||||||
|
|||||||
Vendored
+15
@@ -0,0 +1,15 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { cacheLife, cacheTag } from "next/cache";
|
||||||
|
|
||||||
|
import { getActiveStygianSchedule } from "@/lib/stygian/repository";
|
||||||
|
import { STYGIAN_CACHE_TAG } from "@/lib/cache/tags";
|
||||||
|
|
||||||
|
export { STYGIAN_CACHE_TAG };
|
||||||
|
|
||||||
|
export async function getCachedActiveStygianSchedule() {
|
||||||
|
"use cache: remote";
|
||||||
|
cacheLife({ stale: 30, revalidate: 60, expire: 60 * 60 });
|
||||||
|
cacheTag(STYGIAN_CACHE_TAG);
|
||||||
|
return getActiveStygianSchedule();
|
||||||
|
}
|
||||||
Vendored
+2
@@ -3,12 +3,14 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
cacheTagsForOutboxEvent,
|
cacheTagsForOutboxEvent,
|
||||||
DIRECTORY_CACHE_TAG,
|
DIRECTORY_CACHE_TAG,
|
||||||
|
STYGIAN_CACHE_TAG,
|
||||||
} from "./tags";
|
} from "./tags";
|
||||||
|
|
||||||
describe("public cache tags", () => {
|
describe("public cache tags", () => {
|
||||||
it("maps outbox events to dependency-aware tags", () => {
|
it("maps outbox events to dependency-aware tags", () => {
|
||||||
expect(cacheTagsForOutboxEvent({ eventType: "directory.updated", aggregateId: "id" })).toEqual([DIRECTORY_CACHE_TAG]);
|
expect(cacheTagsForOutboxEvent({ eventType: "directory.updated", aggregateId: "id" })).toEqual([DIRECTORY_CACHE_TAG]);
|
||||||
expect(cacheTagsForOutboxEvent({ eventType: "page.updated", aggregateId: "page" })).toEqual(["buzz:page:page"]);
|
expect(cacheTagsForOutboxEvent({ eventType: "page.updated", aggregateId: "page" })).toEqual(["buzz:page:page"]);
|
||||||
|
expect(cacheTagsForOutboxEvent({ eventType: "stygian.updated", aggregateId: "5269001" })).toEqual([STYGIAN_CACHE_TAG]);
|
||||||
expect(cacheTagsForOutboxEvent({ eventType: "admin.updated", aggregateId: "id" })).toEqual([]);
|
expect(cacheTagsForOutboxEvent({ eventType: "admin.updated", aggregateId: "id" })).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Vendored
+2
@@ -1,5 +1,6 @@
|
|||||||
export const DIRECTORY_CACHE_TAG = "buzz:directory";
|
export const DIRECTORY_CACHE_TAG = "buzz:directory";
|
||||||
export const GLOSSARY_CACHE_TAG = "buzz:glossary";
|
export const GLOSSARY_CACHE_TAG = "buzz:glossary";
|
||||||
|
export const STYGIAN_CACHE_TAG = "buzz:stygian";
|
||||||
|
|
||||||
export function pageCacheTag(pageId: string): string {
|
export function pageCacheTag(pageId: string): string {
|
||||||
return `buzz:page:${pageId}`;
|
return `buzz:page:${pageId}`;
|
||||||
@@ -13,5 +14,6 @@ export type OutboxCacheEvent = {
|
|||||||
export function cacheTagsForOutboxEvent(event: OutboxCacheEvent): string[] {
|
export function cacheTagsForOutboxEvent(event: OutboxCacheEvent): string[] {
|
||||||
if (event.eventType === "directory.updated") return [DIRECTORY_CACHE_TAG];
|
if (event.eventType === "directory.updated") return [DIRECTORY_CACHE_TAG];
|
||||||
if (event.eventType === "page.updated") return [pageCacheTag(event.aggregateId)];
|
if (event.eventType === "page.updated") return [pageCacheTag(event.aggregateId)];
|
||||||
|
if (event.eventType === "stygian.updated") return [STYGIAN_CACHE_TAG];
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,15 @@ describe("invalidation event envelopes", () => {
|
|||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts public Stygian invalidations only on the Stygian topic", () => {
|
||||||
|
expect(toInvalidationEvent({
|
||||||
|
topic: "stygian",
|
||||||
|
aggregateId: "5269001",
|
||||||
|
eventType: "stygian.updated",
|
||||||
|
payload: { id: "5269001", version: 4 },
|
||||||
|
})).toEqual({ type: "stygian.updated", id: "5269001", version: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps valid scopes only on authenticated admin events", () => {
|
it("keeps valid scopes only on authenticated admin events", () => {
|
||||||
const id = "4afeea7b-6f24-43f3-b747-2db98335e01e";
|
const id = "4afeea7b-6f24-43f3-b747-2db98335e01e";
|
||||||
expect(toInvalidationEvent({
|
expect(toInvalidationEvent({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export const INVALIDATION_TYPES = [
|
export const INVALIDATION_TYPES = [
|
||||||
"directory.updated",
|
"directory.updated",
|
||||||
"page.updated",
|
"page.updated",
|
||||||
|
"stygian.updated",
|
||||||
"admin.updated",
|
"admin.updated",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -59,6 +60,8 @@ export function expectedTopic(event: InvalidationEvent): string {
|
|||||||
return "directory";
|
return "directory";
|
||||||
case "page.updated":
|
case "page.updated":
|
||||||
return `page:${event.id}`;
|
return `page:${event.id}`;
|
||||||
|
case "stygian.updated":
|
||||||
|
return "stygian";
|
||||||
case "admin.updated":
|
case "admin.updated":
|
||||||
return "admin";
|
return "admin";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { and, asc, desc, eq, sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
import { getDb } from "@/db";
|
||||||
|
import {
|
||||||
|
stygianBosses,
|
||||||
|
stygianRecommendationGroups,
|
||||||
|
stygianRecommendationItems,
|
||||||
|
stygianSchedules,
|
||||||
|
outboxEvents,
|
||||||
|
} from "@/db/schema";
|
||||||
|
import { getMediaStorage, publicMediaUrl } from "@/lib/media/storage";
|
||||||
|
import {
|
||||||
|
lunarisStygianScheduleSchema,
|
||||||
|
selectedStygianBosses,
|
||||||
|
stygianImageFileName,
|
||||||
|
stygianRecommendationsSchema,
|
||||||
|
type StygianRecommendationItemInput,
|
||||||
|
} from "@/lib/stygian/schema";
|
||||||
|
|
||||||
|
const SCHEDULE_ROOT = "https://lunaris.moe/data/leylinechallenge";
|
||||||
|
const ASSET_ROOT = "https://api.lunaris.moe/data/assets/leyline";
|
||||||
|
const FIRST_SCHEDULE_ID = 5269001;
|
||||||
|
|
||||||
|
function stygianAggregateId(scheduleId: number) {
|
||||||
|
return `00000000-0000-4000-8000-${String(scheduleId).padStart(12, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lunarisDate(value: string): Date {
|
||||||
|
const result = new Date(`${value.replace(" ", "T")}+08:00`);
|
||||||
|
if (Number.isNaN(result.valueOf())) throw new Error(`Invalid Lunaris date: ${value}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncStygianArchive() {
|
||||||
|
const db = getDb();
|
||||||
|
const storage = await getMediaStorage();
|
||||||
|
const imported: number[] = [];
|
||||||
|
let stoppedAt = FIRST_SCHEDULE_ID;
|
||||||
|
|
||||||
|
for (let scheduleId = FIRST_SCHEDULE_ID; ; scheduleId += 1) {
|
||||||
|
stoppedAt = scheduleId;
|
||||||
|
const response = await fetch(`${SCHEDULE_ROOT}/${scheduleId}.json`, { cache: "no-store", signal: AbortSignal.timeout(30_000) });
|
||||||
|
if (response.status === 404) break;
|
||||||
|
if (response.status >= 500) throw new Error(`Lunaris returned ${response.status} for schedule ${scheduleId}.`);
|
||||||
|
if (!response.ok) throw new Error(`Lunaris returned ${response.status} for schedule ${scheduleId}.`);
|
||||||
|
if (!response.headers.get("content-type")?.includes("application/json")) break;
|
||||||
|
|
||||||
|
let json: unknown;
|
||||||
|
try {
|
||||||
|
json = await response.json();
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const parsed = lunarisStygianScheduleSchema.safeParse(json);
|
||||||
|
if (!parsed.success || parsed.data.scheduleId !== scheduleId) break;
|
||||||
|
|
||||||
|
let bosses: ReturnType<typeof selectedStygianBosses>;
|
||||||
|
try {
|
||||||
|
bosses = selectedStygianBosses(parsed.data);
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageKeyByName = new Map<string, string>();
|
||||||
|
for (const boss of bosses) {
|
||||||
|
if (imageKeyByName.has(boss.imageName)) continue;
|
||||||
|
const imageFileName = stygianImageFileName(boss.imageName);
|
||||||
|
const imageKey = `catalog/stygian/${scheduleId}/${imageFileName}`;
|
||||||
|
const file = storage.file(imageKey);
|
||||||
|
if (!(await file.exists())) {
|
||||||
|
const image = await fetch(`${ASSET_ROOT}/${encodeURIComponent(imageFileName)}`, { signal: AbortSignal.timeout(30_000) });
|
||||||
|
if (!image.ok || !image.headers.get("content-type")?.startsWith("image/")) {
|
||||||
|
throw new Error(`Unable to download artwork for ${boss.name}.`);
|
||||||
|
}
|
||||||
|
await storage.write(imageKey, image, { type: "image/png" });
|
||||||
|
}
|
||||||
|
imageKeyByName.set(boss.imageName, imageKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx.insert(stygianSchedules).values({
|
||||||
|
scheduleId,
|
||||||
|
challengeName: parsed.data.challengeName,
|
||||||
|
scheduleStartTime: lunarisDate(parsed.data.scheduleStartTime),
|
||||||
|
scheduleEndTime: lunarisDate(parsed.data.scheduleEndTime),
|
||||||
|
}).onConflictDoUpdate({
|
||||||
|
target: stygianSchedules.scheduleId,
|
||||||
|
set: {
|
||||||
|
challengeName: parsed.data.challengeName,
|
||||||
|
scheduleStartTime: lunarisDate(parsed.data.scheduleStartTime),
|
||||||
|
scheduleEndTime: lunarisDate(parsed.data.scheduleEndTime),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const boss of bosses) {
|
||||||
|
await tx.insert(stygianBosses).values({
|
||||||
|
scheduleId,
|
||||||
|
level: boss.level,
|
||||||
|
position: boss.position,
|
||||||
|
configId: boss.configId,
|
||||||
|
name: boss.name,
|
||||||
|
hp: String(boss.hp),
|
||||||
|
description: boss.description,
|
||||||
|
mechanics: boss.mechanics,
|
||||||
|
imageKey: imageKeyByName.get(boss.imageName)!,
|
||||||
|
}).onConflictDoUpdate({
|
||||||
|
target: [stygianBosses.scheduleId, stygianBosses.level, stygianBosses.position],
|
||||||
|
set: {
|
||||||
|
configId: boss.configId,
|
||||||
|
name: boss.name,
|
||||||
|
hp: String(boss.hp),
|
||||||
|
description: boss.description,
|
||||||
|
mechanics: boss.mechanics,
|
||||||
|
imageKey: imageKeyByName.get(boss.imageName)!,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
imported.push(scheduleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { imported, stoppedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listStygianSchedules() {
|
||||||
|
return getDb().select().from(stygianSchedules).orderBy(desc(stygianSchedules.scheduleId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStygianSchedule(scheduleId: number) {
|
||||||
|
const db = getDb();
|
||||||
|
const [schedule] = await db.select().from(stygianSchedules).where(eq(stygianSchedules.scheduleId, scheduleId)).limit(1);
|
||||||
|
if (!schedule) return null;
|
||||||
|
const [bosses, groups, items] = await Promise.all([
|
||||||
|
db.select().from(stygianBosses).where(eq(stygianBosses.scheduleId, scheduleId)).orderBy(asc(stygianBosses.level), asc(stygianBosses.position)),
|
||||||
|
db.select().from(stygianRecommendationGroups).where(eq(stygianRecommendationGroups.scheduleId, scheduleId)).orderBy(asc(stygianRecommendationGroups.sortOrder)),
|
||||||
|
db.select({ item: stygianRecommendationItems, group: stygianRecommendationGroups })
|
||||||
|
.from(stygianRecommendationItems)
|
||||||
|
.innerJoin(stygianRecommendationGroups, eq(stygianRecommendationItems.groupId, stygianRecommendationGroups.id))
|
||||||
|
.where(eq(stygianRecommendationGroups.scheduleId, scheduleId))
|
||||||
|
.orderBy(asc(stygianRecommendationItems.position)),
|
||||||
|
]);
|
||||||
|
const itemsByGroup = new Map<string, typeof stygianRecommendationItems.$inferSelect[]>();
|
||||||
|
for (const row of items) {
|
||||||
|
const bucket = itemsByGroup.get(row.group.id) ?? [];
|
||||||
|
bucket.push(row.item);
|
||||||
|
itemsByGroup.set(row.group.id, bucket);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...schedule,
|
||||||
|
bosses: bosses.map((boss) => ({ ...boss, hp: Number(boss.hp), imageUrl: publicMediaUrl(boss.imageKey) })),
|
||||||
|
recommendations: groups.map((group) => ({ ...group, items: itemsByGroup.get(group.id) ?? [] })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveStygianSchedule() {
|
||||||
|
const [active] = await getDb().select({ scheduleId: stygianSchedules.scheduleId }).from(stygianSchedules)
|
||||||
|
.where(eq(stygianSchedules.isActive, true)).orderBy(desc(stygianSchedules.activatedAt)).limit(1);
|
||||||
|
return active ? getStygianSchedule(active.scheduleId) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemValues(groupId: string, position: number, item: StygianRecommendationItemInput) {
|
||||||
|
return {
|
||||||
|
groupId,
|
||||||
|
position,
|
||||||
|
kind: item.kind,
|
||||||
|
catalogKey: item.kind === "character" || item.kind === "weapon" || item.kind === "artifact" ? item.catalogKey : null,
|
||||||
|
weaponKey: item.kind === "character" ? item.weaponKey : null,
|
||||||
|
artifactKey: item.kind === "character" ? item.artifactKey : null,
|
||||||
|
characterKey: item.kind === "constellation" ? item.characterKey : null,
|
||||||
|
constellationLevel: item.kind === "constellation" || item.kind === "character" ? item.constellationLevel : null,
|
||||||
|
element: item.kind === "element" ? item.element : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveStygianRecommendations(scheduleId: number, input: unknown) {
|
||||||
|
const value = stygianRecommendationsSchema.parse(input);
|
||||||
|
return getDb().transaction(async (tx) => {
|
||||||
|
const [updated] = await tx.update(stygianSchedules).set({ version: sql`${stygianSchedules.version} + 1`, updatedAt: new Date() })
|
||||||
|
.where(and(eq(stygianSchedules.scheduleId, scheduleId), eq(stygianSchedules.version, value.expectedVersion)))
|
||||||
|
.returning({ version: stygianSchedules.version });
|
||||||
|
if (!updated) throw new Error("ข้อมูลถูกแก้ไขจากหน้าอื่น กรุณารีเฟรชแล้วลองอีกครั้ง");
|
||||||
|
const oldGroups = await tx.select({ id: stygianRecommendationGroups.id }).from(stygianRecommendationGroups).where(and(
|
||||||
|
eq(stygianRecommendationGroups.scheduleId, scheduleId),
|
||||||
|
eq(stygianRecommendationGroups.level, value.level),
|
||||||
|
eq(stygianRecommendationGroups.bossPosition, value.bossPosition),
|
||||||
|
));
|
||||||
|
for (const group of oldGroups) await tx.delete(stygianRecommendationGroups).where(eq(stygianRecommendationGroups.id, group.id));
|
||||||
|
|
||||||
|
const groups = [
|
||||||
|
...value.teams.map((items) => ({ kind: "team", items })),
|
||||||
|
...value.units.map((item) => ({ kind: "unit", items: [item] })),
|
||||||
|
];
|
||||||
|
for (const [sortOrder, group] of groups.entries()) {
|
||||||
|
const [created] = await tx.insert(stygianRecommendationGroups).values({
|
||||||
|
scheduleId,
|
||||||
|
level: value.level,
|
||||||
|
bossPosition: value.bossPosition,
|
||||||
|
kind: group.kind,
|
||||||
|
sortOrder,
|
||||||
|
}).returning({ id: stygianRecommendationGroups.id });
|
||||||
|
await tx.insert(stygianRecommendationItems).values(group.items.map((item, position) => itemValues(created.id, position, item)));
|
||||||
|
}
|
||||||
|
const aggregateId = stygianAggregateId(scheduleId);
|
||||||
|
await tx.insert(outboxEvents).values({ topic: "stygian", aggregateId, eventType: "stygian.updated", payload: { id: aggregateId, version: updated.version, scheduleId } });
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activateStygianSchedule(scheduleId: number, expectedVersion: number) {
|
||||||
|
return getDb().transaction(async (tx) => {
|
||||||
|
const [target] = await tx.select({ scheduleId: stygianSchedules.scheduleId }).from(stygianSchedules).where(and(
|
||||||
|
eq(stygianSchedules.scheduleId, scheduleId),
|
||||||
|
eq(stygianSchedules.version, expectedVersion),
|
||||||
|
)).limit(1);
|
||||||
|
if (!target) throw new Error("ข้อมูลถูกแก้ไขจากหน้าอื่น กรุณารีเฟรชแล้วลองอีกครั้ง");
|
||||||
|
await tx.update(stygianSchedules).set({ isActive: false, activatedAt: null }).where(eq(stygianSchedules.isActive, true));
|
||||||
|
const [updated] = await tx.update(stygianSchedules).set({
|
||||||
|
isActive: true,
|
||||||
|
activatedAt: new Date(),
|
||||||
|
version: sql`${stygianSchedules.version} + 1`,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}).where(eq(stygianSchedules.scheduleId, scheduleId)).returning({ version: stygianSchedules.version });
|
||||||
|
const aggregateId = stygianAggregateId(scheduleId);
|
||||||
|
await tx.insert(outboxEvents).values({ topic: "stygian", aggregateId, eventType: "stygian.updated", payload: { id: aggregateId, version: updated.version, scheduleId } });
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
lunarisStygianScheduleSchema,
|
||||||
|
selectedStygianBosses,
|
||||||
|
stygianImageFileName,
|
||||||
|
stygianImageName,
|
||||||
|
stygianRecommendationsSchema,
|
||||||
|
} from "@/lib/stygian/schema";
|
||||||
|
|
||||||
|
function level(monsterLevel: number) {
|
||||||
|
return {
|
||||||
|
monsterLevel,
|
||||||
|
levelConfigs: Array.from({ length: 3 }, (_, index) => ({
|
||||||
|
id: monsterLevel * 10 + index,
|
||||||
|
specialMonsterIcon: `UI_MonsterIcon_Boss_${index}`,
|
||||||
|
monsterStats: { hp: 1234.56 + index },
|
||||||
|
enLevelName: `Boss ${index}`,
|
||||||
|
enMonsterSpecialDesc: `Description ${index}`,
|
||||||
|
enMonsterSpecialMechanicName: ["Mechanic", "", "Missing description"],
|
||||||
|
enLevelMaxDescription: ["Do the thing", "Ignored", ""],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedule = {
|
||||||
|
scheduleId: 5269001,
|
||||||
|
challengeName: "5.7",
|
||||||
|
scheduleStartTime: "2025-06-25 10:00:00",
|
||||||
|
scheduleEndTime: "2025-08-06 09:59:59",
|
||||||
|
levels: [level(40), level(105), level(110)],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Stygian Lunaris parsing", () => {
|
||||||
|
it("selects exactly the three level 105 and 110 bosses", () => {
|
||||||
|
const parsed = lunarisStygianScheduleSchema.parse(schedule);
|
||||||
|
const bosses = selectedStygianBosses(parsed);
|
||||||
|
expect(bosses).toHaveLength(6);
|
||||||
|
expect(bosses.map((boss) => boss.level)).toEqual([105, 105, 105, 110, 110, 110]);
|
||||||
|
expect(bosses[0]).toMatchObject({
|
||||||
|
name: "Boss 0",
|
||||||
|
hp: 1234.56,
|
||||||
|
mechanics: [{ name: "Mechanic", description: "Do the thing" }],
|
||||||
|
imageName: "UI_Img_LeyLineChallenge_Boss_0",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a schedule missing one of the three required bosses", () => {
|
||||||
|
const parsed = lunarisStygianScheduleSchema.parse({
|
||||||
|
...schedule,
|
||||||
|
levels: [level(105), { ...level(110), levelConfigs: level(110).levelConfigs.slice(0, 2) }],
|
||||||
|
});
|
||||||
|
expect(() => selectedStygianBosses(parsed)).toThrow("exactly three level 110 bosses");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves existing Ley Line image names", () => {
|
||||||
|
expect(stygianImageName("UI_Img_LeyLineChallenge_Gastrobot01")).toBe("UI_Img_LeyLineChallenge_Gastrobot01");
|
||||||
|
expect(stygianImageName("UI_Img_LeyLineChallenge_Gastrobot01.png")).toBe("UI_Img_LeyLineChallenge_Gastrobot01");
|
||||||
|
expect(stygianImageFileName("UI_Img_LeyLineChallenge_Gastrobot01")).toBe("UI_Img_LeyLineChallenge_Gastrobot01.png");
|
||||||
|
expect(stygianImageFileName("UI_Img_LeyLineChallenge_Gastrobot01.png")).toBe("UI_Img_LeyLineChallenge_Gastrobot01.png");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Stygian recommendation validation", () => {
|
||||||
|
const base = { expectedVersion: 1, level: 110, bossPosition: 0, units: [] };
|
||||||
|
|
||||||
|
it("accepts teams containing one to four characters", () => {
|
||||||
|
expect(stygianRecommendationsSchema.safeParse({
|
||||||
|
...base,
|
||||||
|
teams: [[
|
||||||
|
{ kind: "character", catalogKey: "10000001", weaponKey: null, artifactKey: null, constellationLevel: null },
|
||||||
|
{ kind: "character", catalogKey: "10000002", weaponKey: "WEAPON", artifactKey: null, constellationLevel: 2 },
|
||||||
|
{ kind: "character", catalogKey: "10000003", weaponKey: null, artifactKey: "ARTIFACT", constellationLevel: 0 },
|
||||||
|
{ kind: "character", catalogKey: "10000004", weaponKey: null, artifactKey: null, constellationLevel: 6 },
|
||||||
|
]],
|
||||||
|
}).success).toBe(true);
|
||||||
|
expect(stygianRecommendationsSchema.safeParse({
|
||||||
|
...base,
|
||||||
|
teams: [[{ kind: "character", catalogKey: "10000001", weaponKey: null, artifactKey: null, constellationLevel: null }]],
|
||||||
|
}).success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty, non-character, oversized, or duplicate teams", () => {
|
||||||
|
const character = { kind: "character", catalogKey: "10000001", weaponKey: null, artifactKey: null, constellationLevel: null };
|
||||||
|
expect(stygianRecommendationsSchema.safeParse({ ...base, teams: [[]] }).success).toBe(false);
|
||||||
|
expect(stygianRecommendationsSchema.safeParse({ ...base, teams: [[character, character]] }).success).toBe(false);
|
||||||
|
expect(stygianRecommendationsSchema.safeParse({ ...base, teams: [[character, ...Array.from({ length: 4 }, (_, index) => ({ kind: "weapon", catalogKey: `W${index}` }))]] }).success).toBe(false);
|
||||||
|
expect(stygianRecommendationsSchema.safeParse({ ...base, teams: [[character, { kind: "element", element: "Cryo" }, character, character]] }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows an unrestricted number of team rows and flexible units", () => {
|
||||||
|
const teams = Array.from({ length: 100 }, (_, index) => Array.from({ length: 4 }, (_, member) => ({ kind: "character", catalogKey: `C${index}-${member}`, weaponKey: null, artifactKey: null, constellationLevel: null })));
|
||||||
|
const units = Array.from({ length: 100 }, (_, index) => ({ kind: "artifact", catalogKey: `A${index}` }));
|
||||||
|
expect(stygianRecommendationsSchema.safeParse({ ...base, teams, units }).success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import * as z from "zod";
|
||||||
|
|
||||||
|
export const stygianLevels = [105, 110] as const;
|
||||||
|
export const stygianElements = ["Anemo", "Geo", "Electro", "Dendro", "Hydro", "Pyro", "Cryo"] as const;
|
||||||
|
|
||||||
|
const levelConfigSchema = z.object({
|
||||||
|
id: z.number().int(),
|
||||||
|
specialMonsterIcon: z.string().min(1),
|
||||||
|
monsterStats: z.object({ hp: z.number().finite().positive() }),
|
||||||
|
enLevelName: z.string().min(1),
|
||||||
|
enMonsterSpecialDesc: z.string().default(""),
|
||||||
|
enMonsterSpecialMechanicName: z.array(z.string()),
|
||||||
|
enLevelMaxDescription: z.array(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const lunarisStygianScheduleSchema = z.object({
|
||||||
|
scheduleId: z.number().int().positive(),
|
||||||
|
challengeName: z.string().min(1),
|
||||||
|
scheduleStartTime: z.string().min(1),
|
||||||
|
scheduleEndTime: z.string().min(1),
|
||||||
|
levels: z.array(z.object({
|
||||||
|
monsterLevel: z.number().int(),
|
||||||
|
levelConfigs: z.array(levelConfigSchema),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const characterRecommendationSchema = z.strictObject({
|
||||||
|
kind: z.literal("character"),
|
||||||
|
catalogKey: z.string().min(1).max(64),
|
||||||
|
weaponKey: z.string().min(1).max(64).nullable().default(null),
|
||||||
|
artifactKey: z.string().min(1).max(64).nullable().default(null),
|
||||||
|
constellationLevel: z.number().int().min(0).max(6).nullable().default(null),
|
||||||
|
});
|
||||||
|
|
||||||
|
const catalogItemSchema = z.discriminatedUnion("kind", [
|
||||||
|
characterRecommendationSchema,
|
||||||
|
z.strictObject({ kind: z.literal("weapon"), catalogKey: z.string().min(1).max(64) }),
|
||||||
|
z.strictObject({ kind: z.literal("artifact"), catalogKey: z.string().min(1).max(64) }),
|
||||||
|
z.strictObject({ kind: z.literal("element"), element: z.enum(stygianElements) }),
|
||||||
|
z.strictObject({
|
||||||
|
kind: z.literal("constellation"),
|
||||||
|
characterKey: z.string().min(1).max(64),
|
||||||
|
constellationLevel: z.number().int().min(1).max(6),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
function recommendationKey(item: z.infer<typeof catalogItemSchema>) {
|
||||||
|
if (item.kind === "element") return `${item.kind}:${item.element}`;
|
||||||
|
if (item.kind === "constellation") return `${item.kind}:${item.characterKey}:${item.constellationLevel}`;
|
||||||
|
return `${item.kind}:${item.catalogKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamSchema = z.array(characterRecommendationSchema).min(1).max(4).refine(
|
||||||
|
(items) => new Set(items.map(recommendationKey)).size === items.length,
|
||||||
|
"รายการในทีมต้องไม่ซ้ำกัน",
|
||||||
|
);
|
||||||
|
|
||||||
|
export const stygianRecommendationsSchema = z.strictObject({
|
||||||
|
expectedVersion: z.number().int().positive(),
|
||||||
|
level: z.union([z.literal(105), z.literal(110)]),
|
||||||
|
bossPosition: z.number().int().min(0).max(2),
|
||||||
|
teams: z.array(teamSchema),
|
||||||
|
units: z.array(catalogItemSchema).refine(
|
||||||
|
(items) => new Set(items.map(recommendationKey)).size === items.length,
|
||||||
|
"รายการตัวแนะนำต้องไม่ซ้ำกัน",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type StygianRecommendationItemInput = z.infer<typeof catalogItemSchema>;
|
||||||
|
export type StygianRecommendationsInput = z.infer<typeof stygianRecommendationsSchema>;
|
||||||
|
|
||||||
|
export function stygianImageName(icon: string): string {
|
||||||
|
return icon
|
||||||
|
.replace(/\.png$/iu, "")
|
||||||
|
.replace(/^UI_MonsterIcon_/u, "UI_Img_LeyLineChallenge_");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stygianImageFileName(icon: string): string {
|
||||||
|
return `${stygianImageName(icon)}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectedStygianBosses(value: z.infer<typeof lunarisStygianScheduleSchema>) {
|
||||||
|
return stygianLevels.flatMap((level) => {
|
||||||
|
const source = value.levels.find((entry) => entry.monsterLevel === level);
|
||||||
|
if (!source || source.levelConfigs.length !== 3) {
|
||||||
|
throw new Error(`Stygian schedule ${value.scheduleId} must contain exactly three level ${level} bosses.`);
|
||||||
|
}
|
||||||
|
return source.levelConfigs.map((boss, position) => ({
|
||||||
|
level,
|
||||||
|
position,
|
||||||
|
configId: boss.id,
|
||||||
|
name: boss.enLevelName,
|
||||||
|
hp: boss.monsterStats.hp,
|
||||||
|
description: boss.enMonsterSpecialDesc,
|
||||||
|
imageName: stygianImageName(boss.specialMonsterIcon),
|
||||||
|
mechanics: boss.enMonsterSpecialMechanicName.flatMap((name, index) => {
|
||||||
|
const description = boss.enLevelMaxDescription[index]?.trim();
|
||||||
|
return name.trim() && description ? [{ name: name.trim(), description }] : [];
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user