296 lines
8.5 KiB
TypeScript
296 lines
8.5 KiB
TypeScript
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=#[0-9a-f]{6}(?:[0-9a-f]{2})?>|<\/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<string, CatalogTextDetail> = new Map(),
|
|
namedDetails: ReadonlyMap<string, CatalogTextDetail> = 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<CatalogTextNode, { type: "color" | "link" }>;
|
|
}> = [{ 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 = /^<color=(#[0-9a-f]{6}(?:[0-9a-f]{2})?)>$/iu.exec(tag);
|
|
if (link) {
|
|
const node: Extract<CatalogTextNode, { type: "link" }> = {
|
|
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<CatalogTextNode, { type: "color" }> = {
|
|
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() === "</color>" && 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<typeof characterDetailSchema>,
|
|
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<typeof characterDetailSchema>,
|
|
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<typeof characterDetailSchema>,
|
|
): 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<typeof characterDetailSchema>,
|
|
): 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<typeof characterDetailSchema>,
|
|
): 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 }));
|
|
}
|