explodorama / files / src / sim / scores.ts
scores.ts
| 1 | import { SIM_VERSION, type RunRecord } from './record' |
| 2 | import type { Death, GameState, Powers, RunOptions, Stats } from './types' |
| 3 | |
| 4 | /** |
| 5 | * The high-score board (user request, 2026-10-24): one entry per finished run (game over or |
| 6 | * the win). Kept in the browser for now, but shaped for a global board later: each entry has |
| 7 | * its format and rules version, and the whole run record (seed and actions), so a server can |
| 8 | * replay it by the same rules and check the score before accepting it. Pure; storage is in |
| 9 | * ui/scores.ts. |
| 10 | */ |
| 11 | export interface ScoreEntry { |
| 12 | /** This shape's version, for a global board to read old entries. */ |
| 13 | format: 1 |
| 14 | simVersion: number |
| 15 | /** When the run ended, ISO 8601. */ |
| 16 | date: string |
| 17 | seed: number |
| 18 | options: RunOptions |
| 19 | won: boolean |
| 20 | /** The level the run ended on (the last one, for a win). */ |
| 21 | level: number |
| 22 | coins: number |
| 23 | kills: number |
| 24 | turns: number |
| 25 | livesLost: number |
| 26 | /** How the run ended, for a game over: the death on the last level. */ |
| 27 | death: Death | null |
| 28 | /** The abilities and passes held at the end, and the stats. */ |
| 29 | powers: (keyof Powers)[] |
| 30 | stats: Stats |
| 31 | /** Which copy of the game it was played on (channel.ts), and the commit it was built from. Absent on older entries. */ |
| 32 | channel?: 'stable' | 'next' | 'local' |
| 33 | commit?: string |
| 34 | /** Everything needed to replay the run and check it. */ |
| 35 | record: RunRecord |
| 36 | } |
| 37 | |
| 38 | /** How many entries the board keeps. */ |
| 39 | export const BOARD_SIZE = 25 |
| 40 | |
| 41 | /** Whether a finished run goes on the board: not an Immortal one (it earns no achievements). */ |
| 42 | export function countsForScores(s: GameState): boolean { |
| 43 | return (s.status === 'gameOver' || s.status === 'won') && !s.options.immortal |
| 44 | } |
| 45 | |
| 46 | /** The entry for a run that has just ended. */ |
| 47 | export function scoreEntry(s: GameState, record: RunRecord, date: Date): ScoreEntry { |
| 48 | return { |
| 49 | format: 1, |
| 50 | simVersion: SIM_VERSION, |
| 51 | date: date.toISOString(), |
| 52 | seed: s.seed, |
| 53 | options: { ...s.options }, |
| 54 | won: s.status === 'won', |
| 55 | level: s.level, |
| 56 | coins: s.coins, |
| 57 | kills: s.runKills, |
| 58 | turns: s.runTurns, |
| 59 | livesLost: s.deaths, |
| 60 | death: s.status === 'gameOver' ? s.death : null, |
| 61 | powers: (Object.keys(s.powers) as (keyof Powers)[]).filter((p) => s.powers[p]), |
| 62 | stats: { ...s.stats }, |
| 63 | record: structuredClone(record), |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | /** |
| 68 | * Better first (user's choice, 2026-10-24: level, then coins): a win before anything else, then |
| 69 | * the level reached, then coins, then fewer turns, then the earlier run. |
| 70 | */ |
| 71 | export function compareEntries(a: ScoreEntry, b: ScoreEntry): number { |
| 72 | return Number(b.won) - Number(a.won) || b.level - a.level || b.coins - a.coins || a.turns - b.turns || a.date.localeCompare(b.date) |
| 73 | } |
| 74 | |
| 75 | /** The board with `entry` added: sorted, at most BOARD_SIZE long; and where it ranks (1-based), or 0 if it didn't make it. */ |
| 76 | export function addEntry(board: ScoreEntry[], entry: ScoreEntry): { board: ScoreEntry[]; rank: number } { |
| 77 | const all = [...board, entry].sort(compareEntries) |
| 78 | const kept = all.slice(0, BOARD_SIZE) |
| 79 | return { board: kept, rank: kept.indexOf(entry) + 1 } |
| 80 | } |