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>
150 lines
5.2 KiB
TypeScript
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);
|
|
});
|
|
});
|