not-tonight/tests/floor/player.test.ts
type-two 3eee8432d4 LANE-FLOOR: the Floor — patrol, flashlight/UV, cut-off, stalls, pat-down
FloorDemoScene (F from Parade): an 80x45-tile venue you patrol in the dark with
a flashlight cone that reveals detail, plus all three v0.2 infractions and the
UV stamp check. Self-populates from the patron generator; consumes Patron[] only,
so Phase 2 can hand it the real admitted list.

Pure, tested logic (venueMap, cone, player, crowdSim, bangJudge, escalation,
patDown); Phaser layer stays thin (FloorView + three overlays). 120 new tests,
169 total.

Two bugs found by running it rather than by the suite, both fixed:
- bump penalties had a per-agent cooldown but no sim-wide bound, so the leak rate
  scaled with crowd size and an unattended venue pinned vibe to 0 / aggro to 100
  by ~1AM. Sim-wide gate added, with a regression test.
- beat.update() sat below the overlay early-return, freezing the beat clock behind
  modals and making the stall rhythm game unwinnable. Clock and beat now always run.

Also retuned dwell times and the tile/darkness palette for readability, and
dropped Light2D in favour of the cone mask it was fighting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:49:13 +10:00

135 lines
5.1 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
PLAYER_ACCEL, PLAYER_FRICTION, PLAYER_MAX_SPEED, PLAYER_RADIUS,
freshPlayer, stepPlayer,
} from '../../src/scenes/floor/player';
import type { PlayerInput, PlayerState } from '../../src/scenes/floor/player';
import { TILE, VENUE, isWalkableWorld, tileToWorld } from '../../src/scenes/floor/venueMap';
const NONE: PlayerInput = { up: false, down: false, left: false, right: false };
const input = (over: Partial<PlayerInput>): PlayerInput => ({ ...NONE, ...over });
// Open dance floor, and a spot one tile north of the south border wall.
const OPEN = tileToWorld(38, 23);
const NEAR_SOUTH_WALL = tileToWorld(40, 43);
const SOUTH_WALL_Y = 44 * TILE;
function run(p: PlayerState, i: PlayerInput, steps: number, ms = 16): PlayerState {
let s = p;
for (let n = 0; n < steps; n++) s = stepPlayer(s, i, ms, VENUE);
return s;
}
const speedOf = (s: PlayerState): number => Math.hypot(s.vx, s.vy);
/** The collision test the mover itself uses. */
function standing(s: PlayerState): boolean {
return isWalkableWorld(VENUE, s.x + PLAYER_RADIUS, s.y)
&& isWalkableWorld(VENUE, s.x - PLAYER_RADIUS, s.y)
&& isWalkableWorld(VENUE, s.x, s.y + PLAYER_RADIUS)
&& isWalkableWorld(VENUE, s.x, s.y - PLAYER_RADIUS);
}
describe('freshPlayer', () => {
it('starts at rest where it was put', () => {
const p = freshPlayer(100, 200);
expect(p).toEqual({ x: 100, y: 200, vx: 0, vy: 0, facing: 0 });
});
it('exposes a heavy tuning — accel far outstrips top speed', () => {
expect(PLAYER_ACCEL).toBeGreaterThan(PLAYER_MAX_SPEED);
expect(PLAYER_FRICTION).toBeGreaterThan(0);
});
});
describe('acceleration', () => {
it('accelerates from rest', () => {
const p = stepPlayer(freshPlayer(OPEN.x, OPEN.y), input({ right: true }), 16, VENUE);
expect(p.vx).toBeGreaterThan(0);
expect(p.x).toBeGreaterThan(OPEN.x);
expect(p.vy).toBe(0);
});
it('clamps to PLAYER_MAX_SPEED however long you hold it', () => {
const p = run(freshPlayer(OPEN.x, OPEN.y), input({ right: true }), 40);
expect(speedOf(p)).toBeCloseTo(PLAYER_MAX_SPEED, 6);
expect(speedOf(p)).toBeLessThanOrEqual(PLAYER_MAX_SPEED + 1e-9);
});
it('caps diagonals at the same top speed, not 1.41x', () => {
const p = run(freshPlayer(OPEN.x, OPEN.y), input({ right: true, down: true }), 40);
expect(speedOf(p)).toBeLessThanOrEqual(PLAYER_MAX_SPEED + 1e-9);
expect(p.vx).toBeCloseTo(p.vy, 6);
});
});
describe('friction', () => {
it('decelerates with no input, without reversing', () => {
const moving = run(freshPlayer(OPEN.x, OPEN.y), input({ right: true }), 40);
const a = run(moving, NONE, 5);
const b = run(a, NONE, 5);
expect(speedOf(a)).toBeLessThan(speedOf(moving));
expect(speedOf(b)).toBeLessThan(speedOf(a));
expect(b.vx).toBeGreaterThan(0);
expect(run(b, NONE, 400).vx).toBeCloseTo(0, 3);
});
});
describe('facing', () => {
it('follows the direction of travel', () => {
const right = run(freshPlayer(OPEN.x, OPEN.y), input({ right: true }), 20);
expect(right.facing).toBeCloseTo(0, 4);
const up = run(freshPlayer(OPEN.x, OPEN.y), input({ up: true }), 20);
expect(up.facing).toBeCloseTo(-Math.PI / 2, 4);
});
it('persists when input stops and the body coasts to a halt', () => {
const up = run(freshPlayer(OPEN.x, OPEN.y), input({ up: true }), 20);
const stopped = run(up, NONE, 600);
expect(speedOf(stopped)).toBeLessThan(1);
expect(stopped.facing).toBeCloseTo(up.facing, 6);
});
});
describe('collision', () => {
it('cannot walk through a wall', () => {
const p = run(freshPlayer(NEAR_SOUTH_WALL.x, NEAR_SOUTH_WALL.y), input({ down: true }), 200);
expect(standing(p)).toBe(true);
expect(p.y + PLAYER_RADIUS).toBeLessThan(SOUTH_WALL_Y);
expect(p.vy).toBe(0);
});
it('slides along a wall when moving diagonally into it', () => {
const start = freshPlayer(NEAR_SOUTH_WALL.x, NEAR_SOUTH_WALL.y);
const p = run(start, input({ down: true, right: true }), 120);
expect(standing(p)).toBe(true);
expect(p.x).toBeGreaterThan(start.x + 50);
expect(p.y + PLAYER_RADIUS).toBeLessThan(SOUTH_WALL_Y);
});
it('does not tunnel on a monstrous delta', () => {
const one = stepPlayer(freshPlayer(NEAR_SOUTH_WALL.x, NEAR_SOUTH_WALL.y), input({ down: true }), 60000, VENUE);
expect(standing(one)).toBe(true);
expect(one.y + PLAYER_RADIUS).toBeLessThan(SOUTH_WALL_Y);
// ...and repeated giant deltas are still just clamped steps.
const many = run(one, input({ down: true }), 30, 9999);
expect(standing(many)).toBe(true);
expect(many.y + PLAYER_RADIUS).toBeLessThan(SOUTH_WALL_Y);
});
it('keeps a full patrol of the open floor on walkable ground', () => {
let p = freshPlayer(OPEN.x, OPEN.y);
const legs: PlayerInput[] = [
input({ right: true }), input({ down: true }), input({ left: true }),
input({ up: true }), input({ up: true, left: true }), input({ down: true, right: true }),
];
for (const leg of legs) {
for (let n = 0; n < 300; n++) {
p = stepPlayer(p, leg, 16, VENUE);
expect(standing(p)).toBe(true);
}
}
});
});