not-tonight/tests/floor/bangJudge.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

150 lines
5.2 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
applyBang,
BANGS_TO_BUST,
beatError,
freshStall,
judgeBang,
OK_MS,
PERFECT_MS,
scoreStall,
type StallState,
} from '../../src/scenes/floor/bangJudge';
const INTERVAL = 60_000 / 128;
const TICK = { beatIndex: 4, audioTimeMs: 10_000 };
describe('beatError', () => {
it('is zero exactly on the tick', () => {
expect(beatError(TICK.audioTimeMs, TICK, INTERVAL)).toBe(0);
});
it('measures to the nearest boundary, not to the tick itself', () => {
expect(beatError(TICK.audioTimeMs + INTERVAL, TICK, INTERVAL)).toBeCloseTo(0, 6);
expect(beatError(TICK.audioTimeMs + 3 * INTERVAL + 25, TICK, INTERVAL)).toBeCloseTo(25, 6);
});
it('judges a bang landing before the tick against the previous boundary', () => {
expect(beatError(TICK.audioTimeMs - INTERVAL + 30, TICK, INTERVAL)).toBeCloseTo(30, 6);
expect(beatError(TICK.audioTimeMs - 20, TICK, INTERVAL)).toBeCloseTo(20, 6);
});
it('peaks halfway between beats', () => {
expect(beatError(TICK.audioTimeMs + INTERVAL / 2, TICK, INTERVAL)).toBeCloseTo(INTERVAL / 2, 6);
});
});
describe('judgeBang', () => {
it('grades an exact hit as perfect', () => {
expect(judgeBang(TICK.audioTimeMs, TICK, INTERVAL)).toBe('perfect');
});
it('still grades perfect a full beat interval later', () => {
expect(judgeBang(TICK.audioTimeMs + INTERVAL, TICK, INTERVAL)).toBe('perfect');
expect(judgeBang(TICK.audioTimeMs + 8 * INTERVAL, TICK, INTERVAL)).toBe('perfect');
});
it('grades halfway between beats as a miss', () => {
expect(judgeBang(TICK.audioTimeMs + INTERVAL / 2, TICK, INTERVAL)).toBe('miss');
expect(judgeBang(TICK.audioTimeMs + 2.5 * INTERVAL, TICK, INTERVAL)).toBe('miss');
});
it('treats PERFECT_MS as inclusive and just past it as ok', () => {
expect(judgeBang(TICK.audioTimeMs + PERFECT_MS, TICK, INTERVAL)).toBe('perfect');
expect(judgeBang(TICK.audioTimeMs - PERFECT_MS, TICK, INTERVAL)).toBe('perfect');
expect(judgeBang(TICK.audioTimeMs + PERFECT_MS + 0.5, TICK, INTERVAL)).toBe('ok');
});
it('treats OK_MS as inclusive and just past it as a miss', () => {
expect(judgeBang(TICK.audioTimeMs + OK_MS, TICK, INTERVAL)).toBe('ok');
expect(judgeBang(TICK.audioTimeMs - OK_MS, TICK, INTERVAL)).toBe('ok');
expect(judgeBang(TICK.audioTimeMs + OK_MS + 0.5, TICK, INTERVAL)).toBe('miss');
});
it('grades late bangs relative to whichever boundary is closest', () => {
// 100ms past the fourth boundary — ok, even though it is ~1875ms past the tick.
expect(judgeBang(TICK.audioTimeMs + 4 * INTERVAL + 100, TICK, INTERVAL)).toBe('ok');
});
});
describe('applyBang', () => {
it('does not mutate the input state', () => {
const s = freshStall();
const snapshot: StallState = { ...s };
applyBang(s, 'perfect');
expect(s).toEqual(snapshot);
});
it('advances the combo on perfect and ok alike', () => {
const a = applyBang(freshStall(), 'perfect');
const b = applyBang(a, 'ok');
expect(b.combo).toBe(2);
expect(b.bangs).toBe(2);
expect(b.aggro).toBe(0);
expect(b.lastGrade).toBe('ok');
});
it('resets the combo and adds aggro on a miss', () => {
const a = applyBang(applyBang(freshStall(), 'ok'), 'miss');
expect(a.combo).toBe(0);
expect(a.bangs).toBe(2);
expect(a.aggro).toBeCloseTo(0.8, 6);
expect(a.busted).toBe(false);
});
it('busts after three consecutive ok bangs', () => {
let s = freshStall();
for (let i = 0; i < BANGS_TO_BUST; i++) s = applyBang(s, 'ok');
expect(s.busted).toBe(true);
});
it('stays busted once busted, even after a later miss', () => {
let s = freshStall();
for (let i = 0; i < BANGS_TO_BUST; i++) s = applyBang(s, 'perfect');
s = applyBang(s, 'miss');
expect(s.busted).toBe(true);
expect(s.combo).toBe(0);
});
it('is not busted when a miss interrupts the run', () => {
let s = freshStall();
for (const g of ['ok', 'miss', 'ok', 'ok'] as const) s = applyBang(s, g);
expect(s.busted).toBe(false);
expect(s.combo).toBe(2);
expect(s.bangs).toBe(4);
});
});
describe('scoreStall', () => {
it('pays out big when the stall is busted', () => {
let s = freshStall();
for (let i = 0; i < BANGS_TO_BUST; i++) s = applyBang(s, 'perfect');
const out = scoreStall(s);
expect(out.solved).toBe(true);
expect(out.vibeDelta).toBe(9);
expect(out.aggroDelta).toBe(0);
});
it('costs vibe when the player made a scene and achieved nothing', () => {
const out = scoreStall(applyBang(freshStall(), 'miss'));
expect(out.solved).toBe(false);
expect(out.vibeDelta).toBe(-1.5);
expect(out.aggroDelta).toBeCloseTo(0.8, 6);
});
it('clamps accumulated aggro at 6', () => {
let s = freshStall();
for (let i = 0; i < 12; i++) s = applyBang(s, 'miss');
expect(s.aggro).toBeGreaterThan(6);
expect(scoreStall(s).aggroDelta).toBe(6);
});
it('carries aggro from misses through a bust', () => {
let s = freshStall();
for (const g of ['miss', 'miss', 'ok', 'ok', 'perfect'] as const) s = applyBang(s, g);
const out = scoreStall(s);
expect(out.solved).toBe(true);
expect(out.aggroDelta).toBeCloseTo(1.6, 6);
});
});