diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index 13b403c..59eaaf8 100644 --- a/LANEHANDOVER.md +++ b/LANEHANDOVER.md @@ -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 749–1115 raw vibe into 50 points of headroom (≈94% discarded by the clamp), + vibe pins at 100 by ~10:00 PM and stays there 81–86% 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:30–12:30 slam" is currently fiction + (measured avg queue 0.1–0.2, max 3–5 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. diff --git a/src/rules/idCheck.ts b/src/rules/idCheck.ts index 88014b2..8bf3549 100644 --- a/src/rules/idCheck.ts +++ b/src/rules/idCheck.ts @@ -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')}`; diff --git a/src/scenes/door/DoorScene.ts b/src/scenes/door/DoorScene.ts index 3a33083..186d022 100644 --- a/src/scenes/door/DoorScene.ts +++ b/src/scenes/door/DoorScene.ts @@ -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 diff --git a/src/scenes/door/IdCardView.ts b/src/scenes/door/IdCardView.ts index b166a76..90aefb1 100644 --- a/src/scenes/door/IdCardView.ts +++ b/src/scenes/door/IdCardView.ts @@ -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); diff --git a/src/scenes/door/NightScene.ts b/src/scenes/door/NightScene.ts index 54a53fb..57de465 100644 --- a/src/scenes/door/NightScene.ts +++ b/src/scenes/door/NightScene.ts @@ -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 = []; diff --git a/src/scenes/door/QueueManager.ts b/src/scenes/door/QueueManager.ts index 501ca86..9528c14 100644 --- a/src/scenes/door/QueueManager.ts +++ b/src/scenes/door/QueueManager.ts @@ -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); diff --git a/src/scenes/door/SobrietyModal.ts b/src/scenes/door/SobrietyModal.ts index a477269..2af7184 100644 --- a/src/scenes/door/SobrietyModal.ts +++ b/src/scenes/door/SobrietyModal.ts @@ -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 diff --git a/src/scenes/door/inspect.ts b/src/scenes/door/inspect.ts index 4f192ff..8ea88a3 100644 --- a/src/scenes/door/inspect.ts +++ b/src/scenes/door/inspect.ts @@ -18,7 +18,7 @@ const SHOE_WORDS: Record = { const LEG_WORDS: Record = { jeans: 'jeans', chinos: 'chinos', - skirt: 'a skirt', + skirt: 'skirt', trackies: 'trackies', shorts: 'shorts', slacks: 'slacks', @@ -26,29 +26,29 @@ const LEG_WORDS: Record = { }; const TOP_WORDS: Record = { - 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 = { - 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 = { - 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 = { @@ -70,8 +70,20 @@ const WORDS: Partial>> = { }; /** - * 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')]; } diff --git a/tests/inspect.test.ts b/tests/inspect.test.ts index 09b5463..ee323ec 100644 --- a/tests/inspect.test.ts +++ b/tests/inspect.test.ts @@ -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', () => {