wrank.ts
· 5.4 KiB · TypeScript
Raw
import fs from "fs";
import path from "path";
import { WRankData, WRankWeek, WRankEntry, Nation, ClassKey } from "../types";
import { cfg } from "./config";
const WRANK_PATH = path.join(__dirname, "../../data/wrank.json");
let _data: WRankData = {};
export function loadWRank(): void {
try { _data = JSON.parse(fs.readFileSync(WRANK_PATH, "utf8")); }
catch { _data = {}; }
}
function saveWRank(): void {
fs.writeFileSync(WRANK_PATH, JSON.stringify(_data, null, 2));
}
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(getWeekKey());
}
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 = getWeekKey();
const week = ensureWeek(weekKey);
const list = week.entries[nation.toLowerCase() as "capella" | "procyon"];
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);
updateBringer(week);
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 = cfg("wRankGoal");
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(getWeekKey());
if (nation === Nation.Capella) week.bringer.capellaOverride = charName;
else week.bringer.procyonOverride = charName;
saveWRank();
}
export function clearBringerOverride(nation: Nation): void {
const week = ensureWeek(getWeekKey());
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): WRankEntry | 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
ensureWeek(getWeekKey(new Date()));
saveWRank();
}
| 1 | import fs from "fs"; |
| 2 | import path from "path"; |
| 3 | import { WRankData, WRankWeek, WRankEntry, Nation, ClassKey } from "../types"; |
| 4 | import { cfg } from "./config"; |
| 5 | |
| 6 | const WRANK_PATH = path.join(__dirname, "../../data/wrank.json"); |
| 7 | let _data: WRankData = {}; |
| 8 | |
| 9 | export function loadWRank(): void { |
| 10 | try { _data = JSON.parse(fs.readFileSync(WRANK_PATH, "utf8")); } |
| 11 | catch { _data = {}; } |
| 12 | } |
| 13 | |
| 14 | function saveWRank(): void { |
| 15 | fs.writeFileSync(WRANK_PATH, JSON.stringify(_data, null, 2)); |
| 16 | } |
| 17 | |
| 18 | export function getWeekKey(date: Date = new Date()): string { |
| 19 | const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); |
| 20 | d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); |
| 21 | const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); |
| 22 | const week = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7); |
| 23 | return `${d.getUTCFullYear()}-W${String(week).padStart(2, "0")}`; |
| 24 | } |
| 25 | |
| 26 | function ensureWeek(weekKey: string): WRankWeek { |
| 27 | if (!_data[weekKey]) { |
| 28 | _data[weekKey] = { |
| 29 | weekKey, |
| 30 | entries: { capella: [], procyon: [] }, |
| 31 | scoreIndex: {}, |
| 32 | bringer: { capella: null, procyon: null }, |
| 33 | }; |
| 34 | } |
| 35 | return _data[weekKey]; |
| 36 | } |
| 37 | |
| 38 | export function getCurrentWeek(): WRankWeek { |
| 39 | return ensureWeek(getWeekKey()); |
| 40 | } |
| 41 | |
| 42 | export function getWeek(weekKey: string): WRankWeek | null { |
| 43 | return _data[weekKey] ?? null; |
| 44 | } |
| 45 | |
| 46 | // Add or update a score submission for a player |
| 47 | export function recordScore( |
| 48 | userKey: string, |
| 49 | characterName: string, |
| 50 | cls: ClassKey, |
| 51 | nation: Nation, |
| 52 | pts: number, |
| 53 | historyKey: string // e.g. "2026-05-31-20" |
| 54 | ): void { |
| 55 | const weekKey = getWeekKey(); |
| 56 | const week = ensureWeek(weekKey); |
| 57 | const list = week.entries[nation.toLowerCase() as "capella" | "procyon"]; |
| 58 | |
| 59 | const existing = list.find((e) => e.characterName === characterName); |
| 60 | |
| 61 | if (existing) { |
| 62 | // Check if this slot was already counted |
| 63 | const alreadyCounted = week.scoreIndex[userKey]?.includes(historyKey); |
| 64 | if (!alreadyCounted) { |
| 65 | existing.weeklyPoints += pts; |
| 66 | existing.tgCount += 1; |
| 67 | } else { |
| 68 | // Overwrite: recalculate by removing old pts for this slot |
| 69 | // We'll just set the new pts — full recalc would require reading history |
| 70 | // For now, simple overwrite of total is handled at score submission level |
| 71 | existing.weeklyPoints = existing.weeklyPoints - (existing.weeklyPoints / existing.tgCount) + pts; |
| 72 | } |
| 73 | existing.characterName = characterName; |
| 74 | existing.class = cls; |
| 75 | existing.nation = nation; |
| 76 | } else { |
| 77 | list.push({ |
| 78 | userKey, |
| 79 | characterName, |
| 80 | class: cls, |
| 81 | nation, |
| 82 | weeklyPoints: pts, |
| 83 | tgCount: 1, |
| 84 | currentRank: 0, |
| 85 | previousRank: undefined, |
| 86 | }); |
| 87 | } |
| 88 | |
| 89 | // Update score index |
| 90 | const indexKey = characterName; |
| 91 | if (!week.scoreIndex[indexKey]) week.scoreIndex[indexKey] = []; |
| 92 | if (!week.scoreIndex[indexKey].includes(historyKey)) { |
| 93 | week.scoreIndex[indexKey].push(historyKey); |
| 94 | } |
| 95 | |
| 96 | recomputeRanks(week, nation); |
| 97 | updateBringer(week); |
| 98 | saveWRank(); |
| 99 | } |
| 100 | |
| 101 | function recomputeRanks(week: WRankWeek, nation: Nation): void { |
| 102 | const list = week.entries[nation.toLowerCase() as "capella" | "procyon"]; |
| 103 | const sorted = [...list].sort((a, b) => b.weeklyPoints - a.weeklyPoints); |
| 104 | sorted.forEach((entry, i) => { |
| 105 | const live = list.find((e) => e.characterName === entry.characterName)!; |
| 106 | const newRank = i + 1; |
| 107 | // Only snapshot previousRank when rank actually changes |
| 108 | if (live.currentRank !== 0 && live.currentRank !== newRank) { |
| 109 | live.previousRank = live.currentRank; |
| 110 | } |
| 111 | live.currentRank = newRank; |
| 112 | }); |
| 113 | } |
| 114 | |
| 115 | function updateBringer(week: WRankWeek): void { |
| 116 | const goal = cfg("wRankGoal"); |
| 117 | for (const nation of ["capella", "procyon"] as const) { |
| 118 | // Don't overwrite manual override |
| 119 | if (nation === "capella" && week.bringer.capellaOverride) continue; |
| 120 | if (nation === "procyon" && week.bringer.procyonOverride) continue; |
| 121 | |
| 122 | const qualified = week.entries[nation] |
| 123 | .filter((e) => e.tgCount >= goal) |
| 124 | .sort((a, b) => a.currentRank - b.currentRank); |
| 125 | week.bringer[nation] = qualified[0]?.characterName ?? null; |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | export function setBringerOverride(nation: Nation, charName: string): void { |
| 130 | const week = ensureWeek(getWeekKey()); |
| 131 | if (nation === Nation.Capella) week.bringer.capellaOverride = charName; |
| 132 | else week.bringer.procyonOverride = charName; |
| 133 | saveWRank(); |
| 134 | } |
| 135 | |
| 136 | export function clearBringerOverride(nation: Nation): void { |
| 137 | const week = ensureWeek(getWeekKey()); |
| 138 | if (nation === Nation.Capella) delete week.bringer.capellaOverride; |
| 139 | else delete week.bringer.procyonOverride; |
| 140 | updateBringer(week); |
| 141 | saveWRank(); |
| 142 | } |
| 143 | |
| 144 | export function getBringer(nation: Nation): string | null { |
| 145 | const week = getCurrentWeek(); |
| 146 | if (nation === Nation.Capella) return week.bringer.capellaOverride ?? week.bringer.capella; |
| 147 | return week.bringer.procyonOverride ?? week.bringer.procyon; |
| 148 | } |
| 149 | |
| 150 | export function getEntry(characterName: string, nation: Nation): WRankEntry | null { |
| 151 | const week = getCurrentWeek(); |
| 152 | const list = week.entries[nation.toLowerCase() as "capella" | "procyon"]; |
| 153 | console.log(`[getEntry] weekKey=${week.weekKey} nation=${nation} listLength=${list?.length} looking for=${characterName}`); |
| 154 | console.log(`[getEntry] available:`, list?.map(e => e.characterName)); |
| 155 | return list.find((e) => e.characterName === characterName) ?? null; |
| 156 | } |
| 157 | |
| 158 | // Called every Monday 00:00 by cron |
| 159 | export function resetWeek(): void { |
| 160 | // Week is already archived in _data by weekKey — just ensure next week exists |
| 161 | ensureWeek(getWeekKey(new Date())); |
| 162 | saveWRank(); |
| 163 | } |