import * as z from "zod"; export interface CatalogConstellation { level: number; name: string; description: string; descriptionNodes?: CatalogTextNode[]; iconKey: string | null; } export interface CatalogTextDetail { name: string; description: string; } export type CatalogTextNode = | { type: "text"; value: string } | { type: "color"; value: string; children: CatalogTextNode[] } | { type: "link"; target: string; detail: CatalogTextDetail | null; children: CatalogTextNode[]; }; export interface CatalogTalent { type: "NA" | "E" | "Q"; name: string; description: string; iconKey: string | null; } export interface CatalogAscensionMaterial { key: string; amount: number; } export interface CatalogAscensionGroup { type: string; items: CatalogAscensionMaterial[]; } export type CatalogLevelingGroup = CatalogAscensionGroup; export interface CatalogMaterial { key: string; name: string; description: string | null; rarity: number | null; type: string | null; imageKey: string; } export interface CatalogBaseStat { key: string; value: number | string; } const constellationSchema = z.object({ name: z.string(), description: z.string(), icon: z.string(), }); const skillSchema = z.object({ name: z.string(), description: z.string(), icon: z.string(), }); const hyperlinkSchema = z.object({ id: z.number().int(), name: z.string(), description: z.string(), }); const attributeSchema = z.record(z.string(), z.union([z.number(), z.string()])); const levelingSchema = z.record(z.string(), z.record(z.string(), z.number().int())); export const characterDetailSchema = z.object({ info: z.object({ element: z.string().optional(), weapon: z.string().optional(), attributes: z.array(attributeSchema).nullish(), }).nullish(), constellations: z.record(z.string(), constellationSchema).nullish(), hyperlinks: z.array(hyperlinkSchema).nullish(), skills: z.object({ normalattack: skillSchema.optional(), elementalskill: skillSchema.optional(), elementalburst: skillSchema.optional(), leveling: levelingSchema.optional(), }).nullish(), ascension: z.record( z.string(), z.union([z.number().int(), z.record(z.string(), z.number().int())]), ).nullish(), leveling: levelingSchema.nullish(), attributes: z.array(attributeSchema).nullish(), }); export function cleanLunarisText(value: string): string { return value .replace(/\\n/gu, "\n") .replace(/\{\/?LINK[^}]*\}/gu, "") .replace(/<\/?[a-z]+(?:=[^>]*)?>/giu, "") .replace(/^"|"$/gu, "") .trim(); } const lunarisTagPattern = /(\{LINK#[^}]+\}|\{\/LINK\}||<\/color>|<[^>]+>)/giu; function textFromNodes(nodes: CatalogTextNode[]): string { return nodes.map((node) => node.type === "text" ? node.value : textFromNodes(node.children) ).join(""); } export function parseLunarisText( value: string, linkDetails: ReadonlyMap = new Map(), namedDetails: ReadonlyMap = new Map(), ): CatalogTextNode[] { const appendText = (children: CatalogTextNode[], text: string) => { const previous = children.at(-1); if (previous?.type === "text") previous.value += text; else children.push({ type: "text", value: text }); }; const root: CatalogTextNode[] = []; const stack: Array<{ type: "root" | "color" | "link"; children: CatalogTextNode[]; node?: Extract; }> = [{ type: "root", children: root }]; const source = value .replace(/\\n/gu, "\n") .replace(/^"|"$/gu, ""); let offset = 0; for (const match of source.matchAll(lunarisTagPattern)) { const index = match.index; const current = stack.at(-1)!; if (index > offset) { appendText(current.children, source.slice(offset, index)); } const tag = match[0]; const link = /^\{LINK#([^}]+)\}$/iu.exec(tag); const color = /^$/iu.exec(tag); if (link) { const node: Extract = { type: "link", target: link[1], detail: linkDetails.get(link[1]) ?? null, children: [], }; current.children.push(node); stack.push({ type: "link", children: node.children, node }); } else if (color) { const node: Extract = { type: "color", value: color[1], children: [], }; current.children.push(node); stack.push({ type: "color", children: node.children, node }); } else if (tag.toUpperCase() === "{/LINK}" && stack.at(-1)?.type === "link") { const frame = stack.pop(); if (frame?.node?.type === "link" && !frame.node.detail) { frame.node.detail = namedDetails.get(textFromNodes(frame.children).trim()) ?? null; } } else if (tag.toLowerCase() === "" && stack.at(-1)?.type === "color") { stack.pop(); } offset = index + tag.length; } if (offset < source.length) { appendText(stack.at(-1)!.children, source.slice(offset)); } return root; } export function avatarIconName(value: string): string { return value.replace(/^UI_Gacha_AvatarIcon_/u, "UI_AvatarIcon_"); } function characterAssetName(value: string): string { return avatarIconName(value).replace(/^UI_AvatarIcon_/u, ""); } export function splashImageName(value: string): string { return `UI_Gacha_AvatarImg_${characterAssetName(value)}.png`; } export function nameCardImageName(value: string): string { return `UI_NameCardPic_${characterAssetName(value)}_P.png`; } export function supportsWeaponType( characterWeaponType: string | null | undefined, weaponType: string | null | undefined, ): boolean { return Boolean(characterWeaponType && characterWeaponType === weaponType); } export function constellationSnapshot( value: z.infer, iconKey: (icon: string) => string, ): CatalogConstellation[] { const result: CatalogConstellation[] = []; const linkDetails = new Map( (value.hyperlinks ?? []).map((item) => [ `N${item.id}`, { name: cleanLunarisText(item.name), description: cleanLunarisText(item.description), }, ]), ); const namedDetails = new Map( [ value.skills?.normalattack, value.skills?.elementalskill, value.skills?.elementalburst, ].flatMap((skill) => skill ? [[ cleanLunarisText(skill.name), { name: cleanLunarisText(skill.name), description: cleanLunarisText(skill.description), }, ] as const] : []), ); for (const index of Array.from({ length: 6 }, (_, itemIndex) => itemIndex)) { const level = index + 1; const item = value.constellations?.[`const${level}`]; if (!item) continue; result.push({ level, name: cleanLunarisText(item.name), description: cleanLunarisText(item.description), descriptionNodes: parseLunarisText(item.description, linkDetails, namedDetails), iconKey: iconKey(item.icon), }); } return result; } export function talentSnapshot( value: z.infer, iconKey: (icon: string) => string, ): CatalogTalent[] { const talents = [ ["NA", value.skills?.normalattack], ["E", value.skills?.elementalskill], ["Q", value.skills?.elementalburst], ] as const; return talents.flatMap(([type, talent]) => talent ? [{ type, name: cleanLunarisText(talent.name), description: cleanLunarisText(talent.description), iconKey: iconKey(talent.icon), }] : []); } export function ascensionSnapshot( value: z.infer, ): CatalogAscensionGroup[] { return Object.entries(value.ascension ?? {}).map(([type, entries]) => ({ type, items: typeof entries === "number" ? [{ key: type, amount: entries }] : Object.entries(entries).map(([key, amount]) => ({ key, amount })), })); } export function levelingSnapshot( value: z.infer, ): CatalogLevelingGroup[] { return Object.entries(value.skills?.leveling ?? value.leveling ?? {}).map(([type, entries]) => ({ type, items: Object.entries(entries).map(([key, amount]) => ({ key, amount })), })); } export function baseStatsSnapshot( value: z.infer, ): CatalogBaseStat[] { const level90 = (value.info?.attributes ?? value.attributes)?.find((entry) => entry.level === 90); if (!level90) return []; return Object.entries(level90) .filter(([key]) => key !== "level" && key !== "ascension") .map(([key, stat]) => ({ key, value: stat })); }