explodorama / files / test / demo.test.ts
demo.test.ts
| 1 | import { describe, expect, it } from 'vitest' |
| 2 | import { isValidTarget } from '../src/sim/actions' |
| 3 | import { isDeadly, predictDanger } from '../src/sim/danger' |
| 4 | import { generateScene, MAX_STEPS, MIN_STEPS, replayScene, SCENE_KINDS, SCENES, showsAtEnd, type Gesture } from '../src/sim/demo' |
| 5 | import { autoMoveAction, safeStep } from '../src/sim/path' |
| 6 | import { resolvePick, tapMeaning } from '../src/sim/picks' |
| 7 | import { step } from '../src/sim/turn' |
| 8 | import 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 | */ |
| 14 | function 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) |
| 18 | return means === 'move' || means === 'diagonal' || means === 'kick' || means === 'detonate' || means === 'teleport' || means === 'slide' |
| 19 | ? { id: means, target: g.tile } |
| 20 | : null |
| 21 | } |
| 22 | 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) |
| 30 | if (g.kind === 'pick') return null |
| 31 | if (g.kind === 'choice') return s.chargeOffer ? { id: g.swap ? 'swapCharge' : 'sellCharge' } : null |
| 32 | 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 | } |
| 36 | |
| 37 | describe('demo scenes', () => { |
| 38 | for (const kind of SCENE_KINDS.filter((k) => !SCENES[k].fixed)) { |
| 39 | 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)`, () => { |
| 40 | for (let seed = 1; seed <= 6; seed++) { |
| 41 | const scene = generateScene(kind, seed)! |
| 42 | expect(scene).not.toBeNull() |
| 43 | // 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) |
| 48 | 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([]) |
| 52 | // 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) |
| 55 | // Alive all along; at the end, dead in a scene about a way to die, alive in the rest. |
| 56 | let s = scene.start |
| 57 | let destination: Vec | null = null |
| 58 | for (const [i, a] of scene.actions.entries()) { |
| 59 | const g = scene.gestures[i]! |
| 60 | if (g.kind === 'walk') destination = g.tile |
| 61 | expect(controlsTake(s, g, destination)).toEqual(a) |
| 62 | s = step(s, a).state |
| 63 | if (destination && s.player.x === destination.x && s.player.y === destination.y) destination = null |
| 64 | const last = i === scene.actions.length - 1 |
| 65 | expect(last && SCENES[kind].dies ? ['dead', 'gameOver'] : ['playing', 'cleared']).toContain(s.status) |
| 66 | } |
| 67 | expect(showsAtEnd(scene)).toBe(true) |
| 68 | // 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 | } |
| 76 | } |
| 77 | }) |
| 78 | } |
| 79 | |
| 80 | 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 | |
| 88 | it('the same seed gives the same scene', () => { |
| 89 | expect(generateScene('kill', 7)).toEqual(generateScene('kill', 7)) |
| 90 | }) |
| 91 | }) |