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>
168 lines
5.9 KiB
TypeScript
168 lines
5.9 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
NORMAL_CONE, UV_CONE, angleDelta, coneVisibility, conePolygon, inCone,
|
|
} from '../../src/scenes/floor/cone';
|
|
import type { ConeSpec } from '../../src/scenes/floor/cone';
|
|
|
|
const EAST: ConeSpec = { x: 100, y: 100, facing: 0, ...NORMAL_CONE };
|
|
const at = (spec: ConeSpec, dist: number, off: number) => ({
|
|
x: spec.x + Math.cos(spec.facing + off) * dist,
|
|
y: spec.y + Math.sin(spec.facing + off) * dist,
|
|
});
|
|
|
|
describe('angleDelta', () => {
|
|
it('is zero for identical angles and full turns apart', () => {
|
|
expect(angleDelta(0, 0)).toBeCloseTo(0, 12);
|
|
expect(angleDelta(Math.PI * 2, 0)).toBeCloseTo(0, 12);
|
|
expect(angleDelta(-Math.PI * 4, 0)).toBeCloseTo(0, 12);
|
|
});
|
|
|
|
it('takes the short way around the wrap point', () => {
|
|
expect(angleDelta(0.1, -0.1)).toBeCloseTo(0.2, 12);
|
|
// 175 deg and -175 deg are 10 deg apart, not 350.
|
|
const a = Math.PI - 0.087;
|
|
const b = -Math.PI + 0.087;
|
|
expect(Math.abs(angleDelta(a, b))).toBeCloseTo(0.174, 6);
|
|
expect(Math.abs(angleDelta(b, a))).toBeCloseTo(0.174, 6);
|
|
});
|
|
|
|
it('stays in (-PI, PI] for a sweep of inputs', () => {
|
|
for (let i = -40; i <= 40; i++) {
|
|
const d = angleDelta(i * 0.37, i * -0.61);
|
|
expect(d).toBeGreaterThan(-Math.PI);
|
|
expect(d).toBeLessThanOrEqual(Math.PI + 1e-12);
|
|
}
|
|
});
|
|
|
|
it('maps an exact half turn to +PI, not -PI', () => {
|
|
expect(angleDelta(Math.PI, 0)).toBeCloseTo(Math.PI, 12);
|
|
expect(angleDelta(0, Math.PI)).toBeCloseTo(Math.PI, 12);
|
|
});
|
|
});
|
|
|
|
describe('inCone', () => {
|
|
it('sees straight ahead', () => {
|
|
expect(inCone(EAST, 200, 100)).toBe(true);
|
|
expect(inCone(EAST, EAST.x, EAST.y)).toBe(true);
|
|
});
|
|
|
|
it('is blind behind and to the sides', () => {
|
|
expect(inCone(EAST, 0, 100)).toBe(false);
|
|
expect(inCone(EAST, 100, 20)).toBe(false);
|
|
expect(inCone(EAST, ...([at(EAST, 50, 1.2).x, at(EAST, 50, 1.2).y] as const))).toBe(false);
|
|
});
|
|
|
|
it('is blind beyond range', () => {
|
|
const far = at(EAST, EAST.range + 1, 0);
|
|
expect(inCone(EAST, far.x, far.y)).toBe(false);
|
|
const near = at(EAST, EAST.range - 1, 0);
|
|
expect(inCone(EAST, near.x, near.y)).toBe(true);
|
|
});
|
|
|
|
it('respects facing — rotating the cone rotates what it sees', () => {
|
|
const north: ConeSpec = { ...EAST, facing: -Math.PI / 2 };
|
|
expect(inCone(north, 100, 20)).toBe(true);
|
|
expect(inCone(north, 200, 100)).toBe(false);
|
|
});
|
|
|
|
it('works across the PI wrap', () => {
|
|
const west: ConeSpec = { ...EAST, facing: Math.PI };
|
|
const p = at(west, 60, 0.2);
|
|
expect(inCone(west, p.x, p.y)).toBe(true);
|
|
expect(inCone(west, 200, 100)).toBe(false);
|
|
});
|
|
|
|
it('UV torch is tighter and shorter than the normal one', () => {
|
|
expect(UV_CONE.halfAngle).toBeLessThan(NORMAL_CONE.halfAngle);
|
|
expect(UV_CONE.range).toBeLessThan(NORMAL_CONE.range);
|
|
const uv: ConeSpec = { x: 100, y: 100, facing: 0, ...UV_CONE };
|
|
const p = at(EAST, 160, 0.5);
|
|
expect(inCone(EAST, p.x, p.y)).toBe(true);
|
|
expect(inCone(uv, p.x, p.y)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('coneVisibility', () => {
|
|
it('is 0 wherever inCone is false', () => {
|
|
for (let off = -2; off <= 2; off += 0.1) {
|
|
for (const dist of [10, 90, 189, 191, 400]) {
|
|
const p = at(EAST, dist, off);
|
|
if (!inCone(EAST, p.x, p.y)) expect(coneVisibility(EAST, p.x, p.y)).toBe(0);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('is strictly positive inside and never above 1', () => {
|
|
for (const off of [0, 0.2, -0.2, 0.5, -0.5]) {
|
|
for (const dist of [1, 40, 120, 180]) {
|
|
const p = at(EAST, dist, off);
|
|
const v = coneVisibility(EAST, p.x, p.y);
|
|
expect(v).toBeGreaterThan(0);
|
|
expect(v).toBeLessThanOrEqual(1);
|
|
}
|
|
}
|
|
expect(coneVisibility(EAST, EAST.x, EAST.y)).toBe(1);
|
|
});
|
|
|
|
it('is brighter on-axis than off-axis at the same distance', () => {
|
|
const onAxis = at(EAST, 100, 0);
|
|
const offAxis = at(EAST, 100, 0.4);
|
|
expect(coneVisibility(EAST, onAxis.x, onAxis.y))
|
|
.toBeGreaterThan(coneVisibility(EAST, offAxis.x, offAxis.y));
|
|
});
|
|
|
|
it('decreases monotonically with distance along the axis', () => {
|
|
let prev = Infinity;
|
|
for (let d = 1; d < EAST.range; d += 10) {
|
|
const p = at(EAST, d, 0);
|
|
const v = coneVisibility(EAST, p.x, p.y);
|
|
expect(v).toBeLessThan(prev);
|
|
prev = v;
|
|
}
|
|
});
|
|
|
|
it('fades to nothing at the cone edge', () => {
|
|
const edge = at(EAST, 100, EAST.halfAngle);
|
|
expect(coneVisibility(EAST, edge.x, edge.y)).toBeCloseTo(0, 9);
|
|
const rim = at(EAST, EAST.range, 0);
|
|
expect(coneVisibility(EAST, rim.x, rim.y)).toBeCloseTo(0, 9);
|
|
});
|
|
});
|
|
|
|
describe('conePolygon', () => {
|
|
it('starts at the apex and closes back on it', () => {
|
|
const pts = conePolygon(EAST, 12);
|
|
expect(pts[0]).toEqual({ x: EAST.x, y: EAST.y });
|
|
expect(pts[pts.length - 1]).toEqual(pts[0]);
|
|
expect(pts).toHaveLength(12 + 3); // apex + (segments+1) arc points + closing apex
|
|
});
|
|
|
|
it('puts every arc point at exactly range, inside the cone', () => {
|
|
const pts = conePolygon(EAST, 16).slice(1, -1);
|
|
for (const p of pts) {
|
|
expect(Math.hypot(p.x - EAST.x, p.y - EAST.y)).toBeCloseTo(EAST.range, 9);
|
|
expect(Math.abs(angleDelta(Math.atan2(p.y - EAST.y, p.x - EAST.x), EAST.facing)))
|
|
.toBeLessThanOrEqual(EAST.halfAngle + 1e-9);
|
|
}
|
|
});
|
|
|
|
it('spans the full cone width, edge to edge', () => {
|
|
const pts = conePolygon(EAST, 8);
|
|
const firstArc = pts[1]!;
|
|
const lastArc = pts[pts.length - 2]!;
|
|
expect(angleDelta(Math.atan2(firstArc.y - EAST.y, firstArc.x - EAST.x), EAST.facing))
|
|
.toBeCloseTo(-EAST.halfAngle, 9);
|
|
expect(angleDelta(Math.atan2(lastArc.y - EAST.y, lastArc.x - EAST.x), EAST.facing))
|
|
.toBeCloseTo(EAST.halfAngle, 9);
|
|
});
|
|
|
|
it('tolerates a degenerate segment count', () => {
|
|
expect(conePolygon(EAST, 0)).toHaveLength(4);
|
|
expect(conePolygon(EAST, 1)).toHaveLength(4);
|
|
});
|
|
|
|
it('defaults to a smooth arc', () => {
|
|
expect(conePolygon(EAST).length).toBeGreaterThan(20);
|
|
});
|
|
});
|