fuzzyworld

explodorama / files / src / sim / scores.ts

scores.ts

Blame: the commit that last changed each line · file · history

0580e85e 2026-10-24 Pavel Safronov1import { SIM_VERSION, type RunRecord } from './record'
2import 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 */
11export 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
bb3823d2 2026-10-31 Pavel Safronov31 /** 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
0580e85e 2026-10-24 Pavel Safronov34 /** Everything needed to replay the run and check it. */
35 record: RunRecord
36}
37
38/** How many entries the board keeps. */
39export const BOARD_SIZE = 25
40
41/** Whether a finished run goes on the board: not an Immortal one (it earns no achievements). */
42export 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. */
47export 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 */
71export 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. */
76export 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}