Late game: 1:30 lockout law, ID passback (SEEN TONIGHT), regular step-up lines, yard fence-jumpers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-20 18:45:50 +10:00
parent 389376c2f1
commit 4471bf4376
16 changed files with 264 additions and 18 deletions

View File

@ -1200,3 +1200,66 @@ sting queued. **Tests: 651. Gate clean. Deployed, live 200.**
**Next:** SCENARIOS.md 🔜 items (ID passback, regular step-up lines, **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 smoking-door slip-in); the lockout law wants its own design pass; John's two
human passes still open. 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). 23 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.

View File

@ -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-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 }, { 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 // vibe commentary
{ id: 'vibe-mid', text: 'why is the floor MID rn. fix it', fromMin: 60 }, { 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 }, { id: 'vibe-good', text: 'room is going OFF right now whatever ur doing keep doing it', fromMin: 100 },

View File

@ -74,6 +74,20 @@ export const CLAIM_LINES: readonly string[] = [
'i was here last weekend', '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. */ /** What the room mutters when you send someone away. Never cruel about the patron. */
export const DENY_REACTIONS: readonly string[] = [ export const DENY_REACTIONS: readonly string[] = [
'the queue goes quiet', 'the queue goes quiet',

View File

@ -22,6 +22,8 @@ export interface IdFakeTells {
wrongHologram?: boolean; wrongHologram?: boolean;
jokeName?: boolean; jokeName?: boolean;
photoMismatch?: boolean; photoMismatch?: boolean;
/** The same card was already admitted tonight (passback — set by the door). */
passback?: boolean;
} }
export interface IdCard { export interface IdCard {

View File

@ -17,6 +17,7 @@ const TELL_ORDER = [
'jokeName', 'jokeName',
'photoMismatch', 'photoMismatch',
'expired', 'expired',
'passback',
] as const; ] as const;
export type IdTell = (typeof TELL_ORDER)[number]; 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 // 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. // set at all. Deriving this here is what makes those catchable.
expired, 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]); const tells = TELL_ORDER.filter((t) => present[t]);

View File

@ -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. // 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. // 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 = { export const JUDGE_TUNING = {
/** Deny someone who genuinely broke something. The job, done right and /** 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: * 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 // 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 // an ADMIT — filing heat strikes and capacity breaches for a call the player
// never made. Falling through to deny is inert by comparison. // 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; const T = JUDGE_TUNING;
// Early return on purpose. Whatever else was wrong with him, Dazza only ever // 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 || id.looksFake ||
patron.age < 18 || patron.age < 18 ||
tooDrunkToServe || 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 vibeDelta = justified ? T.denyViolatorVibe : T.denyCleanVibe;
let aggroDelta = justified ? T.denyViolatorAggro : T.denyCleanAggro; 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) { if (ctx.insideCount >= ctx.licensed) {
// The STRIKE reason is deliberately stable: shouldRecordStrike dedupes by // The STRIKE reason is deliberately stable: shouldRecordStrike dedupes by
// reason, and running the room over capacity is ONE offence the inspector // reason, and running the room over capacity is ONE offence the inspector

View File

@ -3,7 +3,7 @@ import { renderDoll } from '../../patrons/doll';
import { dollPlan } from '../../patrons/dollPlan'; import { dollPlan } from '../../patrons/dollPlan';
import { activeRules, announcedRules, violations, type DoorDressCodeRule } from '../../rules/dressCode'; import { activeRules, announcedRules, violations, type DoorDressCodeRule } from '../../rules/dressCode';
import { idVerdict, formatDate } from '../../rules/idCheck'; 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 type { DoorOutcome } from '../../rules/doorTypes';
import { CONTRABAND } from '../../data/contraband'; import { CONTRABAND } from '../../data/contraband';
import type { Patron, Verdict } from '../../data/types'; import type { Patron, Verdict } from '../../data/types';
@ -91,6 +91,8 @@ export class DoorScene extends Phaser.Scene {
private present = true; private present = true;
private kaydenAccMs = 0; private kaydenAccMs = 0;
private readonly retiredRules = new Set<string>(); private readonly retiredRules = new Set<string>();
/** Every card ADMITTED tonight, name|dob -> clock label. The passback net. */
private readonly seenCards = new Map<string, string>();
private venueDoorGlow: Phaser.GameObjects.Rectangle | null = null; private venueDoorGlow: Phaser.GameObjects.Rectangle | null = null;
constructor() { constructor() {
@ -139,6 +141,14 @@ export class DoorScene extends Phaser.Scene {
this.retireStaleRules(); 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('heat:strike', () => this.refreshCounters()),
this.night.bus.on('night:phaseChange', ({ location }) => { this.night.bus.on('night:phaseChange', ({ location }) => {
this.present = location === 'door'; this.present = location === 'door';
@ -395,6 +405,12 @@ export class DoorScene extends Phaser.Scene {
} }
this.testedThisPatron = false; this.testedThisPatron = false;
this.pattedThisPatron = 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.night.sfx?.play('ropeUnhook');
this.up.show(p, this.queue.encounterFor(p)); this.up.show(p, this.queue.encounterFor(p));
this.showTray(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) { if (this.tutorial.alpha > 0 && this.elapsedMs > TUTORIAL_MS) {
this.tweens.add({ targets: this.tutorial, alpha: 0, duration: 800 }); this.tweens.add({ targets: this.tutorial, alpha: 0, duration: 800 });
} }

View File

@ -133,6 +133,16 @@ export class IdCardView {
root.add(scene.add.rectangle(92, 44, 58, 3, 0xffffff, 0.3).setAngle(-10)); 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 }); 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) { if (p.idCard.fake?.peelingLaminate) {
// lifted corner + a couple of detached flecks // 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)); root.add(scene.add.triangle(CARD_W / 2 - 22, CARD_H / 2 - 22, 0, 0, 30, 0, 30, -30, 0xf6f2e8, 0.9));

View File

@ -3,7 +3,7 @@ import { renderDoll } from '../../patrons/doll';
import { dollPlan } from '../../patrons/dollPlan'; import { dollPlan } from '../../patrons/dollPlan';
import { drunkStage } from '../../rules/drunk'; import { drunkStage } from '../../rules/drunk';
import type { Patron } from '../../data/types'; 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 { encounterById, type EncounterId } from '../../rules/encounters';
import { MONO } from './ui'; import { MONO } from './ui';
import { ZONE_BANDS, ZONE_ORDER, describeZone, greetingIndex, type InspectZone } from './inspect'; 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 // 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 // (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. // generic "i'm on the list" shared with every influencer.
const pool = p.flags.knowsOwner // The disguised regular's denial-of-everything outranks every other line —
? OWNER_HINTS // it IS the tell (same face, same walk, "never been here"). A merely
: p.flags.claimsGuestList // knocked-back regular gets his olive branch in ahead of list claims.
? CLAIM_LINES const pool = p.flags.disguised === true
: drunk === 'messy' || drunk === 'maggot' || drunk === 'loose' ? REGULAR_DISGUISED_LINES
? DRUNK_GREETINGS : p.flags.regularId !== undefined && (p.flags.timesDeniedBefore ?? 0) > 0
: PATRON_GREETINGS; ? 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 line = pool[greetingIndex(p, pool.length)] ?? PATRON_GREETINGS[0]!;
const bubbleY = -DOLL_H * SCALE - 16; const bubbleY = -DOLL_H * SCALE - 16;

View File

@ -91,6 +91,9 @@ export class QueueManager {
private readonly listRng: RngStream; private readonly listRng: RngStream;
private readonly encounterRng: RngStream; private readonly encounterRng: RngStream;
private readonly castRng: 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 */ /** tonight's clipboard — read by the door's guest-list panel */
readonly guestList: GuestList; readonly guestList: GuestList;
/** scripted arrivals still owed tonight, soonest first */ /** scripted arrivals still owed tonight, soonest first */
@ -129,6 +132,12 @@ export class QueueManager {
this.listRng = rng.stream('guestList'); this.listRng = rng.stream('guestList');
this.encounterRng = rng.stream('encounters'); this.encounterRng = rng.stream('encounters');
this.castRng = rng.stream('castPick'); 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.guestList = generateGuestList(
this.listRng, this.listRng,
this.listRng.int(8, Math.min(14, ENCOUNTERS.length + 12)), 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)); 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 // 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 // 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 // a door-phase prop and patrons/ is frozen — see rules/guestList.ts for why

View File

@ -89,6 +89,9 @@ export class FloorDemoScene extends Phaser.Scene {
private choice!: ChoiceOverlay; private choice!: ChoiceOverlay;
private readonly jointDealt = new Set<string>(); private readonly jointDealt = new Set<string>();
private rack: RackState | null = null; 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 rackReadyAtMin = 6;
private rackMarker: Phaser.GameObjects.Image | null = null; private rackMarker: Phaser.GameObjects.Image | null = null;
private carryBumpMs = 0; private carryBumpMs = 0;
@ -215,6 +218,13 @@ export class FloorDemoScene extends Phaser.Scene {
this.rack = null; this.rack = null;
this.rackReadyAtMin = 6; 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 = [ this.ctxOffs = [
// The floor is fed by the door: every admission walks in a few seconds later. // The floor is fed by the door: every admission walks in a few seconds later.
this.bus.on('door:verdict', ({ patron, verdict }) => { 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); 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, // Player at the door: the floor keeps living (drinks keep being drunk,
// stalls keep filling, maggots keep ripening → radio pressure), but // stalls keep filling, maggots keep ripening → radio pressure), but
// nothing needs the camera, the cone, or input. // nothing needs the camera, the cone, or input.

View File

@ -64,6 +64,8 @@ describe('dazzaDue', () => {
'vibe-queue', 'vibe-queue',
'kayden', 'kayden',
'owner-scare', 'owner-scare',
'lockout-warn',
'lockout-live',
'philosophy-1', 'philosophy-1',
'philosophy-2', 'philosophy-2',
]); ]);

View File

@ -277,3 +277,16 @@ describe('integration sweep over generated patrons', () => {
}).not.toThrow(); }).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');
});
});

View File

@ -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/dressCode', () => ({ violations: mocks.violations }));
vi.mock('../src/rules/idCheck', () => ({ idVerdict: mocks.idVerdict })); 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 // 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 // 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', () => { describe('judge — stalls and structure', () => {
it('sobriety tests and pat-downs only cost queue patience', () => { it('sobriety tests and pat-downs only cost queue patience', () => {
for (const v of ['sobrietyTest', 'patDown'] as Verdict[]) { for (const v of ['sobrietyTest', 'patDown'] as Verdict[]) {

View File

@ -41,8 +41,10 @@ describe('door economy (bot nights, 5 seeds)', () => {
}); });
it('careful play fills the room — admits comfortably beat denies', () => { it('careful play fills the room — admits comfortably beat denies', () => {
const a = avg(careful.map((r) => r.admitted)); // Pre-lockout counts: after 1:30 the law says deny-everyone, and blanket
const d = avg(careful.map((r) => r.denied)); // 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); expect(a).toBeGreaterThan(d * 1.2);
}); });

View File

@ -10,7 +10,7 @@ import { SeededRNG } from '../../src/core/SeededRNG';
import { _resetPatronSerial } from '../../src/patrons/generator'; import { _resetPatronSerial } from '../../src/patrons/generator';
import { QueueManager } from '../../src/scenes/door/QueueManager'; import { QueueManager } from '../../src/scenes/door/QueueManager';
import { shouldRecordStrike } from '../../src/rules/heat'; 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 { violations } from '../../src/rules/dressCode';
import { idVerdict } from '../../src/rules/idCheck'; import { idVerdict } from '../../src/rules/idCheck';
import type { DeferredHit } from '../../src/rules/doorTypes'; import type { DeferredHit } from '../../src/rules/doorTypes';
@ -32,6 +32,9 @@ export interface NightMetrics {
endClockMin: number; endClockMin: number;
admitted: number; admitted: number;
denied: 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 */ /** vibe sampled every 5 clock-min */
vibeSamples: number[]; vibeSamples: number[];
vibeMin: number; vibeMin: number;
@ -84,6 +87,8 @@ export function simulateNight(
endClockMin: 360, endClockMin: 360,
admitted: 0, admitted: 0,
denied: 0, denied: 0,
admittedPreLockout: 0,
deniedPreLockout: 0,
vibeSamples: [state.vibe], vibeSamples: [state.vibe],
vibeMin: state.vibe, vibeMin: state.vibe,
vibeMax: state.vibe, vibeMax: state.vibe,
@ -169,8 +174,12 @@ export function simulateNight(
if (verdict === 'admit') { if (verdict === 'admit') {
state.capacity.inside++; state.capacity.inside++;
metrics.admitted++; metrics.admitted++;
if (state.clockMin < LOCKOUT_MIN) metrics.admittedPreLockout++;
leaveAt.push(state.clockMin + leaverRng.int(50, 140)); 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 } = {}; const delta: { vibe?: number; aggro?: number; hype?: number } = {};
if (outcome.vibeDelta) delta.vibe = outcome.vibeDelta; if (outcome.vibeDelta) delta.vibe = outcome.vibeDelta;
if (outcome.aggroDelta) delta.aggro = outcome.aggroDelta; 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 // 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. // the door simply shuts. This is what playing the licence looks like.
if (insideCount >= licensed) return 'deny'; 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); const id = idVerdict(p.idCard, nightDate);
if (id.underage || id.looksFake || p.age < 18) return 'deny'; if (id.underage || id.looksFake || p.age < 18) return 'deny';
if (violations(p, clockMin).length > 0) return 'deny'; if (violations(p, clockMin).length > 0) return 'deny';