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>
229 lines
8.2 KiB
TypeScript
229 lines
8.2 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
MAP_H, MAP_W, TILE, VENUE,
|
|
isWalkable, isWalkableWorld, tileAt, tileColour, tileToWorld, worldToTile,
|
|
} from '../../src/scenes/floor/venueMap';
|
|
import type { AnchorKind, TileKind, WorldPoint } from '../../src/scenes/floor/venueMap';
|
|
|
|
const ANCHOR_KINDS: readonly AnchorKind[] = ['bar', 'dance', 'booth', 'toilet', 'smoke', 'entry', 'exit'];
|
|
const allAnchors = (): WorldPoint[] => ANCHOR_KINDS.flatMap((k) => [...VENUE.anchors[k]]);
|
|
|
|
const key = (tx: number, ty: number): number => ty * MAP_W + tx;
|
|
|
|
/** Every walkable tile 4-connected to the seed. */
|
|
function flood(seed: WorldPoint): Set<number> {
|
|
const start = worldToTile(seed.x, seed.y);
|
|
const seen = new Set<number>();
|
|
if (!isWalkable(VENUE, start.tx, start.ty)) return seen;
|
|
const stack = [start];
|
|
seen.add(key(start.tx, start.ty));
|
|
while (stack.length > 0) {
|
|
const cur = stack.pop()!;
|
|
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) {
|
|
const tx = cur.tx + dx;
|
|
const ty = cur.ty + dy;
|
|
const k = key(tx, ty);
|
|
if (seen.has(k) || !isWalkable(VENUE, tx, ty)) continue;
|
|
seen.add(k);
|
|
stack.push({ tx, ty });
|
|
}
|
|
}
|
|
return seen;
|
|
}
|
|
|
|
describe('venueMap dimensions', () => {
|
|
it('matches the shared geometry constants', () => {
|
|
expect(TILE).toBe(16);
|
|
expect(VENUE.width).toBe(MAP_W);
|
|
expect(VENUE.height).toBe(MAP_H);
|
|
expect(MAP_W * TILE).toBe(1280);
|
|
expect(MAP_H * TILE).toBe(720);
|
|
});
|
|
|
|
it('has one tile per cell, all of known kinds', () => {
|
|
expect(VENUE.tiles).toHaveLength(MAP_W * MAP_H);
|
|
const known = new Set<TileKind>([
|
|
'void', 'floor', 'wall', 'bar', 'stool', 'dance',
|
|
'booth', 'stall', 'stallDoor', 'exit', 'smokeDoor', 'neon',
|
|
]);
|
|
for (const t of VENUE.tiles) expect(known.has(t)).toBe(true);
|
|
});
|
|
|
|
it('is ringed by non-walkable edges apart from doors', () => {
|
|
for (let tx = 0; tx < MAP_W; tx++) {
|
|
for (const ty of [0, MAP_H - 1]) {
|
|
const kind = tileAt(VENUE, tx, ty);
|
|
if (kind === 'smokeDoor') continue;
|
|
expect(isWalkable(VENUE, tx, ty)).toBe(false);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('contains every authored tile kind', () => {
|
|
const seen = new Set(VENUE.tiles);
|
|
for (const k of ['floor', 'wall', 'bar', 'stool', 'dance', 'booth', 'stall', 'stallDoor', 'exit', 'smokeDoor', 'neon'] as const) {
|
|
expect(seen.has(k)).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('tileAt / walkability', () => {
|
|
it('reports void out of bounds', () => {
|
|
expect(tileAt(VENUE, -1, 0)).toBe('void');
|
|
expect(tileAt(VENUE, 0, -1)).toBe('void');
|
|
expect(tileAt(VENUE, MAP_W, 0)).toBe('void');
|
|
expect(tileAt(VENUE, 0, MAP_H)).toBe('void');
|
|
expect(isWalkable(VENUE, -5, -5)).toBe(false);
|
|
});
|
|
|
|
it('walks floor/dance/exit/smokeDoor/stall and nothing else', () => {
|
|
const walkable = new Set<TileKind>(['floor', 'dance', 'exit', 'smokeDoor', 'stall']);
|
|
for (let ty = 0; ty < MAP_H; ty++) {
|
|
for (let tx = 0; tx < MAP_W; tx++) {
|
|
expect(isWalkable(VENUE, tx, ty)).toBe(walkable.has(tileAt(VENUE, tx, ty)));
|
|
}
|
|
}
|
|
});
|
|
|
|
it('agrees between tile and world walkability queries', () => {
|
|
for (const a of allAnchors()) expect(isWalkableWorld(VENUE, a.x, a.y)).toBe(true);
|
|
expect(isWalkableWorld(VENUE, -8, -8)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('coordinate conversion', () => {
|
|
it('round-trips tile -> world -> tile', () => {
|
|
for (let ty = 0; ty < MAP_H; ty += 3) {
|
|
for (let tx = 0; tx < MAP_W; tx += 3) {
|
|
const w = tileToWorld(tx, ty);
|
|
expect(worldToTile(w.x, w.y)).toEqual({ tx, ty });
|
|
}
|
|
}
|
|
});
|
|
|
|
it('returns tile centres, and floors world coords', () => {
|
|
expect(tileToWorld(0, 0)).toEqual({ x: 8, y: 8 });
|
|
expect(tileToWorld(3, 5)).toEqual({ x: 56, y: 88 });
|
|
expect(worldToTile(0, 0)).toEqual({ tx: 0, ty: 0 });
|
|
expect(worldToTile(15.9, 31.9)).toEqual({ tx: 0, ty: 1 });
|
|
});
|
|
});
|
|
|
|
describe('anchors', () => {
|
|
it('meets the minimum counts per kind', () => {
|
|
const min: Record<AnchorKind, number> = {
|
|
bar: 6, dance: 8, booth: 4, toilet: 4, smoke: 1, entry: 1, exit: 1,
|
|
};
|
|
for (const k of ANCHOR_KINDS) {
|
|
expect(VENUE.anchors[k].length).toBeGreaterThanOrEqual(min[k]);
|
|
}
|
|
});
|
|
|
|
it('places every anchor on a walkable tile centre', () => {
|
|
for (const k of ANCHOR_KINDS) {
|
|
for (const a of VENUE.anchors[k]) {
|
|
expect(a.x % TILE).toBe(TILE / 2);
|
|
expect(a.y % TILE).toBe(TILE / 2);
|
|
expect(isWalkableWorld(VENUE, a.x, a.y)).toBe(true);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('CRITICAL: every anchor is 4-connected to every other anchor', () => {
|
|
const anchors = allAnchors();
|
|
const first = anchors[0]!;
|
|
const reachable = flood(first);
|
|
for (const a of anchors) {
|
|
const t = worldToTile(a.x, a.y);
|
|
expect(
|
|
reachable.has(key(t.tx, t.ty)),
|
|
`anchor at tile ${t.tx},${t.ty} is cut off from ${JSON.stringify(worldToTile(first.x, first.y))}`,
|
|
).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('leaves no orphaned open floor — every non-stall walkable tile is reachable', () => {
|
|
const reachable = flood(VENUE.anchors.entry[0]!);
|
|
const orphans: string[] = [];
|
|
for (let ty = 0; ty < MAP_H; ty++) {
|
|
for (let tx = 0; tx < MAP_W; tx++) {
|
|
if (!isWalkable(VENUE, tx, ty) || reachable.has(key(tx, ty))) continue;
|
|
// Cubicle interiors are sealed behind their doors by design.
|
|
if (tileAt(VENUE, tx, ty) === 'stall') continue;
|
|
orphans.push(`${tx},${ty}`);
|
|
}
|
|
}
|
|
expect(orphans, `unreachable open tiles: ${orphans.join(' ')}`).toEqual([]);
|
|
});
|
|
|
|
it('anchors of different kinds sit on distinct tiles within their kind', () => {
|
|
for (const k of ANCHOR_KINDS) {
|
|
const keys = VENUE.anchors[k].map((a) => {
|
|
const t = worldToTile(a.x, a.y);
|
|
return key(t.tx, t.ty);
|
|
});
|
|
expect(new Set(keys).size).toBe(keys.length);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('stalls', () => {
|
|
it('has four cubicles with unique ids', () => {
|
|
expect(VENUE.stalls).toHaveLength(4);
|
|
expect(new Set(VENUE.stalls.map((s) => s.id)).size).toBe(4);
|
|
for (const s of VENUE.stalls) expect(s.id).toMatch(/^s[1-4]$/);
|
|
});
|
|
|
|
it('door tiles are walkable and sit directly south of a stallDoor tile', () => {
|
|
for (const s of VENUE.stalls) {
|
|
const d = worldToTile(s.door.x, s.door.y);
|
|
expect(isWalkable(VENUE, d.tx, d.ty)).toBe(true);
|
|
expect(tileAt(VENUE, d.tx, d.ty - 1)).toBe('stallDoor');
|
|
}
|
|
});
|
|
|
|
it('inside points land on stall tiles', () => {
|
|
for (const s of VENUE.stalls) {
|
|
// The 2x2 interior centre is a tile corner; probe just inside it.
|
|
expect(isWalkableWorld(VENUE, s.inside.x - 1, s.inside.y - 1)).toBe(true);
|
|
const t = worldToTile(s.inside.x - 1, s.inside.y - 1);
|
|
expect(tileAt(VENUE, t.tx, t.ty)).toBe('stall');
|
|
}
|
|
});
|
|
|
|
it('seals each cubicle behind its door — the only way in is StallDef.inside', () => {
|
|
for (const s of VENUE.stalls) {
|
|
const d = worldToTile(s.door.x, s.door.y);
|
|
const reachable = flood(s.door);
|
|
const interior = worldToTile(s.inside.x - 1, s.inside.y - 1);
|
|
expect(reachable.has(key(interior.tx, interior.ty))).toBe(false);
|
|
// ...and the door tile still connects back to the rest of the venue.
|
|
expect(reachable.has(key(worldToTile(VENUE.anchors.entry[0]!.x, VENUE.anchors.entry[0]!.y).tx,
|
|
worldToTile(VENUE.anchors.entry[0]!.x, VENUE.anchors.entry[0]!.y).ty))).toBe(true);
|
|
expect(isWalkable(VENUE, d.tx, d.ty)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('exposes one toilet anchor per stall door', () => {
|
|
const doors = VENUE.stalls.map((s) => `${s.door.x},${s.door.y}`).sort();
|
|
const anchors = VENUE.anchors.toilet.map((a) => `${a.x},${a.y}`).sort();
|
|
expect(anchors).toEqual(doors);
|
|
});
|
|
});
|
|
|
|
describe('tileColour', () => {
|
|
it('gives every kind a distinct 24-bit colour', () => {
|
|
const kinds: readonly TileKind[] = [
|
|
'void', 'floor', 'wall', 'bar', 'stool', 'dance',
|
|
'booth', 'stall', 'stallDoor', 'exit', 'smokeDoor', 'neon',
|
|
];
|
|
const colours = kinds.map(tileColour);
|
|
for (const c of colours) {
|
|
expect(Number.isInteger(c)).toBe(true);
|
|
expect(c).toBeGreaterThanOrEqual(0);
|
|
expect(c).toBeLessThanOrEqual(0xffffff);
|
|
}
|
|
expect(new Set(colours).size).toBe(kinds.length);
|
|
});
|
|
});
|