import { readFile } from "node:fs/promises"; import { join } from "node:path"; import * as z from "zod"; import { getDb } from "@/db/client"; import { catalogArtifacts, catalogCharacters, catalogWeapons, } from "@/db/schema"; import { avatarIconName, characterDetailSchema, constellationSnapshot, nameCardImageName, splashImageName, talentSnapshot, } from "@/lib/catalog/lunaris"; import type { CatalogConstellation, CatalogTalent, } from "@/lib/catalog/lunaris"; import { customTravelers, isUpstreamTraveler } from "@/lib/catalog/travelers"; import { withCatalogSyncLock } from "@/lib/catalog/sync-lock"; import { compareLunarisVersions, lunarisVersionSchema, } from "@/lib/catalog/version"; import { getMediaStorage } from "@/lib/media/storage"; const VERSION_URL = "https://api.lunaris.moe/data/version.json"; const API_ROOT = "https://api.lunaris.moe/data"; const versionSchema = z.object({ version: lunarisVersionSchema, timestamp: z.number().int().optional(), }); const currentVersionSchema = z.object({ version: lunarisVersionSchema }); const characterSchema = z.record( z.string(), z.object({ enName: z.string(), CardImg: z.string(), element: z.string().optional(), qualityType: z.string().optional(), weaponType: z.string().optional(), }), ); const weaponSchema = z.record( z.string(), z.object({ enName: z.string(), weaponIcon: z.string(), qualityType: z.string().optional(), weaponType: z.string().optional(), }), ); const artifactSchema = z.record( z.string(), z.object({ enName: z.string(), setIcon: z.string(), qualityType: z.string().optional(), }), ); interface SyncedCharacter { key: string; name: string; element: string | null; rarity: number | null; weaponType: string | null; imageKey: string; elementIconKey: string | null; splashImageKey: string | null; nameCardImageKey: string | null; sourceImage: string | null; splashSourceImage: string | null; nameCardSourceImage: string | null; localImage: string | null; constellations: CatalogConstellation[]; talents: CatalogTalent[]; } async function getJson(url: string): Promise { 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}`); return response.json(); } async function getOptionalJson(url: string): Promise { const response = await fetch(url, { signal: AbortSignal.timeout(30_000), cache: "no-store", }); if (response.status === 404) return null; if (!response.ok) throw new Error(`Lunaris returned ${response.status} for ${url}`); return response.json(); } function webpName(value: string): string { return value.endsWith(".webp") ? value : `${value}.webp`; } function rarity(value: string | undefined): number | null { if (!value) return null; return ( { QUALITY_WHITE: 1, QUALITY_GREEN: 2, QUALITY_BLUE: 3, QUALITY_PURPLE: 4, QUALITY_ORANGE: 5, }[value] ?? null ); } export async function getLatestLunarisVersion(): Promise { return versionSchema.parse(await getJson(VERSION_URL)).version; } async function mapConcurrent( items: T[], task: (item: T) => Promise, ): Promise { const results = new Array(items.length); let next = 0; async function worker() { while (next < items.length) { const index = next++; results[index] = await task(items[index]); } } await Promise.all( Array.from({ length: Math.min(4, items.length) }, () => worker()), ); return results; } async function uploadAssets( assets: Array<{ source?: string; body?: Uint8Array; target: string; type?: string; }>, ): Promise> { const storage = await getMediaStorage(); let next = 0; const missing = new Set(); async function worker() { while (next < assets.length) { const asset = assets[next++]; if (!(await storage.file(asset.target).exists())) { 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", }); } } } } await Promise.all( Array.from({ length: Math.min(4, assets.length) }, () => worker()), ); return missing; } async function syncCatalogVersion(version: string): Promise { const [charactersRaw, weaponsRaw, artifactsRaw] = await Promise.all([ getJson(`${API_ROOT}/${version}/charlist.json`), getJson(`${API_ROOT}/${version}/weaponlist.json`), getJson(`${API_ROOT}/${version}/artifactlist.json`), ]); const characterData = characterSchema.parse(charactersRaw); const weaponData = weaponSchema.parse(weaponsRaw); const artifactData = artifactSchema.parse(artifactsRaw); 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; const avatarIcon = webpName(avatarIconName(value.CardImg)); const splashImage = splashImageName(value.CardImg); const nameCardImage = nameCardImageName(value.CardImg); const element = detail?.info?.element ?? value.element ?? null; return { key, name: value.enName, element, rarity: rarity(value.qualityType), weaponType: detail?.info?.weapon ?? value.weaponType ?? null, imageKey: `catalog/characters/avatar/${avatarIcon}`, elementIconKey: element ? `catalog/elements/${element.toLowerCase()}.png` : null, splashImageKey: `catalog/characters/splash/${splashImage}`, nameCardImageKey: `catalog/characters/namecard/${nameCardImage}`, sourceImage: `${API_ROOT}/assets/avataricon/${encodeURIComponent(avatarIcon)}`, splashSourceImage: `${API_ROOT}/assets/gachaimg/${encodeURIComponent(splashImage)}`, nameCardSourceImage: `${API_ROOT}/assets/namecardpic/${encodeURIComponent(nameCardImage)}`, localImage: null, constellations: detail ? constellationSnapshot( detail, (icon) => `catalog/characters/constellations/${webpName(icon)}`, ) : [], talents: detail ? talentSnapshot( detail, (icon) => `catalog/characters/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 { key: traveler.key, name: traveler.name, element: traveler.element, rarity: traveler.rarity, weaponType: "WEAPON_SWORD_ONE_HAND", imageKey: `catalog/characters/avatar/${traveler.asset}`, elementIconKey: `catalog/elements/${traveler.element.toLowerCase()}.png`, splashImageKey: null, nameCardImageKey: null, sourceImage: null, splashSourceImage: null, nameCardSourceImage: null, localImage: traveler.asset, constellations: constellationSnapshot( detail, (icon) => `catalog/characters/constellations/${webpName(icon)}`, ), talents: talentSnapshot( detail, (icon) => `catalog/characters/talents/${webpName(icon)}`, ), }; }, ); let characters = [...remoteCharacters, ...customCharacters]; let weapons = Object.entries(weaponData) .filter( ([, value]) => value.enName && value.enName !== "???" && value.weaponIcon, ) .map(([key, value]) => ({ key, name: value.enName, rarity: rarity(value.qualityType), weaponType: value.weaponType ?? null, imageKey: `catalog/weapons/${webpName(value.weaponIcon)}`, sourceImage: `${API_ROOT}/assets/weaponicon/${encodeURIComponent(webpName(value.weaponIcon))}`, })); let artifacts = Object.entries(artifactData) .filter( ([, value]) => value.enName && value.enName !== "???" && value.setIcon, ) .map(([key, value]) => ({ key, name: value.enName, rarity: rarity(value.qualityType), imageKey: `catalog/artifacts/${webpName(value.setIcon)}`, sourceImage: `${API_ROOT}/assets/artifacts/${encodeURIComponent(webpName(value.setIcon))}`, })); 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 }, ); if (character.splashImageKey && character.splashSourceImage) { uniqueAssets.set(character.splashImageKey, { source: character.splashSourceImage, target: character.splashImageKey, type: "image/png", }); } if (character.nameCardImageKey && character.nameCardSourceImage) { uniqueAssets.set(character.nameCardImageKey, { source: character.nameCardSourceImage, target: character.nameCardImageKey, type: "image/png", }); } } 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`, target: character.elementIconKey, type: "image/png", }); } for (const character of characters) { for (const constellation of character.constellations) { if (constellation.iconKey) uniqueAssets.set(constellation.iconKey, { source: `${API_ROOT}/assets/skills/${encodeURIComponent(constellation.iconKey.split("/").at(-1)!)}`, 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([...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, splashImageKey: item.splashImageKey && !missingAssets.has(item.splashImageKey) ? item.splashImageKey : null, nameCardImageKey: item.nameCardImageKey && !missingAssets.has(item.nameCardImageKey) ? item.nameCardImageKey : 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/characters.json", JSON.stringify( characters.map((item) => ({ key: item.key, name: item.name, element: item.element, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey, elementIconKey: item.elementIconKey, splashImageKey: item.splashImageKey, nameCardImageKey: item.nameCardImageKey, constellations: item.constellations, talents: item.talents, })), ), { type: "application/json", acl: "public-read" }, ), storage.write( "catalog/weapons.json", JSON.stringify( weapons.map((item) => ({ key: item.key, name: item.name, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey, })), ), { type: "application/json", acl: "public-read" }, ), storage.write( "catalog/artifacts.json", JSON.stringify( artifacts.map((item) => ({ key: item.key, name: item.name, rarity: item.rarity, imageKey: item.imageKey, })), ), { type: "application/json", acl: "public-read" }, ), ]); await getDb().transaction(async (tx) => { await tx.delete(catalogCharacters); await tx.delete(catalogWeapons); await tx.delete(catalogArtifacts); if (characters.length) await tx .insert(catalogCharacters) .values( characters.map((item) => ({ key: item.key, name: item.name, element: item.element, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey, elementIconKey: item.elementIconKey, coopImageKey: item.splashImageKey, nameCardImageKey: item.nameCardImageKey, constellations: item.constellations, talents: item.talents, })), ); if (weapons.length) await tx .insert(catalogWeapons) .values( weapons.map((item) => ({ 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) => ({ key: item.key, name: item.name, rarity: item.rarity, imageKey: item.imageKey, })), ); }); await storage.write("catalog/current.json", JSON.stringify({ version }), { type: "application/json", acl: "public-read", }); } export type CatalogSyncResult = "synced" | "unchanged"; export async function getCurrentCatalogVersion(): Promise { const storage = await getMediaStorage(); const current = storage.file("catalog/current.json"); if (!(await current.exists())) return null; return currentVersionSchema.parse(await current.json()).version; } export async function processCatalogSync( requestedVersion?: string, ): Promise { const version = lunarisVersionSchema.parse( requestedVersion ?? (await getLatestLunarisVersion()), ); return withCatalogSyncLock(async () => { const currentVersion = await getCurrentCatalogVersion(); if ( currentVersion && compareLunarisVersions(version, currentVersion) <= 0 ) { return "unchanged"; } await syncCatalogVersion(version); return "synced"; }); }