not-tonight/tests/inspect.test.ts
type-two e0b5831166 LANE-DOOR: land the adversarial review findings
The important one: the dress-code card was highlighting every rule a patron
broke the moment they stepped up, so optimal play was reading a red line off the
desk. That deleted the inspection loop the whole scene exists for. The highlight
now fires after the ruling, as feedback.

Second: tonight's date appeared nowhere in the game, so working out whether an ID
was underage — the tell that files a heat strike and costs the licence — was
guesswork. It is now on the card beside the DOB and under the HUD clock, which
finally makes the boundary cases idCheck.ts was written for reachable.

Also:
- determinism: the partner's generatePatron call sat behind an enqueue-success
  check, and enqueue fails at maxQueue, which is player-controlled — so from the
  first queue overflow the rest of the night's patrons diverged. Same class as
  the comeback-stream bug, fixed for couples and missed for patrons.
- the sobriety modal's backdrop wasn't interactive; you could rule straight
  through it
- tooltips read "navy a blazer" — articles baked into the noun tables, then
  colour-prefixed. They are the only detection channel for four rules under
  placeholder art, so they have to read cleanly.
- bumbag and chain are drawn on the body but were described only in the head zone
- NightScene.endNight pushed heat strikes past core/meters.ts
- dropped the diff's one `as never` for a real type guard (CCR-4)
- replaced a vocabulary-coverage test whose string-equality heuristic silently
  stopped working once a legitimate word matched its own type id

231 tests. Balance findings (vibe pins at 100, stalling is dominant, the slam
never materialises) are measured and written up in LANEHANDOVER.md but not
applied — that is a whole-economy retune and it wants a human playtest, not a
bot with perfect information.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 19:06:31 +10:00

