import { describe, expect, it } from 'vitest'; import { POSTS, PROBES, VENUE, isWalkable, tileAt, tileToWorld, type TilePoint, } from '../../src/scenes/floor/venueMap'; import { freshPlayer, stepPlayer, type PlayerInput } from '../../src/scenes/floor/player'; // Two run-ending softlocks shipped from one mistake: a role stood the player on // a tile the player cannot stand on. Handing the taps back dropped you on a // `stool`; the DJ post stood you inside the sealed `djbooth`. Every direction // blocked, for the rest of the night — and both roster roles mount their post // automatically, so it was a single keypress from an unplayable shift. // // These tests are the guard rail, and they are deliberately about MOVEMENT // rather than tile names: the question is never "what kind of tile is this", it // is "can the player actually leave". const DIRECTIONS: ReadonlyArray<[string, PlayerInput]> = [ ['up', { up: true, down: false, left: false, right: false }], ['down', { up: false, down: true, left: false, right: false }], ['left', { up: false, down: false, left: true, right: false }], ['right', { up: false, down: false, left: false, right: true }], ]; /** Distance the player covers from `at` in one second of 60fps frames. */ function travel(at: TilePoint, input: PlayerInput): number { const world = tileToWorld(at.tx, at.ty); const start = { ...freshPlayer(world.x, world.y) }; let p = start; for (let i = 0; i < 60; i++) p = stepPlayer(p, input, 16.67, VENUE); return Math.hypot(p.x - world.x, p.y - world.y); } /** Interaction range in FloorDemoScene. A probe needs somewhere to stand within it. */ const INTERACT_RANGE = 46; describe('posts — tiles a role stands the player ON', () => { it('are walkable', () => { for (const [name, at] of Object.entries(POSTS)) { expect( isWalkable(VENUE, at.tx, at.ty), `post '${name}' is a '${tileAt(VENUE, at.tx, at.ty)}' tile, which is not walkable`, ).toBe(true); } }); it('can be walked off — the softlock test', () => { for (const [name, at] of Object.entries(POSTS)) { const best = Math.max(...DIRECTIONS.map(([, input]) => travel(at, input))); expect(best, `post '${name}' traps the player: no direction moves them`).toBeGreaterThan(8); } }); }); describe('probes — anchors the player walks UP TO', () => { it('have somewhere walkable to stand within interact range', () => { const reach = Math.ceil(INTERACT_RANGE / 16); for (const [name, at] of Object.entries(PROBES)) { const world = tileToWorld(at.tx, at.ty); let found = false; for (let dx = -reach; dx <= reach && !found; dx++) { for (let dy = -reach; dy <= reach && !found; dy++) { const tx = at.tx + dx; const ty = at.ty + dy; if (!isWalkable(VENUE, tx, ty)) continue; const w = tileToWorld(tx, ty); if (Math.hypot(w.x - world.x, w.y - world.y) <= INTERACT_RANGE) found = true; } } expect(found, `probe '${name}' has no walkable tile within ${INTERACT_RANGE}px — unreachable`).toBe(true); } }); }); describe('the tiles those softlocks landed on', () => { it('a bar stool is still not walkable — the taps must never hand you back onto one', () => { expect(tileAt(VENUE, 47, 6)).toBe('stool'); expect(isWalkable(VENUE, 47, 6)).toBe(false); }); it('the DJ booth interior is still sealed — the decks post belongs at its counter', () => { expect(isWalkable(VENUE, 55, 22)).toBe(false); expect(isWalkable(VENUE, POSTS.decks!.tx, POSTS.decks!.ty)).toBe(true); }); });