import "server-only"; import { and, asc, desc, eq, sql } from "drizzle-orm"; import { getDb } from "@/db"; import { stygianBosses, stygianRecommendationGroups, stygianRecommendationItems, stygianSchedules, outboxEvents, } from "@/db/schema"; import { getMediaStorage, publicMediaUrl } from "@/lib/media/storage"; import { lunarisStygianScheduleSchema, selectedStygianBosses, stygianImageFileName, stygianRecommendationsSchema, type StygianRecommendationItemInput, } from "@/lib/stygian/schema"; const SCHEDULE_ROOT = "https://lunaris.moe/data/leylinechallenge"; const ASSET_ROOT = "https://api.lunaris.moe/data/assets/leyline"; const FIRST_SCHEDULE_ID = 5269001; function stygianAggregateId(scheduleId: number) { return `00000000-0000-4000-8000-${String(scheduleId).padStart(12, "0")}`; } function lunarisDate(value: string): Date { const result = new Date(`${value.replace(" ", "T")}+08:00`); if (Number.isNaN(result.valueOf())) throw new Error(`Invalid Lunaris date: ${value}`); return result; } export async function syncStygianArchive() { const db = getDb(); const storage = await getMediaStorage(); const imported: number[] = []; let stoppedAt = FIRST_SCHEDULE_ID; for (let scheduleId = FIRST_SCHEDULE_ID; ; scheduleId += 1) { stoppedAt = scheduleId; const response = await fetch(`${SCHEDULE_ROOT}/${scheduleId}.json`, { cache: "no-store", signal: AbortSignal.timeout(30_000) }); if (response.status === 404) break; if (response.status >= 500) throw new Error(`Lunaris returned ${response.status} for schedule ${scheduleId}.`); if (!response.ok) throw new Error(`Lunaris returned ${response.status} for schedule ${scheduleId}.`); if (!response.headers.get("content-type")?.includes("application/json")) break; let json: unknown; try { json = await response.json(); } catch { break; } const parsed = lunarisStygianScheduleSchema.safeParse(json); if (!parsed.success || parsed.data.scheduleId !== scheduleId) break; let bosses: ReturnType; try { bosses = selectedStygianBosses(parsed.data); } catch { break; } const imageKeyByName = new Map(); for (const boss of bosses) { if (imageKeyByName.has(boss.imageName)) continue; const imageFileName = stygianImageFileName(boss.imageName); const imageKey = `catalog/stygian/${scheduleId}/${imageFileName}`; const file = storage.file(imageKey); if (!(await file.exists())) { const image = await fetch(`${ASSET_ROOT}/${encodeURIComponent(imageFileName)}`, { signal: AbortSignal.timeout(30_000) }); if (!image.ok || !image.headers.get("content-type")?.startsWith("image/")) { throw new Error(`Unable to download artwork for ${boss.name}.`); } await storage.write(imageKey, image, { type: "image/png" }); } imageKeyByName.set(boss.imageName, imageKey); } await db.transaction(async (tx) => { const scheduleValues = { challengeName: parsed.data.challengeName, scheduleStartTime: lunarisDate(parsed.data.scheduleStartTime), scheduleEndTime: lunarisDate(parsed.data.scheduleEndTime), }; await tx.insert(stygianSchedules).values({ scheduleId, ...scheduleValues, }).onConflictDoUpdate({ target: stygianSchedules.scheduleId, set: { ...scheduleValues, updatedAt: new Date(), }, }); for (const boss of bosses) { const bossValues = { configId: boss.configId, name: boss.name, hp: String(boss.hp), description: boss.description, mechanics: boss.mechanics, recommendedMechanics: boss.recommendedMechanics, recommendedMechanicTypes: boss.recommendedMechanicTypes, imageKey: imageKeyByName.get(boss.imageName)!, }; await tx.insert(stygianBosses).values({ scheduleId, level: boss.level, position: boss.position, ...bossValues, }).onConflictDoUpdate({ target: [stygianBosses.scheduleId, stygianBosses.level, stygianBosses.position], set: bossValues, }); } }); imported.push(scheduleId); } return { imported, stoppedAt }; } export async function listStygianSchedules() { return getDb().select().from(stygianSchedules).orderBy(desc(stygianSchedules.scheduleId)); } export async function getStygianSchedule(scheduleId: number) { const db = getDb(); const [schedule] = await db.select().from(stygianSchedules).where(eq(stygianSchedules.scheduleId, scheduleId)).limit(1); if (!schedule) return null; const [bosses, groups, items] = await Promise.all([ db.select().from(stygianBosses).where(eq(stygianBosses.scheduleId, scheduleId)).orderBy(asc(stygianBosses.level), asc(stygianBosses.position)), db.select().from(stygianRecommendationGroups).where(eq(stygianRecommendationGroups.scheduleId, scheduleId)).orderBy(asc(stygianRecommendationGroups.sortOrder)), db.select({ item: stygianRecommendationItems, group: stygianRecommendationGroups }) .from(stygianRecommendationItems) .innerJoin(stygianRecommendationGroups, eq(stygianRecommendationItems.groupId, stygianRecommendationGroups.id)) .where(eq(stygianRecommendationGroups.scheduleId, scheduleId)) .orderBy(asc(stygianRecommendationItems.position)), ]); const itemsByGroup = new Map(); for (const row of items) { const bucket = itemsByGroup.get(row.group.id) ?? []; bucket.push(row.item); itemsByGroup.set(row.group.id, bucket); } return { ...schedule, bosses: bosses.map((boss) => ({ ...boss, hp: Number(boss.hp), imageUrl: publicMediaUrl(boss.imageKey) })), recommendations: groups.map((group) => ({ ...group, items: itemsByGroup.get(group.id) ?? [] })), }; } function itemValues(groupId: string, position: number, item: StygianRecommendationItemInput) { return { groupId, position, kind: item.kind, catalogKey: item.kind === "character" || item.kind === "weapon" || item.kind === "artifact" ? item.catalogKey : null, weaponKey: item.kind === "character" ? item.weaponKey : null, artifactKey: item.kind === "character" ? item.artifactKey : null, refinement: item.kind === "character" ? item.refinement : null, constellationLevel: item.kind === "character" ? item.constellationLevel : null, element: item.kind === "element" ? item.element : null, }; } export async function saveStygianRecommendations(scheduleId: number, input: unknown) { const value = stygianRecommendationsSchema.parse(input); return getDb().transaction(async (tx) => { const [updated] = await tx.update(stygianSchedules).set({ version: sql`${stygianSchedules.version} + 1`, updatedAt: new Date() }) .where(and(eq(stygianSchedules.scheduleId, scheduleId), eq(stygianSchedules.version, value.expectedVersion))) .returning({ version: stygianSchedules.version }); if (!updated) throw new Error("ข้อมูลถูกแก้ไขจากหน้าอื่น กรุณารีเฟรชแล้วลองอีกครั้ง"); const oldGroups = await tx.select({ id: stygianRecommendationGroups.id }).from(stygianRecommendationGroups).where(and( eq(stygianRecommendationGroups.scheduleId, scheduleId), eq(stygianRecommendationGroups.level, value.level), eq(stygianRecommendationGroups.bossPosition, value.bossPosition), )); for (const group of oldGroups) await tx.delete(stygianRecommendationGroups).where(eq(stygianRecommendationGroups.id, group.id)); const groups = [ ...value.teams.map((items) => ({ kind: "team", items })), ...value.units.map((item) => ({ kind: "unit", items: [item] })), ]; for (const [sortOrder, group] of groups.entries()) { const [created] = await tx.insert(stygianRecommendationGroups).values({ scheduleId, level: value.level, bossPosition: value.bossPosition, kind: group.kind, sortOrder, }).returning({ id: stygianRecommendationGroups.id }); await tx.insert(stygianRecommendationItems).values(group.items.map((item, position) => itemValues(created.id, position, item))); } const aggregateId = stygianAggregateId(scheduleId); await tx.insert(outboxEvents).values({ topic: "stygian", aggregateId, eventType: "stygian.updated", payload: { id: aggregateId, version: updated.version, scheduleId } }); return updated; }); }