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