264 lines
No EOL
10 KiB
TypeScript
264 lines
No EOL
10 KiB
TypeScript
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];
|
|
},
|
|
}; |