wrank.ts
· 10 KiB · TypeScript
Raw
import { UserKey, CharName, Nation, ClassKey, Character, CLASSES } from "@types";
import { Config } from "@systems/config";
import { Bringer } from "@systems/bringer";
import { Nations } from "@systems/nations";
import { Store } from "@systems/store";
import { Paths } from "@paths";
import { TGKey } from "@systems/tg-key";
import { Runtime } from "@systems/runtime";
import { Logger } from "@systems/logger";
import { CharacterRegistry } from "@registry/character-registry";
const log = Logger.for("wrank");
// ─── Runtime ──────────────────────────────────────────────────────────────────
Runtime.phase("load", () => WRank.load(), { name: "WRank.load" });
// ─── Types ────────────────────────────────────────────────────────────────────
interface SerializableWRankEntry {
userKey: UserKey;
characterName: CharName;
class: ClassKey;
nation: Nation;
weeklyPoints: number;
tgCount: number;
currentRank: number;
previousRank?: number;
lastRankChangeAt?: string; // ISO timestamp — used for delta snapshot timing
}
/** Runtime shape — Character object instead of flat fields */
export interface WRankEntry {
character: Character;
weeklyPoints: number;
tgCount: number;
currentRank: number;
previousRank?: number;
lastRankChangeAt?: string;
}
export interface WRankWeek {
weekKey: string;
entries: Record<Nation, SerializableWRankEntry[]>;
scoreIndex: Record<CharName, TGKey[]>;
bringer: {
[Nation.Capella]: string | null;
[Nation.Procyon]: string | null;
capellaOverride?: string;
procyonOverride?: string;
};
}
export interface WRankData {
[weekKey: string]: WRankWeek;
}
// ─── State ────────────────────────────────────────────────────────────────────
let _data: WRankData = {};
// ─── Hydration ────────────────────────────────────────────────────────────────
function hydrateEntry(raw: SerializableWRankEntry): WRankEntry {
const found = CharacterRegistry.find(raw.characterName);
const character: Character = found ?? {
name: raw.characterName,
class: CLASSES[raw.class] ?? { key: raw.class, name: raw.class, shortName: raw.class },
level: 0,
nation: raw.nation,
ownerKey: raw.userKey,
};
return {
character,
weeklyPoints: raw.weeklyPoints,
tgCount: raw.tgCount,
currentRank: raw.currentRank,
previousRank: raw.previousRank,
lastRankChangeAt: raw.lastRankChangeAt,
};
}
// ─── Internal helpers ─────────────────────────────────────────────────────────
function ensureWeek(weekKey: string): WRankWeek {
if (!_data[weekKey]) {
_data[weekKey] = {
weekKey,
entries: { [Nation.Capella]: [], [Nation.Procyon]: [] },
scoreIndex: {},
bringer: { [Nation.Capella]: null, [Nation.Procyon]: null },
};
}
return _data[weekKey];
}
function recomputeRanks(week: WRankWeek, nation: Nation): void {
const list = week.entries[nation];
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;
if (live.currentRank !== 0 && live.currentRank !== newRank) {
live.previousRank = live.currentRank;
live.lastRankChangeAt = new Date().toISOString();
}
live.currentRank = newRank;
});
}
// ─── WRank namespace ──────────────────────────────────────────────────────────
export const WRank = {
// ── Persistence ─────────────────────────────────────────────────────────────
load(): void {
_data = Store.readOrDefault<WRankData>(Paths.data("wrank.json"), {});
},
save(): void {
Store.write(Paths.data("wrank.json"), _data);
},
// ── Week helpers ─────────────────────────────────────────────────────────────
weekKey(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")}`;
},
currentWeek(): WRankWeek {
return ensureWeek(WRank.weekKey());
},
weekFromKey(weekKey: string): WRankWeek | null {
return _data[weekKey] ?? null;
},
allWeeks(): WRankData {
return _data;
},
// ── Score recording ──────────────────────────────────────────────────────────
recordScore(
userKey: UserKey,
characterName: CharName,
cls: ClassKey,
nation: Nation,
pts: number,
historyKey: TGKey
): void {
const week = ensureWeek(WRank.weekKey());
const list = week.entries[nation];
const existing = list.find((e) => e.characterName === characterName);
if (existing) {
const alreadyCounted = week.scoreIndex[characterName]?.includes(historyKey);
if (!alreadyCounted) {
existing.weeklyPoints += pts;
existing.tgCount += 1;
} else {
existing.weeklyPoints = existing.weeklyPoints - (existing.weeklyPoints / existing.tgCount) + pts;
}
existing.class = cls;
existing.nation = nation;
} else {
list.push({
userKey,
characterName,
class: cls,
nation,
weeklyPoints: pts,
tgCount: 1,
currentRank: 0,
previousRank: undefined,
});
}
if (!week.scoreIndex[characterName]) week.scoreIndex[characterName] = [];
if (!week.scoreIndex[characterName].includes(historyKey)) {
week.scoreIndex[characterName].push(historyKey);
}
recomputeRanks(week, nation);
WRank.save();
},
// ── Entry lookup ─────────────────────────────────────────────────────────────
entry(characterName: CharName, nation: Nation, weekKey?: string): WRankEntry | null {
const week = weekKey ? (_data[weekKey] ?? null) : WRank.currentWeek();
const list = week.entries[nation];
const raw = list.find((e) => e.characterName === characterName);
return raw ? hydrateEntry(raw) : null;
},
entriesForNation(nation: Nation, week?: WRankWeek): WRankEntry[] {
const _week = week ?? WRank.currentWeek();
return _week.entries[nation].map(hydrateEntry);
},
// ── Snapshot ─────────────────────────────────────────────────────────────────
/**
* Snapshot previousRank = currentRank for entries whose rank hasn't
* changed in olderThan ms. Defaults to snapshotting all entries.
*/
snapshot({ olderThan }: { olderThan?: number } = {}): void {
const week = WRank.currentWeek();
const now = Date.now();
for (const nation of [Nation.Capella, Nation.Procyon] as const) {
for (const entry of week.entries[nation]) {
if (entry.currentRank === 0) continue;
if (olderThan) {
const lastChange = entry.lastRankChangeAt
? new Date(entry.lastRankChangeAt).getTime()
: 0;
if (now - lastChange < olderThan) continue; // changed too recently
}
entry.previousRank = entry.currentRank;
}
}
WRank.save();
log.info("Snapshot complete.");
},
// ── Weekly reset ─────────────────────────────────────────────────────────────
resetWeek(): void {
// Fire the weekly reset on Monday 00:00 (WRank reset) (UTC+2 = Sunday 22:00)
const nowUtcNoon = new Date();
nowUtcNoon.setUTCHours(12, 0, 0, 0);
const newWeekKey = WRank.weekKey(nowUtcNoon);
const prevWeekKey = WRank.weekKey(new Date(nowUtcNoon.getTime() - 7 * 24 * 60 * 60 * 1000));
const prevWeek = _data[prevWeekKey];
const newWeek = ensureWeek(newWeekKey);
if (prevWeek) {
const goal = Config.get({ section: "wrank", key: "goal" });
for (const nation of [Nation.Capella, Nation.Procyon]) {
const rank1 = prevWeek.entries[nation].find((e) => e.currentRank === 1);
newWeek.bringer[nation] = (rank1 && rank1.tgCount >= goal) ? rank1.characterName : null;
}
}
WRank.save();
log.info(`Week reset to ${newWeekKey}. Bringer: ${JSON.stringify(newWeek.bringer)}`);
},
// ── Bringer (legacy — use Bringer namespace directly) ────────────────────────
getBringer(nation: Nation): string | null {
const week = WRank.currentWeek();
return (week.bringer as any)[`${nation}Override`] ?? week.bringer[nation];
},
};
| 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 | }; |