import { UserKey, CharName, Nation, ClassKey, Character, CLASSES } from "@types"; import { Config } from "@systems/config"; import { Bringer } from "@systems/bringer"; import { Nations } from "@systems/nations"; import { Store } from "@systems/store"; import { Paths } from "@paths"; import { TGKey } from "@systems/tg-key"; import { Runtime } from "@systems/runtime"; import { Logger } from "@systems/logger"; import { CharacterRegistry } from "@registry/character-registry"; const log = Logger.for("wrank"); // ─── Runtime ────────────────────────────────────────────────────────────────── Runtime.phase("load", () => WRank.load(), { name: "WRank.load" }); // ─── Types ──────────────────────────────────────────────────────────────────── interface SerializableWRankEntry { userKey: UserKey; characterName: CharName; class: ClassKey; nation: Nation; weeklyPoints: number; tgCount: number; currentRank: number; previousRank?: number; lastRankChangeAt?: string; // ISO timestamp — used for delta snapshot timing } /** Runtime shape — Character object instead of flat fields */ export interface WRankEntry { character: Character; weeklyPoints: number; tgCount: number; currentRank: number; previousRank?: number; lastRankChangeAt?: string; } export interface WRankWeek { weekKey: string; entries: Record; scoreIndex: Record; bringer: { [Nation.Capella]: string | null; [Nation.Procyon]: string | null; capellaOverride?: string; procyonOverride?: string; }; } export interface WRankData { [weekKey: string]: WRankWeek; } // ─── State ──────────────────────────────────────────────────────────────────── let _data: WRankData = {}; // ─── Hydration ──────────────────────────────────────────────────────────────── function hydrateEntry(raw: SerializableWRankEntry): WRankEntry { const found = CharacterRegistry.find(raw.characterName); const character: Character = found ?? { name: raw.characterName, class: CLASSES[raw.class] ?? { key: raw.class, name: raw.class, shortName: raw.class }, level: 0, nation: raw.nation, ownerKey: raw.userKey, }; return { character, weeklyPoints: raw.weeklyPoints, tgCount: raw.tgCount, currentRank: raw.currentRank, previousRank: raw.previousRank, lastRankChangeAt: raw.lastRankChangeAt, }; } // ─── Internal helpers ───────────────────────────────────────────────────────── function ensureWeek(weekKey: string): WRankWeek { if (!_data[weekKey]) { _data[weekKey] = { weekKey, entries: { [Nation.Capella]: [], [Nation.Procyon]: [] }, scoreIndex: {}, bringer: { [Nation.Capella]: null, [Nation.Procyon]: null }, }; } return _data[weekKey]; } function recomputeRanks(week: WRankWeek, nation: Nation): void { const list = week.entries[nation]; const sorted = [...list].sort((a, b) => b.weeklyPoints - a.weeklyPoints); sorted.forEach((entry, i) => { const live = list.find((e) => e.characterName === entry.characterName)!; const newRank = i + 1; if (live.currentRank !== 0 && live.currentRank !== newRank) { live.previousRank = live.currentRank; live.lastRankChangeAt = new Date().toISOString(); } live.currentRank = newRank; }); } // ─── WRank namespace ────────────────────────────────────────────────────────── export const WRank = { // ── Persistence ───────────────────────────────────────────────────────────── load(): void { _data = Store.readOrDefault(Paths.data("wrank.json"), {}); }, save(): void { Store.write(Paths.data("wrank.json"), _data); }, // ── Week helpers ───────────────────────────────────────────────────────────── weekKey(date: Date = new Date()): string { const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); const week = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7); return `${d.getUTCFullYear()}-W${String(week).padStart(2, "0")}`; }, currentWeek(): WRankWeek { return ensureWeek(WRank.weekKey()); }, weekFromKey(weekKey: string): WRankWeek | null { return _data[weekKey] ?? null; }, allWeeks(): WRankData { return _data; }, // ── Score recording ────────────────────────────────────────────────────────── recordScore( userKey: UserKey, characterName: CharName, cls: ClassKey, nation: Nation, pts: number, historyKey: TGKey ): void { const week = ensureWeek(WRank.weekKey()); const list = week.entries[nation]; const existing = list.find((e) => e.characterName === characterName); if (existing) { const alreadyCounted = week.scoreIndex[characterName]?.includes(historyKey); if (!alreadyCounted) { existing.weeklyPoints += pts; existing.tgCount += 1; } else { existing.weeklyPoints = existing.weeklyPoints - (existing.weeklyPoints / existing.tgCount) + pts; } existing.class = cls; existing.nation = nation; } else { list.push({ userKey, characterName, class: cls, nation, weeklyPoints: pts, tgCount: 1, currentRank: 0, previousRank: undefined, }); } if (!week.scoreIndex[characterName]) week.scoreIndex[characterName] = []; if (!week.scoreIndex[characterName].includes(historyKey)) { week.scoreIndex[characterName].push(historyKey); } recomputeRanks(week, nation); WRank.save(); }, // ── Entry lookup ───────────────────────────────────────────────────────────── entry(characterName: CharName, nation: Nation, weekKey?: string): WRankEntry | null { const week = weekKey ? (_data[weekKey] ?? null) : WRank.currentWeek(); const list = week.entries[nation]; const raw = list.find((e) => e.characterName === characterName); return raw ? hydrateEntry(raw) : null; }, entriesForNation(nation: Nation, week?: WRankWeek): WRankEntry[] { const _week = week ?? WRank.currentWeek(); return _week.entries[nation].map(hydrateEntry); }, // ── Snapshot ───────────────────────────────────────────────────────────────── /** * Snapshot previousRank = currentRank for entries whose rank hasn't * changed in olderThan ms. Defaults to snapshotting all entries. */ snapshot({ olderThan }: { olderThan?: number } = {}): void { const week = WRank.currentWeek(); const now = Date.now(); for (const nation of [Nation.Capella, Nation.Procyon] as const) { for (const entry of week.entries[nation]) { if (entry.currentRank === 0) continue; if (olderThan) { const lastChange = entry.lastRankChangeAt ? new Date(entry.lastRankChangeAt).getTime() : 0; if (now - lastChange < olderThan) continue; // changed too recently } entry.previousRank = entry.currentRank; } } WRank.save(); log.info("Snapshot complete."); }, // ── Weekly reset ───────────────────────────────────────────────────────────── resetWeek(): void { // Fire the weekly reset on Monday 00:00 (WRank reset) (UTC+2 = Sunday 22:00) const nowUtcNoon = new Date(); nowUtcNoon.setUTCHours(12, 0, 0, 0); const newWeekKey = WRank.weekKey(nowUtcNoon); const prevWeekKey = WRank.weekKey(new Date(nowUtcNoon.getTime() - 7 * 24 * 60 * 60 * 1000)); const prevWeek = _data[prevWeekKey]; const newWeek = ensureWeek(newWeekKey); if (prevWeek) { const goal = Config.get({ section: "wrank", key: "goal" }); for (const nation of [Nation.Capella, Nation.Procyon]) { const rank1 = prevWeek.entries[nation].find((e) => e.currentRank === 1); newWeek.bringer[nation] = (rank1 && rank1.tgCount >= goal) ? rank1.characterName : null; } } WRank.save(); log.info(`Week reset to ${newWeekKey}. Bringer: ${JSON.stringify(newWeek.bringer)}`); }, // ── Bringer (legacy — use Bringer namespace directly) ──────────────────────── getBringer(nation: Nation): string | null { const week = WRank.currentWeek(); return (week.bringer as any)[`${nation}Override`] ?? week.bringer[nation]; }, };