not-tonight/tests/floor/venueMap.test.ts

269 lines
9.7 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { LIGHTS, PROPS,
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',
'djbooth', 'djfloor', 'yard', 'fence', 'sink',
]);
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', 'djbooth', 'djfloor', 'yard', 'fence', 'sink'] 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/yard and nothing else', () => {
const walkable = new Set<TileKind>(['floor', 'dance', 'exit', 'smokeDoor', 'stall', 'yard']);
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);
});
});
describe('props & lights (docs/SPACES.md)', () => {
it('every prop sits in bounds with a positive footprint', () => {
for (const p of PROPS) {
expect(p.tw, p.id).toBeGreaterThan(0);
expect(p.th, p.id).toBeGreaterThan(0);
expect(p.tx, p.id).toBeGreaterThanOrEqual(0);
expect(p.ty, p.id).toBeGreaterThanOrEqual(0);
expect(p.tx + p.tw, p.id).toBeLessThanOrEqual(MAP_W);
expect(p.ty + p.th, p.id).toBeLessThanOrEqual(MAP_H);
}
});
it('prop ids are unique', () => {
expect(new Set(PROPS.map((p) => p.id)).size).toBe(PROPS.length);
});
it('every zone light sits in bounds with a sane radius', () => {
for (const l of LIGHTS) {
expect(l.tx, l.id).toBeGreaterThanOrEqual(0);
expect(l.tx, l.id).toBeLessThan(MAP_W);
expect(l.ty, l.id).toBeGreaterThanOrEqual(0);
expect(l.ty, l.id).toBeLessThan(MAP_H);
expect(l.radius, l.id).toBeGreaterThan(4);
expect(l.radius, l.id).toBeLessThan(120);
}
});
it('the DJ booth interior is sealed — a stage, not a room', () => {
for (let ty = 20; ty <= 24; ty++) {
for (let tx = 53; tx <= 57; tx++) expect(isWalkable(VENUE, tx, ty)).toBe(false);
}
});
it('the smoking yard is walkable and entered through its door', () => {
expect(tileAt(VENUE, 18, 38)).toBe('smokeDoor');
expect(isWalkable(VENUE, 18, 40)).toBe(true);
});
});