Остання активність 1 month ago

nuno ревизій цього gist 1 month ago. До ревизії

1 file changed, 264 insertions

wrank.ts(файл створено)

@@ -0,0 +1,264 @@
1 + import { UserKey, CharName, Nation, ClassKey, Character, CLASSES } from "@types";
2 + import { Config } from "@systems/config";
3 + import { Bringer } from "@systems/bringer";
4 + import { Nations } from "@systems/nations";
5 + import { Store } from "@systems/store";
6 + import { Paths } from "@paths";
7 + import { TGKey } from "@systems/tg-key";
8 + import { Runtime } from "@systems/runtime";
9 + import { Logger } from "@systems/logger";
10 + import { CharacterRegistry } from "@registry/character-registry";
11 +
12 + const log = Logger.for("wrank");
13 +
14 + // ─── Runtime ──────────────────────────────────────────────────────────────────
15 + Runtime.phase("load", () => WRank.load(), { name: "WRank.load" });
16 +
17 + // ─── Types ────────────────────────────────────────────────────────────────────
18 +
19 + interface 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 */
32 + export interface WRankEntry {
33 + character: Character;
34 + weeklyPoints: number;
35 + tgCount: number;
36 + currentRank: number;
37 + previousRank?: number;
38 + lastRankChangeAt?: string;
39 + }
40 +
41 + export 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 +
53 + export interface WRankData {
54 + [weekKey: string]: WRankWeek;
55 + }
56 +
57 + // ─── State ────────────────────────────────────────────────────────────────────
58 +
59 + let _data: WRankData = {};
60 +
61 + // ─── Hydration ────────────────────────────────────────────────────────────────
62 +
63 + function 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 +
84 + function 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 +
96 + function 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 ──────────────────────────────────────────────────────────
112 + export 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 + // Fire the weekly reset on Monday 00:00 (WRank reset) (UTC+2 = Sunday 22:00)
238 + const nowUtcNoon = new Date();
239 + nowUtcNoon.setUTCHours(12, 0, 0, 0);
240 +
241 + const newWeekKey = WRank.weekKey(nowUtcNoon);
242 + const prevWeekKey = WRank.weekKey(new Date(nowUtcNoon.getTime() - 7 * 24 * 60 * 60 * 1000));
243 + const prevWeek = _data[prevWeekKey];
244 + const newWeek = ensureWeek(newWeekKey);
245 +
246 + if (prevWeek) {
247 + const goal = Config.get({ section: "wrank", key: "goal" });
248 + for (const nation of [Nation.Capella, Nation.Procyon]) {
249 + const rank1 = prevWeek.entries[nation].find((e) => e.currentRank === 1);
250 + newWeek.bringer[nation] = (rank1 && rank1.tgCount >= goal) ? rank1.characterName : null;
251 + }
252 + }
253 +
254 + WRank.save();
255 + log.info(`Week reset to ${newWeekKey}. Bringer: ${JSON.stringify(newWeek.bringer)}`);
256 + },
257 +
258 + // ── Bringer (legacy — use Bringer namespace directly) ────────────────────────
259 +
260 + getBringer(nation: Nation): string | null {
261 + const week = WRank.currentWeek();
262 + return (week.bringer as any)[`${nation}Override`] ?? week.bringer[nation];
263 + },
264 + };
Новіше Пізніше