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:
parent
389376c2f1
commit
4471bf4376
@ -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). 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.
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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]);
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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[]) {
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user