Last active 1 month ago

wrank.ts Raw
1import { UserKey, CharName, Nation, ClassKey, Character, CLASSES } from "@types";
2import { Config } from "@systems/config";
3import { Bringer } from "@systems/bringer";
4import { Nations } from "@systems/nations";
5import { Store } from "@systems/store";
6import { Paths } from "@paths";
7import { TGKey } from "@systems/tg-key";
8import { Runtime } from "@systems/runtime";
9import { Logger } from "@systems/logger";
10import { CharacterRegistry } from "@registry/character-registry";
11
12const log = Logger.for("wrank");
13
14// ─── Runtime ──────────────────────────────────────────────────────────────────
15Runtime.phase("load", () => WRank.load(), { name: "WRank.load" });
16
17// ─── Types ────────────────────────────────────────────────────────────────────
18
19interface SerializableWRankEntry {
20 userKey: UserKey;
21 characterName: CharName;
22 class: ClassKey;
23 nation: Nation;
24 weeklyPoints: number;
25 tgCount: number;
26 currentRank: number;
27 previousRank?: number;
28 lastRankChangeAt?: string; // ISO timestamp — used for delta snapshot timing
29}
30
31/** Runtime shape — Character object instead of flat fields */
32export interface WRankEntry {
33 character: Character;
34 weeklyPoints: number;
35 tgCount: number;
36 currentRank: number;
37 previousRank?: number;
38 lastRankChangeAt?: string;
39}
40
41export interface WRankWeek {
42 weekKey: string;
43 entries: Record<Nation, SerializableWRankEntry[]>;
44 scoreIndex: Record<CharName, TGKey[]>;
45 bringer: {
46 [Nation.Capella]: string | null;
47 [Nation.Procyon]: string | null;
48 capellaOverride?: string;
49 procyonOverride?: string;
50 };
51}
52
53export interface WRankData {
54 [weekKey: string]: WRankWeek;
55}
56
57// ─── State ────────────────────────────────────────────────────────────────────
58
59let _data: WRankData = {};
60
61// ─── Hydration ────────────────────────────────────────────────────────────────
62
63function hydrateEntry(raw: SerializableWRankEntry): WRankEntry {
64 const found = CharacterRegistry.find(raw.characterName);
65 const character: Character = found ?? {
66 name: raw.characterName,
67 class: CLASSES[raw.class] ?? { key: raw.class, name: raw.class, shortName: raw.class },
68 level: 0,
69 nation: raw.nation,
70 ownerKey: raw.userKey,
71 };
72 return {
73 character,
74 weeklyPoints: raw.weeklyPoints,
75 tgCount: raw.tgCount,
76 currentRank: raw.currentRank,
77 previousRank: raw.previousRank,
78 lastRankChangeAt: raw.lastRankChangeAt,
79 };
80}
81
82// ─── Internal helpers ─────────────────────────────────────────────────────────
83
84function ensureWeek(weekKey: string): WRankWeek {
85 if (!_data[weekKey]) {
86 _data[weekKey] = {
87 weekKey,
88 entries: { [Nation.Capella]: [], [Nation.Procyon]: [] },
89 scoreIndex: {},
90 bringer: { [Nation.Capella]: null, [Nation.Procyon]: null },
91 };
92 }
93 return _data[weekKey];
94}
95
96function recomputeRanks(week: WRankWeek, nation: Nation): void {
97 const list = week.entries[nation];
98 const sorted = [...list].sort((a, b) => b.weeklyPoints - a.weeklyPoints);
99
100 sorted.forEach((entry, i) => {
101 const live = list.find((e) => e.characterName === entry.characterName)!;
102 const newRank = i + 1;
103 if (live.currentRank !== 0 && live.currentRank !== newRank) {
104 live.previousRank = live.currentRank;
105 live.lastRankChangeAt = new Date().toISOString();
106 }
107 live.currentRank = newRank;
108 });
109}
110
111// ─── WRank namespace ──────────────────────────────────────────────────────────
112export const WRank = {
113
114 // ── Persistence ─────────────────────────────────────────────────────────────
115
116 load(): void {
117 _data = Store.readOrDefault<WRankData>(Paths.data("wrank.json"), {});
118 },
119
120 save(): void {
121 Store.write(Paths.data("wrank.json"), _data);
122 },
123
124 // ── Week helpers ─────────────────────────────────────────────────────────────
125
126 weekKey(date: Date = new Date()): string {
127 const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
128 d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
129 const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
130 const week = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
131 return `${d.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
132 },
133
134 currentWeek(): WRankWeek {
135 return ensureWeek(WRank.weekKey());
136 },
137
138 weekFromKey(weekKey: string): WRankWeek | null {
139 return _data[weekKey] ?? null;
140 },
141
142 allWeeks(): WRankData {
143 return _data;
144 },
145
146 // ── Score recording ──────────────────────────────────────────────────────────
147
148 recordScore(
149 userKey: UserKey,
150 characterName: CharName,
151 cls: ClassKey,
152 nation: Nation,
153 pts: number,
154 historyKey: TGKey
155 ): void {
156 const week = ensureWeek(WRank.weekKey());
157 const list = week.entries[nation];
158
159 const existing = list.find((e) => e.characterName === characterName);
160
161 if (existing) {
162 const alreadyCounted = week.scoreIndex[characterName]?.includes(historyKey);
163 if (!alreadyCounted) {
164 existing.weeklyPoints += pts;
165 existing.tgCount += 1;
166 } else {
167 existing.weeklyPoints = existing.weeklyPoints - (existing.weeklyPoints / existing.tgCount) + pts;
168 }
169 existing.class = cls;
170 existing.nation = nation;
171 } else {
172 list.push({
173 userKey,
174 characterName,
175 class: cls,
176 nation,
177 weeklyPoints: pts,
178 tgCount: 1,
179 currentRank: 0,
180 previousRank: undefined,
181 });
182 }
183
184 if (!week.scoreIndex[characterName]) week.scoreIndex[characterName] = [];
185 if (!week.scoreIndex[characterName].includes(historyKey)) {
186 week.scoreIndex[characterName].push(historyKey);
187 }
188
189 recomputeRanks(week, nation);
190 WRank.save();
191 },
192
193 // ── Entry lookup ─────────────────────────────────────────────────────────────
194
195 entry(characterName: CharName, nation: Nation, weekKey?: string): WRankEntry | null {
196 const week = weekKey ? (_data[weekKey] ?? null) : WRank.currentWeek();
197 const list = week.entries[nation];
198 const raw = list.find((e) => e.characterName === characterName);
199 return raw ? hydrateEntry(raw) : null;
200 },
201
202 entriesForNation(nation: Nation, week?: WRankWeek): WRankEntry[] {
203 const _week = week ?? WRank.currentWeek();
204 return _week.entries[nation].map(hydrateEntry);
205 },
206
207 // ── Snapshot ─────────────────────────────────────────────────────────────────
208
209 /**
210 * Snapshot previousRank = currentRank for entries whose rank hasn't
211 * changed in olderThan ms. Defaults to snapshotting all entries.
212 */
213 snapshot({ olderThan }: { olderThan?: number } = {}): void {
214 const week = WRank.currentWeek();
215 const now = Date.now();
216
217 for (const nation of [Nation.Capella, Nation.Procyon] as const) {
218 for (const entry of week.entries[nation]) {
219 if (entry.currentRank === 0) continue;
220 if (olderThan) {
221 const lastChange = entry.lastRankChangeAt
222 ? new Date(entry.lastRankChangeAt).getTime()
223 : 0;
224 if (now - lastChange < olderThan) continue; // changed too recently
225 }
226 entry.previousRank = entry.currentRank;
227 }
228 }
229
230 WRank.save();
231 log.info("Snapshot complete.");
232 },
233
234 // ── Weekly reset ─────────────────────────────────────────────────────────────
235
236 resetWeek(): void {
237 const prevWeekKey = WRank.weekKey(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000));
238 const prevWeek = _data[prevWeekKey];
239 const newWeek = ensureWeek(WRank.weekKey());
240
241 if (prevWeek) {
242 Bringer.update({ week: prevWeek });
243 for (const nation of [Nation.Capella, Nation.Procyon]) {
244 const bringer = prevWeek.bringer[nation];
245 if (bringer) newWeek.bringer[nation] = bringer;
246 }
247 }
248
249 WRank.save();
250 log.info(`Reset to ${WRank.weekKey()}.`);
251 },
252
253 // ── Bringer (legacy — use Bringer namespace directly) ────────────────────────
254
255 getBringer(nation: Nation): string | null {
256 const week = WRank.currentWeek();
257 return (week.bringer as any)[`${nation}Override`] ?? week.bringer[nation];
258 },
259};