- 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>
91 lines
3.5 KiB
TypeScript
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);
|
|
});
|
|
});
|