Files
buzz-sheet/lib/catalog/sync.ts
T
gunshiz 76395a0b42
CI / Verify (push) Successful in 1m13s
CI / Build immutable images and deploy (push) Successful in 5m34s
feat(catalog) : sync Lunaris updates from Discord
2026-09-03 17:51:02 +07:00

528 lines
17 KiB
TypeScript

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<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}`);
return response.json();
}
async function getOptionalJson(url: string): Promise<unknown | null> {
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<string> {
return versionSchema.parse(await getJson(VERSION_URL)).version;
}
async function mapConcurrent<T, R>(
items: T[],
task: (item: T) => Promise<R>,
): Promise<R[]> {
const results = new Array<R>(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<Set<string>> {
const storage = await getMediaStorage();
let next = 0;
const missing = new Set<string>();
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<void> {
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<string | null> {
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<CatalogSyncResult> {
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";
});
}