tg-bot-ts/src/systems/score.ts
Nuno Duque Nunes 049ea7b77f fix: unify score submission on Score.submit, fix TGScore type drift, fix playedBy semantics for borrowed characters
- Score.submit/score/set.ts/score-inject.ts now all share one code path
- TGScore consolidated to single canonical type (was duplicated in types.ts and score.ts)
- Fixed atk/def/heal flat-vs-nested TGStats drift across leaderboard.ts, result layouts
- Fixed playedBy semantics — now correctly identifies the actual player on borrowed characters
- Attendance.allSubmitted now correctly matches against playedBy (borrower) not just userKey (owner)
- score-inject gained atk/def/heal/date/played_by parameters for full parity with real submission
- Added migrate-stats-shape.py and fix-class-keys.py maintenance scripts
2026-06-22 04:27:03 +01:00

176 lines
No EOL
5.6 KiB
TypeScript

/**
* Score — manages TG score submission and retrieval.
*
* Usage:
* import { Score } from "@systems/score";
*
* Score.get({ character, slot, date })
* Score.getWeeklySummary({ userKey })
* Score.submit({ character, borrowedFrom, pts, k, d, slot })
*/
import { Character, Nation, UserKey, SlotHour, TGStats, TGScore } from "@types";
import { WRank } from "@systems/wrank";
import { Store } from "@systems/store";
import { Paths } from "@helpers/paths";
import { TGKey } from "@systems/tg-key";
import { RuntimeEvents } from "@systems/runtime";
// export interface TGScore {
// userKey: UserKey;
// playedBy?: UserKey; // if borrowed
// characterName: string;
// class: string;
// nation: Nation;
// pts: number;
// k?: number;
// d?: number;
// stats?: TGStats;
// submittedAt: string;
// slot: SlotHour;
// date: string;
// submittedByOfficer: boolean;
// wRankAtSubmission?: {
// rank: number;
// delta: number;
// };
// }
export interface WeeklySummary {
userKey: UserKey;
character: Character;
scores: TGScore[];
totalPts: number;
totalK: number;
totalD: number;
tgCount: number;
currentRank?: number;
previousRank?: number;
}
function getHistoryPath(historyKey: TGKey): string {
return Paths.data("tg-history", `${historyKey}.json`);
}
function loadHistory(historyKey: TGKey): { scores: TGScore[] } {
return Store.readOrDefault(getHistoryPath(historyKey), { scores: [] });
}
function saveHistory(historyKey: TGKey, data: { scores: TGScore[] }): void {
Store.write(getHistoryPath(historyKey), data);
}
export const Score = {
/**
* Get a score for a character in a specific TG.
*/
get({ character, slot, historyKey }: {
character: Character;
slot: SlotHour;
historyKey?: TGKey;
}): TGScore | null {
const key = historyKey ?? TGKey.current({ slot });
const history = loadHistory(key);
return history.scores.find(
(s) => s.userKey === character.ownerKey && s.characterName === character.name
) ?? null;
},
/**
* Get weekly summary for a character.
*/
getWeeklySummary({ character }: { character: Character }): WeeklySummary {
const week = WRank.currentWeek();
const entry = WRank.entry(character.name, character.nation);
const scores: TGScore[] = [];
for (const historyKey of (week.scoreIndex[character.name] ?? [])) {
const history = loadHistory(historyKey as TGKey);
const score = history.scores.find(
(s) => s.userKey === character.ownerKey && s.characterName === character.name
);
if (score) scores.push(score);
}
const totalPts = scores.reduce((sum, s) => sum + s.pts, 0);
const totalK = scores.reduce((sum, s) => sum + (s.k ?? 0), 0);
const totalD = scores.reduce((sum, s) => sum + (s.d ?? 0), 0);
return {
userKey: character.ownerKey,
character,
scores,
totalPts,
totalK,
totalD,
tgCount: scores.length,
currentRank: entry?.currentRank,
previousRank: entry?.previousRank,
};
},
/**
* Submit a score for a character.
* Handles W.Rank snapshot at submission time.
*/
async submit({ character, playedBy, pts, k, d, atk, def, heal, slot, date, submittedByOfficer }: {
character: Character;
playedBy?: UserKey;
pts: number;
k?: number;
d?: number;
atk?: number;
def?: number;
heal?: number;
slot: SlotHour;
date?: string; // ← NEW, optional, defaults to today
submittedByOfficer?: boolean;
}): Promise<void> {
const resolvedDate = date ?? new Date().toISOString().slice(0, 10);
const historyKey = TGKey.from({ date: resolvedDate, slot });
const history = loadHistory(historyKey);
const existingEntry = WRank.entry(character.name, character.nation);
const wRankAtSubmission = existingEntry ? {
rank: existingEntry.currentRank,
delta: existingEntry.currentRank - (existingEntry.previousRank ?? existingEntry.currentRank),
} : undefined;
const score: TGScore = {
userKey: character.ownerKey,
playedBy: playedBy,
characterName: character.name,
class: character.class.key,
nation: character.nation,
pts,
k,
d,
stats: atk !== undefined || def !== undefined || heal !== undefined
? { atk, def, heal }
: undefined,
submittedAt: new Date().toISOString(),
slot,
date: resolvedDate,
submittedByOfficer: submittedByOfficer ?? false,
wRankAtSubmission,
};
history.scores = history.scores.filter(
(s) => !(s.userKey === character.ownerKey &&
s.characterName === character.name &&
s.slot === slot &&
s.date === resolvedDate)
);
history.scores.push(score);
saveHistory(historyKey, history);
WRank.recordScore(
character.ownerKey,
character.name,
character.class.key,
character.nation,
pts,
historyKey
);
await RuntimeEvents.emit("scoreSubmitted", { historyKey, character });
},
};