Last active 3 weeks ago

wrank.ts Raw
1import fs from "fs";
2import path from "path";
3import { WRankData, WRankWeek, WRankEntry, Nation, ClassKey } from "../types";
4import { cfg } from "./config";
5
6const WRANK_PATH = path.join(__dirname, "../../data/wrank.json");
7let _data: WRankData = {};
8
9export function loadWRank(): void {
10 try { _data = JSON.parse(fs.readFileSync(WRANK_PATH, "utf8")); }
11 catch { _data = {}; }
12}
13
14function saveWRank(): void {
15 fs.writeFileSync(WRANK_PATH, JSON.stringify(_data, null, 2));
16}
17
18export 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
26function 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
38export function getCurrentWeek(): WRankWeek {
39 return ensureWeek(getWeekKey());
40}
41
42export function getWeek(weekKey: string): WRankWeek | null {
43 return _data[weekKey] ?? null;
44}
45
46// Add or update a score submission for a player
47export 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
101function 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
115function 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
129export 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
136export 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
144export 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
150export 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
159export 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}