feat(guides): autosave structured guide data
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { cleanLunarisText, constellationSnapshot } from "@/lib/catalog/lunaris";
|
||||
|
||||
describe("Lunaris character data", () => {
|
||||
it("removes display markup from constellation text", () => {
|
||||
expect(cleanLunarisText('"A {LINK#N1}<color=#fff>bright</color>{/LINK} morning"')).toBe("A bright morning");
|
||||
});
|
||||
|
||||
it("normalizes constellation levels and icon keys", () => {
|
||||
const result = constellationSnapshot({
|
||||
constellations: {
|
||||
const1: { name: '"First"', description: "Detail", icon: "UI_Talent_Test_01" },
|
||||
},
|
||||
}, (icon) => `catalog/1/constellations/${icon}.webp`);
|
||||
expect(result).toEqual([{
|
||||
level: 1,
|
||||
name: "First",
|
||||
description: "Detail",
|
||||
iconKey: "catalog/1/constellations/UI_Talent_Test_01.webp",
|
||||
}]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as z from "zod";
|
||||
|
||||
export interface CatalogConstellation {
|
||||
level: number;
|
||||
name: string;
|
||||
description: string;
|
||||
iconKey: string | null;
|
||||
}
|
||||
|
||||
const constellationSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
icon: z.string(),
|
||||
});
|
||||
|
||||
export const characterDetailSchema = z.object({
|
||||
constellations: z.record(z.string(), constellationSchema),
|
||||
});
|
||||
|
||||
export function cleanLunarisText(value: string): string {
|
||||
return value
|
||||
.replace(/\{\/?LINK[^}]*\}/gu, "")
|
||||
.replace(/<\/?color(?:=[^>]*)?>/gu, "")
|
||||
.replace(/^"|"$/gu, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function constellationSnapshot(
|
||||
value: z.infer<typeof characterDetailSchema>,
|
||||
iconKey: (icon: string) => string,
|
||||
): CatalogConstellation[] {
|
||||
const result: CatalogConstellation[] = [];
|
||||
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),
|
||||
iconKey: iconKey(item.icon),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
+45
-7
@@ -9,6 +9,7 @@ import {
|
||||
catalogVersions,
|
||||
catalogWeapons,
|
||||
} from "@/db/schema";
|
||||
import { characterDetailSchema, constellationSnapshot } from "@/lib/catalog/lunaris";
|
||||
import { getMediaStorage } from "@/lib/media/storage";
|
||||
|
||||
const VERSION_URL = "https://api.lunaris.moe/data/version.json";
|
||||
@@ -42,6 +43,13 @@ async function getJson(url: string): Promise<unknown> {
|
||||
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`;
|
||||
}
|
||||
@@ -51,6 +59,19 @@ function rarity(value: string | undefined): number | null {
|
||||
return { QUALITY_WHITE: 1, QUALITY_GREEN: 2, QUALITY_BLUE: 3, QUALITY_PURPLE: 4, QUALITY_ORANGE: 5 }[value] ?? null;
|
||||
}
|
||||
|
||||
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(
|
||||
jobId: string,
|
||||
assets: Array<{ source: string; target: string }>,
|
||||
@@ -98,11 +119,19 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
|
||||
const weaponData = weaponSchema.parse(weaponsRaw);
|
||||
const artifactData = artifactSchema.parse(artifactsRaw);
|
||||
|
||||
let characters = Object.entries(characterData).filter(([, value]) => value.enName && value.enName !== "???" && value.GachaImg).map(([key, value]) => ({
|
||||
version, key, name: value.enName, element: value.element ?? null, rarity: rarity(value.qualityType), weaponType: value.weaponType ?? null,
|
||||
imageKey: `catalog/${version}/characters/${webpName(value.GachaImg)}`,
|
||||
sourceImage: `${API_ROOT}/assets/gachaimg/${encodeURIComponent(webpName(value.GachaImg))}`,
|
||||
}));
|
||||
let characters = await mapConcurrent(
|
||||
Object.entries(characterData).filter(([, value]) => value.enName && value.enName !== "???" && value.GachaImg),
|
||||
async ([key, value]) => {
|
||||
const detailRaw = await getOptionalJson(`${API_ROOT}/${version}/en/char/${encodeURIComponent(key)}.json`);
|
||||
const detail = detailRaw ? characterDetailSchema.parse(detailRaw) : null;
|
||||
return {
|
||||
version, key, name: value.enName, element: value.element ?? null, rarity: rarity(value.qualityType), weaponType: value.weaponType ?? null,
|
||||
imageKey: `catalog/${version}/characters/${webpName(value.GachaImg)}`,
|
||||
sourceImage: `${API_ROOT}/assets/gachaimg/${encodeURIComponent(webpName(value.GachaImg))}`,
|
||||
constellations: detail ? constellationSnapshot(detail, (icon) => `catalog/${version}/constellations/${webpName(icon)}`) : [],
|
||||
};
|
||||
},
|
||||
);
|
||||
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)}`,
|
||||
@@ -115,14 +144,23 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
|
||||
}));
|
||||
const uniqueAssets = new Map<string, { source: string; target: string }>();
|
||||
for (const item of [...characters, ...weapons, ...artifacts]) uniqueAssets.set(item.imageKey, { source: item.sourceImage, target: item.imageKey });
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
const missingAssets = await uploadAssets(jobId, [...uniqueAssets.values()]);
|
||||
characters = characters.filter((item) => !missingAssets.has(item.imageKey));
|
||||
characters = characters.map((item) => ({ ...item, constellations: item.constellations.map((constellation) => ({ ...constellation, iconKey: constellation.iconKey && !missingAssets.has(constellation.iconKey) ? constellation.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 }))), { 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, constellations: item.constellations }))), { 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, imageKey: item.imageKey }))), { type: "application/json", acl: "public-read" }),
|
||||
]);
|
||||
@@ -132,7 +170,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 })));
|
||||
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, constellations: item.constellations })));
|
||||
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, imageKey: item.imageKey })));
|
||||
await tx.update(catalogVersions).set({ active: false });
|
||||
|
||||
Reference in New Issue
Block a user