explodorama / files / src / sim / ruleCompare.ts
ruleCompare.ts
351348d1 2026-11-04 Pavel Safronov | 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 | } |