208 lines
8.8 KiB
TypeScript
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;
|
|
});
|
|
}
|