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>
This commit is contained in:
type-two 2026-07-19 19:06:31 +10:00
parent 30162d331d
commit e0b5831166
9 changed files with 239 additions and 51 deletions

View File

@ -233,3 +233,97 @@ its tweens/timers land in a state that looks like a game bug but isn't. Cost me
- Sobriety line-walk (the third challenge) is still a stretch item; alphabet + riddle ship.
- Phase 2 integration should lift clock/meters/fail-state ownership out of `NightScene`
into the real night-flow machine and replace the temporary summary.
---
### SESSION — LANE-DOOR (review-fix pass) — 2026-07-19 19:05
**Branch/commits:** lane/door @ 30162d3..(this commit)
Ran a four-lens adversarial review over the lane (discipline / scene lifecycle /
playability / balance) and landed the findings I could verify in-session.
**Fixed:**
1. **The game was answering its own question.** `DoorScene` lit up every rule a patron
broke on the dress-code card the moment they stepped up. Optimal play was "read the red
line", which deletes the inspection loop the entire scene exists for — all 8 dress-code
rules were free. The highlight now fires AFTER the ruling, for ~2s, as feedback.
This is the single most important fix in this pass.
2. **Tonight's date appeared nowhere in the game.** The card showed `04/12/2000` and the
HUD showed `11:42 PM`; the player was asked to subtract a birthdate from a date they
were never given. `underage` is the tell that files a heat strike and costs the
licence, and it was pure guesswork. Now stamped on the card beside the DOB and under
the HUD clock. Every boundary case `idCheck.ts` was carefully written for
(`turnsEighteenTonight`, leap-day DOB) is finally reachable by a player.
3. **Determinism, again, worse than the one I already fixed.** In `spawnArrival` the
partner's `generatePatron` call sat behind `if (enqueued)`, and enqueue fails at
`maxQueue` — which is player-controlled. From the first overflow onward every
remaining patron of the night differed. All draws are now unconditional. Same class as
the comeback-stream bug; I fixed it for `couples` and left it in `patrons`.
4. **You could rule straight through the open sobriety modal.** Its backdrop was not
interactive, so ADMIT/NOT TONIGHT stayed live underneath. Now eats clicks.
5. **Tooltips read as broken text**`navy a blazer`, `white a singlet`, `cream a bucket
hat`. Articles were baked into the noun tables and then colour-prefixed. Since the
tooltips are the ONLY detection channel for four rules under placeholder art, the one
system the player has to trust looked broken. Nouns are bare now.
6. `bumbag` and `chain` are drawn on the torso/waist but were described only in the head
zone — hovering the visible item told you about a jacket. Both body zones now name them.
7. `NightScene.endNight` pushed to `state.heatStrikes` directly, bypassing `core/meters.ts`
fifty lines after correctly emitting `heat:strike`. Now emits. CONTRACTS.md §4.
8. Dropped the one `as never` in the diff (`DoorScene`'s `dazza:text` handler) for an
honest type guard — see CCR-4.
9. Test quality: `tests/inspect.test.ts` proved its vocabulary coverage by string-comparing
output against the raw type id, which silently stopped working once a legitimate word
('skirt', 'cap') matched its own id. Replaced with an exact `hasWordFor` check.
**Tests:** 231 passing / 0 failing (+2). Re-verified in-browser after the changes:
competent play still runs to 3:00 AM; deny-everything still riots (aggro 100 @ 10:42 PM);
one patron can still only be ruled once, plus up to 2 regular comebacks.
**New contract change request:**
4. **CCR-4 — `dazza:text` payload should carry the lane's rule type.** `EventMap` types it
`rule?: DressCodeRule`, so a rule this lane emits comes back without the `short` card
label the lane added to it. Currently narrowed with a runtime type guard in
`DoorScene`. Either widen the payload or accept the guard.
**Reviewer findings I did NOT land, with reasons:**
- **The economy is slack and dawdling is dominant.** Measured over 5 seeds: a competent
night pours 7491115 raw vibe into 50 points of headroom (≈94% discarded by the clamp),
vibe pins at 100 by ~10:00 PM and stays there 8186% of the night, and aggro peaks at
5/100 because relief refunds ~10:1. `hype` is a pure ratchet — nothing in `src/` ever
emits a negative hype delta — so stalling is paid twice (vibe multiplier + cash bonus)
and a player who plays *slower* earns 24% more at identical accuracy. Recommended:
`denyViolatorVibe` 6→1, `admitCleanVibe` 3→1, `QUEUE_TUNING.calmPerSec` 0.9→0.25.
I did not apply these: it is a whole-economy retune, the measurements come from a bot
with perfect information and zero deliberation time, and I would rather you tune it
against a human than have me chase a number I can't feel. Flagging it as the top item
for the next pass.
- **The queue never actually backs up.** Player throughput ≈0.83 arrivals/in-game-min
against a curve that peaks at 0.42 — the "10:3012:30 slam" is currently fiction
(measured avg queue 0.10.2, max 35 against 8 visible slots). Fixing it means roughly
doubling the curve to ~170 arrivals a night, which is a big feel change; wants a human
playtest first.
- **`noSongRequesters` (activeFrom 240) and `noHandHolders` (280) are nearly decorative** —
they arrive after 84% and 93% of the night's patrons have been served, and fire ~1.3 and
~0 times respectively. Their `fromMin` values come from `data/strings/dazza.ts`, which
is not mine to edit. Suggest 240→~105 and 280→~135.
- **`noHandHolders` is still unfair even when it fires.** The linked-hands pixel only draws
between adjacent QUEUE sprites; once a patron steps up to the rope there is no partner
drawn and no tooltip mentions it, so at the moment of judgement the tell is invisible.
CCR-1's justification ("rendered as a linked-hands pixel ... so the rule stays one the
player can SEE") overclaims. Either draw the partner at the rope, add it to
`describeZone`, or cut the rule.
- **Intoxication and contraband are worth zero points.** `judge()` reads neither. Denying
a maggot is scored as denying a clean punter (+7 aggro), and a pat-down that finds a
baggie changes nothing. The drunk channel is the most legible signal the game has — sway
tween, bloodshot pixels, a whole sobriety minigame — and it does not score. This is the
biggest *design* gap in the lane and I'd want your ruling on the intended numbers before
inventing them.
- **The clicker is inert.** `clickerShown` is read only for a text colour and one summary
row; `judge` uses the true `insideCount` for capacity. The brief calls the divergence
"the game", so it needs a consequence — presumably the inspector reads the clicker, not
reality, which is Phase-2 inspector work.
- Minor, unlanded: the ID modal's shade dims the desk hologram swatch but not the card's
(the teal/magenta hue gap carries it today, but a subtler fake colour would break);
`peelingLaminate` is cream-on-cream and near-invisible; `photoMismatch` degrades to a 1/6
coin flip for hatted patrons; three player-facing strings are still inline in `judge.ts`
against CONTRACTS.md §7, one of which ("Good story for them one day") reads as narrator
rather than the game's voice.

View File

@ -77,6 +77,15 @@ export function idVerdict(id: IdCard, nightDate: Date): IdVerdict {
}
/** DD/MM/YYYY — Australian order, hand-rolled because Intl output varies by host. */
/**
* Australian DD/MM/YYYY. Exported because the DESK needs to show tonight's date
* in the same format the card shows the DOB without it the player is asked to
* subtract a birthdate from a date the game never told them, which made
* `underage` (the tell that costs the licence) pure guesswork.
*/
export const formatDate = (d: Date): string =>
`${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
const ddmmyyyy = (iso: string): string => {
const [y = '', m = '', d = ''] = iso.slice(0, 10).split('-');
return `${d.padStart(2, '0')}/${m.padStart(2, '0')}/${y.padStart(4, '0')}`;

View File

@ -1,8 +1,8 @@
import Phaser from 'phaser';
import { renderDoll } from '../../patrons/doll';
import { dollPlan } from '../../patrons/dollPlan';
import { violations } from '../../rules/dressCode';
import { idVerdict } from '../../rules/idCheck';
import { violations, type DoorDressCodeRule } from '../../rules/dressCode';
import { idVerdict, formatDate } from '../../rules/idCheck';
import { judge } from '../../rules/judge';
import type { DoorOutcome } from '../../rules/doorTypes';
import { CONTRABAND } from '../../data/contraband';
@ -33,6 +33,14 @@ const DOOR_X = 566;
const QUEUE_FOOT_Y = 228;
const TUTORIAL_MS = 26_000;
/**
* TODO(contract): CCR-4 `dazza:text` carries `rule?: DressCodeRule`, so a rule
* emitted by this lane comes back without the `short` card label the lane added.
* Until the payload widens, narrow it here honestly instead of casting.
*/
const isDoorRule = (r: unknown): r is DoorDressCodeRule =>
typeof r === 'object' && r !== null && typeof (r as DoorDressCodeRule).short === 'string';
interface QueueSprite {
patron: Patron;
image: Phaser.GameObjects.Image;
@ -100,7 +108,11 @@ export class DoorScene extends Phaser.Scene {
this.hypeText.setText(`HYPE x${hype.toFixed(1)}`);
}),
this.night.bus.on('dazza:text', ({ rule }) => {
if (rule && 'short' in rule) this.codeCard.add(this, rule as never, this.elapsedMs);
// The bus payload is typed with the frozen DressCodeRule, which has no
// `short`. Narrowing back to the lane's own DoorDressCodeRule is a real
// contract gap (CCR-4) — this predicate is the honest version of it
// rather than an `as never` punch-through.
if (isDoorRule(rule)) this.codeCard.add(this, rule, this.elapsedMs);
}),
this.night.bus.on('heat:strike', () => this.refreshCounters()),
];
@ -253,6 +265,14 @@ export class DoorScene extends Phaser.Scene {
.text(W - 6, 5, '9:00 PM', { fontFamily: MONO, fontSize: '10px', color: '#9fe8a0' })
.setOrigin(1, 0)
.setDepth(501);
this.add
.text(W - 6, 16, formatDate(this.night.nightDate), {
fontFamily: MONO,
fontSize: '7px',
color: DOOR_PALETTE.inkDim,
})
.setOrigin(1, 0)
.setDepth(501);
this.toast = this.add
.text(UP_X, 178, '', { fontFamily: MONO, fontSize: '8px', color: '#f0e4d0', align: 'center' })
@ -285,7 +305,6 @@ export class DoorScene extends Phaser.Scene {
this.up.show(p);
this.showTray(p);
this.setVerdictButtons(true);
this.codeCard.highlight(violations(p, this.night.state.clockMin).map((r) => r.id));
this.tweens.add({
targets: this.ropeProp.getByName('rope') as Phaser.GameObjects.Rectangle,
@ -360,7 +379,6 @@ export class DoorScene extends Phaser.Scene {
this.idCard.close();
this.trayCard?.destroy();
this.trayCard = null;
this.codeCard.highlight([]);
// STATE SETTLES NOW, ANIMATION IS JUST ANIMATION.
//
@ -378,6 +396,15 @@ export class DoorScene extends Phaser.Scene {
this.applyOutcome(p, verdict, outcome);
this.queue.resolveUp(verdict === 'deny');
// Highlight AFTER the call, never before. Lighting up the broken rules the
// moment a patron steps up hands the player the answer and reduces all
// eight dress-code rules to reading a red line off the desk — it deletes
// the inspection loop the whole scene exists for. As post-verdict feedback
// it teaches instead: here is what you just missed.
const broken = violations(p, this.night.state.clockMin).map((r) => r.id);
this.codeCard.highlight(broken);
this.time.delayedCall(2200, () => this.codeCard.highlight([]));
// `busy` gates input for the length of the animation. It is still released
// from a callback, so it still gets a watchdog — but now the worst case is a
// couple of dead seconds, not a corrupted night. The token stops ruling N's

View File

@ -1,6 +1,6 @@
import Phaser from 'phaser';
import { renderDoll } from '../../patrons/doll';
import { idVerdict, idDisplayLines } from '../../rules/idCheck';
import { idVerdict, idDisplayLines, formatDate } from '../../rules/idCheck';
import type { Patron } from '../../data/types';
import { DOOR_PALETTE, MONO, text } from './ui';
@ -110,6 +110,22 @@ export class IdCardView {
field('DATE OF BIRTH', lines.dob, 2, true);
field('EXPIRES', lines.expiry, 40);
// The whole ID minigame is one subtraction, and the player cannot do it
// without the other operand. Printed on the card itself rather than only on
// the desk so both numbers are in the same glance.
root.add(
scene.add
.text(92, 4, `TONIGHT\n${formatDate(this.nightDate)}`, {
fontFamily: MONO,
fontSize: '8px',
color: '#8a3a3a',
align: 'center',
lineSpacing: 2,
})
.setOrigin(0.5)
.setAngle(-4),
);
// hologram over the corner — wrong colour is the tell, the desk has a swatch
const holoColour = p.idCard.fake?.wrongHologram ? FAKE_HOLOGRAM : GENUINE_HOLOGRAM;
const holo = scene.add.rectangle(92, 44, 58, 22, holoColour, 0.55).setAngle(-10);

View File

@ -233,9 +233,10 @@ export class NightScene extends Phaser.Scene {
// Unfired deferred hits are the 3 AM audit — they land whether you made it
// to closing or not.
for (const hit of this.deferred) {
// Emit rather than push: core/meters.ts is the only thing allowed to
// mutate NightState (CONTRACTS.md §4), and the log listener picks it up.
if (hit.heatStrike && shouldRecordStrike(this.state, hit.heatStrike)) {
this.state.heatStrikes.push(hit.heatStrike);
this.log.heatStrikes.push(hit.heatStrike);
this.bus.emit('heat:strike', hit.heatStrike);
}
}
this.deferred = [];

View File

@ -218,13 +218,17 @@ export class QueueManager {
}
private spawnArrival(): void {
const p = this.enqueue(this.make());
// The roll happens whether or not the queue had room, so the couples stream
// advances at the same rate every run. Rolling only on success would make
// couple outcomes depend on queue length, i.e. on how the player is playing.
// EVERY draw here happens unconditionally, before anything looks at whether
// the queue had room. Queue length is player-controlled, so a draw taken
// behind an `if (enqueued)` would make the rest of the night's patrons
// depend on how the player was playing — CONTRACTS.md §6 says same seed,
// same night, full stop.
const first = this.make();
const isCouple = this.coupleRng.chance(QUEUE_TUNING.coupleChance);
if (p && isCouple) {
const partner = this.make();
const partner = isCouple ? this.make() : null;
const p = this.enqueue(first);
if (p && partner) {
p.flags.handHolding = true;
partner.flags.handHolding = true;
this.enqueue(partner);

View File

@ -35,7 +35,9 @@ export class SobrietyModal {
const root = scene.add.container(320, 180).setDepth(760);
this.root = root;
root.add(scene.add.rectangle(0, 0, 1280, 720, 0x05050a, 0.7));
// Interactive so it EATS clicks: without this the ADMIT / NOT TONIGHT
// buttons underneath stay live and you can rule on someone mid-test.
root.add(scene.add.rectangle(0, 0, 1280, 720, 0x05050a, 0.7).setInteractive());
root.add(scene.add.rectangle(0, 0, 340, 210, 0x14121a).setStrokeStyle(1, 0x5a4a68));
root.add(
scene.add

View File

@ -18,7 +18,7 @@ const SHOE_WORDS: Record<string, string> = {
const LEG_WORDS: Record<string, string> = {
jeans: 'jeans',
chinos: 'chinos',
skirt: 'a skirt',
skirt: 'skirt',
trackies: 'trackies',
shorts: 'shorts',
slacks: 'slacks',
@ -26,29 +26,29 @@ const LEG_WORDS: Record<string, string> = {
};
const TOP_WORDS: Record<string, string> = {
tee: 'a tee',
shirt: 'a shirt',
singlet: 'a singlet',
polo: 'a polo',
crop: 'a crop top',
dressShirt: 'a dress shirt',
jersey: 'a footy jersey',
tee: 'tee',
shirt: 'shirt',
singlet: 'singlet',
polo: 'polo',
crop: 'crop top',
dressShirt: 'dress shirt',
jersey: 'footy jersey',
};
const OUTER_WORDS: Record<string, string> = {
blazer: 'a blazer',
bomber: 'a bomber jacket',
hoodie: 'a hoodie',
leather: 'a leather jacket',
puffer: 'a puffer',
blazer: 'blazer',
bomber: 'bomber jacket',
hoodie: 'hoodie',
leather: 'leather jacket',
puffer: 'puffer',
};
const ACC_WORDS: Record<string, string> = {
bucketHat: 'a bucket hat',
cap: 'a cap',
bucketHat: 'bucket hat',
cap: 'cap',
sunnies: 'sunnies',
bumbag: 'a bumbag',
chain: 'a chain',
bumbag: 'bumbag',
chain: 'chain',
};
const HAIR_WORDS: Record<string, string> = {
@ -70,8 +70,20 @@ const WORDS: Partial<Record<OutfitSlot, Record<string, string>>> = {
};
/**
* One line about one slot. Colour comes first because colour is what the
* dress code keeps changing its mind about.
* Does this slot/type pair have a hand-written word? Exported so a test can
* assert coverage EXACTLY, rather than inferring it by string-comparing the
* output against the raw type id that heuristic silently stopped working the
* moment a legitimate word ('skirt', 'cap') matched its own id.
*/
export function hasWordFor(slot: OutfitSlot, type: string): boolean {
return type === 'none' || WORDS[slot]?.[type] !== undefined;
}
/**
* One line about one slot. Colour comes first because colour is what the dress
* code keeps changing its mind about so the noun tables above hold BARE nouns
* with no article, or you get "navy a blazer". Hair is the exception: it is
* never colour-prefixed, so HAIR_WORDS keeps its articles.
*/
export function describeSlot(p: Patron, slot: OutfitSlot): string {
const layer = p.outfit.find((l) => l.slot === slot);
@ -124,6 +136,13 @@ export function greetingIndex(p: Patron, poolSize: number): number {
return poolSize > 0 ? p.dollSeed % poolSize : 0;
}
/** Accessories the renderer draws on the body rather than the head. */
function waistOrCollar(p: Patron): string[] {
const acc = p.outfit.find((l) => l.slot === 'accessory');
if (!acc || (acc.type !== 'bumbag' && acc.type !== 'chain')) return [];
return [describeSlot(p, 'accessory')];
}
export type InspectZone = 'head' | 'face' | 'torso' | 'legs' | 'shoes';
export const ZONE_ORDER: readonly InspectZone[] = ['head', 'face', 'torso', 'legs', 'shoes'];
@ -148,9 +167,12 @@ export function describeZone(p: Patron, zone: InspectZone): string[] {
case 'face':
return [describeEyes(p), describeStance(p)];
case 'torso':
return [describeSlot(p, 'top'), describeSlot(p, 'outer')];
// A chain hangs at the collar and a bumbag at the waist — dollPlan draws
// both on the torso, so the torso zone has to name them or the player
// hovers the thing they can see and is told about a jacket instead.
return [describeSlot(p, 'top'), describeSlot(p, 'outer'), ...waistOrCollar(p)];
case 'legs':
return [describeSlot(p, 'legs')];
return [describeSlot(p, 'legs'), ...waistOrCollar(p)];
case 'shoes':
return [describeSlot(p, 'shoes')];
}

View File

@ -12,6 +12,7 @@ import {
describeStance,
describeZone,
greetingIndex,
hasWordFor,
type InspectZone,
} from '../src/scenes/door/inspect';
@ -41,26 +42,38 @@ const patron = (overrides: SlotOverrides = {}, intoxication = 0): Patron => ({
const slots = Object.keys(OUTFIT_VOCAB) as OutfitSlot[];
const NIGHT = new Date('2026-07-18T00:00:00');
/** The text before ' — ' is the garment name; anything after it is a note. */
const nameOf = (line: string): string => line.split(' — ')[0] ?? '';
describe('describeSlot vocabulary coverage', () => {
it('has plain-English words for every type in OUTFIT_VOCAB', () => {
for (const slot of slots) {
// 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]) {
const colour = def.colours[0] ?? 'black';
const line = describeSlot(patron({ [slot]: { type: def.type, colour } }), slot);
expect(line.length).toBeGreaterThan(0);
if (def.type === 'none') continue;
// A type with no word falls through to the raw id, i.e. exactly the
// colour+type (or bare type for hair). Ids that are already plural
// English ('jeans', 'sunnies') read fine that way; everything else has
// to be translated, so a new garment without a word fails here.
if (def.type.endsWith('s')) continue;
const rawFallback = slot === 'hair' ? def.type : `${colour} ${def.type}`;
expect(nameOf(line)).not.toBe(rawFallback);
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', () => {