explodorama / files / src / sim / testScenes.ts
testScenes.ts
| 1 | import { autopilotPlan } from './autopilot' |
| 2 | import { predictDanger } from './danger' |
| 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' |
| 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). */ |
| 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' } |
| 40 | /** The autopilot's next turn (sim/autopilot.ts). */ |
| 41 | const autopilot: Turn = (s) => autopilotPlan(s, predictDanger(s)).action! |
| 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 | }, |
| 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 | |
| 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 | } |