fuzzyworld

explodorama / files / src / sim / testScenes.ts

testScenes.ts

8.5 KB · history · blame

1import { autopilotPlan } from './autopilot'
2import { predictDanger } from './danger'
3import type { Gesture, Scene } from './demo'
4import { board } from './picture'
5import { idx } from './state'
6import { step } from './turn'
7import type { Action, GameState, Vec } from './types'
8
9/**
10 * Developer mode's test scenes (user request, 2026-10-31): fixed situations that check the edge
11 * cases, each a hand-drawn room and a scripted game, played like the demo's scenes. They are not
12 * in the landing page's demo (unless `?dev`), and the almanac lists them on its Tests tab in a
13 * Developer run. Each says what to expect, so a look is a check; test/test-scenes.test.ts checks
14 * the same by running them.
15 *
16 * To add one: a name in TestKind and an entry in TEST_SCENES (picture as in test/helpers.ts
17 * `board`, `%` a gem); the demo's table (sim/demo.ts) and the icons (main.ts) follow from the list.
18 */
19export type TestKind =
20 | 'testTwoGemsSlide' | 'testTwoGemsOil' | 'testGemsAndCharge' | 'testGemUnderRun'
21 | 'testGemDeath' | 'testOilBomb' | 'testOilEnemy' | 'testOilExit' | 'testAutopilot' | 'testAutopilotCrates'
22
23/** A turn of the script: an action, or one worked out from the state it is played in (a choice from the offer). */
24type Turn = Action | ((s: GameState) => Action)
25
26export interface TestScene {
27 name: string
28 caption: string
29 /** What should happen, one point per line, for the almanac. */
30 expect: string[]
31 icon: string
32 picture: string
33 setup?: (s: GameState) => void
34 script: Turn[]
35}
36
37/** The first power-up on the gem's offer. */
38const pick: Turn = (s) => ({ id: 'choose', kind: s.offer![0]! })
39const sell: Turn = { id: 'sellCharge' }
40/** The autopilot's next turn (sim/autopilot.ts). */
41const autopilot: Turn = (s) => autopilotPlan(s, predictDanger(s)).action!
42const at = (x: number, y: number): Vec => ({ x, y })
43/** Oil under every gem, bomb and enemy of the picture, so a spill runs through them. */
44const oilUnder = (s: GameState): void => {
45 for (const p of [...s.gems.map((g) => g.pos), ...s.bombs.map((b) => b.pos), ...s.enemies.map((e) => e.pos)]) s.oil.push(idx(s, p))
46}
47
48export const TEST_SCENES: Record<TestKind, TestScene> = {
49 testTwoGemsSlide: {
50 name: 'Slide over two gems',
51 caption: 'Test: a Slide over two gems asks for the first gem’s choice, then the second’s.',
52 expect: ['Two gems are taken on one turn.', 'The first choice opens; once it is made, the second opens (not skipped).', 'An ability chosen first is not offered again the second time.'],
53 icon: 'gem_green',
54 picture: `
55 #########
56 #P.%.%..#
57 #########`,
58 setup: (s) => (s.powers.slide = true),
59 script: [{ id: 'slide', target: at(7, 1) }, pick, pick],
60 },
61 testTwoGemsOil: {
62 name: 'Oil slide over two gems',
63 caption: 'Test: sliding on oil over two gems asks for both choices, one after the other.',
64 expect: ['The slide carries the player over both gems.', 'Two choices follow, one after the other.'],
65 icon: 'oil',
66 picture: `
67 #########
68 #P~%~%~.#
69 #########`,
70 setup: oilUnder,
71 script: [{ id: 'move', target: at(2, 1) }, pick, pick],
72 },
73 testGemsAndCharge: {
74 name: 'Two gems and a charge',
75 caption: 'Test: a Nuke between two gems, carrying a Freeze: both gem choices, and the charge’s sell-or-swap.',
76 expect: ['The first gem’s choice opens.', 'Then the charge’s: sell the Nuke, or swap it for the Freeze.', 'Only then the second gem’s choice.'],
77 icon: 'gem_green',
78 picture: `
79 #########
80 #P.%.%..#
81 #########`,
82 setup: (s) => {
83 s.powers.slide = true
84 s.charge = { kind: 'freeze', left: 1 }
85 s.drops.push({ pos: at(4, 1), kind: 'nuke' })
86 },
87 script: [{ id: 'slide', target: at(7, 1) }, pick, sell, pick],
88 },
89 testGemUnderRun: {
90 name: 'Run over a gem',
91 caption: 'Test: a Run passes over the tile in between, and picks up the gem lying there.',
92 expect: ['The gem is on the tile the Run crosses, not the one it lands on.', 'Its choice opens.'],
93 icon: 'gem_green',
94 picture: `
95 #########
96 #P%.....#
97 #########`,
98 setup: (s) => (s.powers.run = true),
99 script: [{ id: 'run', target: at(3, 1) }, pick],
100 },
101 testGemDeath: {
102 name: 'Gem taken, then killed',
103 caption: 'Test: taking a gem on the turn a blast kills you. No choice opens; the retry has the gem back.',
104 expect: ['The player steps onto the gem and the bomb goes off.', 'A life is lost and no choice opens.'],
105 icon: 'fireball',
106 picture: `
107 #########
108 #P%1....#
109 #########`,
110 script: [{ id: 'move', target: at(2, 1) }],
111 },
112 testOilBomb: {
113 name: 'Oil slide stops at a bomb',
114 caption: 'Test: sliding on oil stops short of a bomb lying on the spill.',
115 expect: ['The player slides along the oil and stops on the tile before the bomb.'],
116 icon: 'bomb',
117 picture: `
118 #########
119 #P~~~5..#
120 #########`,
121 setup: oilUnder,
122 script: [{ id: 'move', target: at(2, 1) }],
123 },
124 testOilEnemy: {
125 name: 'Oil slide stops at an enemy',
126 caption: 'Test: sliding on oil stops short of an enemy standing on the spill, instead of into it.',
127 expect: ['The player slides and stops next to the Blob; no life is lost in the slide itself.'],
128 icon: 'slime_normal_rest',
129 picture: `
130 #########
131 #P~~~B..#
132 #########`,
133 setup: oilUnder,
134 script: [{ id: 'move', target: at(2, 1) }],
135 },
136 testOilExit: {
137 name: 'Oil slide stops on the exit',
138 caption: 'Test: the slide stops on an open exit, and the level is cleared.',
139 expect: ['The exit is open (no enemies).', 'The slide ends on it, not at the end of the spill.'],
140 icon: 'door_open_top',
141 picture: `
142 #########
143 #P~~~E~.#
144 #########`,
145 setup: (s) => {
146 for (const x of [2, 3, 4, 5, 6]) s.oil.push(idx(s, at(x, 1)))
147 },
148 script: [{ id: 'move', target: at(2, 1) }],
149 },
150 testAutopilot: {
151 name: 'Autopilot gathers, then the exit',
152 caption: 'Test: with the last enemy gone, the autopilot walks to a coin, a charge (one is held), a gem, then the exit.',
153 expect: ['It steps on the coin, then the charge, and the charge’s sell-or-swap opens.', 'After the answer it walks on to the gem, whose choice opens.', 'After that it walks to the exit, and the level is cleared; every step is a turn.'],
154 icon: 'door_open_top',
155 picture: `
156 ###########
157 #P.$...%.E#
158 ###########`,
159 setup: (s) => {
160 s.charge = { kind: 'freeze', left: 1 }
161 s.drops.push({ pos: at(5, 1), kind: 'nuke' })
162 },
163 script: [autopilot, autopilot, autopilot, autopilot, sell, autopilot, autopilot, pick, autopilot, autopilot],
164 },
165 testAutopilotCrates: {
166 name: 'Autopilot waits for the crates',
167 caption: 'Test: a gem lies under a crate that is crumbling. The autopilot waits for it, then fetches the gem, then goes to the exit.',
168 expect: ['It waits while the crate is still there.', 'Once the crate is gone it walks to the gem, whose choice opens.', 'After the answer it walks to the exit.'],
169 icon: 'gem_green',
170 picture: `
171 ###########
172 #P..%....E#
173 ###########`,
174 setup: (s) => {
175 s.cells[idx(s, at(4, 1))] = 'block'
176 s.crumbling.push({ cell: idx(s, at(4, 1)), turns: 2 })
177 },
178 script: [autopilot, autopilot, autopilot, autopilot, autopilot, pick, autopilot, autopilot, autopilot, autopilot, autopilot],
179 },
180}
181
182export const TEST_KINDS = Object.keys(TEST_SCENES) as TestKind[]
183
184/** The gesture a turn's action is played with (the demo's finger; a choice has no tile). */
185function gestureOf(a: Action): Gesture {
186 if (a.id === 'choose' || a.id === 'sellCharge' || a.id === 'swapCharge' || a.id === 'fireCharge') return { kind: 'pick' }
187 return 'target' in a && a.target ? { kind: 'tap', tile: a.target } : { kind: 'button', button: 'wait' }
188}
189
190/** A test scene played out: its room and turns, as the demo's scenes are. */
191export function buildTestScene(kind: TestKind): Scene {
192 const def = TEST_SCENES[kind]
193 const s = board(def.picture)
194 def.setup?.(s)
195 // The room starts at turn 0 like the demo's; spikes keep their rhythm.
196 const spikes = s.spikes.map((sp) => ({ ...sp, phase: (sp.phase + s.turn) % 2 }))
197 // A bomb of the picture was placed before turn 0, so it ticks on the first turn.
198 const start: GameState = { ...s, turn: 0, spikes, bombs: s.bombs.map((b) => ({ ...b, placedTurn: -1 })) }
199 const actions: Action[] = []
200 let now = start
201 for (const turn of def.script) {
202 const action = typeof turn === 'function' ? turn(now) : turn
203 actions.push(action)
204 now = step(now, action).state
205 }
206 return { kind, start, actions, gestures: actions.map(gestureOf), moment: actions.length - 1 }
207}