- 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
176 lines
No EOL
5.6 KiB
TypeScript
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 });
|
|
},
|
|
}; |