From ed9e7209d0c2a827724395244da680174ba7ae5a Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Thu, 11 Jun 2026 04:29:17 +0100 Subject: [PATCH] feat: WRankEntry hydration, Nation enum keys, Config section access, Cron system rework fix (remove old), WRank delta fix with lastRankChangeAt, midnight snapshot cron --- src/scheduler.ts | 33 -- src/subcommands/bringer/clear.ts | 5 +- src/subcommands/poll/reload.ts | 4 +- src/subcommands/rank/get.ts | 3 +- src/subcommands/rank/post.ts | 13 +- src/subcommands/score/set.ts | 2 +- src/systems/bringer.ts | 23 +- src/systems/format.ts | 3 +- src/systems/nations.ts | 4 + src/systems/{scheduler.ts => scheduler.tsx} | 0 src/systems/scheduler/index.ts | 2 + src/systems/scheduler/midnight-snapshot.ts | 10 + src/systems/scores.ts | 4 +- src/systems/tg.ts | 12 +- src/systems/wrank.ts | 358 +++++++++++--------- src/types.ts | 45 --- src/ui/poll/layouts/default.ts | 3 +- src/ui/poll/layouts/side-by-side.ts | 3 +- 18 files changed, 241 insertions(+), 286 deletions(-) delete mode 100644 src/scheduler.ts rename src/systems/{scheduler.ts => scheduler.tsx} (100%) create mode 100644 src/systems/scheduler/midnight-snapshot.ts diff --git a/src/scheduler.ts b/src/scheduler.ts deleted file mode 100644 index 3635afb..0000000 --- a/src/scheduler.ts +++ /dev/null @@ -1,33 +0,0 @@ -import cron from "node-cron"; -import { TextChannel } from "discord.js"; - -// Lock poll at TG start (20:00), reveal Submit Score button at TG end (20:35) -// Runs daily — no-ops silently if no poll is active for that slot. - -cron.schedule("0 20 * * *", async () => { - const slot = 20; - const state = polls.get(slot); - if (!state || state.locked) return; - - lockPoll(slot); - - const channel = await client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel; - await updatePollMessage(channel, slot, Config.get({ section: "poll", key: "lockMessage" })); - console.log(`[${new Date().toISOString()}] Poll locked for ${slot}:00.`); -}); - -cron.schedule("35 20 * * *", async () => { - const slot = 20; - const state = polls.get(slot); - if (!state) return; - - const channel = await client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel; - await updatePollMessage(channel, slot, undefined, true); // showSubmit = true - console.log(`[${new Date().toISOString()}] Submit Score button shown for ${slot}:00 TG.`); -}); - -// ─── NOTE on future slots ───────────────────────────────────────────────────── -// -// Right now only slot 20 has an active poll. When we add more votable slots, -// pull the active slot from Config.get({ section: "poll", key: "slots" }).filter(s => s.active) and schedule -// dynamically, or make the cron time configurable in config.json. \ No newline at end of file diff --git a/src/subcommands/bringer/clear.ts b/src/subcommands/bringer/clear.ts index fed2153..ae8bc74 100644 --- a/src/subcommands/bringer/clear.ts +++ b/src/subcommands/bringer/clear.ts @@ -1,13 +1,12 @@ import { ChatInputCommandInteraction } from "discord.js"; -import { clearBringerOverride } from "@systems/wrank"; import { replyAndDelete } from "@utils"; import { Nation } from "@types"; import { Bringer } from "@systems/bringer"; -import { getCurrentWeek, saveWRank } from "@systems/wrank"; +import { WRank } from "@systems/wrank"; export async function handleBringerClear(interaction: ChatInputCommandInteraction): Promise { const nation = interaction.options.getString("nation", true) as Nation; Bringer.clearOverride({ nation }); - saveWRank(); + WRank.save(); return void replyAndDelete(interaction, `✅ Bringer override cleared for **${nation}**.`); } \ No newline at end of file diff --git a/src/subcommands/poll/reload.ts b/src/subcommands/poll/reload.ts index 3c0be2f..b035d08 100644 --- a/src/subcommands/poll/reload.ts +++ b/src/subcommands/poll/reload.ts @@ -2,7 +2,7 @@ import { ChatInputCommandInteraction, TextChannel } from "discord.js"; import { loadMessages } from "@systems/messages"; import { Emoji } from "@systems/emojis"; import { loadCharacters } from "@systems/characters"; -import { loadWRank } from "@systems/wrank"; +import { WRank } from "@systems/wrank"; import { Config } from "@systems/config"; import { polls, updatePollMessage } from "@systems/poll"; import { persist } from "@systems/pollPersistence"; @@ -24,7 +24,7 @@ export async function handleReload(interaction: ChatInputCommandInteraction): Pr if (should("messages")) { loadMessages(); reloaded.push("messages"); } if (should("emojis")) { Emoji.load(); reloaded.push("emojis"); } if (should("characters")) { loadCharacters(); reloaded.push("characters"); } - if (should("wrank")) { loadWRank(); reloaded.push("wrank"); } + if (should("wrank")) { WRank.load(); reloaded.push("wrank"); } // Re-render active poll message(s) so embed reflects reloaded data if (should("poll") || should("emojis") || should("all")) { diff --git a/src/subcommands/rank/get.ts b/src/subcommands/rank/get.ts index fd4ceda..a21c559 100644 --- a/src/subcommands/rank/get.ts +++ b/src/subcommands/rank/get.ts @@ -6,6 +6,7 @@ import { Bringer } from "@systems/bringer"; import { replyAndDelete } from "@src/utils"; import { Nation } from "@types"; import { TG } from "@systems/tg"; +import { Nations } from "@systems/nations"; export async function handleRankGet(interaction: ChatInputCommandInteraction): Promise { const member = await interaction.guild!.members.fetch(interaction.user.id); @@ -30,7 +31,7 @@ export async function handleRankGet(interaction: ChatInputCommandInteraction): P const goal = Config.get({ section: "wrank", key: "goal" }); const weekKey = WRank.weekKey(); - for (const nation of ["capella", "procyon"] as const) { + for (const nation of Nations.all()) { const entry = week.entries[nation].find((e) => e.userKey === userKey); if (!entry) continue; diff --git a/src/subcommands/rank/post.ts b/src/subcommands/rank/post.ts index 206e932..f2cee9e 100644 --- a/src/subcommands/rank/post.ts +++ b/src/subcommands/rank/post.ts @@ -16,27 +16,26 @@ export async function handleRankPost(interaction: ChatInputCommandInteraction): const weekKey = WRank.weekKey(); const formatNation = (nation: Nation): string => { - const key = Nations.key(nation); - const entries = [...week.entries[key]].sort((a, b) => a.currentRank - b.currentRank); + const entries = WRank.entriesForNation(nation).sort((a, b) => a.currentRank - b.currentRank); if (entries.length === 0) return "—"; - const bringer = Bringer.get({ nation: NATION_FROM_KEY[key], week }); + const bringer = Bringer.get({ nation, week }); return entries.map((e) => { const isDone = e.tgCount >= goal; // ── Character indicator ─────────────────────────────────────────────────── - const char = CharacterRegistry.find(e.characterName); + const char = CharacterRegistry.find(e.character.name); console.log(`[rank/post.ts:] char: ${char}`); - const charStr = char ? format.char(char) : `${e.class} ${e.characterName}`; + const charStr = char ? format.char(char) : `${e.character.class} ${e.character.name}`; // ── Rank indicator ─────────────────────────────────────────────────── const rankStr = format.wrank.rank(e, goal); const deltaStr = format.wrank.delta(e, { brackets: false }); // ── Bringer label ──────────────────────────────────────────────────── - const bringerStr = bringer === e.userKey && isDone - ? ` · ${key === "capella" ? "Luminous Bringer" : "Storm Bringer"}` + const bringerStr = bringer === e.character.ownerKey && isDone + ? ` · ${nation === Nation.Capella ? "Luminous Bringer" : "Storm Bringer"}` : ""; // ── Score indicator ─────────────────────────────────────────────────── diff --git a/src/subcommands/score/set.ts b/src/subcommands/score/set.ts index 2e831e4..831c1bd 100644 --- a/src/subcommands/score/set.ts +++ b/src/subcommands/score/set.ts @@ -53,7 +53,7 @@ export async function handleScoreSet(interaction: ChatInputCommandInteraction): characterName: char.name, cls: char.class.key, nation: char.nation, - pts: ptsArg, + pts: ptsArg!, k, d, slot, diff --git a/src/systems/bringer.ts b/src/systems/bringer.ts index 5194ac6..0fce385 100644 --- a/src/systems/bringer.ts +++ b/src/systems/bringer.ts @@ -14,13 +14,6 @@ import { Config } from "@systems/config"; import { WRank, WRankWeek } from "@systems/wrank"; - // ─── Helpers ────────────────────────────────────────────────────────────────── - - const NATION_BRINGER_KEY: Record = { - [Nation.Capella]: "capella", - [Nation.Procyon]: "procyon", - }; - // ─── Namespace ──────────────────────────────────────────────────────────────── export const Bringer = { @@ -29,10 +22,9 @@ * Returns the override if set, otherwise the earned Bringer. */ get({ nation, week }: { nation: Nation; week?: WRankWeek }): string | null { - const key = NATION_BRINGER_KEY[nation]; - const overrideKey = `${key}Override` as "capellaOverride" | "procyonOverride"; + const overrideKey = `${nation}Override` as "capellaOverride" | "procyonOverride"; const _week = week ?? WRank.currentWeek(); - return _week.bringer[overrideKey] ?? _week.bringer[key]; + return _week.bringer[overrideKey] ?? _week.bringer[nation]; }, /** @@ -40,8 +32,7 @@ * Stores the character name — will return Character once Character has ownerKey. */ override({ nation, character, week }: { nation: Nation; character: Character; week?: WRankWeek }): void { - const key = NATION_BRINGER_KEY[nation]; - const overrideKey = `${key}Override` as "capellaOverride" | "procyonOverride"; + const overrideKey = `${nation}Override` as "capellaOverride" | "procyonOverride"; const _week = week ?? WRank.currentWeek(); _week.bringer[overrideKey] = character.name; WRank.save(); @@ -51,8 +42,7 @@ * Clear the manual Bringer override for a nation. */ clearOverride({ nation, week }: { nation: Nation; week?: WRankWeek }): void { - const key = NATION_BRINGER_KEY[nation]; - const overrideKey = `${key}Override` as "capellaOverride" | "procyonOverride"; + const overrideKey = `${nation}Override` as "capellaOverride" | "procyonOverride"; const _week = week ?? WRank.currentWeek(); delete _week.bringer[overrideKey]; WRank.save(); @@ -65,13 +55,12 @@ update({ week }: { week?: WRankWeek }): void { const goal = Config.get({ section: "wrank", key: "goal" }); for (const nation of [Nation.Capella, Nation.Procyon]) { - const key = NATION_BRINGER_KEY[nation]; const _week = week ?? WRank.currentWeek(); - const list = _week.entries[key]; + const list = _week.entries[nation]; const qualified = list .filter((e) => e.tgCount >= goal) .sort((a, b) => a.currentRank - b.currentRank); - _week.bringer[key] = qualified[0]?.characterName ?? null; + _week.bringer[nation] = qualified[0]?.characterName ?? null; } WRank.save(); diff --git a/src/systems/format.ts b/src/systems/format.ts index 33af684..e6fed28 100644 --- a/src/systems/format.ts +++ b/src/systems/format.ts @@ -1,5 +1,6 @@ -import { Character, ClassKey, CharacterClass, Nation, WRankEntry } from "@src/types"; +import { Character, ClassKey, CharacterClass, Nation } from "@src/types"; import { Emoji } from "@systems/emojis"; +import { WRankEntry } from "@systems/wrank"; // ─── Individual formatters ──────────────────────────────────────────────────── diff --git a/src/systems/nations.ts b/src/systems/nations.ts index 03d6a12..4b2fc23 100644 --- a/src/systems/nations.ts +++ b/src/systems/nations.ts @@ -18,6 +18,10 @@ export const NATION_FROM_KEY: Record<"capella" | "procyon", Nation> = { }; export const Nations = { + all(): Nation[] { + return [Nation.Capella, Nation.Procyon]; + }, + key(nation: Nation): "capella" | "procyon" { return NATION_KEY[nation]; }, diff --git a/src/systems/scheduler.ts b/src/systems/scheduler.tsx similarity index 100% rename from src/systems/scheduler.ts rename to src/systems/scheduler.tsx diff --git a/src/systems/scheduler/index.ts b/src/systems/scheduler/index.ts index d0e00db..a64198c 100644 --- a/src/systems/scheduler/index.ts +++ b/src/systems/scheduler/index.ts @@ -17,10 +17,12 @@ // Import all jobs import { job as weeklyReset } from "@scheduler/weekly-reset"; import { job as midnightCleanup } from "@scheduler/midnight-cleanup"; + import { job as midnightSnapshot } from "@scheduler/midnight-snapshot"; const STATIC_JOBS: ScheduledJob[] = [ weeklyReset, midnightCleanup, + midnightSnapshot ]; type PollCallback = (slot: TGSlot) => Promise; diff --git a/src/systems/scheduler/midnight-snapshot.ts b/src/systems/scheduler/midnight-snapshot.ts new file mode 100644 index 0000000..9184899 --- /dev/null +++ b/src/systems/scheduler/midnight-snapshot.ts @@ -0,0 +1,10 @@ +import { ScheduledJob } from "./types"; +import { WRank } from "@systems/wrank"; + +export const job: ScheduledJob = { + name: "midnight-wrank-snapshot", + cron: "0 0 * * *", + run() { + WRank.snapshot({ olderThan: 24 * 60 * 60 * 1000 }); // only snapshot entries unchanged for 24h+ + }, +}; \ No newline at end of file diff --git a/src/systems/scores.ts b/src/systems/scores.ts index a728743..d7ee543 100644 --- a/src/systems/scores.ts +++ b/src/systems/scores.ts @@ -1,7 +1,7 @@ import { TGScore, Nation, ClassKey } from "../types"; import { Config } from "./config"; import { upsertScore, todayString } from "./history"; -import { recordScore } from "./wrank"; +import { WRank } from "./wrank"; // Normalize a slot string to a 24h integer hour // Accepts: "20", "8", "8pm", "20:00", "midnight", "midday", "noon" @@ -92,5 +92,5 @@ export function submitScore(sub: ScoreSubmission): void { }; upsertScore(score); - recordScore(sub.userKey, sub.characterName, sub.cls, sub.nation, sub.pts, historyKey); + WRank.recordScore(sub.userKey, sub.characterName, sub.cls, sub.nation, sub.pts, historyKey); } diff --git a/src/systems/tg.ts b/src/systems/tg.ts index 70bcd44..3ae944a 100644 --- a/src/systems/tg.ts +++ b/src/systems/tg.ts @@ -43,20 +43,19 @@ if (prevWeek) { const goal = Config.get({ section: "wrank", key: "goal" }); for (const nation of [Nation.Capella, Nation.Procyon]) { - const key = Nations.key(nation); - const entries = prevWeek.entries[key]; + const entries = prevWeek.entries[nation]; const rank1 = entries.find((e) => e.currentRank === 1); // Rank 1 with >= goal TGs becomes Bringer — no exceptions // Officers use Bringer.override() for manual adjustments if (rank1 && rank1.tgCount >= goal) { - newWeek.bringer[key] = rank1.characterName; + newWeek.bringer[nation] = rank1.characterName; } else { - newWeek.bringer[key] = null; + newWeek.bringer[nation] = null; } // Overrides do NOT carry forward — each week starts clean - delete (newWeek.bringer as any)[`${key}Override`]; + delete (newWeek.bringer as any)[`${nation}Override`]; } } @@ -103,9 +102,6 @@ getBringer({ nation }: { nation: Nation }): string | null { return Bringer.get({ nation }); }, - getBringer1(nation: Nation): string | null { - return Bringer.get({ nation }); - }, // ── W.Rank ──────────────────────────────────────────────────────────────── diff --git a/src/systems/wrank.ts b/src/systems/wrank.ts index ef474ad..03139c0 100644 --- a/src/systems/wrank.ts +++ b/src/systems/wrank.ts @@ -1,40 +1,51 @@ -import fs from "fs"; -import path from "path"; -import { HistoryKey, UserKey, CharName, Nation, ClassKey } from "@types"; +import { HistoryKey, 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 { 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" }); -const WRANK_PATH = path.join(__dirname, "../../data/wrank.json"); -let _data: WRankData = {}; +// ─── Types ──────────────────────────────────────────────────────────────────── -/** Raw shape stored in wrank.json */ interface SerializableWRankEntry { - userKey: UserKey; - characterName: CharName; - class: ClassKey; - nation: Nation; - weeklyPoints: number; - tgCount: number; - currentRank: number; - previousRank?: number; + 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; // "2026-W22" - entries: Record<"capella" | "procyon", SerializableWRankEntry[]>; // still serializable for now + weekKey: string; + entries: Record; scoreIndex: Record; bringer: { - capella: string | null; // userKey of bringer, null if none qualified - procyon: string | null; - capellaOverride?: string; // manually set by officer - procyonOverride?: string; + [Nation.Capella]: string | null; + [Nation.Procyon]: string | null; + capellaOverride?: string; + procyonOverride?: string; }; } @@ -42,183 +53,202 @@ export interface WRankData { [weekKey: string]: WRankWeek; } -export function loadWRank(): void { - _data = Store.readOrDefault(Paths.data("wrank.json"), {}); +// ─── 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, + }; } -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")}`; -} +// ─── Internal helpers ───────────────────────────────────────────────────────── function ensureWeek(weekKey: string): WRankWeek { if (!_data[weekKey]) { _data[weekKey] = { weekKey, - entries: { capella: [], procyon: [] }, + entries: { [Nation.Capella]: [], [Nation.Procyon]: [] }, scoreIndex: {}, - bringer: { capella: null, procyon: null }, + bringer: { [Nation.Capella]: null, [Nation.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 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; - // Only snapshot previousRank when rank actually changes if (live.currentRank !== 0 && live.currentRank !== newRank) { - live.previousRank = live.currentRank; + live.previousRank = live.currentRank; + live.lastRankChangeAt = new Date().toISOString(); } live.currentRank = newRank; }); } -function updateBringer(week: WRankWeek): void { - const goal = Config.get({ section: "wrank", key: "goal" }); - 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; +// ─── WRank namespace ────────────────────────────────────────────────────────── +export const WRank = { - const qualified = week.entries[nation] - .filter((e) => e.tgCount >= goal) - .sort((a, b) => a.currentRank - b.currentRank); - week.bringer[nation] = qualified[0]?.characterName ?? null; - } -} + // ── Persistence ───────────────────────────────────────────────────────────── + + load(): void { + _data = Store.readOrDefault(Paths.data("wrank.json"), {}); + }, -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(); -} + save(): void { + Store.write(Paths.data("wrank.json"), _data); + }, -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(); -} + // ── Week helpers ───────────────────────────────────────────────────────────── -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; -} + 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")}`; + }, -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; -} + currentWeek(): WRankWeek { + return ensureWeek(WRank.weekKey()); + }, -// 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())); + weekFromKey(weekKey: string): WRankWeek | null { + return _data[weekKey] ?? null; + }, - 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; + // ── Score recording ────────────────────────────────────────────────────────── + + recordScore( + userKey: UserKey, + characterName: CharName, + cls: ClassKey, + nation: Nation, + pts: number, + historyKey: HistoryKey + ): 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): WRankEntry | null { + const week = WRank.currentWeek(); + const list = week.entries[nation]; + const raw = list.find((e) => e.characterName === characterName); + return raw ? hydrateEntry(raw) : null; + }, + + entriesForNation(nation: Nation): WRankEntry[] { + const 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(); -} + WRank.save(); + log.info("Snapshot complete."); + }, -export const WRank = { - save: saveWRank, - load: loadWRank, - currentWeek: getCurrentWeek, - weekFromKey: getWeek, - weekKey: getWeekKey, - recordScore, - entry: getEntry, - resetWeek, + // ── Weekly reset ───────────────────────────────────────────────────────────── + + resetWeek(): void { + const prevWeekKey = WRank.weekKey(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)); + const prevWeek = _data[prevWeekKey]; + const newWeek = ensureWeek(WRank.weekKey()); + + if (prevWeek) { + Bringer.update({ week: prevWeek }); + for (const nation of [Nation.Capella, Nation.Procyon]) { + const bringer = prevWeek.bringer[nation]; + if (bringer) newWeek.bringer[nation] = bringer; + } + } + + WRank.save(); + log.info(`Reset to ${WRank.weekKey()}.`); + }, + + // ── 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]; + }, }; \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 5ec91db..b9294d4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -231,18 +231,6 @@ export interface TGResult { // ─── W.Rank ────────────────────────────────────────────────────────────────── -// temporary until WRank refactor -export interface WRankEntry { - userKey: UserKey; - characterName: CharName; - class: ClassKey; - nation: Nation; - weeklyPoints: number; - tgCount: number; - currentRank: number; - previousRank?: number; -} - // export interface WRankEntry { // character: Character; // weeklyPoints: number; @@ -264,39 +252,6 @@ export interface WRankEntry { // activeCharacter: Character | null; // } -// export interface WRankEntry { -// identity: UserIdentity, -// character: Character, -// weeklyPoints: number; // cumulative pts this week -// tgCount: number; // number of slots with submission this week -// currentRank: number; // computed after each submission -// previousRank?: number; // before latest recomputation -// } - -// function serialize(entry: WRankEntry): SerializableWRankEntry { -// return { -// userKey: entry.identity.userKey, -// characterName: entry.character.name, -// class: entry.character.class, -// nation: entry.character.nation, -// weeklyPoints: entry.weeklyPoints, -// tgCount: entry.tgCount, -// currentRank: entry.currentRank, -// previousRank: entry.previousRank -// }; -// } - -// interface SerializableWRankEntry { -// userKey: string | null; -// characterName: string; // snapshotted -// class: ClassKey; // snapshotted -// nation: Nation; // snapshotted -// weeklyPoints: number; // cumulative pts this week -// tgCount: number; // number of slots with submission this week -// currentRank: number; // computed after each submission -// previousRank?: number; // before latest recomputation -// } - // ─── Bringer ───────────────────────────────────────────────────────────────── export interface BringerState { diff --git a/src/ui/poll/layouts/default.ts b/src/ui/poll/layouts/default.ts index 28468bb..dc0b712 100644 --- a/src/ui/poll/layouts/default.ts +++ b/src/ui/poll/layouts/default.ts @@ -4,7 +4,8 @@ */ import { EmbedBuilder } from "discord.js"; - import { PollState, VoteEntry, Nation, WRankEntry } from "@types"; + import { PollState, VoteEntry, Nation } from "@types"; + import { WRankEntry } from "@systems/wrank"; import { Config } from "@systems/config"; import { WRank } from "@systems/wrank"; import { Bringer } from "@systems/bringer"; diff --git a/src/ui/poll/layouts/side-by-side.ts b/src/ui/poll/layouts/side-by-side.ts index 1102a84..e1febf2 100644 --- a/src/ui/poll/layouts/side-by-side.ts +++ b/src/ui/poll/layouts/side-by-side.ts @@ -4,7 +4,8 @@ */ import { EmbedBuilder } from "discord.js"; - import { PollState, VoteEntry, Nation, WRankEntry } from "@types"; + import { PollState, VoteEntry, Nation } from "@types"; + import { WRankEntry } from "@systems/wrank"; import { Config } from "@systems/config"; import { WRank } from "@systems/wrank"; import { Bringer } from "@systems/bringer";