feat : big update
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
cleanLunarisText,
|
||||
constellationSnapshot,
|
||||
supportsWeaponType,
|
||||
talentSnapshot,
|
||||
} from "@/lib/catalog/lunaris";
|
||||
|
||||
describe("Lunaris character data", () => {
|
||||
@@ -19,6 +20,10 @@ describe("Lunaris character data", () => {
|
||||
expect(cleanLunarisText('"A {LINK#N1}<color=#fff>bright</color>{/LINK} morning"')).toBe("A bright morning");
|
||||
});
|
||||
|
||||
it("converts escaped Lunaris newlines into displayable lines", () => {
|
||||
expect(cleanLunarisText("First line\\n\\n<i>Second line</i>")).toBe("First line\n\nSecond line");
|
||||
});
|
||||
|
||||
it("normalizes constellation levels and icon keys", () => {
|
||||
const result = constellationSnapshot({
|
||||
constellations: {
|
||||
@@ -49,6 +54,22 @@ describe("Lunaris character data", () => {
|
||||
expect(detail.info?.element).toBe("Cryo");
|
||||
});
|
||||
|
||||
it("maps normal, skill, and burst talent metadata", () => {
|
||||
const detail = characterDetailSchema.parse({
|
||||
skills: {
|
||||
normalattack: { name: "Normal", description: "Line 1\\nLine 2", icon: "Skill_A_Test" },
|
||||
elementalskill: { name: "Skill", description: "Skill detail", icon: "Skill_S_Test" },
|
||||
elementalburst: { name: "Burst", description: "Burst detail", icon: "Skill_E_Test" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(talentSnapshot(detail, (icon) => `${icon}.webp`)).toEqual([
|
||||
{ type: "NA", name: "Normal", description: "Line 1\nLine 2", iconKey: "Skill_A_Test.webp" },
|
||||
{ type: "E", name: "Skill", description: "Skill detail", iconKey: "Skill_S_Test.webp" },
|
||||
{ type: "Q", name: "Burst", description: "Burst detail", iconKey: "Skill_E_Test.webp" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("matches only weapons supported by the character", () => {
|
||||
expect(
|
||||
supportsWeaponType("WEAPON_SWORD_ONE_HAND", "WEAPON_SWORD_ONE_HAND"),
|
||||
|
||||
+37
-1
@@ -7,24 +7,43 @@ export interface CatalogConstellation {
|
||||
iconKey: string | null;
|
||||
}
|
||||
|
||||
export interface CatalogTalent {
|
||||
type: "NA" | "E" | "Q";
|
||||
name: string;
|
||||
description: string;
|
||||
iconKey: string | null;
|
||||
}
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
export const characterDetailSchema = z.object({
|
||||
info: z.object({
|
||||
element: z.string().optional(),
|
||||
weapon: z.string().optional(),
|
||||
}).nullish(),
|
||||
constellations: z.record(z.string(), constellationSchema).nullish(),
|
||||
skills: z.object({
|
||||
normalattack: skillSchema.optional(),
|
||||
elementalskill: skillSchema.optional(),
|
||||
elementalburst: skillSchema.optional(),
|
||||
}).nullish(),
|
||||
});
|
||||
|
||||
export function cleanLunarisText(value: string): string {
|
||||
return value
|
||||
.replace(/\\n/gu, "\n")
|
||||
.replace(/\{\/?LINK[^}]*\}/gu, "")
|
||||
.replace(/<\/?color(?:=[^>]*)?>/gu, "")
|
||||
.replace(/<\/?[a-z]+(?:=[^>]*)?>/giu, "")
|
||||
.replace(/^"|"$/gu, "")
|
||||
.trim();
|
||||
}
|
||||
@@ -58,3 +77,20 @@ export function constellationSnapshot(
|
||||
}
|
||||
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),
|
||||
}] : []);
|
||||
}
|
||||
|
||||
+68
-12
@@ -1,4 +1,6 @@
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import * as z from "zod";
|
||||
|
||||
import { getDb } from "@/db/client";
|
||||
@@ -13,7 +15,10 @@ import {
|
||||
avatarIconName,
|
||||
characterDetailSchema,
|
||||
constellationSnapshot,
|
||||
talentSnapshot,
|
||||
} from "@/lib/catalog/lunaris";
|
||||
import type { CatalogConstellation, CatalogTalent } from "@/lib/catalog/lunaris";
|
||||
import { customTravelers, isUpstreamTraveler } from "@/lib/catalog/travelers";
|
||||
import { getMediaStorage } from "@/lib/media/storage";
|
||||
|
||||
const VERSION_URL = "https://api.lunaris.moe/data/version.json";
|
||||
@@ -42,6 +47,21 @@ const artifactSchema = z.record(z.string(), z.object({
|
||||
qualityType: z.string().optional(),
|
||||
}));
|
||||
|
||||
interface SyncedCharacter {
|
||||
version: string;
|
||||
key: string;
|
||||
name: string;
|
||||
element: string | null;
|
||||
rarity: number | null;
|
||||
weaponType: string | null;
|
||||
imageKey: string;
|
||||
elementIconKey: string | null;
|
||||
sourceImage: string | null;
|
||||
localImage: string | null;
|
||||
constellations: CatalogConstellation[];
|
||||
talents: CatalogTalent[];
|
||||
}
|
||||
|
||||
async function getJson(url: string): Promise<unknown> {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(30_000), cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`Lunaris returned ${response.status} for ${url}`);
|
||||
@@ -79,7 +99,7 @@ async function mapConcurrent<T, R>(items: T[], task: (item: T) => Promise<R>): P
|
||||
|
||||
async function uploadAssets(
|
||||
jobId: string,
|
||||
assets: Array<{ source: string; target: string; type?: string }>,
|
||||
assets: Array<{ source?: string; body?: Uint8Array; target: string; type?: string }>,
|
||||
): Promise<Set<string>> {
|
||||
const storage = await getMediaStorage();
|
||||
let next = 0;
|
||||
@@ -89,10 +109,14 @@ async function uploadAssets(
|
||||
async function worker() {
|
||||
while (next < assets.length) {
|
||||
const asset = assets[next++];
|
||||
const response = await fetch(asset.source, { signal: AbortSignal.timeout(30_000), cache: "no-store" });
|
||||
if (response.status === 404) missing.add(asset.target);
|
||||
else if (!response.ok) throw new Error(`Asset returned ${response.status}: ${asset.source}`);
|
||||
else await storage.write(asset.target, response, { type: asset.type ?? "image/webp", acl: "public-read" });
|
||||
if (asset.body) {
|
||||
await storage.write(asset.target, asset.body, { type: asset.type ?? "image/webp", acl: "public-read" });
|
||||
} else {
|
||||
const response = await fetch(asset.source!, { signal: AbortSignal.timeout(30_000), cache: "no-store" });
|
||||
if (response.status === 404) missing.add(asset.target);
|
||||
else if (!response.ok) throw new Error(`Asset returned ${response.status}: ${asset.source}`);
|
||||
else await storage.write(asset.target, response, { type: asset.type ?? "image/webp", acl: "public-read" });
|
||||
}
|
||||
completed += 1;
|
||||
if (completed % 10 === 0 || completed === assets.length) {
|
||||
await getDb().update(catalogSyncJobs).set({ progress: completed }).where(eq(catalogSyncJobs.id, jobId));
|
||||
@@ -124,8 +148,8 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
|
||||
const weaponData = weaponSchema.parse(weaponsRaw);
|
||||
const artifactData = artifactSchema.parse(artifactsRaw);
|
||||
|
||||
let characters = await mapConcurrent(
|
||||
Object.entries(characterData).filter(([, value]) => value.enName && value.enName !== "???" && value.CardImg),
|
||||
const remoteCharacters: SyncedCharacter[] = await mapConcurrent(
|
||||
Object.entries(characterData).filter(([, value]) => value.enName && value.enName !== "???" && !isUpstreamTraveler(value.enName) && value.CardImg),
|
||||
async ([key, value]) => {
|
||||
const detailRaw = await getOptionalJson(`${API_ROOT}/${version}/en/char/${encodeURIComponent(key)}.json`);
|
||||
const detail = detailRaw ? characterDetailSchema.parse(detailRaw) : null;
|
||||
@@ -136,10 +160,30 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
|
||||
imageKey: `catalog/${version}/characters/${avatarIcon}`,
|
||||
elementIconKey: element ? `catalog/${version}/elements/${element.toLowerCase()}.png` : null,
|
||||
sourceImage: `${API_ROOT}/assets/avataricon/${encodeURIComponent(avatarIcon)}`,
|
||||
localImage: null,
|
||||
constellations: detail ? constellationSnapshot(detail, (icon) => `catalog/${version}/constellations/${webpName(icon)}`) : [],
|
||||
talents: detail ? talentSnapshot(detail, (icon) => `catalog/${version}/talents/${webpName(icon)}`) : [],
|
||||
};
|
||||
},
|
||||
);
|
||||
const customCharacters: SyncedCharacter[] = await mapConcurrent([...customTravelers], async (traveler) => {
|
||||
const detail = characterDetailSchema.parse(await getJson(`${API_ROOT}/${version}/en/char/${traveler.detailKey}.json`));
|
||||
return {
|
||||
version,
|
||||
key: traveler.key,
|
||||
name: traveler.name,
|
||||
element: traveler.element,
|
||||
rarity: 5,
|
||||
weaponType: "WEAPON_SWORD_ONE_HAND",
|
||||
imageKey: `catalog/${version}/characters/${traveler.asset}`,
|
||||
elementIconKey: `catalog/${version}/elements/${traveler.element.toLowerCase()}.png`,
|
||||
sourceImage: null,
|
||||
localImage: traveler.asset,
|
||||
constellations: constellationSnapshot(detail, (icon) => `catalog/${version}/constellations/${webpName(icon)}`),
|
||||
talents: talentSnapshot(detail, (icon) => `catalog/${version}/talents/${webpName(icon)}`),
|
||||
};
|
||||
});
|
||||
let characters = [...remoteCharacters, ...customCharacters];
|
||||
let weapons = Object.entries(weaponData).filter(([, value]) => value.enName && value.enName !== "???" && value.weaponIcon).map(([key, value]) => ({
|
||||
version, key, name: value.enName, rarity: rarity(value.qualityType), weaponType: value.weaponType ?? null,
|
||||
imageKey: `catalog/${version}/weapons/${webpName(value.weaponIcon)}`,
|
||||
@@ -150,8 +194,14 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
|
||||
imageKey: `catalog/${version}/artifacts/${webpName(value.setIcon)}`,
|
||||
sourceImage: `${API_ROOT}/assets/artifacts/${encodeURIComponent(webpName(value.setIcon))}`,
|
||||
}));
|
||||
const uniqueAssets = new Map<string, { source: string; target: string; type?: string }>();
|
||||
for (const item of [...characters, ...weapons, ...artifacts]) uniqueAssets.set(item.imageKey, { source: item.sourceImage, target: item.imageKey });
|
||||
const uniqueAssets = new Map<string, { source?: string; body?: Uint8Array; target: string; type?: string }>();
|
||||
for (const character of characters) {
|
||||
uniqueAssets.set(character.imageKey, character.localImage ? {
|
||||
body: await readFile(join(process.cwd(), "lib/catalog/assets", character.localImage)),
|
||||
target: character.imageKey,
|
||||
} : { source: character.sourceImage!, target: character.imageKey });
|
||||
}
|
||||
for (const item of [...weapons, ...artifacts]) uniqueAssets.set(item.imageKey, { source: item.sourceImage, target: item.imageKey });
|
||||
for (const character of characters) {
|
||||
if (character.elementIconKey && character.element) uniqueAssets.set(character.elementIconKey, {
|
||||
source: `${API_ROOT}/assets/icons/${encodeURIComponent(character.element.toLowerCase())}.png`,
|
||||
@@ -166,16 +216,22 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
|
||||
target: constellation.iconKey,
|
||||
});
|
||||
}
|
||||
for (const talent of character.talents) {
|
||||
if (talent.iconKey) uniqueAssets.set(talent.iconKey, {
|
||||
source: `${API_ROOT}/assets/skills/${encodeURIComponent(talent.iconKey.split("/").at(-1)!)}`,
|
||||
target: talent.iconKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
const missingAssets = await uploadAssets(jobId, [...uniqueAssets.values()]);
|
||||
characters = characters.filter((item) => !missingAssets.has(item.imageKey));
|
||||
characters = characters.map((item) => ({ ...item, elementIconKey: item.elementIconKey && !missingAssets.has(item.elementIconKey) ? item.elementIconKey : null, constellations: item.constellations.map((constellation) => ({ ...constellation, iconKey: constellation.iconKey && !missingAssets.has(constellation.iconKey) ? constellation.iconKey : null })) }));
|
||||
characters = characters.map((item) => ({ ...item, elementIconKey: item.elementIconKey && !missingAssets.has(item.elementIconKey) ? item.elementIconKey : null, constellations: item.constellations.map((constellation) => ({ ...constellation, iconKey: constellation.iconKey && !missingAssets.has(constellation.iconKey) ? constellation.iconKey : null })), talents: item.talents.map((talent) => ({ ...talent, iconKey: talent.iconKey && !missingAssets.has(talent.iconKey) ? talent.iconKey : null })) }));
|
||||
weapons = weapons.filter((item) => !missingAssets.has(item.imageKey));
|
||||
artifacts = artifacts.filter((item) => !missingAssets.has(item.imageKey));
|
||||
|
||||
const storage = await getMediaStorage();
|
||||
await Promise.all([
|
||||
storage.write(`catalog/${version}/characters.json`, JSON.stringify(characters.map((item) => ({ version: item.version, key: item.key, name: item.name, element: item.element, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey, elementIconKey: item.elementIconKey, constellations: item.constellations }))), { type: "application/json", acl: "public-read" }),
|
||||
storage.write(`catalog/${version}/characters.json`, JSON.stringify(characters.map((item) => ({ version: item.version, key: item.key, name: item.name, element: item.element, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey, elementIconKey: item.elementIconKey, constellations: item.constellations, talents: item.talents }))), { type: "application/json", acl: "public-read" }),
|
||||
storage.write(`catalog/${version}/weapons.json`, JSON.stringify(weapons.map((item) => ({ version: item.version, key: item.key, name: item.name, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey }))), { type: "application/json", acl: "public-read" }),
|
||||
storage.write(`catalog/${version}/artifacts.json`, JSON.stringify(artifacts.map((item) => ({ version: item.version, key: item.key, name: item.name, rarity: item.rarity, imageKey: item.imageKey }))), { type: "application/json", acl: "public-read" }),
|
||||
]);
|
||||
@@ -185,7 +241,7 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
|
||||
await tx.delete(catalogCharacters).where(eq(catalogCharacters.version, version));
|
||||
await tx.delete(catalogWeapons).where(eq(catalogWeapons.version, version));
|
||||
await tx.delete(catalogArtifacts).where(eq(catalogArtifacts.version, version));
|
||||
if (characters.length) await tx.insert(catalogCharacters).values(characters.map((item) => ({ version: item.version, key: item.key, name: item.name, element: item.element, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey, elementIconKey: item.elementIconKey, constellations: item.constellations })));
|
||||
if (characters.length) await tx.insert(catalogCharacters).values(characters.map((item) => ({ version: item.version, key: item.key, name: item.name, element: item.element, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey, elementIconKey: item.elementIconKey, constellations: item.constellations, talents: item.talents })));
|
||||
if (weapons.length) await tx.insert(catalogWeapons).values(weapons.map((item) => ({ version: item.version, key: item.key, name: item.name, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey })));
|
||||
if (artifacts.length) await tx.insert(catalogArtifacts).values(artifacts.map((item) => ({ version: item.version, key: item.key, name: item.name, rarity: item.rarity, imageKey: item.imageKey })));
|
||||
await tx.update(catalogVersions).set({ active: false });
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { customTravelers, isUpstreamTraveler } from "@/lib/catalog/travelers";
|
||||
|
||||
describe("custom Traveler catalog", () => {
|
||||
it("blacklists every upstream Traveler spelling variant", () => {
|
||||
expect(isUpstreamTraveler("Traveler")).toBe(true);
|
||||
expect(isUpstreamTraveler(" traveler ")).toBe(true);
|
||||
expect(isUpstreamTraveler("TRAVELER")).toBe(true);
|
||||
expect(isUpstreamTraveler("Traveler Pyro")).toBe(false);
|
||||
});
|
||||
|
||||
it("defines seven distinct element records with distinct local images", async () => {
|
||||
expect(customTravelers).toHaveLength(7);
|
||||
expect(new Set(customTravelers.map((traveler) => traveler.key)).size).toBe(7);
|
||||
expect(new Set(customTravelers.map((traveler) => traveler.element)).size).toBe(7);
|
||||
|
||||
const hashes = await Promise.all(customTravelers.map(async (traveler) => {
|
||||
const body = await readFile(join(process.cwd(), "lib/catalog/assets", traveler.asset));
|
||||
return createHash("sha256").update(body).digest("hex");
|
||||
}));
|
||||
expect(new Set(hashes).size).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
export const customTravelers = [
|
||||
{ key: "traveler_pyro", name: "Traveler Pyro", element: "Pyro", detailKey: "10000005_PYRO", asset: "traveler_pyro.webp" },
|
||||
{ key: "traveler_hydro", name: "Traveler Hydro", element: "Hydro", detailKey: "10000005_HYDRO", asset: "traveler_hydro.webp" },
|
||||
{ key: "traveler_anemo", name: "Traveler Anemo", element: "Anemo", detailKey: "10000005_ANEMO", asset: "traveler_anemo.webp" },
|
||||
{ key: "traveler_geo", name: "Traveler Geo", element: "Geo", detailKey: "10000005_GEO", asset: "traveler_geo.webp" },
|
||||
{ key: "traveler_electro", name: "Traveler Electro", element: "Electro", detailKey: "10000005_ELECTRO", asset: "traveler_electro.webp" },
|
||||
{ key: "traveler_dendro", name: "Traveler Dendro", element: "Dendro", detailKey: "10000005_DENDRO", asset: "traveler_dendro.webp" },
|
||||
{ key: "traveler_cryo", name: "Traveler Cryo", element: "Cryo", detailKey: "10000005_CRYO", asset: "traveler_cyro.webp" },
|
||||
] as const;
|
||||
|
||||
export function isUpstreamTraveler(name: string): boolean {
|
||||
return name.trim().toLocaleLowerCase("en-US") === "traveler";
|
||||
}
|
||||
Reference in New Issue
Block a user