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

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);
});
});