244 lines
9.1 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { SeededRNG } from '../src/core/SeededRNG';
import { generatePatron, _resetPatronSerial, type GeneratorContext } from '../src/patrons/generator';
import { OUTFIT_VOCAB } from '../src/data/outfits';
import { drunkStage } from '../src/rules/drunk';
import type { DrunkStage, OutfitLayer, OutfitSlot, Patron } from '../src/data/types';
import {
ZONE_BANDS,
ZONE_ORDER,
describeEyes,
describeSlot,
describeStance,
describeZone,
greetingIndex,
hasWordFor,
type InspectZone,
} from '../src/scenes/door/inspect';
const BASE_OUTFIT: readonly OutfitLayer[] = [
{ slot: 'shoes', type: 'boot', colour: 'brown' },
{ slot: 'legs', type: 'jeans', colour: 'denim' },
{ slot: 'top', type: 'tee', colour: 'red' },
{ slot: 'outer', type: 'hoodie', colour: 'green' },
{ slot: 'hair', type: 'mullet', colour: 'brown' },
{ slot: 'accessory', type: 'cap', colour: 'navy' },
];
type SlotOverrides = Partial<Record<OutfitSlot, Partial<OutfitLayer>>>;
const patron = (overrides: SlotOverrides = {}, intoxication = 0): Patron => ({
id: 'p-test',
dollSeed: 12345,
archetype: 'punter',
age: 25,
idCard: { name: 'Shazza Vella', dob: '2001-03-02', expiry: '2030-01-01', photoSeed: 12345 },
outfit: BASE_OUTFIT.map((l) => ({ ...l, ...overrides[l.slot] })),
intoxication,
tolerance: 0.5,
flags: {},
});
const slots = Object.keys(OUTFIT_VOCAB) as OutfitSlot[];
const NIGHT = new Date('2026-07-18T00:00:00');
describe('describeSlot vocabulary coverage', () => {
it('has plain-English words for every type in OUTFIT_VOCAB', () => {
// Exact, not heuristic: a garment added to the vocabulary without a word
// here would otherwise reach the player as a raw id, and the tooltips are
// the only channel through which four of the dress-code rules are readable.
const missing: string[] = [];
for (const slot of Object.keys(OUTFIT_VOCAB) as OutfitSlot[]) {
for (const def of OUTFIT_VOCAB[slot]) {
if (!hasWordFor(slot, def.type)) missing.push(`${slot}.${def.type}`);
}
}
expect(missing).toEqual([]);
});
it('never leaks a camelCase type id into player-facing text', () => {
for (const slot of Object.keys(OUTFIT_VOCAB) as OutfitSlot[]) {
for (const def of OUTFIT_VOCAB[slot]) {
const line = describeSlot(patron({ [slot]: { type: def.type, colour: 'black' } }), slot);
expect(line).not.toMatch(/[a-z][A-Z]/);
expect(line.length).toBeGreaterThan(0);
}
}
});
it('prefixes a colour without producing "navy a blazer"', () => {
// The noun tables must hold BARE nouns; an article baked into one shows up
// here immediately.
for (const [slot, type] of [['outer', 'blazer'], ['top', 'singlet'], ['accessory', 'bucketHat'], ['legs', 'skirt']] as const) {
const line = describeSlot(patron({ [slot]: { type, colour: 'navy' } }), slot);
expect(line).not.toMatch(/\b(a|an) /);
expect(line.startsWith('navy ')).toBe(true);
}
});
it('never leaks a camelCase type id into player-facing text', () => {
for (const slot of slots) {
for (const def of OUTFIT_VOCAB[slot]) {
if (def.type === def.type.toLowerCase()) continue;
const colour = def.colours[0] ?? 'black';
const line = describeSlot(patron({ [slot]: { type: def.type, colour } }), slot);
expect(line).not.toContain(def.type);
}
}
});
});
describe('describeSlot', () => {
it('names the colour of clothing but not of hair', () => {
expect(describeSlot(patron({ top: { type: 'polo', colour: 'purple' } }), 'top')).toContain('purple');
expect(describeSlot(patron({ shoes: { type: 'heel', colour: 'pink' } }), 'shoes')).toContain('pink');
const hair = describeSlot(patron({ hair: { type: 'mullet', colour: 'brown' } }), 'hair');
expect(hair).not.toContain('brown');
expect(hair).toBe('a mullet');
});
it('uses absence phrasing when a slot is empty', () => {
expect(describeSlot(patron({ outer: { type: 'none' } }), 'outer')).toBe('no jacket');
expect(describeSlot(patron({ accessory: { type: 'none' } }), 'accessory')).toBe('no accessories');
});
it('calls out vintage as old, and pristine white sneakers as new', () => {
const old = describeSlot(patron({ top: { type: 'tee', colour: 'black', vintage: true } }), 'top');
expect(old).toMatch(/old/);
const fresh = describeSlot(patron({ shoes: { type: 'sneaker', colour: 'white' } }), 'shoes');
expect(fresh).toMatch(/new/);
// Only white sneakers read as new; the same shoe in another colour must not.
const black = describeSlot(patron({ shoes: { type: 'sneaker', colour: 'black' } }), 'shoes');
expect(black).not.toMatch(/new/);
});
it('mentions a logo when the layer has one', () => {
const line = describeSlot(patron({ top: { type: 'jersey', colour: 'red', logo: true } }), 'top');
expect(line).toContain('logo');
expect(describeSlot(patron({ top: { type: 'jersey', colour: 'red' } }), 'top')).not.toContain('logo');
});
});
describe('drunk tells', () => {
const byStage: Record<DrunkStage, number> = {
sober: 0.05,
tipsy: 0.3,
loose: 0.5,
messy: 0.75,
maggot: 0.95,
};
const stages = Object.keys(byStage) as DrunkStage[];
it('the chosen intoxication values really do span all five stages', () => {
for (const stage of stages) expect(drunkStage(byStage[stage])).toBe(stage);
});
it('describeEyes and describeStance give a distinct non-empty line per stage', () => {
for (const fn of [describeEyes, describeStance]) {
const lines = stages.map((s) => fn(patron({}, byStage[s])));
for (const line of lines) expect(line.length).toBeGreaterThan(0);
expect(new Set(lines).size).toBe(5);
}
});
it('eyes and stance are different observations, not the same line twice', () => {
for (const stage of stages) {
const p = patron({}, byStage[stage]);
expect(describeEyes(p)).not.toBe(describeStance(p));
}
});
});
describe('describeZone', () => {
it('surfaces every outfit slot across the five zones', () => {
const p = patron({
shoes: { type: 'thong', colour: 'orange' },
legs: { type: 'cargo', colour: 'denim' },
top: { type: 'crop', colour: 'purple' },
outer: { type: 'puffer', colour: 'cream' },
accessory: { type: 'bumbag', colour: 'yellow' },
hair: { type: 'shaved', colour: 'pink' },
});
const all = ZONE_ORDER.flatMap((z) => describeZone(p, z)).join(' | ');
for (const colour of ['orange', 'denim', 'purple', 'cream', 'yellow']) {
expect(all).toContain(colour);
}
// Hair deliberately carries no colour, so it is checked by its garment word.
expect(all).toContain('shaved head');
});
it('every zone returns at least one line', () => {
const p = patron();
for (const zone of ZONE_ORDER) {
const lines = describeZone(p, zone);
expect(lines.length).toBeGreaterThan(0);
for (const line of lines) expect(line.trim().length).toBeGreaterThan(0);
}
});
});
describe('ZONE_BANDS', () => {
it('tiles the 32x48 queue doll with no gaps or overlaps', () => {
const first = ZONE_ORDER[0];
const last = ZONE_ORDER[ZONE_ORDER.length - 1];
expect(first).toBeDefined();
expect(last).toBeDefined();
expect(ZONE_BANDS[first!].y0).toBe(0);
expect(ZONE_BANDS[last!].y1).toBe(48);
for (let i = 0; i < ZONE_ORDER.length - 1; i++) {
const here = ZONE_ORDER[i];
const next = ZONE_ORDER[i + 1];
expect(here).toBeDefined();
expect(next).toBeDefined();
expect(ZONE_BANDS[here!].y1).toBe(ZONE_BANDS[next!].y0);
expect(ZONE_BANDS[here!].y1).toBeGreaterThan(ZONE_BANDS[here!].y0);
}
});
it('has a band for every zone in ZONE_ORDER and no strays', () => {
expect(Object.keys(ZONE_BANDS).sort()).toEqual([...ZONE_ORDER].sort());
});
});
describe('greetingIndex', () => {
it('is deterministic and inside the pool', () => {
const p = patron();
for (const size of [1, 2, 3, 7, 13]) {
const i = greetingIndex(p, size);
expect(i).toBe(greetingIndex(p, size));
expect(i).toBeGreaterThanOrEqual(0);
expect(i).toBeLessThan(size);
expect(Number.isInteger(i)).toBe(true);
}
});
it('returns 0 rather than NaN for an empty pool', () => {
expect(greetingIndex(patron(), 0)).toBe(0);
});
it('spreads different patrons across the pool', () => {
_resetPatronSerial();
const ctx: GeneratorContext = { rng: new SeededRNG(5), nightDate: NIGHT };
const seen = new Set<number>();
for (let i = 0; i < 50; i++) seen.add(greetingIndex(generatePatron(ctx, 0), 5));
expect(seen.size).toBeGreaterThan(1);
});
});
describe('total over generated patrons', () => {
it('describes 200 random patrons without an empty or undefined line', () => {
_resetPatronSerial();
const ctx: GeneratorContext = { rng: new SeededRNG(3), nightDate: NIGHT };
const zones: readonly InspectZone[] = ZONE_ORDER;
for (let i = 0; i < 200; i++) {
const p = generatePatron(ctx, i);
for (const zone of zones) {
for (const line of describeZone(p, zone)) {
expect(line.trim().length).toBeGreaterThan(0);
expect(line).not.toContain('undefined');
}
}
}
});
});