fuzzyworld

explodorama / files / test / demo.test.ts

demo.test.ts

Blame: the commit that last changed each line · file · history

52d68b59 2026-10-24 Pavel Safronov1import { describe, expect, it } from 'vitest'
36c5d6a2 2026-10-24 Pavel Safronov2import { isValidTarget } from '../src/sim/actions'
e3d8dc49 2026-10-27 Pavel Safronov3import { isDeadly, predictDanger } from '../src/sim/danger'
33a0256b 2026-10-24 Pavel Safronov4import { generateScene, MAX_STEPS, MIN_STEPS, replayScene, SCENE_KINDS, SCENES, showsAtEnd, type Gesture } from '../src/sim/demo'
36c5d6a2 2026-10-24 Pavel Safronov5import { autoMoveAction, safeStep } from '../src/sim/path'
6import { resolvePick, tapMeaning } from '../src/sim/picks'
52d68b59 2026-10-24 Pavel Safronov7import { step } from '../src/sim/turn'
36c5d6a2 2026-10-24 Pavel Safronov8import type { Action, GameState, Vec } from '../src/sim/types'
9
10/**
11 * The action the real controls take for a gesture (main.ts: `tapTile`, the long press, `pick`),
12 * given auto-move's destination; null if they would take none.
13 */
14function controlsTake(s: GameState, g: Gesture, destination: Vec | null): Action | null {
15 const safe = (p: Vec) => safeStep(s, predictDanger(s), p)
16 if (g.kind === 'tap') {
17 const means = tapMeaning(s, g.tile)
7f242d36 2026-10-25 Pavel Safronov18 return means === 'move' || means === 'diagonal' || means === 'kick' || means === 'detonate' || means === 'teleport' || means === 'slide'
36c5d6a2 2026-10-24 Pavel Safronov19 ? { id: means, target: g.tile }
20 : null
21 }
e3d8dc49 2026-10-27 Pavel Safronov22 if (g.kind === 'hold') return isValidTarget(s, 'bomb', g.tile) && !isDeadly(s, { id: 'bomb', target: g.tile }) ? { id: 'bomb', target: g.tile } : null
23 // Auto-move never takes a move that costs a life.
24 const auto = (to: Vec) => {
25 const a = autoMoveAction(s, to, safe)
26 return a && !isDeadly(s, a) ? a : null
27 }
28 if (g.kind === 'walk') return tapMeaning(s, g.tile) === 'walk' ? auto(g.tile) : null
29 if (g.kind === 'go') return destination && auto(destination)
8edb5cb9 2026-11-01 Pavel Safronov30 if (g.kind === 'pick') return null
0bfdc8e6 2026-10-25 Pavel Safronov31 if (g.kind === 'choice') return s.chargeOffer ? { id: g.swap ? 'swapCharge' : 'sellCharge' } : null
36c5d6a2 2026-10-24 Pavel Safronov32 if (g.button === 'wait') return { id: 'wait' }
33 const id = g.tile && resolvePick(s, g.button, g.tile)
34 return id ? { id, target: g.tile } : null
35}
52d68b59 2026-10-24 Pavel Safronov36
37describe('demo scenes', () => {
8edb5cb9 2026-11-01 Pavel Safronov38 for (const kind of SCENE_KINDS.filter((k) => !SCENES[k].fixed)) {
36c5d6a2 2026-10-24 Pavel Safronov39 it(`${kind}: found, its moment ${MIN_STEPS}-${MAX_STEPS} turns in from a calm start, each turn played by its gesture, the player alive (or dying at the end of a death scene)`, () => {
fa3e81a7 2026-10-24 Pavel Safronov40 for (let seed = 1; seed <= 6; seed++) {
52d68b59 2026-10-24 Pavel Safronov41 const scene = generateScene(kind, seed)!
42 expect(scene).not.toBeNull()
b8849d36 2026-11-01 Pavel Safronov43 // The moment is MIN-MAX turns in (a scene may ask for more); a few turns of aftermath may follow it.
44 const moment = scene.moment!
45 expect(moment + 1).toBeGreaterThanOrEqual(MIN_STEPS)
46 expect(moment + 1).toBeLessThanOrEqual(SCENES[kind].maxSteps ?? MAX_STEPS)
47 expect(scene.actions.length - (moment + 1)).toBeLessThanOrEqual(SCENES[kind].tail ?? 0)
36c5d6a2 2026-10-24 Pavel Safronov48 expect(scene.gestures.length).toBe(scene.actions.length)
49 // Calm: the scene's own bombs are placed in it, so the countdown is seen from the start.
50 expect(scene.start.bombs).toEqual([])
51 expect(scene.start.burning).toEqual([])
e3384c6b 2026-10-24 Pavel Safronov52 // The top bar shows in the demo: each scene starts as if fresh.
53 expect(scene.start.turn).toBe(0)
54 expect(scene.start.coins).toBe(0)
d5e3ad4d 2026-10-24 Pavel Safronov55 // Alive all along; at the end, dead in a scene about a way to die, alive in the rest.
fa3e81a7 2026-10-24 Pavel Safronov56 let s = scene.start
36c5d6a2 2026-10-24 Pavel Safronov57 let destination: Vec | null = null
d5e3ad4d 2026-10-24 Pavel Safronov58 for (const [i, a] of scene.actions.entries()) {
36c5d6a2 2026-10-24 Pavel Safronov59 const g = scene.gestures[i]!
60 if (g.kind === 'walk') destination = g.tile
61 expect(controlsTake(s, g, destination)).toEqual(a)
fa3e81a7 2026-10-24 Pavel Safronov62 s = step(s, a).state
36c5d6a2 2026-10-24 Pavel Safronov63 if (destination && s.player.x === destination.x && s.player.y === destination.y) destination = null
d5e3ad4d 2026-10-24 Pavel Safronov64 const last = i === scene.actions.length - 1
65 expect(last && SCENES[kind].dies ? ['dead', 'gameOver'] : ['playing', 'cleared']).toContain(s.status)
52d68b59 2026-10-24 Pavel Safronov66 }
fa3e81a7 2026-10-24 Pavel Safronov67 expect(showsAtEnd(scene)).toBe(true)
33a0256b 2026-10-24 Pavel Safronov68 // Sensible play, unless the scene is about a mistake: nothing lets enemies out of the exit
69 // or the gem (but in the scene about that), and no bomb goes on the exit.
70 if (!SCENES[kind].dies) {
71 for (const t of replayScene(scene).turns) {
72 if (kind !== 'doorBlast') expect(t.events.some((e) => e.type === 'enemiesSpawned')).toBe(false)
73 if (kind !== 'doorBlast' && t.action.id === 'bomb' && 'target' in t.action) expect(t.action.target).not.toEqual(t.prev.exit)
74 }
75 }
52d68b59 2026-10-24 Pavel Safronov76 }
77 })
78 }
79
b8849d36 2026-11-01 Pavel Safronov80 it('rubble: the ceiling falls at the moment, and the aftermath shows it', () => {
81 const scene = generateScene('rubble', 3)!
82 expect(scene.start.cracked.length).toBe(1)
83 const { turns } = replayScene(scene)
84 expect(turns[scene.moment!]!.events.some((e) => e.type === 'ceilingFell')).toBe(true)
85 expect(turns[turns.length - 1]!.next.cells.includes('rubble')).toBe(true)
86 })
87
52d68b59 2026-10-24 Pavel Safronov88 it('the same seed gives the same scene', () => {
89 expect(generateScene('kill', 7)).toEqual(generateScene('kill', 7))
90 })
91})