fuzzyworld

explodorama / files / src / sim / replayCompare.ts

replayCompare.ts

4.5 KB · history · blame

1import { autopilotPlan } from './autopilot'
2import { predictDanger } from './danger'
3import { newRun } from './state'
4import { step } from './turn'
5import { SIM_VERSION, type RunRecord } from './record'
6import type { Action, GameState } from './types'
7
8/**
9 * A bug report's run, set against what today's autopilot does from the same place (Q103, user
10 * request, 2026-11-03): the dev replay page (`?replay=` in `main.ts`) plays two players side by
11 * side, one turn each at a time, in a loop: red plays `prefix` then `old` (the report), blue plays
12 * `prefix` then `neu` (today's autopilot).
13 */
14export interface ReplayScript {
15 /** The room as it stood `lead` turns before the autopilot took over. */
16 start: GameState
17 /** Turns both share: from `start` to the moment the last enemy died (the autopilot took over). */
18 prefix: Action[]
19 /** What the report did from there on, and a few waits after it to show how it ends. */
20 old: Action[]
21 /** What today's autopilot does from there on, to as long as the report went, a few turns more. */
22 neu: Action[]
23 /** How each fork ends: the status and lives left. */
24 oldEnd: { status: GameState['status']; lives: number }
25 newEnd: { status: GameState['status']; lives: number }
26 /** The record's rules version differs from today's: the replay may not match the report. */
27 versionMismatch: boolean
28 level: number
29}
30
31export interface ReplayOptions {
32 /** Turns shown before the autopilot took over. */
33 lead?: number
34 /** Turns past the report's last that are still shown. */
35 extra?: number
36}
37
38/** Plays `actions` from `from`, keeping those the rules take while the game is on; the states on the way. */
39function play(from: GameState, actions: Action[]): { kept: Action[]; states: GameState[] } {
40 const kept: Action[] = []
41 const states: GameState[] = []
42 let s = from
43 for (const a of actions) {
44 if (s.status !== 'playing') break
45 try {
46 s = step(s, a).state
47 } catch {
48 break
49 }
50 kept.push(a)
51 states.push(s)
52 }
53 return { kept, states }
54}
55
56/** Today's autopilot from `from` (answering offers with the first choice), for at most `limit` turns. */
57function autopilotRun(from: GameState, limit: number): { actions: Action[]; states: GameState[] } {
58 const actions: Action[] = []
59 const states: GameState[] = []
60 let s = from
61 for (let i = 0; i < limit && s.status === 'playing'; i++) {
62 const action: Action | null = s.offer ? { id: 'choose', kind: s.offer[0]! } : s.chargeOffer ? { id: 'sellCharge' } : autopilotPlan(s, predictDanger(s)).action
63 if (!action) break
64 s = step(s, action).state
65 actions.push(action)
66 states.push(s)
67 }
68 return { actions, states }
69}
70
71export function buildReplay(record: RunRecord, options: ReplayOptions = {}): ReplayScript {
72 const { lead = 6, extra = 4 } = options
73 // The state after each recorded action; `after(-1)` is the start of the run.
74 const first = newRun(record.seed, record.startLevel, record.startCharge, record.options)
75 const states: GameState[] = []
76 let s = first
77 for (const a of record.actions) {
78 s = step(s, a).state
79 states.push(s)
80 }
81 const after = (i: number): GameState => (i < 0 ? first : states[i]!)
82 const last = after(record.actions.length - 1)
83 // The level the report ends in, from the turn that opened it.
84 let levelStart = -1
85 for (let i = 0; i < states.length; i++) if (states[i]!.level === last.level) { levelStart = i; break }
86 if (levelStart < 0) levelStart = record.actions.length - 1
87 // The autopilot takes over when the last enemy is gone: the first such turn on this level.
88 let takeover = record.actions.length - 1
89 for (let i = levelStart; i < states.length; i++) {
90 if (states[i]!.level === last.level && states[i]!.enemies.length === 0 && states[i]!.status === 'playing') { takeover = i; break }
91 }
92 const startIdx = Math.max(levelStart, takeover - lead)
93 const start = after(startIdx)
94 const prefix = record.actions.slice(startIdx + 1, takeover + 1)
95 const fork = after(takeover)
96 const recorded = record.actions.slice(takeover + 1)
97 const waits: Action[] = Array.from({ length: extra }, () => ({ id: 'wait' }))
98 const old = play(fork, [...recorded, ...waits])
99 const neu = autopilotRun(fork, recorded.length + extra)
100 const end = (from: GameState, st: GameState[]) => {
101 const e = st.at(-1) ?? from
102 return { status: e.status, lives: e.lives }
103 }
104 return {
105 start,
106 prefix,
107 old: old.kept,
108 neu: neu.actions,
109 oldEnd: end(fork, old.states),
110 newEnd: end(fork, neu.states),
111 versionMismatch: record.simVersion !== SIM_VERSION,
112 level: last.level,
113 }
114}