feat(catalog) : sync Lunaris updates from Discord
CI / Verify (push) Successful in 1m13s
CI / Build immutable images and deploy (push) Successful in 5m34s

This commit is contained in:
2026-09-03 17:51:02 +07:00 Unverified
parent 2018005758
commit 76395a0b42
17 changed files with 520 additions and 19 deletions
+57
View File
@@ -0,0 +1,57 @@
import { randomUUID } from "node:crypto";
import { getRedisClient, redisEventPrefix } from "@/lib/redis/client";
const LOCK_TTL_MS = 120_000;
const RENEW_INTERVAL_MS = 30_000;
const renewScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("pexpire", KEYS[1], ARGV[2])
end
return 0
`;
const releaseScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
end
return 0
`;
export class CatalogSyncBusyError extends Error {
constructor() {
super("กำลัง Sync Catalog อยู่");
this.name = "CatalogSyncBusyError";
}
}
export async function withCatalogSyncLock<T>(
task: () => Promise<T>,
): Promise<T> {
const redis = await getRedisClient();
const key = `${redisEventPrefix()}:catalog-sync-lock`;
const token = randomUUID();
const acquired = await redis.set(key, token, "PX", LOCK_TTL_MS, "NX");
if (acquired !== "OK") throw new CatalogSyncBusyError();
let renewing = false;
const timer = setInterval(() => {
if (renewing) return;
renewing = true;
void redis
.eval(renewScript, 1, key, token, String(LOCK_TTL_MS))
.catch(() => undefined)
.finally(() => {
renewing = false;
});
}, RENEW_INTERVAL_MS);
timer.unref();
try {
return await task();
} finally {
clearInterval(timer);
await redis.eval(releaseScript, 1, key, token).catch(() => undefined);
}
}
+37 -3
View File
@@ -21,15 +21,21 @@ import type {
CatalogTalent,
} from "@/lib/catalog/lunaris";
import { customTravelers, isUpstreamTraveler } from "@/lib/catalog/travelers";
import { withCatalogSyncLock } from "@/lib/catalog/sync-lock";
import {
compareLunarisVersions,
lunarisVersionSchema,
} from "@/lib/catalog/version";
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 versionSchema = z.object({
version: z.string().regex(/^\d+(?:\.\d+)+$/u),
version: lunarisVersionSchema,
timestamp: z.number().int().optional(),
});
const currentVersionSchema = z.object({ version: lunarisVersionSchema });
const characterSchema = z.record(
z.string(),
z.object({
@@ -179,8 +185,7 @@ async function uploadAssets(
return missing;
}
export async function processCatalogSync(): Promise<void> {
const version = await getLatestLunarisVersion();
async function syncCatalogVersion(version: string): Promise<void> {
const [charactersRaw, weaponsRaw, artifactsRaw] = await Promise.all([
getJson(`${API_ROOT}/${version}/charlist.json`),
getJson(`${API_ROOT}/${version}/weaponlist.json`),
@@ -491,3 +496,32 @@ export async function processCatalogSync(): Promise<void> {
acl: "public-read",
});
}
export type CatalogSyncResult = "synced" | "unchanged";
export async function getCurrentCatalogVersion(): Promise<string | null> {
const storage = await getMediaStorage();
const current = storage.file("catalog/current.json");
if (!(await current.exists())) return null;
return currentVersionSchema.parse(await current.json()).version;
}
export async function processCatalogSync(
requestedVersion?: string,
): Promise<CatalogSyncResult> {
const version = lunarisVersionSchema.parse(
requestedVersion ?? (await getLatestLunarisVersion()),
);
return withCatalogSyncLock(async () => {
const currentVersion = await getCurrentCatalogVersion();
if (
currentVersion &&
compareLunarisVersions(version, currentVersion) <= 0
) {
return "unchanged";
}
await syncCatalogVersion(version);
return "synced";
});
}
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import {
compareLunarisVersions,
parseLunarisVersionChange,
} from "./version";
describe("Lunaris version announcements", () => {
it("extracts a version change from a full announcement", () => {
expect(
parseLunarisVersionChange(`Lunaris has been updated!
Version Change: 7.0.53.2 → 7.0.54
Characters
Odette`),
).toEqual({
previousVersion: "7.0.53.2",
targetVersion: "7.0.54",
});
});
it("ignores ordinary and malformed messages", () => {
expect(parseLunarisVersionChange("hello")).toBeNull();
expect(parseLunarisVersionChange("Version Change: latest → 7.0.54")).toBeNull();
expect(parseLunarisVersionChange("Version Change: 7.0.53 -> 7.0.54")).toBeNull();
});
it("orders numeric dotted versions with different segment counts", () => {
expect(compareLunarisVersions("7.0.54", "7.0.53.2")).toBe(1);
expect(compareLunarisVersions("7.0.54", "7.0.54.0")).toBe(0);
expect(compareLunarisVersions("7.0.53.2", "7.0.54")).toBe(-1);
});
});
+33
View File
@@ -0,0 +1,33 @@
import * as z from "zod";
export const lunarisVersionSchema = z.string().regex(/^\d+(?:\.\d+)+$/u);
export interface LunarisVersionChange {
previousVersion: string;
targetVersion: string;
}
export function parseLunarisVersionChange(
message: string,
): LunarisVersionChange | null {
const match = message.match(
/^Version Change:\s*(\d+(?:\.\d+)+)\s*→\s*(\d+(?:\.\d+)+)\s*$/imu,
);
if (!match) return null;
return {
previousVersion: lunarisVersionSchema.parse(match[1]),
targetVersion: lunarisVersionSchema.parse(match[2]),
};
}
export function compareLunarisVersions(left: string, right: string): number {
const leftParts = lunarisVersionSchema.parse(left).split(".").map(Number);
const rightParts = lunarisVersionSchema.parse(right).split(".").map(Number);
const length = Math.max(leftParts.length, rightParts.length);
for (let index = 0; index < length; index += 1) {
const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
if (difference !== 0) return Math.sign(difference);
}
return 0;
}