Files
buzz-sheet/lib/stygian/repository.ts
T
2026-10-01 00:13:38 +07:00

208 lines
8.8 KiB
TypeScript

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<typeof selectedStygianBosses>;
try {
bosses = selectedStygianBosses(parsed.data);
} catch {
break;
}
const imageKeyByName = new Map<string, string>();
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) => {
await tx.insert(stygianSchedules).values({
scheduleId,
challengeName: parsed.data.challengeName,
scheduleStartTime: lunarisDate(parsed.data.scheduleStartTime),
scheduleEndTime: lunarisDate(parsed.data.scheduleEndTime),
}).onConflictDoUpdate({
target: stygianSchedules.scheduleId,
set: {
challengeName: parsed.data.challengeName,
scheduleStartTime: lunarisDate(parsed.data.scheduleStartTime),
scheduleEndTime: lunarisDate(parsed.data.scheduleEndTime),
updatedAt: new Date(),
},
});
for (const boss of bosses) {
await tx.insert(stygianBosses).values({
scheduleId,
level: boss.level,
position: boss.position,
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)!,
}).onConflictDoUpdate({
target: [stygianBosses.scheduleId, stygianBosses.level, stygianBosses.position],
set: {
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)!,
},
});
}
});
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<string, typeof stygianRecommendationItems.$inferSelect[]>();
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;
});
}