feat : big update
CI / Verify (push) Successful in 51s
CI / Build immutable images and deploy (push) Successful in 1m36s

This commit is contained in:
2026-08-30 10:52:37 +00:00 Unverified
parent 0a643dd60e
commit 273baa55d8
22 changed files with 4025 additions and 87 deletions
+21
View File
@@ -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
View File
@@ -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
View File
@@ -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 });
+27
View File
@@ -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);
});
});
+13
View File
@@ -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";
}