explodorama / files / src / sim / testScenes.ts
testScenes.ts
92cc4ee3 2026-11-02 Pavel Safronov | 1 | import { autopilotPlan } from './autopilot' |
| 2 | import { predictDanger } from './danger' | |
8edb5cb9 2026-11-01 Pavel Safronov | 3 | import type { Gesture, Scene } from './demo' |
| 4 | import { board } from './picture' | |
| 5 | import { idx } from './state' | |
| 6 | import { step } from './turn' | |
| 7 | import 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 | */ | |
| 19 | export type TestKind = | |
| 20 | | 'testTwoGemsSlide' | 'testTwoGemsOil' | 'testGemsAndCharge' | 'testGemUnderRun' | |
92cc4ee3 2026-11-02 Pavel Safronov | 21 | | 'testGemDeath' | 'testOilBomb' | 'testOilEnemy' | 'testOilExit' | 'testAutopilot' | 'testAutopilotCrates' |
8edb5cb9 2026-11-01 Pavel Safronov | 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). */ | |
| 24 | type Turn = Action | ((s: GameState) => Action) | |
| 25 | ||
| 26 | export 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. */ | |
| 38 | const pick: Turn = (s) => ({ id: 'choose', kind: s.offer![0]! }) | |
| 39 | const sell: Turn = { id: 'sellCharge' } | |
92cc4ee3 2026-11-02 Pavel Safronov | 40 | /** The autopilot's next turn (sim/autopilot.ts). */ |
| 41 | const autopilot: Turn = (s) => autopilotPlan(s, predictDanger(s)).action! | |
8edb5cb9 2026-11-01 Pavel Safronov | 42 | const 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. */ | |
| 44 | const 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 | ||
| 48 | export 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 | }, | |
92cc4ee3 2026-11-02 Pavel Safronov | 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 | }, | |
8edb5cb9 2026-11-01 Pavel Safronov | 180 | } |
| 181 | ||
| 182 | export 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). */ | |
| 185 | function 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. */ | |
| 191 | export 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 | } |