feat: WRankEntry hydration, Nation enum keys, Config section access, Cron system rework fix (remove old), WRank delta fix with lastRankChangeAt, midnight snapshot cron

This commit is contained in:
Nuno Duque Nunes 2026-06-11 04:29:17 +01:00
parent 17ff1d932f
commit ed9e7209d0
18 changed files with 241 additions and 286 deletions

View file

@ -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.

View file

@ -1,13 +1,12 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { clearBringerOverride } from "@systems/wrank";
import { replyAndDelete } from "@utils"; import { replyAndDelete } from "@utils";
import { Nation } from "@types"; import { Nation } from "@types";
import { Bringer } from "@systems/bringer"; import { Bringer } from "@systems/bringer";
import { getCurrentWeek, saveWRank } from "@systems/wrank"; import { WRank } from "@systems/wrank";
export async function handleBringerClear(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleBringerClear(interaction: ChatInputCommandInteraction): Promise<void> {
const nation = interaction.options.getString("nation", true) as Nation; const nation = interaction.options.getString("nation", true) as Nation;
Bringer.clearOverride({ nation }); Bringer.clearOverride({ nation });
saveWRank(); WRank.save();
return void replyAndDelete(interaction, `✅ Bringer override cleared for **${nation}**.`); return void replyAndDelete(interaction, `✅ Bringer override cleared for **${nation}**.`);
} }

View file

