diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index 75644f3..5927c1f 100644 --- a/LANEHANDOVER.md +++ b/LANEHANDOVER.md @@ -1200,3 +1200,66 @@ sting queued. **Tests: 651. Gate clean. Deployed, live 200.** **Next:** SCENARIOS.md ๐Ÿ”œ items (ID passback, regular step-up lines, smoking-door slip-in); the lockout law wants its own design pass; John's two human passes still open. + +--- + +## SESSION โ€” FABLE-SOLO-9 ยท 2026-07-20 + +**Branch:** main (solo session, Fable) ยท **Gate:** lint โœ“ build โœ“ test โœ“ (656 tests, 39 files) +**Deployed:** https://monsterrobot.games/not-tonight/ (rsync + cache-busted curl 200) + +### The late game: four features from the SCENARIOS.md queue + +1. **1:30 lockout law** (`rules/judge.ts` โ€” `LOCKOUT_MIN = 270`). After clock 270 + every denial is JUSTIFIED (pays violator-rate vibe โ€” `judgeDeny` now takes + ctx), and every admission files ONE deferred heat strike with the stable + reason `'admission after the 1:30 lockout'` (dedupe holds it to one write-up a + night โ€” the over-capacity pattern). Dazza texts the warning at 255 and the law + at 270 (`dazza.ts` lockout-warn/lockout-live, rule-less). Door HUD clock goes + red with a `ยท LOCKOUT` suffix. CAREFUL sim bot obeys the law; the + "admits beat denies" band now measures **pre-lockout** counts + (`admittedPreLockout`/`deniedPreLockout`) so blanket legal denials don't drown + the fill signal. + +2. **ID passback** (the tell the DESK sets, not the generator). + - `types.ts`: `IdFakeTells.passback?: boolean`; `idCheck.ts`: in TELL_ORDER, + `present.passback = flags?.passback === true`, so it justifies denial. + - `QueueManager` records every admitted card off `door:verdict`; from clock + 150, punter/almost18/financeBro arrivals have a 5% chance (stream + `'passback'`) of carrying a CLONE of an admitted card โ€” original photoSeed, + so the face is naturally wrong. + - `DoorScene.seenCards` (name|dob โ†’ clock label) records all admits, Kayden's + included; at `callNext()` a match sets `fake.passback`. Kayden never checks โ€” + the same card walks past him all night. + - `IdCardView` stamps **SEEN TONIGHT** in red on the zoomed card. + +3. **Regular step-up lines** (`strings/door.ts` REGULAR_RETURN_LINES / + REGULAR_DISGUISED_LINES; `PatronUpView` pool priority). Disguised regulars + outrank everything ("never been here before in my life" IS the tell); + once-denied regulars get their olive branch ahead of owner/list claims. + +4. **Yard fence-jumpers** (`FloorDemoScene`, night mode). 2โ€“3 per night in clock + [120,300] from a separate `SeededRNG(ctx.seed ^ 0xfe9ce)` (door streams + untouched), spawned at yard tile (18,41) with `uvStamped: false` โ€” no + door:verdict, no stamp, catchable only by UV cone. Fires even while the + player works the rope. + +### Verification (browser pane, frame-stepped) +- HUD at warped clock 273: `"1:33 AM ยท LOCKOUT"` colour `#ff8080` โœ“ +- All 3 jumper times consumed โ†’ 3 unstamped agents in/near the yard โœ“ +- Seeded seenCards + callNext โ†’ `fake.passback: true`, SEEN TONIGHT renders + (screenshot taken) โœ“ +- Returning line and disguised line both render in the bubble โœ“ + +### Tests added/updated +- `judge.test.ts`: lockout describe (justified denial at 270, exactly one + deferred strike w/ stable reason, none at 269). +- `idCheck.test.ts`: passback tell present/absent. +- `dazzaSchedule.test.ts`: non-rule pin gains lockout-warn/lockout-live. +- `sim/economy.ts(.test.ts)`: CAREFUL denies post-lockout; preLockout metrics. + +### Notes for future lanes +- The passback tell rides IN `idCard.fake` but is desk-authored โ€” anything that + regenerates or copies a card mid-night must not carry `fake.passback` across. +- Fence-jumpers are invisible to the door economy on purpose; if a future lane + adds a "jumpers ejected" metric it belongs to floor incidents, not door counts. diff --git a/src/data/strings/dazza.ts b/src/data/strings/dazza.ts index a528ae2..80c4c12 100644 --- a/src/data/strings/dazza.ts +++ b/src/data/strings/dazza.ts @@ -22,6 +22,10 @@ export const DAZZA_TEXTS: readonly DazzaLine[] = [ { id: 'rule-suits', text: 'too corporate in here tonight. no blazers til the suits ive already got leave', ruleId: 'noBlazers', fromMin: 200 }, { id: 'rule-couples', text: 'no more couples holding hands in the queue its making the room feel married', ruleId: 'noHandHolders', fromMin: 135 }, + // the 1:30 lockout (state law; rules/judge.ts LOCKOUT_MIN) + { id: 'lockout-warn', text: 'lockout in 15. after 130 the law says this door is SHUT. the queue will not agree with the law', fromMin: 255 }, + { id: 'lockout-live', text: '130. lockout. nobody new gets in. i dont care who terry is. especially if its terry', fromMin: 270 }, + // vibe commentary { id: 'vibe-mid', text: 'why is the floor MID rn. fix it', fromMin: 60 }, { id: 'vibe-good', text: 'room is going OFF right now whatever ur doing keep doing it', fromMin: 100 }, diff --git a/src/data/strings/door.ts b/src/data/strings/door.ts index fdfaab7..be0f23a 100644 --- a/src/data/strings/door.ts +++ b/src/data/strings/door.ts @@ -74,6 +74,20 @@ export const CLAIM_LINES: readonly string[] = [ 'i was here last weekend', ]; +/** A regular you have knocked back before, stepping up again. He kept count. */ +export const REGULAR_RETURN_LINES: readonly string[] = [ + 'back again. we good? we\'re good', + 'different jacket tonight. thought about what u said', + 'no hard feelings about last time. some feelings. no hard ones', +]; + +/** The disguise era (two knockbacks): same face, same card, new "man". */ +export const REGULAR_DISGUISED_LINES: readonly string[] = [ + 'first time at this establishment. love what youve done', + 'never been here before in my life. u seem nice though', + 'my brother speaks very highly of the door staff here. very highly', +]; + /** What the room mutters when you send someone away. Never cruel about the patron. */ export const DENY_REACTIONS: readonly string[] = [ 'the queue goes quiet', diff --git a/src/data/types.ts b/src/data/types.ts index 46b5a51..a588747 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -22,6 +22,8 @@ export interface IdFakeTells { wrongHologram?: boolean; jokeName?: boolean; photoMismatch?: boolean; + /** The same card was already admitted tonight (passback โ€” set by the door). */ + passback?: boolean; } export interface IdCard { diff --git a/src/rules/idCheck.ts b/src/rules/idCheck.ts index 8bf3549..ae7f3b2 100644 --- a/src/rules/idCheck.ts +++ b/src/rules/idCheck.ts @@ -17,6 +17,7 @@ const TELL_ORDER = [ 'jokeName', 'photoMismatch', 'expired', + 'passback', ] as const; export type IdTell = (typeof TELL_ORDER)[number]; @@ -62,6 +63,9 @@ export function idVerdict(id: IdCard, nightDate: Date): IdVerdict { // The generator ships fakes whose ONLY tell is the date, with no fake flags // set at all. Deriving this here is what makes those catchable. expired, + // Set by the DOOR, not the generator: the desk remembers every card it has + // admitted tonight, and this card has been through once already. + passback: flags?.passback === true, }; const tells = TELL_ORDER.filter((t) => present[t]); diff --git a/src/rules/judge.ts b/src/rules/judge.ts index ae60dc7..8674f42 100644 --- a/src/rules/judge.ts +++ b/src/rules/judge.ts @@ -15,6 +15,14 @@ import type { Patron, Verdict } from '../data/types'; // pays vibe, it just costs far more aggro than denying a real violator does. // Arbitrary power is rewarded and expensive at the same time. That is the game. +/** + * The 1:30 AM lockout (clock minute 270). Queensland's actual gift to game + * design: after this minute, NEW admissions are against the LAW โ€” the licence + * is on the line, the queue does not care, and Dazza cares in both directions. + * Denials after lockout are always justified; admissions file a strike. + */ +export const LOCKOUT_MIN = 270; + export const JUDGE_TUNING = { /** Deny someone who genuinely broke something. The job, done right โ€” and * routine. The room expects it; the payoff is small (tuning pass 2026-07-19: @@ -106,10 +114,15 @@ export function judge(patron: Patron, verdict: Verdict, ctx: JudgeContext): Door // verb (bribe, guest-list override) an else-branch would silently score it as // an ADMIT โ€” filing heat strikes and capacity breaches for a call the player // never made. Falling through to deny is inert by comparison. - return verdict === 'admit' ? judgeAdmit(patron, broken, id, ctx) : judgeDeny(patron, broken, id); + return verdict === 'admit' ? judgeAdmit(patron, broken, id, ctx) : judgeDeny(patron, broken, id, ctx); } -function judgeDeny(patron: Patron, broken: DoorDressCodeRule[], id: IdVerdict): DoorOutcome { +function judgeDeny( + patron: Patron, + broken: DoorDressCodeRule[], + id: IdVerdict, + ctx: JudgeContext, +): DoorOutcome { const T = JUDGE_TUNING; // Early return on purpose. Whatever else was wrong with him, Dazza only ever @@ -140,7 +153,9 @@ function judgeDeny(patron: Patron, broken: DoorDressCodeRule[], id: IdVerdict): id.looksFake || patron.age < 18 || tooDrunkToServe || - seriousContraband; + seriousContraband || + // After lockout, "no" is the law โ€” it is never arbitrary again tonight. + ctx.clockMin >= LOCKOUT_MIN; let vibeDelta = justified ? T.denyViolatorVibe : T.denyCleanVibe; let aggroDelta = justified ? T.denyViolatorAggro : T.denyCleanAggro; @@ -256,6 +271,18 @@ function judgeAdmit( }); } + if (ctx.clockMin >= LOCKOUT_MIN) { + // One stable reason: the inspector writes up "traded after lockout" once, + // however many you let through. (Letting many through still bleeds vibe + // via laps/ripens like any other admit โ€” the strike is the licence part.) + deferred.push({ + atClockMin: T.auditClockMin, + heatStrike: { reason: 'admission after the 1:30 lockout', deferred: true }, + reason: `lockout breach โ€” ${patron.id} admitted at clock ${ctx.clockMin}`, + patronId: patron.id, + }); + } + if (ctx.insideCount >= ctx.licensed) { // The STRIKE reason is deliberately stable: shouldRecordStrike dedupes by // reason, and running the room over capacity is ONE offence the inspector diff --git a/src/scenes/door/DoorScene.ts b/src/scenes/door/DoorScene.ts index 6107d1c..b2b85f3 100644 --- a/src/scenes/door/DoorScene.ts +++ b/src/scenes/door/DoorScene.ts @@ -3,7 +3,7 @@ import { renderDoll } from '../../patrons/doll'; import { dollPlan } from '../../patrons/dollPlan'; import { activeRules, announcedRules, violations, type DoorDressCodeRule } from '../../rules/dressCode'; import { idVerdict, formatDate } from '../../rules/idCheck'; -import { judge } from '../../rules/judge'; +import { LOCKOUT_MIN, judge } from '../../rules/judge'; import type { DoorOutcome } from '../../rules/doorTypes'; import { CONTRABAND } from '../../data/contraband'; import type { Patron, Verdict } from '../../data/types'; @@ -91,6 +91,8 @@ export class DoorScene extends Phaser.Scene { private present = true; private kaydenAccMs = 0; private readonly retiredRules = new Set(); + /** Every card ADMITTED tonight, name|dob -> clock label. The passback net. */ + private readonly seenCards = new Map(); private venueDoorGlow: Phaser.GameObjects.Rectangle | null = null; constructor() { @@ -139,6 +141,14 @@ export class DoorScene extends Phaser.Scene { this.retireStaleRules(); } }), + this.night.bus.on('door:verdict', ({ patron, verdict }) => { + if (verdict !== 'admit') return; + // The desk remembers the card, whoever waved it through. + this.seenCards.set( + `${patron.idCard.name}|${patron.idCard.dob}`, + this.night.clock.label, + ); + }), this.night.bus.on('heat:strike', () => this.refreshCounters()), this.night.bus.on('night:phaseChange', ({ location }) => { this.present = location === 'door'; @@ -395,6 +405,12 @@ export class DoorScene extends Phaser.Scene { } this.testedThisPatron = false; this.pattedThisPatron = false; + // Passback: this exact card already went in tonight. The tell is set HERE โ€” + // it is the DESK's memory, not the generator's โ€” so Kayden (who never + // checks) waves the same card through all night. + if (this.seenCards.has(`${p.idCard.name}|${p.idCard.dob}`)) { + p.idCard.fake = { ...(p.idCard.fake ?? {}), passback: true }; + } this.night.sfx?.play('ropeUnhook'); this.up.show(p, this.queue.encounterFor(p)); this.showTray(p); @@ -724,7 +740,9 @@ export class DoorScene extends Phaser.Scene { } } - this.clockText.setText(this.night.clock.label); + const locked = this.night.state.clockMin >= LOCKOUT_MIN; + this.clockText.setText(locked ? `${this.night.clock.label} ยท LOCKOUT` : this.night.clock.label); + this.clockText.setColor(locked ? '#ff8080' : '#9fe8a0'); if (this.tutorial.alpha > 0 && this.elapsedMs > TUTORIAL_MS) { this.tweens.add({ targets: this.tutorial, alpha: 0, duration: 800 }); } diff --git a/src/scenes/door/IdCardView.ts b/src/scenes/door/IdCardView.ts index 90aefb1..4b44281 100644 --- a/src/scenes/door/IdCardView.ts +++ b/src/scenes/door/IdCardView.ts @@ -133,6 +133,16 @@ export class IdCardView { root.add(scene.add.rectangle(92, 44, 58, 3, 0xffffff, 0.3).setAngle(-10)); scene.tweens.add({ targets: holo, alpha: { from: 0.35, to: 0.7 }, duration: 900, yoyo: true, repeat: -1 }); + if (p.idCard.fake?.passback) { + // The desk's own memory, stamped in red: this exact card already went in. + root.add( + scene.add + .text(-20, 30, 'SEEN TONIGHT', { fontFamily: MONO, fontSize: '11px', color: '#d03030' }) + .setOrigin(0.5) + .setAngle(-9) + .setAlpha(0.9), + ); + } if (p.idCard.fake?.peelingLaminate) { // lifted corner + a couple of detached flecks root.add(scene.add.triangle(CARD_W / 2 - 22, CARD_H / 2 - 22, 0, 0, 30, 0, 30, -30, 0xf6f2e8, 0.9)); diff --git a/src/scenes/door/PatronUpView.ts b/src/scenes/door/PatronUpView.ts index e1e50ab..2d06246 100644 --- a/src/scenes/door/PatronUpView.ts +++ b/src/scenes/door/PatronUpView.ts @@ -3,7 +3,7 @@ import { renderDoll } from '../../patrons/doll'; import { dollPlan } from '../../patrons/dollPlan'; import { drunkStage } from '../../rules/drunk'; import type { Patron } from '../../data/types'; -import { CLAIM_LINES, DRUNK_GREETINGS, OWNER_HINTS, PATRON_GREETINGS } from '../../data/strings/door'; +import { CLAIM_LINES, DRUNK_GREETINGS, OWNER_HINTS, PATRON_GREETINGS, REGULAR_DISGUISED_LINES, REGULAR_RETURN_LINES } from '../../data/strings/door'; import { encounterById, type EncounterId } from '../../rules/encounters'; import { MONO } from './ui'; import { ZONE_BANDS, ZONE_ORDER, describeZone, greetingIndex, type InspectZone } from './inspect'; @@ -92,13 +92,20 @@ export class PatronUpView { // knowsOwner is checked FIRST and separately: the owner's mate is the trap // (design ยง3.1), so his line has to be the one that drops the name, not a // generic "i'm on the list" shared with every influencer. - const pool = p.flags.knowsOwner - ? OWNER_HINTS - : p.flags.claimsGuestList - ? CLAIM_LINES - : drunk === 'messy' || drunk === 'maggot' || drunk === 'loose' - ? DRUNK_GREETINGS - : PATRON_GREETINGS; + // The disguised regular's denial-of-everything outranks every other line โ€” + // it IS the tell (same face, same walk, "never been here"). A merely + // knocked-back regular gets his olive branch in ahead of list claims. + const pool = p.flags.disguised === true + ? REGULAR_DISGUISED_LINES + : p.flags.regularId !== undefined && (p.flags.timesDeniedBefore ?? 0) > 0 + ? REGULAR_RETURN_LINES + : p.flags.knowsOwner + ? OWNER_HINTS + : p.flags.claimsGuestList + ? CLAIM_LINES + : drunk === 'messy' || drunk === 'maggot' || drunk === 'loose' + ? DRUNK_GREETINGS + : PATRON_GREETINGS; const line = pool[greetingIndex(p, pool.length)] ?? PATRON_GREETINGS[0]!; const bubbleY = -DOLL_H * SCALE - 16; diff --git a/src/scenes/door/QueueManager.ts b/src/scenes/door/QueueManager.ts index 945388e..f19c7ee 100644 --- a/src/scenes/door/QueueManager.ts +++ b/src/scenes/door/QueueManager.ts @@ -91,6 +91,9 @@ export class QueueManager { private readonly listRng: RngStream; private readonly encounterRng: RngStream; private readonly castRng: RngStream; + private readonly passbackRng: RngStream; + /** Cards of patrons admitted tonight โ€” the pool a passback clones from. */ + private readonly admittedCards: Array<{ name: string; dob: string; expiry: string; photoSeed: number }> = []; /** tonight's clipboard โ€” read by the door's guest-list panel */ readonly guestList: GuestList; /** scripted arrivals still owed tonight, soonest first */ @@ -129,6 +132,12 @@ export class QueueManager { this.listRng = rng.stream('guestList'); this.encounterRng = rng.stream('encounters'); this.castRng = rng.stream('castPick'); + this.passbackRng = rng.stream('passback'); + bus.on('door:verdict', ({ patron, verdict }) => { + if (verdict !== 'admit') return; + const c = patron.idCard; + this.admittedCards.push({ name: c.name, dob: c.dob, expiry: c.expiry, photoSeed: c.photoSeed }); + }); this.guestList = generateGuestList( this.listRng, this.listRng.int(8, Math.min(14, ENCOUNTERS.length + 12)), @@ -316,6 +325,20 @@ export class QueueManager { p = adoptCastIdentity(p, member, mem?.timesDenied ?? 0, disguisedFromMemory(mem)); } + // The passback (docs/SCENARIOS.md): late in the night, some chancer is + // holding a card that walked in HOURS ago โ€” right name, right DOB, and a + // photo of somebody else entirely. No flag is set here; the DESK notices, + // if the desk has been paying attention. + if ( + this.clockMin >= 150 && + this.admittedCards.length > 0 && + (p.archetype === 'punter' || p.archetype === 'almost18' || p.archetype === 'financeBro') && + this.passbackRng.chance(0.05) + ) { + const src = this.admittedCards[this.passbackRng.int(0, this.admittedCards.length - 1)]!; + p.idCard = { name: src.name, dob: src.dob, expiry: src.expiry, photoSeed: src.photoSeed }; + } + // Anyone who says they're on the list gets a name the clipboard has an // opinion about. Done here rather than in the generator because the list is // a door-phase prop and patrons/ is frozen โ€” see rules/guestList.ts for why diff --git a/src/scenes/floor/FloorDemoScene.ts b/src/scenes/floor/FloorDemoScene.ts index 1e7a0f3..9ed3ffc 100644 --- a/src/scenes/floor/FloorDemoScene.ts +++ b/src/scenes/floor/FloorDemoScene.ts @@ -89,6 +89,9 @@ export class FloorDemoScene extends Phaser.Scene { private choice!: ChoiceOverlay; private readonly jointDealt = new Set(); private rack: RackState | null = null; + /** Clock minutes at which somebody comes OVER the yard fence (night mode). */ + private jumperTimes: number[] = []; + private jumperRng: SeededRNG | null = null; private rackReadyAtMin = 6; private rackMarker: Phaser.GameObjects.Image | null = null; private carryBumpMs = 0; @@ -215,6 +218,13 @@ export class FloorDemoScene extends Phaser.Scene { this.rack = null; this.rackReadyAtMin = 6; + // Fence-jumpers (SCENARIOS.md / FLOOR2 ยง2): two or three a night come over + // the smoking-yard fence โ€” no stamp, no door:verdict, no idea. Their RNG is + // its own instance so the door's streams never feel them. + this.jumperRng = new SeededRNG(ctx.seed ^ 0xfe9ce); + const jr = this.jumperRng.stream('jumpers'); + this.jumperTimes = Array.from({ length: jr.int(2, 3) }, () => jr.int(120, 300)).sort((a, b) => a - b); + this.ctxOffs = [ // The floor is fed by the door: every admission walks in a few seconds later. this.bus.on('door:verdict', ({ patron, verdict }) => { @@ -352,6 +362,18 @@ export class FloorDemoScene extends Phaser.Scene { if (this.liveCrowd < MAX_CROWD) this.crowd.spawn(s.patron); } + while (this.jumperTimes.length > 0 && this.clock.clockMin >= this.jumperTimes[0]!) { + this.jumperTimes.shift(); + if (this.jumperRng && this.liveCrowd < MAX_CROWD) { + const p = generatePatron({ rng: this.jumperRng, nightDate: this.clock.nightDate }, this.clock.clockMin); + p.flags.uvStamped = false; + const a = this.crowd.spawn(p); + const yard = tileToWorld(18, 41); + a.x = yard.x; + a.y = yard.y; + } + } + // Player at the door: the floor keeps living (drinks keep being drunk, // stalls keep filling, maggots keep ripening โ†’ radio pressure), but // nothing needs the camera, the cone, or input. diff --git a/tests/dazzaSchedule.test.ts b/tests/dazzaSchedule.test.ts index 541a0ee..aefa89b 100644 --- a/tests/dazzaSchedule.test.ts +++ b/tests/dazzaSchedule.test.ts @@ -64,6 +64,8 @@ describe('dazzaDue', () => { 'vibe-queue', 'kayden', 'owner-scare', + 'lockout-warn', + 'lockout-live', 'philosophy-1', 'philosophy-2', ]); diff --git a/tests/idCheck.test.ts b/tests/idCheck.test.ts index ca5a356..c447c2f 100644 --- a/tests/idCheck.test.ts +++ b/tests/idCheck.test.ts @@ -277,3 +277,16 @@ describe('integration sweep over generated patrons', () => { }).not.toThrow(); }); }); + +describe('idVerdict โ€” passback', () => { + it('a passback-flagged card looks fake, with passback among the tells', () => { + const v = idVerdict(card({ fake: { passback: true } }), NIGHT); + expect(v.looksFake).toBe(true); + expect(v.tells).toContain('passback'); + }); + + it('the flag defaults off โ€” an unflagged card carries no passback tell', () => { + const v = idVerdict(card(), NIGHT); + expect(v.tells).not.toContain('passback'); + }); +}); diff --git a/tests/judge.test.ts b/tests/judge.test.ts index b448081..3b7c0fb 100644 --- a/tests/judge.test.ts +++ b/tests/judge.test.ts @@ -9,7 +9,7 @@ const mocks = vi.hoisted(() => ({ violations: vi.fn(), idVerdict: vi.fn() })); vi.mock('../src/rules/dressCode', () => ({ violations: mocks.violations })); vi.mock('../src/rules/idCheck', () => ({ idVerdict: mocks.idVerdict })); -const { judge, JUDGE_TUNING: T } = await import('../src/rules/judge'); +const { LOCKOUT_MIN, judge, JUDGE_TUNING: T } = await import('../src/rules/judge'); // The REAL rule labels, pulled past the mock. Hand-written shorts in a test are // how a broken player-facing string ships: every shipped `short` is a caps @@ -386,6 +386,30 @@ describe('judge โ€” admissions', () => { }); }); +describe('judge โ€” the 1:30 lockout', () => { + it('denying a clean patron after lockout is justified โ€” the law is the reason', () => { + const after = judge(makePatron(), 'deny', ctx({ clockMin: LOCKOUT_MIN })); + const before = judge(makePatron(), 'deny', ctx({ clockMin: LOCKOUT_MIN - 1 })); + // Justified denial pays violator-rate vibe; an unjustified clean denial doesn't. + expect(after.vibeDelta).toBe(T.denyViolatorVibe); + expect(before.vibeDelta).not.toBe(after.vibeDelta); + }); + + it('admitting after lockout defers exactly one strike, with a stable reason', () => { + const o = judge(makePatron(), 'admit', ctx({ clockMin: LOCKOUT_MIN + 10 })); + const strikes = (o.deferred ?? []).filter((d) => d.heatStrike); + expect(strikes).toHaveLength(1); + // STABLE โ€” no patron id baked in โ€” so dedupe holds it to one write-up a night + // (the over-capacity strike learned this the hard way). + expect(strikes[0]!.heatStrike!.reason).toBe('admission after the 1:30 lockout'); + }); + + it('admitting one minute before lockout carries no lockout strike', () => { + const o = judge(makePatron(), 'admit', ctx({ clockMin: LOCKOUT_MIN - 1 })); + expect((o.deferred ?? []).some((d) => d.heatStrike)).toBe(false); + }); +}); + describe('judge โ€” stalls and structure', () => { it('sobriety tests and pat-downs only cost queue patience', () => { for (const v of ['sobrietyTest', 'patDown'] as Verdict[]) { diff --git a/tests/sim/economy.test.ts b/tests/sim/economy.test.ts index ea1e0e9..021ea50 100644 --- a/tests/sim/economy.test.ts +++ b/tests/sim/economy.test.ts @@ -41,8 +41,10 @@ describe('door economy (bot nights, 5 seeds)', () => { }); it('careful play fills the room โ€” admits comfortably beat denies', () => { - const a = avg(careful.map((r) => r.admitted)); - const d = avg(careful.map((r) => r.denied)); + // Pre-lockout counts: after 1:30 the law says deny-everyone, and blanket + // legal denials would drown the signal this band exists to hold. + const a = avg(careful.map((r) => r.admittedPreLockout)); + const d = avg(careful.map((r) => r.deniedPreLockout)); expect(a).toBeGreaterThan(d * 1.2); }); diff --git a/tests/sim/economy.ts b/tests/sim/economy.ts index b32eb8e..7276fe6 100644 --- a/tests/sim/economy.ts +++ b/tests/sim/economy.ts @@ -10,7 +10,7 @@ import { SeededRNG } from '../../src/core/SeededRNG'; import { _resetPatronSerial } from '../../src/patrons/generator'; import { QueueManager } from '../../src/scenes/door/QueueManager'; import { shouldRecordStrike } from '../../src/rules/heat'; -import { judge } from '../../src/rules/judge'; +import { LOCKOUT_MIN, judge } from '../../src/rules/judge'; import { violations } from '../../src/rules/dressCode'; import { idVerdict } from '../../src/rules/idCheck'; import type { DeferredHit } from '../../src/rules/doorTypes'; @@ -32,6 +32,9 @@ export interface NightMetrics { endClockMin: number; admitted: number; denied: number; + /** Same counts, before the 1:30 lockout โ€” the fill band measures PLAY, not the law. */ + admittedPreLockout: number; + deniedPreLockout: number; /** vibe sampled every 5 clock-min */ vibeSamples: number[]; vibeMin: number; @@ -84,6 +87,8 @@ export function simulateNight( endClockMin: 360, admitted: 0, denied: 0, + admittedPreLockout: 0, + deniedPreLockout: 0, vibeSamples: [state.vibe], vibeMin: state.vibe, vibeMax: state.vibe, @@ -169,8 +174,12 @@ export function simulateNight( if (verdict === 'admit') { state.capacity.inside++; metrics.admitted++; + if (state.clockMin < LOCKOUT_MIN) metrics.admittedPreLockout++; leaveAt.push(state.clockMin + leaverRng.int(50, 140)); - } else metrics.denied++; + } else { + metrics.denied++; + if (state.clockMin < LOCKOUT_MIN) metrics.deniedPreLockout++; + } const delta: { vibe?: number; aggro?: number; hype?: number } = {}; if (outcome.vibeDelta) delta.vibe = outcome.vibeDelta; if (outcome.aggroDelta) delta.aggro = outcome.aggroDelta; @@ -248,6 +257,8 @@ export const CAREFUL: BotPolicy = { // Watches the clicker: one out, one in doesn't exist yet, so at capacity // the door simply shuts. This is what playing the licence looks like. if (insideCount >= licensed) return 'deny'; + // 1:30 lockout โ€” after LOCKOUT_MIN "no" is the law, and careful play obeys it. + if (clockMin >= LOCKOUT_MIN) return 'deny'; const id = idVerdict(p.idCard, nightDate); if (id.underage || id.looksFake || p.age < 18) return 'deny'; if (violations(p, clockMin).length > 0) return 'deny';