explodorama / files / test / padDemo.test.ts
padDemo.test.ts
92cc4ee3 2026-11-02 Pavel Safronov | 1 | import { describe, expect, it } from 'vitest' |
| 2 | import { DYING_KINDS, generateScene, LIVING_KINDS, PAD_CONTROL_KINDS, PAD_LATER, replayScene, SCENES, SCENE_KINDS } from '../src/sim/demo' | |
| 3 | import { eq } from '../src/sim/state' | |
| 4 | import { PAD_STILL_CAPTIONS, cursorPath, padDemoOrder, padHops, padPlan, pressPart } from '../src/ui/padDemo' | |
| 5 | import { padCaptionHtml } from '../src/ui/padGlyph' | |
| 6 | import { actionableTiles, moveCursor, tileActions } from '../src/ui/padTiles' | |
| 7 | import { board, v } from './helpers' | |
| 8 | ||
| 9 | /** The scenes the pad's demo plays (PAD_LATER ones ask a dialog's question: later). */ | |
| 10 | const KINDS = [...PAD_CONTROL_KINDS, ...LIVING_KINDS, ...DYING_KINDS].filter((k) => !PAD_LATER.includes(k) && !SCENES[k].fixed) | |
| 11 | ||
| 12 | describe('padPlan on the demo scenes', () => { | |
| 13 | for (const kind of KINDS) { | |
| 14 | it(`${kind}: every turn has a plan whose presses end on the action's tile with the face button that does it`, () => { | |
| 15 | for (let seed = 1; seed <= 6; seed++) { | |
| 16 | const scene = generateScene(kind, seed)! | |
| 17 | const { turns } = replayScene(scene) | |
| 18 | for (const [i, t] of turns.entries()) { | |
| 19 | const g = scene.gestures[i]! | |
| 20 | if (g.kind === 'choice' || g.kind === 'pick') { | |
| 21 | expect(padPlan(t.prev, t.action, g)).toBeNull() | |
| 22 | continue | |
| 23 | } | |
| 24 | const plan = padPlan(t.prev, t.action, g) | |
| 25 | expect(plan, `seed ${seed} turn ${i} ${JSON.stringify(t.action)}`).not.toBeNull() | |
| 26 | const target = 'target' in t.action && t.action.target ? t.action.target : t.prev.player | |
| 27 | // Replay the d-pad presses on the cursor, as the pad does: it ends on the target. | |
| 28 | const tiles = actionableTiles(t.prev) | |
| 29 | let cursor = { ...t.prev.player } | |
| 30 | for (const step of plan!) { | |
| 31 | if (step.press.kind === 'dir') cursor = moveCursor(tiles, cursor, step.press.dx, step.press.dy) | |
| 32 | expect(step.cursor).toEqual(step.press.kind === 'dir' ? cursor : target) | |
| 33 | } | |
| 34 | expect(eq(cursor, target)).toBe(true) | |
| 35 | // The last press is the face button whose entry on that tile is this action. | |
| 36 | const last = plan![plan!.length - 1]!.press | |
| 37 | expect(last.kind).toBe('face') | |
| 38 | if (last.kind === 'face') expect(tileActions(t.prev, target).find((a) => a.button === last.button)?.id).toBe(t.action.id) | |
| 39 | } | |
| 40 | } | |
| 41 | }) | |
| 42 | } | |
| 43 | ||
| 44 | it('every scene the pad plays, whose touch caption talks of a tap, finger or hold, has a pad caption', () => { | |
| 45 | for (const kind of KINDS) if (/\b(tap|finger|hold)\b/i.test(SCENES[kind].caption)) expect(SCENES[kind].padCaption, kind).toBeTruthy() | |
| 46 | }) | |
| 47 | }) | |
| 48 | ||
| 49 | describe('cursorPath', () => { | |
| 50 | const room = () => | |
| 51 | board(` | |
| 52 | ####### | |
| 53 | #.....# | |
| 54 | #.....# | |
| 55 | #..P..# | |
| 56 | #.....# | |
| 57 | #.....# | |
| 58 | #######`) | |
| 59 | ||
| 60 | it('is empty to the player\'s own tile', () => { | |
| 61 | expect(cursorPath(room(), v(3, 3))).toEqual([]) | |
| 62 | }) | |
| 63 | it('one press to a tile next to the player', () => { | |
| 64 | expect(cursorPath(room(), v(4, 3))).toEqual([{ dx: 1, dy: 0, cursor: v(4, 3) }]) | |
| 65 | }) | |
| 66 | it('null for a tile that is not actionable', () => { | |
| 67 | expect(cursorPath(room(), v(1, 1))).toBeNull() | |
| 68 | expect(cursorPath(room(), v(0, 0))).toBeNull() | |
| 69 | }) | |
| 70 | }) | |
| 71 | ||
| 72 | describe('padPlan', () => { | |
| 73 | const s = board(` | |
| 74 | ####### | |
| 75 | #.....# | |
| 76 | #..P..# | |
| 77 | #.....# | |
| 78 | #######`) | |
| 79 | ||
| 80 | it('Wait is one press, with the cursor on the player', () => { | |
| 81 | expect(padPlan(s, { id: 'wait' }, { kind: 'button', button: 'wait' })).toEqual([{ press: { kind: 'face', button: 'wait' }, cursor: v(3, 2) }]) | |
| 82 | }) | |
| 83 | it('a step is a direction, then Move', () => { | |
| 84 | expect(padPlan(s, { id: 'move', target: v(3, 1) }, { kind: 'tap', tile: v(3, 1) })).toEqual([ | |
| 85 | { press: { kind: 'dir', dx: 0, dy: -1 }, cursor: v(3, 1) }, | |
| 86 | { press: { kind: 'face', button: 'move' }, cursor: v(3, 1) }, | |
| 87 | ]) | |
| 88 | }) | |
| 89 | it('choices and picks are left to the dialogs', () => { | |
| 90 | expect(padPlan(s, { id: 'sellCharge' }, { kind: 'choice', swap: false })).toBeNull() | |
| 91 | expect(padPlan(s, { id: 'continue' }, { kind: 'pick' })).toBeNull() | |
| 92 | }) | |
| 93 | it('a tile where no face button does the action has no plan (the pad picks per button: a tile with two options is not shown)', () => { | |
| 94 | const b = board(` | |
| 95 | ####### | |
| 96 | #.....# | |
| 97 | #.P3..# | |
| 98 | #.....# | |
| 99 | #######`) | |
| 100 | b.powers = { ...b.powers, kick: true, detonate: true } | |
| 101 | const target = v(3, 2) | |
| 102 | const g = { kind: 'tap', tile: target } as const | |
| 103 | expect(padPlan(b, { id: 'kick', target }, g)?.at(-1)?.press).toEqual({ kind: 'face', button: 'move' }) | |
| 104 | expect(padPlan(b, { id: 'detonate', target }, g)?.at(-1)?.press).toEqual({ kind: 'face', button: 'bomb' }) | |
| 105 | expect(padPlan(b, { id: 'throw', target }, g)).toBeNull() | |
| 106 | }) | |
| 107 | it('an action with no tile, or a tile nothing can be done on, has no plan', () => { | |
| 108 | expect(padPlan(s, { id: 'move' }, { kind: 'go' })).toBeNull() | |
| 109 | expect(padPlan(s, { id: 'move', target: v(0, 0) }, { kind: 'tap', tile: v(0, 0) })).toBeNull() | |
| 110 | }) | |
| 111 | }) | |
| 112 | ||
| 113 | describe('padCaptions', () => { | |
| 114 | it('every scene caption and still-scene caption draws, with no unknown picture name', () => { | |
| 115 | for (const k of SCENE_KINDS) { | |
| 116 | const text = SCENES[k].padCaption | |
| 117 | if (text) expect(() => padCaptionHtml(text, 'xbox', (n) => n), k).not.toThrow() | |
| 118 | } | |
| 119 | expect(PAD_STILL_CAPTIONS.padStick).toContain('{stick}') | |
| 120 | expect(PAD_STILL_CAPTIONS.padLook).toContain('{stick}') | |
| 121 | for (const [k, text] of Object.entries(PAD_STILL_CAPTIONS)) expect(() => padCaptionHtml(text, 'playstation', (n) => n), k).not.toThrow() | |
| 122 | }) | |
| 123 | it('the charge scenes name the charge button', () => { | |
| 124 | expect(SCENES.freeze.padCaption).toContain('{charge}') | |
| 125 | expect(SCENES.decoy.padCaption).toContain('{charge}') | |
| 126 | }) | |
| 127 | }) | |
| 128 | ||
| 129 | describe('pressPart', () => { | |
| 130 | it('maps the four directions, the face buttons and the rest to the controller\'s parts', () => { | |
| 131 | expect(pressPart({ kind: 'dir', dx: 0, dy: -1 }).part).toBe('up') | |
| 132 | expect(pressPart({ kind: 'dir', dx: 0, dy: 1 }).part).toBe('down') | |
| 133 | expect(pressPart({ kind: 'dir', dx: -1, dy: 0 }).part).toBe('left') | |
| 134 | expect(pressPart({ kind: 'dir', dx: 1, dy: 0 }).part).toBe('right') | |
| 135 | expect(pressPart({ kind: 'face', button: 'charged' }).part).toBe('charge') | |
| 136 | expect(pressPart({ kind: 'face', button: 'bomb' }).part).toBe('bomb') | |
| 137 | expect(pressPart({ kind: 'shoulder', side: 'r' }).part).toBe('r1') | |
| 138 | expect(pressPart({ kind: 'stick', dir: 'left' })).toEqual({ part: 'stick', stickDir: 'left' }) | |
| 139 | expect(pressPart({ kind: 'start' }).part).toBe('start') | |
| 140 | }) | |
| 141 | }) | |
| 142 | ||
| 143 | describe('padHops', () => { | |
| 144 | const s = board(` | |
| 145 | ####### | |
| 146 | #.....# | |
| 147 | #..P..# | |
| 148 | #.....# | |
| 149 | #######`) | |
| 150 | it('hops to tiles not been on, the first from the player, none twice', () => { | |
| 151 | const hops = padHops(s, 5) | |
| 152 | expect(hops.length).toBeGreaterThan(0) | |
| 153 | expect(hops.length).toBeLessThanOrEqual(5) | |
| 154 | const at = [s.player, ...hops.map((h) => h.to)] | |
| 155 | for (let i = 0; i < at.length; i++) for (let j = i + 1; j < at.length; j++) expect(eq(at[i]!, at[j]!)).toBe(false) | |
| 156 | expect(padHops(s, 0)).toEqual([]) | |
| 157 | }) | |
| 158 | }) | |
| 159 | ||
| 160 | describe('padDemoOrder', () => { | |
| 161 | const touch = ['tapMove', 'holdBomb', 'bombButton', 'wait', 'autoMove', 'topBar', 'whatIsThat', 'run', 'chargeSwap', 'kick'] as const | |
| 162 | const order = padDemoOrder(touch) | |
| 163 | it('puts the pad scenes first', () => { | |
| 164 | expect(order.slice(0, 9)).toEqual(['padCursor', ...PAD_CONTROL_KINDS, 'padL1', 'padR1', 'padStick', 'padLook', 'padStart']) | |
| 165 | }) | |
| 166 | it('drops the touch-only scenes and the ones that need a dialog', () => { | |
| 167 | for (const k of ['holdBomb', 'autoMove', 'topBar', 'whatIsThat', ...PAD_LATER]) expect(order).not.toContain(k) | |
| 168 | expect(order.slice(9)).toEqual(['run', 'kick']) | |
| 169 | }) | |
| 170 | }) |