not-tonight/tests/floor/stations.test.ts
type-two 0d3ce80c6b Six defects from the adversarial review: two softlocks, a dead door, and a bar that punished the right call
- DJ post stood the player inside the sealed djbooth (same class as the taps
  softlock): every direction blocked, and since the sweep needs you to walk to
  the entry, the night could never be paid. DECKS_SPOT moves to the booth's west
  counter, which is where the map comment always said the gear faces from.
- Role stations are now DATA (venueMap POSTS/PROBES) with tests/floor/stations.test.ts
  pinning the whole bug class: posts must be walkable AND escapable, probes must
  have somewhere walkable within interact range. Written as a MOVEMENT test, not
  a tile-name test — the question is whether the player can leave.
- DoorScene.busy is released by a delayedCall on the scene clock, and stopping a
  scene discards pending events, so a ruling in the last 2.6s of a night left it
  stuck true: a dead door — no rope, no verdicts, no way inside — for the rest
  of the run. Now reset per night, along with seenCards/kebab/franko/retiredRules.
- Bar WATER and CUT OFF never settled the patron, so watering a maggot from
  behind the taps left them ticking as unhandled and re-logging every grace
  period: the call that fixed the problem kept being charged for it.
- Every bar verb logged as 'barServe', putting drinks in the record that were
  never poured. One kind per verb; the report system takes unknown kinds by design.
- carpetMsFor() is consumed on read: a regular parked, recalled and knocked back
  came round again later and was paid the entrance dividend a second time.
- Sweep state joins resetNightState() so an interrupted floor score cannot carry
  its cash, or its previous-night pay callback, into the next night.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 03:13:09 +10:00

91 lines
3.5 KiB
TypeScript

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);
});
});