tg-bot-ts/src/systems/wrank.ts
2026-07-30 00:18:53 +01:00

264 lines
No EOL
10 KiB
TypeScript

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