import fs from "fs"; import path from "path"; import { HistoryKey, UserKey, CharName, Nation, ClassKey } from "@types"; import { cfg } from "@systems/config"; import { Bringer } from "@systems/bringer"; import { Nations } from "@systems/nations"; import { Store } from "@systems/store"; import { Paths } from "@paths"; const WRANK_PATH = path.join(__dirname, "../../data/wrank.json"); let _data: WRankData = {}; /** Raw shape stored in wrank.json */ interface SerializableWRankEntry { userKey: UserKey; characterName: CharName; class: ClassKey; nation: Nation; weeklyPoints: number; tgCount: number; currentRank: number; previousRank?: number; } export interface WRankWeek { weekKey: string; // "2026-W22" entries: Record<"capella" | "procyon", SerializableWRankEntry[]>; // still serializable for now scoreIndex: Record; bringer: { capella: string | null; // userKey of bringer, null if none qualified procyon: string | null; capellaOverride?: string; // manually set by officer procyonOverride?: string; }; } export interface WRankData { [weekKey: string]: WRankWeek; } export function loadWRank(): void { _data = Store.readOrDefault(Paths.data("wrank.json"), {}); } export function saveWRank(): void { Store.write(Paths.data("wrank.json"), _data); } export function getWeekKey(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")}`; } function ensureWeek(weekKey: string): WRankWeek { if (!_data[weekKey]) { _data[weekKey] = { weekKey, entries: { capella: [], procyon: [] }, scoreIndex: {}, bringer: { capella: null, procyon: null }, }; } return _data[weekKey]; } export function getCurrentWeek(): WRankWeek { return ensureWeek(WRank.weekKey()); } export function getWeek(weekKey: string): WRankWeek | null { return _data[weekKey] ?? null; } // Add or update a score submission for a player export function recordScore( userKey: string, characterName: string, cls: ClassKey, nation: Nation, pts: number, historyKey: string // e.g. "2026-05-31-20" ): void { const weekKey = WRank.weekKey(); const week = ensureWeek(weekKey); const list = week.entries[Nations.key(nation)]; const existing = list.find((e) => e.characterName === characterName); if (existing) { // Check if this slot was already counted const alreadyCounted = week.scoreIndex[userKey]?.includes(historyKey); if (!alreadyCounted) { existing.weeklyPoints += pts; existing.tgCount += 1; } else { // Overwrite: recalculate by removing old pts for this slot // We'll just set the new pts — full recalc would require reading history // For now, simple overwrite of total is handled at score submission level existing.weeklyPoints = existing.weeklyPoints - (existing.weeklyPoints / existing.tgCount) + pts; } existing.characterName = characterName; existing.class = cls; existing.nation = nation; } else { list.push({ userKey, characterName, class: cls, nation, weeklyPoints: pts, tgCount: 1, currentRank: 0, previousRank: undefined, }); } // Update score index const indexKey = characterName; if (!week.scoreIndex[indexKey]) week.scoreIndex[indexKey] = []; if (!week.scoreIndex[indexKey].includes(historyKey)) { week.scoreIndex[indexKey].push(historyKey); } recomputeRanks(week, nation); saveWRank(); } function recomputeRanks(week: WRankWeek, nation: Nation): void { const list = week.entries[nation.toLowerCase() as "capella" | "procyon"]; 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; // Only snapshot previousRank when rank actually changes if (live.currentRank !== 0 && live.currentRank !== newRank) { live.previousRank = live.currentRank; } live.currentRank = newRank; }); } function updateBringer(week: WRankWeek): void { const goal = cfg("wRankGoal"); for (const nation of ["capella", "procyon"] as const) { // Don't overwrite manual override if (nation === "capella" && week.bringer.capellaOverride) continue; if (nation === "procyon" && week.bringer.procyonOverride) continue; const qualified = week.entries[nation] .filter((e) => e.tgCount >= goal) .sort((a, b) => a.currentRank - b.currentRank); week.bringer[nation] = qualified[0]?.characterName ?? null; } } export function setBringerOverride(nation: Nation, charName: string): void { const week = ensureWeek(WRank.weekKey()); if (nation === Nation.Capella) week.bringer.capellaOverride = charName; else week.bringer.procyonOverride = charName; saveWRank(); } export function clearBringerOverride(nation: Nation): void { const week = ensureWeek(WRank.weekKey()); if (nation === Nation.Capella) delete week.bringer.capellaOverride; else delete week.bringer.procyonOverride; updateBringer(week); saveWRank(); } export function getBringer(nation: Nation): string | null { const week = getCurrentWeek(); if (nation === Nation.Capella) return week.bringer.capellaOverride ?? week.bringer.capella; return week.bringer.procyonOverride ?? week.bringer.procyon; } export function getEntry(characterName: string, nation: Nation): SerializableWRankEntry | null { const week = getCurrentWeek(); const list = week.entries[nation.toLowerCase() as "capella" | "procyon"]; console.log(`[getEntry] weekKey=${week.weekKey} nation=${nation} listLength=${list?.length} looking for=${characterName}`); console.log(`[getEntry] available:`, list?.map(e => e.characterName)); return list.find((e) => e.characterName === characterName) ?? null; } // Called every Monday 00:00 by cron export function resetWeek(): void { // Week is already archived in _data by weekKey — just ensure next week exists const prevWeekKey = WRank.weekKey(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)); const prevWeek = _data[prevWeekKey]; const newWeek = ensureWeek(WRank.weekKey(new Date())); if (prevWeek) { // Carry Bringer forward — W.Rank 1 with goal achieved becomes new Bringer Bringer.update({ week: prevWeek }); for (const nation of [Nation.Capella, Nation.Procyon]) { const key = nation === Nation.Capella ? "capella" : "procyon"; const bringer = prevWeek.bringer[key]; if (bringer) { // Set as override in new week so it carries forward newWeek.bringer[key] = bringer; } } } WRank.save(); } export const WRank = { save: saveWRank, load: loadWRank, currentWeek: getCurrentWeek, weekFromKey: getWeek, weekKey: getWeekKey, recordScore, entry: getEntry, resetWeek, };