fuzzyworld

explodorama / files / src / sim / ruleCompare.ts

ruleCompare.ts

4.1 KB · history · blame

1import type { RunRecord } from './record'
2import type { StepResult } from './turn'
3import 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 */
12export 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. */
18export 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
25export 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). */
36function 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). */
47export 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
59function 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
77export 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}