feat : update

This commit is contained in:
2026-09-06 17:16:38 +07:00 Unverified
parent 5f6b8c1059
commit ce126810bb
13 changed files with 384 additions and 171 deletions
+7 -5
View File
@@ -83,11 +83,13 @@ describe("Lunaris character data", () => {
it("normalizes leveling materials by category", () => {
const detail = characterDetailSchema.parse({
leveling: {
crown: { "104319": 3 },
books: { "104365": 9, "104366": 63, "104367": 114 },
worldmonster: { "112146": 18, "112147": 66, "112148": 93 },
weekly: { "113088": 18 },
skills: {
leveling: {
crown: { "104319": 3 },
books: { "104365": 9, "104366": 63, "104367": 114 },
worldmonster: { "112146": 18, "112147": 66, "112148": 93 },
weekly: { "113088": 18 },
},
},
});
+4 -2
View File
@@ -53,6 +53,7 @@ const skillSchema = z.object({
});
const attributeSchema = z.record(z.string(), z.union([z.number(), z.string()]));
const levelingSchema = z.record(z.string(), z.record(z.string(), z.number().int()));
export const characterDetailSchema = z.object({
info: z.object({
@@ -65,12 +66,13 @@ export const characterDetailSchema = z.object({
normalattack: skillSchema.optional(),
elementalskill: skillSchema.optional(),
elementalburst: skillSchema.optional(),
leveling: levelingSchema.optional(),
}).nullish(),
ascension: z.record(
z.string(),
z.union([z.number().int(), z.record(z.string(), z.number().int())]),
).nullish(),
leveling: z.record(z.string(), z.record(z.string(), z.number().int())).nullish(),
leveling: levelingSchema.nullish(),
attributes: z.array(attributeSchema).nullish(),
});
@@ -157,7 +159,7 @@ export function ascensionSnapshot(
export function levelingSnapshot(
value: z.infer<typeof characterDetailSchema>,
): CatalogLevelingGroup[] {
return Object.entries(value.leveling ?? {}).map(([type, entries]) => ({
return Object.entries(value.skills?.leveling ?? value.leveling ?? {}).map(([type, entries]) => ({
type,
items: Object.entries(entries).map(([key, amount]) => ({ key, amount })),
}));
+58 -14
View File
@@ -40,6 +40,14 @@ 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 PROGRESS_INTERVAL_MS = 400;
function syncConcurrency(): number {
const configured = Number(process.env.CATALOG_SYNC_CONCURRENCY ?? 12);
return Number.isFinite(configured)
? Math.max(1, Math.min(24, Math.floor(configured)))
: 12;
}
const versionSchema = z.object({
version: lunarisVersionSchema,
@@ -160,16 +168,27 @@ async function mapConcurrent<T, R>(
): Promise<R[]> {
const results = new Array<R>(items.length);
let next = 0;
let completed = 0;
let lastProgressAt = 0;
async function reportProgress() {
if (!control) return;
const now = Date.now();
if (completed < items.length && now - lastProgressAt < PROGRESS_INTERVAL_MS) return;
lastProgressAt = now;
await control.report("characters", completed, items.length, `ดึงข้อมูลตัวละคร ${completed}/${items.length}`);
}
async function worker() {
while (next < items.length) {
await control?.checkCancelled();
while (true) {
const index = next++;
if (index >= items.length) return;
await control?.checkCancelled();
results[index] = await task(items[index]);
await control?.report("characters", index + 1, items.length, `ดึงข้อมูลตัวละคร ${index + 1}/${items.length}`);
completed += 1;
await reportProgress();
}
}
await Promise.all(
Array.from({ length: Math.min(4, items.length) }, () => worker()),
Array.from({ length: Math.min(syncConcurrency(), items.length) }, () => worker()),
);
return results;
}
@@ -185,11 +204,22 @@ async function uploadAssets(
): Promise<Set<string>> {
const storage = await getMediaStorage();
let next = 0;
let completed = 0;
let lastProgressAt = 0;
const missing = new Set<string>();
async function reportProgress() {
if (!control) return;
const now = Date.now();
if (completed < assets.length && now - lastProgressAt < PROGRESS_INTERVAL_MS) return;
lastProgressAt = now;
await control.report("assets", completed, assets.length, `ดาวน์โหลดรูปภาพ ${completed}/${assets.length}`);
}
async function worker() {
while (next < assets.length) {
while (true) {
const index = next++;
if (index >= assets.length) return;
await control?.checkCancelled();
const asset = assets[next++];
const asset = assets[index];
if (!(await storage.file(asset.target).exists())) {
if (asset.body) {
await storage.write(asset.target, asset.body, {
@@ -211,11 +241,12 @@ async function uploadAssets(
});
}
}
await control?.report("assets", next, assets.length, `ดาวน์โหลดรูปภาพ ${next}/${assets.length}`);
completed += 1;
await reportProgress();
}
}
await Promise.all(
Array.from({ length: Math.min(4, assets.length) }, () => worker()),
Array.from({ length: Math.min(syncConcurrency(), assets.length) }, () => worker()),
);
return missing;
}
@@ -620,9 +651,20 @@ export async function startCatalogSyncJob(): Promise<CatalogSyncJob> {
}
async function executeCatalogSyncJob(initial: CatalogSyncJob) {
let lastCancellationCheck = 0;
let cancellationCheck: Promise<void> | null = null;
const checkCancelled = async () => {
const current = await getCatalogSyncJob(initial.id);
if (current?.status === "cancelled") throw new Error("CATALOG_SYNC_CANCELLED");
if (Date.now() - lastCancellationCheck < PROGRESS_INTERVAL_MS) return;
if (!cancellationCheck) {
cancellationCheck = (async () => {
const current = await getCatalogSyncJob(initial.id);
lastCancellationCheck = Date.now();
if (current?.status === "cancelled") throw new Error("CATALOG_SYNC_CANCELLED");
})().finally(() => {
cancellationCheck = null;
});
}
await cancellationCheck;
};
const report = async (phase: string, completed: number, total: number, message: string) => {
const current = await getCatalogSyncJob(initial.id);
@@ -651,10 +693,12 @@ export async function getCurrentCatalogVersion(): Promise<string | null> {
async function hasCatalogRecords(): Promise<boolean> {
const db = getDb();
const [character] = await db.select({ key: catalogCharacters.key }).from(catalogCharacters).where(sql`${catalogCharacters.baseStats} <> '[]'::jsonb`).limit(1);
const [material] = await db.select({ key: catalogMaterials.key }).from(catalogMaterials).limit(1);
const [weapon] = await db.select({ key: catalogWeapons.key }).from(catalogWeapons).limit(1);
const [artifact] = await db.select({ key: catalogArtifacts.key }).from(catalogArtifacts).limit(1);
const [[character], [material], [weapon], [artifact]] = await Promise.all([
db.select({ key: catalogCharacters.key }).from(catalogCharacters).where(sql`${catalogCharacters.baseStats} <> '[]'::jsonb AND ${catalogCharacters.leveling} <> '[]'::jsonb`).limit(1),
db.select({ key: catalogMaterials.key }).from(catalogMaterials).limit(1),
db.select({ key: catalogWeapons.key }).from(catalogWeapons).limit(1),
db.select({ key: catalogArtifacts.key }).from(catalogArtifacts).limit(1),
]);
return Boolean(character && material && weapon && artifact);
}
+1 -1
View File
@@ -9,7 +9,7 @@ import {
} from "./validation";
describe("media validation", () => {
it("accepts only supported images no larger than 20 MB", () => {
it("accepts only supported images no larger than 100 MB", () => {
expect(mediaUploadSchema.safeParse({ fileName: "a.png", mimeType: "image/png", byteSize: MAX_MEDIA_BYTES }).success).toBe(true);
expect(mediaUploadSchema.safeParse({ fileName: "a.svg", mimeType: "image/svg+xml", byteSize: 10 }).success).toBe(false);
expect(mediaUploadSchema.safeParse({ fileName: "a.png", mimeType: "image/png", byteSize: MAX_MEDIA_BYTES + 1 }).success).toBe(false);
+1 -1
View File
@@ -1,6 +1,6 @@
import * as z from "zod";
export const MAX_MEDIA_BYTES = 20 * 1024 * 1024;
export const MAX_MEDIA_BYTES = 100 * 1024 * 1024;
export const IMAGE_MIME_TYPES = [
"image/png",
"image/jpeg",