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:
parent
17ff1d932f
commit
ed9e7209d0
18 changed files with 241 additions and 286 deletions
|
|
@ -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.
|
|
||||||
|
|
@ -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}**.`);
|
||||||
}
|
}
|
||||||
|
|
@ -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")) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ───────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
10
src/systems/scheduler/midnight-snapshot.ts
Normal file
10
src/systems/scheduler/midnight-snapshot.ts
Normal 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+
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,51 @@
|
||||||
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;
|
||||||
class: ClassKey;
|
class: ClassKey;
|
||||||
nation: Nation;
|
nation: Nation;
|
||||||
weeklyPoints: number;
|
weeklyPoints: number;
|
||||||
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,183 +53,202 @@ 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 {
|
|
||||||
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 {
|
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);
|
const sorted = [...list].sort((a, b) => b.weeklyPoints - a.weeklyPoints);
|
||||||
|
|
||||||
sorted.forEach((entry, i) => {
|
sorted.forEach((entry, i) => {
|
||||||
const live = list.find((e) => e.characterName === entry.characterName)!;
|
const live = list.find((e) => e.characterName === entry.characterName)!;
|
||||||
const newRank = i + 1;
|
const newRank = i + 1;
|
||||||
// Only snapshot previousRank when rank actually changes
|
|
||||||
if (live.currentRank !== 0 && live.currentRank !== newRank) {
|
if (live.currentRank !== 0 && live.currentRank !== newRank) {
|
||||||
live.previousRank = live.currentRank;
|
live.previousRank = live.currentRank;
|
||||||
|
live.lastRankChangeAt = new Date().toISOString();
|
||||||
}
|
}
|
||||||
live.currentRank = newRank;
|
live.currentRank = newRank;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateBringer(week: WRankWeek): void {
|
// ─── WRank namespace ──────────────────────────────────────────────────────────
|
||||||
const goal = Config.get({ section: "wrank", key: "goal" });
|
export const WRank = {
|
||||||
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]
|
// ── Persistence ─────────────────────────────────────────────────────────────
|
||||||
.filter((e) => e.tgCount >= goal)
|
|
||||||
.sort((a, b) => a.currentRank - b.currentRank);
|
load(): void {
|
||||||
week.bringer[nation] = qualified[0]?.characterName ?? null;
|
_data = Store.readOrDefault<WRankData>(Paths.data("wrank.json"), {});
|
||||||
}
|
},
|
||||||
}
|
|
||||||
|
|
||||||
export function setBringerOverride(nation: Nation, charName: string): void {
|
save(): void {
|
||||||
const week = ensureWeek(WRank.weekKey());
|
Store.write(Paths.data("wrank.json"), _data);
|
||||||
if (nation === Nation.Capella) week.bringer.capellaOverride = charName;
|
},
|
||||||
else week.bringer.procyonOverride = charName;
|
|
||||||
saveWRank();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearBringerOverride(nation: Nation): void {
|
// ── Week helpers ─────────────────────────────────────────────────────────────
|
||||||
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 {
|
weekKey(date: Date = new Date()): string {
|
||||||
const week = getCurrentWeek();
|
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||||
if (nation === Nation.Capella) return week.bringer.capellaOverride ?? week.bringer.capella;
|
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
|
||||||
return week.bringer.procyonOverride ?? week.bringer.procyon;
|
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 {
|
currentWeek(): WRankWeek {
|
||||||
const week = getCurrentWeek();
|
return ensureWeek(WRank.weekKey());
|
||||||
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
|
weekFromKey(weekKey: string): WRankWeek | null {
|
||||||
export function resetWeek(): void {
|
return _data[weekKey] ?? null;
|
||||||
// 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) {
|
// ── Score recording ──────────────────────────────────────────────────────────
|
||||||
// Carry Bringer forward — W.Rank 1 with goal achieved becomes new Bringer
|
|
||||||
Bringer.update({ week: prevWeek });
|
recordScore(
|
||||||
for (const nation of [Nation.Capella, Nation.Procyon]) {
|
userKey: UserKey,
|
||||||
const key = nation === Nation.Capella ? "capella" : "procyon";
|
characterName: CharName,
|
||||||
const bringer = prevWeek.bringer[key];
|
cls: ClassKey,
|
||||||
if (bringer) {
|
nation: Nation,
|
||||||
// Set as override in new week so it carries forward
|
pts: number,
|
||||||
newWeek.bringer[key] = bringer;
|
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 = {
|
// ── Weekly reset ─────────────────────────────────────────────────────────────
|
||||||
save: saveWRank,
|
|
||||||
load: loadWRank,
|
resetWeek(): void {
|
||||||
currentWeek: getCurrentWeek,
|
const prevWeekKey = WRank.weekKey(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000));
|
||||||
weekFromKey: getWeek,
|
const prevWeek = _data[prevWeekKey];
|
||||||
weekKey: getWeekKey,
|
const newWeek = ensureWeek(WRank.weekKey());
|
||||||
recordScore,
|
|
||||||
entry: getEntry,
|
if (prevWeek) {
|
||||||
resetWeek,
|
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];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
45
src/types.ts
45
src/types.ts
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue