explodorama / files / src / sim / ruleCompare.ts
ruleCompare.ts
| 1 | import type { RunRecord } from './record' |
| 2 | import type { StepResult } from './turn' |
| 3 | import type { Action, GameState, RunOptions, ChargeId } from './types' |
| 4 | |
| 5 | /** |
| 6 | * Two versions of the rules played over one whole run (Q103, user request, 2026-11-03): the |
| 7 | * replay page's `?rules=<commit>` mode. An engine is a version's `newRun` and `step`; the older |
| 8 | * one is loaded from `rules-versions/<commit>/` (tools/report-replay.mjs makes it with |
| 9 | * `git archive`), the newer one is today's. Both are given the same recorded actions; this finds |
| 10 | * where their games stop agreeing. |
| 11 | */ |
| 12 | export interface Engine { |
| 13 | newRun(seed: number, level: number, charge: ChargeId | null, options: RunOptions): GameState |
| 14 | step(prev: GameState, action: Action): StepResult |
| 15 | } |
| 16 | |
| 17 | /** One side's game: the start and the result of each turn it could play. */ |
| 18 | export interface Side { |
| 19 | start: GameState |
| 20 | results: StepResult[] |
| 21 | /** The turn (0-based) this side's rules refused, with the reason; its game stops there. */ |
| 22 | refused: { at: number; why: string } | null |
| 23 | } |
| 24 | |
| 25 | export interface RuleComparison { |
| 26 | actions: Action[] |
| 27 | red: Side |
| 28 | blue: Side |
| 29 | /** The first turn (0-based, into `actions`) after which the games differ, or null if they agree to the end. */ |
| 30 | split: number | null |
| 31 | /** What differs there, in words. */ |
| 32 | why: string |
| 33 | } |
| 34 | |
| 35 | /** What two games must agree on to count as the same game: the board's life, not every field (a version may add some). */ |
| 36 | function digest(s: GameState): string { |
| 37 | return JSON.stringify([ |
| 38 | s.level, s.status, s.lives, s.player, s.coins, s.turn, !!s.offer, |
| 39 | s.enemies.map((e) => [e.kind, e.pos]), |
| 40 | s.bombs.map((b) => [b.pos, b.timer]), |
| 41 | // (An older version may lack a field.) |
| 42 | s.burning?.length, s.drops?.length, s.gems?.length, |
| 43 | ]) |
| 44 | } |
| 45 | |
| 46 | /** The first difference between two games, in words ('' if none). */ |
| 47 | export function describeDifference(a: GameState, b: GameState): string { |
| 48 | const at = (p: { x: number; y: number }) => `(${p.x},${p.y})` |
| 49 | if (a.level !== b.level) return `level ${a.level} vs ${b.level}` |
| 50 | if (a.status !== b.status) return `${a.status} vs ${b.status}` |
| 51 | if (a.lives !== b.lives) return `${a.lives} vs ${b.lives} lives` |
| 52 | if (a.player.x !== b.player.x || a.player.y !== b.player.y) return `player at ${at(a.player)} vs ${at(b.player)}` |
| 53 | if (a.enemies.length !== b.enemies.length) return `${a.enemies.length} vs ${b.enemies.length} enemies` |
| 54 | if (a.coins !== b.coins) return `${a.coins} vs ${b.coins} coins` |
| 55 | if (a.bombs.length !== b.bombs.length) return `${a.bombs.length} vs ${b.bombs.length} bombs` |
| 56 | return digest(a) === digest(b) ? '' : 'the boards differ' |
| 57 | } |
| 58 | |
| 59 | function playSide(engine: Engine, record: RunRecord): Side { |
| 60 | const start = engine.newRun(record.seed, record.startLevel, record.startCharge, record.options) |
| 61 | const results: StepResult[] = [] |
| 62 | let s = start |
| 63 | let refused: Side['refused'] = null |
| 64 | for (let i = 0; i < record.actions.length; i++) { |
| 65 | try { |
| 66 | const r = engine.step(s, record.actions[i]!) |
| 67 | results.push(r) |
| 68 | s = r.state |
| 69 | } catch (error) { |
| 70 | refused = { at: i, why: String(error instanceof Error ? error.message : error) } |
| 71 | break |
| 72 | } |
| 73 | } |
| 74 | return { start, results, refused } |
| 75 | } |
| 76 | |
| 77 | export function compareRules(red: Engine, blue: Engine, record: RunRecord): RuleComparison { |
| 78 | const a = playSide(red, record) |
| 79 | const b = playSide(blue, record) |
| 80 | const stateAfter = (side: Side, i: number): GameState => (i < 0 ? side.start : side.results[Math.min(i, side.results.length - 1)]?.state ?? side.start) |
| 81 | let split: number | null = null |
| 82 | let why = '' |
| 83 | const turns = Math.max(a.results.length + (a.refused ? 1 : 0), b.results.length + (b.refused ? 1 : 0)) |
| 84 | for (let i = 0; i < turns; i++) { |
| 85 | const refusedHere = [a, b].filter((x) => x.refused?.at === i) |
| 86 | if (refusedHere.length) { |
| 87 | split = i |
| 88 | why = refusedHere.length === 2 ? 'both refuse this turn' : `${refusedHere[0] === a ? 'red' : 'blue'}'s rules refuse it: ${refusedHere[0]!.refused!.why}` |
| 89 | break |
| 90 | } |
| 91 | const d = describeDifference(stateAfter(a, i), stateAfter(b, i)) |
| 92 | if (d) { |
| 93 | split = i |
| 94 | why = d |
| 95 | break |
| 96 | } |
| 97 | } |
| 98 | return { actions: record.actions, red: a, blue: b, split, why } |
| 99 | } |