@ -2,7 +2,7 @@ import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { loadMessages } from "@systems/messages"; import { loadMessages } from "@systems/messages";
import { Emoji } from "@systems/emojis"; import { Emoji } from "@systems/emojis";
import { loadCharacters } from "@systems/characters"; import { loadCharacters } from "@systems/characters";
import { loadWRank } from "@systems/wrank"; import { WRank } from "@systems/wrank";
import { Config } from "@systems/config"; import { Config } from "@systems/config";
import { polls, updatePollMessage } from "@systems/poll"; import { polls, updatePollMessage } from "@systems/poll";
import { persist } from "@systems/pollPersistence"; 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("messages")) { loadMessages(); reloaded.push("messages"); }
if (should("emojis")) { Emoji.load(); reloaded.push("emojis"); } if (should("emojis")) { Emoji.load(); reloaded.push("emojis"); }
if (should("characters")) { loadCharacters(); reloaded.push("characters"); } 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 // Re-render active poll message(s) so embed reflects reloaded data
if (should("poll") || should("emojis") || should("all")) { if (should("poll") || should("emojis") || should("all")) {

View file

@ -6,6 +6,7 @@ import { Bringer } from "@systems/bringer";
import { replyAndDelete } from "@src/utils"; import { replyAndDelete } from "@src/utils";
import { Nation } from "@types"; import { Nation } from "@types";
import { TG } from "@systems/tg"; import { TG } from "@systems/tg";
import { Nations } from "@systems/nations";
export async function handleRankGet(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleRankGet(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); 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 goal = Config.get({ section: "wrank", key: "goal" });
const weekKey = WRank.weekKey(); 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); const entry = week.entries[nation].find((e) => e.userKey === userKey);
if (!entry) continue; if (!entry) continue;

View file

@ -16,27 +16,26 @@ export async function handleRankPost(interaction: ChatInputCommandInteraction):
const weekKey = WRank.weekKey(); const weekKey = WRank.weekKey();
const formatNation = (nation: Nation): string => { const formatNation = (nation: Nation): string => {
const key = Nations.key(nation); const entries = WRank.entriesForNation(nation).sort((a, b) => a.currentRank - b.currentRank);
const entries = [...week.entries[key]].sort((a, b) => a.currentRank - b.currentRank);
if (entries.length === 0) return "—"; 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) => { return entries.map((e) => {
const isDone = e.tgCount >= goal; const isDone = e.tgCount >= goal;
// ── Character indicator ─────────────────────────────────────────────────── // ── Character indicator ───────────────────────────────────────────────────
const char = CharacterRegistry.find(e.characterName); const char = CharacterRegistry.find(e.character.name);
console.log(`[rank/post.ts:] char: ${char}`); 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 ─────────────────────────────────────────────────── // ── Rank indicator ───────────────────────────────────────────────────
const rankStr = format.wrank.rank(e, goal); const rankStr = format.wrank.rank(e, goal);
const deltaStr = format.wrank.delta(e, { brackets: false }); const deltaStr = format.wrank.delta(e, { brackets: false });
// ── Bringer label ──────────────────────────────────────────────────── // ── Bringer label ────────────────────────────────────────────────────
const bringerStr = bringer === e.userKey && isDone const bringerStr = bringer === e.character.ownerKey && isDone
? ` · ${key === "capella" ? "Luminous Bringer" : "Storm Bringer"}` ? ` · ${nation === Nation.Capella ? "Luminous Bringer" : "Storm Bringer"}`
: ""; : "";
// ── Score indicator ─────────────────────────────────────────────────── // ── Score indicator ───────────────────────────────────────────────────

View file

@ -53,7 +53,7 @@ export async function handleScoreSet(interaction: ChatInputCommandInteraction):
characterName: char.name, characterName: char.name,
cls: char.class.key, cls: char.class.key,
nation: char.nation, nation: char.nation,
pts: ptsArg, pts: ptsArg!,
k, k,
d, d,
slot, slot,

View file

@ -14,13 +14,6 @@
import { Config } from "@systems/config"; import { Config } from "@systems/config";
import { WRank, WRankWeek } from "@systems/wrank"; import { WRank, WRankWeek } from "@systems/wrank";
// ─── Helpers ──────────────────────────────────────────────────────────────────
const NATION_BRINGER_KEY: Record<Nation, "capella" | "procyon"> = {
[Nation.Capella]: "capella",
[Nation.Procyon]: "procyon",
};
// ─── Namespace ──────────────────────────────────────────────────────────────── // ─── Namespace ────────────────────────────────────────────────────────────────
export const Bringer = { export const Bringer = {
@ -29,10 +22,9 @@
* Returns the override if set, otherwise the earned Bringer. * Returns the override if set, otherwise the earned Bringer.
*/ */
get({ nation, week }: { nation: Nation; week?: WRankWeek }): string | null { get({ nation, week }: { nation: Nation; week?: WRankWeek }): string | null {
const key = NATION_BRINGER_KEY[nation]; const overrideKey = `${nation}Override` as "capellaOverride" | "procyonOverride";
const overrideKey = `${key}Override` as "capellaOverride" | "procyonOverride";
const _week = week ?? WRank.currentWeek(); 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. * Stores the character name will return Character once Character has ownerKey.
*/ */
override({ nation, character, week }: { nation: Nation; character: Character; week?: WRankWeek }): void { override({ nation, character, week }: { nation: Nation; character: Character; week?: WRankWeek }): void {
const key = NATION_BRINGER_KEY[nation]; const overrideKey = `${nation}Override` as "capellaOverride" | "procyonOverride";
const overrideKey = `${key}Override` as "capellaOverride" | "procyonOverride";
const _week = week ?? WRank.currentWeek(); const _week = week ?? WRank.currentWeek();
_week.bringer[overrideKey] = character.name; _week.bringer[overrideKey] = character.name;
WRank.save(); WRank.save();
@ -51,8 +42,7 @@
* Clear the manual Bringer override for a nation. * Clear the manual Bringer override for a nation.
*/ */
clearOverride({ nation, week }: { nation: Nation; week?: WRankWeek }): void { clearOverride({ nation, week }: { nation: Nation; week?: WRankWeek }): void {
const key = NATION_BRINGER_KEY[nation]; const overrideKey = `${nation}Override` as "capellaOverride" | "procyonOverride";
const overrideKey = `${key}Override` as "capellaOverride" | "procyonOverride";
const _week = week ?? WRank.currentWeek(); const _week = week ?? WRank.currentWeek();
delete _week.bringer[overrideKey]; delete _week.bringer[overrideKey];
WRank.save(); WRank.save();
@ -65,13 +55,12 @@
update({ week }: { week?: WRankWeek }): void { update({ week }: { week?: WRankWeek }): void {
const goal = Config.get({ section: "wrank", key: "goal" }); const goal = Config.get({ section: "wrank", key: "goal" });
for (const nation of [Nation.Capella, Nation.Procyon]) { for (const nation of [Nation.Capella, Nation.Procyon]) {
const key = NATION_BRINGER_KEY[nation];
const _week = week ?? WRank.currentWeek(); const _week = week ?? WRank.currentWeek();
const list = _week.entries[key]; const list = _week.entries[nation];
const qualified = list const qualified = list
.filter((e) => e.tgCount >= goal) .filter((e) => e.tgCount >= goal)
.sort((a, b) => a.currentRank - b.currentRank); .sort((a, b) => a.currentRank - b.currentRank);
_week.bringer[key] = qualified[0]?.characterName ?? null; _week.bringer[nation] = qualified[0]?.characterName ?? null;
} }
WRank.save(); WRank.save();

View file

@ -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 { Emoji } from "@systems/emojis";
import { WRankEntry } from "@systems/wrank";
// ─── Individual formatters ──────────────────────────────────────────────────── // ─── Individual formatters ────────────────────────────────────────────────────

View file

@ -18,6 +18,10 @@ export const NATION_FROM_KEY: Record<"capella" | "procyon", Nation> = {
}; };
export const Nations = { export const Nations = {
all(): Nation[] {
return [Nation.Capella, Nation.Procyon];
},
key(nation: Nation): "capella" | "procyon" { key(nation: Nation): "capella" | "procyon" {
return NATION_KEY[nation]; return NATION_KEY[nation];
}, },

View file

@ -17,10 +17,12 @@
// Import all jobs // Import all jobs
import { job as weeklyReset } from "@scheduler/weekly-reset"; import { job as weeklyReset } from "@scheduler/weekly-reset";
import { job as midnightCleanup } from "@scheduler/midnight-cleanup"; import { job as midnightCleanup } from "@scheduler/midnight-cleanup";
import { job as midnightSnapshot } from "@scheduler/midnight-snapshot";
const STATIC_JOBS: ScheduledJob[] = [ const STATIC_JOBS: ScheduledJob[] = [
weeklyReset, weeklyReset,
midnightCleanup, midnightCleanup,
midnightSnapshot
]; ];
type PollCallback = (slot: TGSlot) => Promise<void>; type PollCallback = (slot: TGSlot) => Promise<void>;

View file

@ -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+
},
};

View file

@ -1,7 +1,7 @@
import { TGScore, Nation, ClassKey } from "../types"; import { TGScore, Nation, ClassKey } from "../types";
import { Config } from "./config"; import { Config } from "./config";
import { upsertScore, todayString } from "./history"; import { upsertScore, todayString } from "./history";
import { recordScore } from "./wrank"; import { WRank } from "./wrank";
// Normalize a slot string to a 24h integer hour // Normalize a slot string to a 24h integer hour
// Accepts: "20", "8", "8pm", "20:00", "midnight", "midday", "noon" // Accepts: "20", "8", "8pm", "20:00", "midnight", "midday", "noon"
@ -92,5 +92,5 @@ export function submitScore(sub: ScoreSubmission): void {
}; };
upsertScore(score); 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);
} }

View file

@ -43,20 +43,19 @@
if (prevWeek) { if (prevWeek) {
const goal = Config.get({ section: "wrank", key: "goal" }); const goal = Config.get({ section: "wrank", key: "goal" });
for (const nation of [Nation.Capella, Nation.Procyon]) { for (const nation of [Nation.Capella, Nation.Procyon]) {
const key = Nations.key(nation); const entries = prevWeek.entries[nation];
const entries = prevWeek.entries[key];
const rank1 = entries.find((e) => e.currentRank === 1); const rank1 = entries.find((e) => e.currentRank === 1);
// Rank 1 with >= goal TGs becomes Bringer — no exceptions // Rank 1 with >= goal TGs becomes Bringer — no exceptions
// Officers use Bringer.override() for manual adjustments // Officers use Bringer.override() for manual adjustments
if (rank1 && rank1.tgCount >= goal) { if (rank1 && rank1.tgCount >= goal) {
newWeek.bringer[key] = rank1.characterName; newWeek.bringer[nation] = rank1.characterName;
} else { } else {
newWeek.bringer[key] = null; newWeek.bringer[nation] = null;
} }
// Overrides do NOT carry forward — each week starts clean // 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 { getBringer({ nation }: { nation: Nation }): string | null {
return Bringer.get({ nation }); return Bringer.get({ nation });
}, },
getBringer1(nation: Nation): string | null {
return Bringer.get({ nation });
},
// ── W.Rank ──────────────────────────────────────────────────────────────── // ── W.Rank ────────────────────────────────────────────────────────────────

View file

@ -1,20 +1,20 @@
import fs from "fs"; import { HistoryKey, UserKey, CharName, Nation, ClassKey, Character, CLASSES } from "@types";
import path from "path";
import { HistoryKey, UserKey, CharName, Nation, ClassKey } from "@types";
import { Config } from "@systems/config"; import { Config } from "@systems/config";
import { Bringer } from "@systems/bringer"; import { Bringer } from "@systems/bringer";
import { Nations } from "@systems/nations"; import { Nations } from "@systems/nations";
import { Store } from "@systems/store"; import { Store } from "@systems/store";
import { Paths } from "@paths"; import { Paths } from "@paths";
import { Runtime } from "@systems/runtime"; import { Runtime } from "@systems/runtime";
import { Logger } from "@systems/logger";
import { CharacterRegistry } from "@registry/character-registry";
const log = Logger.for("wrank");
// ─── Runtime ────────────────────────────────────────────────────────────────── // ─── Runtime ──────────────────────────────────────────────────────────────────
Runtime.phase("load", () => WRank.load(), { name: "WRank.load" }); Runtime.phase("load", () => WRank.load(), { name: "WRank.load" });
const WRANK_PATH = path.join(__dirname, "../../data/wrank.json"); // ─── Types ────────────────────────────────────────────────────────────────────
let _data: WRankData = {};
/** Raw shape stored in wrank.json */
interface SerializableWRankEntry { interface SerializableWRankEntry {
userKey: UserKey; userKey: UserKey;
characterName: CharName; characterName: CharName;
@ -24,16 +24,27 @@ interface SerializableWRankEntry {
tgCount: number; tgCount: number;
currentRank: number; currentRank: number;
previousRank?: 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 { export interface WRankWeek {
weekKey: string; // "2026-W22" weekKey: string;
entries: Record<"capella" | "procyon", SerializableWRankEntry[]>; // still serializable for now entries: Record<Nation, SerializableWRankEntry[]>;
scoreIndex: Record<CharName, HistoryKey[]>; scoreIndex: Record<CharName, HistoryKey[]>;
bringer: { bringer: {
capella: string | null; // userKey of bringer, null if none qualified [Nation.Capella]: string | null;
procyon: string | null; [Nation.Procyon]: string | null;
capellaOverride?: string; // manually set by officer capellaOverride?: string;
procyonOverride?: string; procyonOverride?: string;
}; };
} }
@ -42,70 +53,114 @@ export interface WRankData {
[weekKey: string]: WRankWeek; [weekKey: string]: WRankWeek;
} }
export function loadWRank(): void { // ─── State ────────────────────────────────────────────────────────────────────
_data = Store.readOrDefault<WRankData>(Paths.data("wrank.json"), {});
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 { // ─── Internal helpers ─────────────────────────────────────────────────────────
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 { function ensureWeek(weekKey: string): WRankWeek {
if (!_data[weekKey]) { if (!_data[weekKey]) {
_data[weekKey] = { _data[weekKey] = {
weekKey, weekKey,
entries: { capella: [], procyon: [] }, entries: { [Nation.Capella]: [], [Nation.Procyon]: [] },
scoreIndex: {}, scoreIndex: {},
bringer: { capella: null, procyon: null }, bringer: { [Nation.Capella]: null, [Nation.Procyon]: null },
}; };
} }
return _data[weekKey]; return _data[weekKey];
} }
export function getCurrentWeek(): WRankWeek { 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<WRankData>(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()); return ensureWeek(WRank.weekKey());
} },
export function getWeek(weekKey: string): WRankWeek | null { weekFromKey(weekKey: string): WRankWeek | null {
return _data[weekKey] ?? null; return _data[weekKey] ?? null;
} },
// Add or update a score submission for a player // ── Score recording ──────────────────────────────────────────────────────────
export function recordScore(
userKey: string, recordScore(
characterName: string, userKey: UserKey,
characterName: CharName,
cls: ClassKey, cls: ClassKey,
nation: Nation, nation: Nation,
pts: number, pts: number,
historyKey: string // e.g. "2026-05-31-20" historyKey: HistoryKey
): void { ): void {
const weekKey = WRank.weekKey(); const week = ensureWeek(WRank.weekKey());
const week = ensureWeek(weekKey); const list = week.entries[nation];
const list = week.entries[Nations.key(nation)];
const existing = list.find((e) => e.characterName === characterName); const existing = list.find((e) => e.characterName === characterName);
if (existing) { if (existing) {
// Check if this slot was already counted const alreadyCounted = week.scoreIndex[characterName]?.includes(historyKey);
const alreadyCounted = week.scoreIndex[userKey]?.includes(historyKey);
if (!alreadyCounted) { if (!alreadyCounted) {
existing.weeklyPoints += pts; existing.weeklyPoints += pts;
existing.tgCount += 1; existing.tgCount += 1;
} else { } 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.weeklyPoints = existing.weeklyPoints - (existing.weeklyPoints / existing.tgCount) + pts;
} }
existing.characterName = characterName;
existing.class = cls; existing.class = cls;
existing.nation = nation; existing.nation = nation;
} else { } else {
@ -121,104 +176,79 @@ export function recordScore(
}); });
} }
// Update score index if (!week.scoreIndex[characterName]) week.scoreIndex[characterName] = [];
const indexKey = characterName; if (!week.scoreIndex[characterName].includes(historyKey)) {
if (!week.scoreIndex[indexKey]) week.scoreIndex[indexKey] = []; week.scoreIndex[characterName].push(historyKey);
if (!week.scoreIndex[indexKey].includes(historyKey)) {
week.scoreIndex[indexKey].push(historyKey);
} }
recomputeRanks(week, nation); recomputeRanks(week, nation);
saveWRank(); WRank.save();
} },
function recomputeRanks(week: WRankWeek, nation: Nation): void { // ── Entry lookup ─────────────────────────────────────────────────────────────
const list = week.entries[nation.toLowerCase() as "capella" | "procyon"];
const sorted = [...list].sort((a, b) => b.weeklyPoints - a.weeklyPoints); entry(characterName: CharName, nation: Nation): WRankEntry | null {
sorted.forEach((entry, i) => { const week = WRank.currentWeek();
const live = list.find((e) => e.characterName === entry.characterName)!; const list = week.entries[nation];
const newRank = i + 1; const raw = list.find((e) => e.characterName === characterName);
// Only snapshot previousRank when rank actually changes return raw ? hydrateEntry(raw) : null;
if (live.currentRank !== 0 && live.currentRank !== newRank) { },
live.previousRank = live.currentRank;
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;
} }
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;
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 { WRank.save();
const week = ensureWeek(WRank.weekKey()); log.info("Snapshot complete.");
if (nation === Nation.Capella) week.bringer.capellaOverride = charName; },
else week.bringer.procyonOverride = charName;
saveWRank();
}
export function clearBringerOverride(nation: Nation): void { // ── Weekly reset ─────────────────────────────────────────────────────────────
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 { resetWeek(): void {
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 prevWeekKey = WRank.weekKey(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000));
const prevWeek = _data[prevWeekKey]; const prevWeek = _data[prevWeekKey];
const newWeek = ensureWeek(WRank.weekKey(new Date())); const newWeek = ensureWeek(WRank.weekKey());
if (prevWeek) { if (prevWeek) {
// Carry Bringer forward — W.Rank 1 with goal achieved becomes new Bringer
Bringer.update({ week: prevWeek }); Bringer.update({ week: prevWeek });
for (const nation of [Nation.Capella, Nation.Procyon]) { for (const nation of [Nation.Capella, Nation.Procyon]) {
const key = nation === Nation.Capella ? "capella" : "procyon"; const bringer = prevWeek.bringer[nation];
const bringer = prevWeek.bringer[key]; if (bringer) newWeek.bringer[nation] = bringer;
if (bringer) {
// Set as override in new week so it carries forward
newWeek.bringer[key] = bringer;
}
} }
} }
WRank.save(); WRank.save();
} log.info(`Reset to ${WRank.weekKey()}.`);
},
// ── Bringer (legacy — use Bringer namespace directly) ────────────────────────
export const WRank = { getBringer(nation: Nation): string | null {
save: saveWRank, const week = WRank.currentWeek();
load: loadWRank, return (week.bringer as any)[`${nation}Override`] ?? week.bringer[nation];
currentWeek: getCurrentWeek, },
weekFromKey: getWeek,
weekKey: getWeekKey,
recordScore,
entry: getEntry,
resetWeek,
}; };

View file

@ -231,18 +231,6 @@ export interface TGResult {
// ─── W.Rank ────────────────────────────────────────────────────────────────── // ─── 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 { // export interface WRankEntry {
// character: Character; // character: Character;
// weeklyPoints: number; // weeklyPoints: number;
@ -264,39 +252,6 @@ export interface WRankEntry {
// activeCharacter: Character | null; // 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 ───────────────────────────────────────────────────────────────── // ─── Bringer ─────────────────────────────────────────────────────────────────
export interface BringerState { export interface BringerState {

View file

@ -4,7 +4,8 @@
*/ */
import { EmbedBuilder } from "discord.js"; 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 { Config } from "@systems/config";
import { WRank } from "@systems/wrank"; import { WRank } from "@systems/wrank";
import { Bringer } from "@systems/bringer"; import { Bringer } from "@systems/bringer";

View file

@ -4,7 +4,8 @@
*/ */
import { EmbedBuilder } from "discord.js"; 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 { Config } from "@systems/config";
import { WRank } from "@systems/wrank"; import { WRank } from "@systems/wrank";
import { Bringer } from "@systems/bringer"; import { Bringer } from "@systems/bringer";