Última actividad 3 weeks ago

gistfile1.txt Sin formato
1import fs from "fs";
2import path from "path";
3import { HistoryKey, UserKey, CharName, Nation, ClassKey } from "@types";
4import { Config } from "@systems/config";
5import { Bringer } from "@systems/bringer";
6import { Nations } from "@systems/nations";
7import { Store } from "@systems/store";
8import { Paths } from "@paths";
9import { Runtime } from "@systems/runtime";
10
11// ─── Runtime ──────────────────────────────────────────────────────────────────
12Runtime.phase("load", () => WRank.load(), { name: "WRank.load" });
13
14const WRANK_PATH = path.join(__dirname, "../../data/wrank.json");
15let _data: WRankData = {};
16
17/** Raw shape stored in wrank.json */
18interface SerializableWRankEntry {
19 userKey: UserKey;
20 characterName: CharName;
21 class: ClassKey;
22 nation: Nation;
23 weeklyPoints: number;
24 tgCount: number;
25 currentRank: number;
26 previousRank?: number;
27}
28
29export interface WRankWeek {
30 weekKey: string; // "2026-W22"
31 entries: Record<"capella" | "procyon", SerializableWRankEntry[]>; // still serializable for now
32 scoreIndex: Record<CharName, HistoryKey[]>;
33 bringer: {
34 capella: string | null; // userKey of bringer, null if none qualified
35 procyon: string | null;
36 capellaOverride?: string; // manually set by officer
37 procyonOverride?: string;
38 };
39}
40
41export interface WRankData {
42 [weekKey: string]: WRankWeek;
43}
44
45export function loadWRank(): void {
46 _data = Store.readOrDefault<WRankData>(Paths.data("wrank.json"), {});
47}
48
49export function saveWRank(): void {
50 Store.write(Paths.data("wrank.json"), _data);
51}
52
53export function getWeekKey(date: Date = new Date()): string {
54 const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
55 d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
56 const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
57 const week = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
58 return `${d.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
59}
60
61function ensureWeek(weekKey: string): WRankWeek {
62 if (!_data[weekKey]) {
63 _data[weekKey] = {
64 weekKey,
65 entries: { capella: [], procyon: [] },
66 scoreIndex: {},
67 bringer: { capella: null, procyon: null },
68 };
69 }
70 return _data[weekKey];
71}
72
73export function getCurrentWeek(): WRankWeek {
74 return ensureWeek(WRank.weekKey());
75}
76
77export function getWeek(weekKey: string): WRankWeek | null {
78 return _data[weekKey] ?? null;
79}
80
81// Add or update a score submission for a player
82export function recordScore(
83 userKey: string,
84 characterName: string,
85 cls: ClassKey,
86 nation: Nation,
87 pts: number,
88 historyKey: string // e.g. "2026-05-31-20"
89): void {
90 const weekKey = WRank.weekKey();
91 const week = ensureWeek(weekKey);
92 const list = week.entries[Nations.key(nation)];
93
94 const existing = list.find((e) => e.characterName === characterName);
95
96 if (existing) {
97 // Check if this slot was already counted
98 const alreadyCounted = week.scoreIndex[userKey]?.includes(historyKey);
99 if (!alreadyCounted) {
100 existing.weeklyPoints += pts;
101 existing.tgCount += 1;
102 } else {
103 // Overwrite: recalculate by removing old pts for this slot
104 // We'll just set the new pts — full recalc would require reading history
105 // For now, simple overwrite of total is handled at score submission level
106 existing.weeklyPoints = existing.weeklyPoints - (existing.weeklyPoints / existing.tgCount) + pts;
107 }
108 existing.characterName = characterName;
109 existing.class = cls;
110 existing.nation = nation;
111 } else {
112 list.push({
113 userKey,
114 characterName,
115 class: cls,
116 nation,
117 weeklyPoints: pts,
118 tgCount: 1,
119 currentRank: 0,
120 previousRank: undefined,
121 });
122 }
123
124 // Update score index
125 const indexKey = characterName;
126 if (!week.scoreIndex[indexKey]) week.scoreIndex[indexKey] = [];
127 if (!week.scoreIndex[indexKey].includes(historyKey)) {
128 week.scoreIndex[indexKey].push(historyKey);
129 }
130
131 recomputeRanks(week, nation);
132 saveWRank();
133}
134
135function recomputeRanks(week: WRankWeek, nation: Nation): void {
136 const list = week.entries[nation.toLowerCase() as "capella" | "procyon"];
137 const sorted = [...list].sort((a, b) => b.weeklyPoints - a.weeklyPoints);
138 sorted.forEach((entry, i) => {
139 const live = list.find((e) => e.characterName === entry.characterName)!;
140 const newRank = i + 1;
141 // Only snapshot previousRank when rank actually changes
142 if (live.currentRank !== 0 && live.currentRank !== newRank) {
143 live.previousRank = live.currentRank;
144 }
145 live.currentRank = newRank;
146 });
147}
148
149function updateBringer(week: WRankWeek): void {
150 const goal = Config.get({ section: "wrank", key: "goal" });
151 for (const nation of ["capella", "procyon"] as const) {
152 // Don't overwrite manual override
153 if (nation === "capella" && week.bringer.capellaOverride) continue;
154 if (nation === "procyon" && week.bringer.procyonOverride) continue;
155
156 const qualified = week.entries[nation]
157 .filter((e) => e.tgCount >= goal)
158 .sort((a, b) => a.currentRank - b.currentRank);
159 week.bringer[nation] = qualified[0]?.characterName ?? null;
160 }
161}
162
163export function setBringerOverride(nation: Nation, charName: string): void {
164 const week = ensureWeek(WRank.weekKey());
165 if (nation === Nation.Capella) week.bringer.capellaOverride = charName;
166 else week.bringer.procyonOverride = charName;
167 saveWRank();
168}
169
170export function clearBringerOverride(nation: Nation): void {
171 const week = ensureWeek(WRank.weekKey());
172 if (nation === Nation.Capella) delete week.bringer.capellaOverride;
173 else delete week.bringer.procyonOverride;
174 updateBringer(week);
175 saveWRank();
176}
177
178export function getBringer(nation: Nation): string | null {
179 const week = getCurrentWeek();
180 if (nation === Nation.Capella) return week.bringer.capellaOverride ?? week.bringer.capella;
181 return week.bringer.procyonOverride ?? week.bringer.procyon;
182}
183
184export function getEntry(characterName: string, nation: Nation): SerializableWRankEntry | null {
185 const week = getCurrentWeek();
186 const list = week.entries[nation.toLowerCase() as "capella" | "procyon"];
187 console.log(`[getEntry] weekKey=${week.weekKey} nation=${nation} listLength=${list?.length} looking for=${characterName}`);
188 console.log(`[getEntry] available:`, list?.map(e => e.characterName));
189 return list.find((e) => e.characterName === characterName) ?? null;
190}
191
192// Called every Monday 00:00 by cron
193export function resetWeek(): void {
194 // Week is already archived in _data by weekKey — just ensure next week exists
195 const prevWeekKey = WRank.weekKey(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000));
196 const prevWeek = _data[prevWeekKey];
197 const newWeek = ensureWeek(WRank.weekKey(new Date()));
198
199 if (prevWeek) {
200 // Carry Bringer forward — W.Rank 1 with goal achieved becomes new Bringer
201 Bringer.update({ week: prevWeek });
202 for (const nation of [Nation.Capella, Nation.Procyon]) {
203 const key = nation === Nation.Capella ? "capella" : "procyon";
204 const bringer = prevWeek.bringer[key];
205 if (bringer) {
206 // Set as override in new week so it carries forward
207 newWeek.bringer[key] = bringer;
208 }
209 }
210 }
211 WRank.save();
212}
213
214function snapshot(): void {
215 const week = WRank.currentWeek();
216 for (const nation of ["capella", "procyon"] as const) {
217 for (const entry of week.entries[nation]) {
218 if (entry.currentRank !== 0) {
219 entry.previousRank = entry.currentRank;
220 }
221 }
222 }
223 WRank.save();
224 console.log("[WRank] Midnight snapshot complete.");
225}
226
227
228export const WRank = {
229 save: saveWRank,
230 load: loadWRank,
231 currentWeek: getCurrentWeek,
232 weekFromKey: getWeek,
233 weekKey: getWeekKey,
234 recordScore,
235 entry: getEntry,
236 resetWeek,
237 snapshot,
238};