import type { EventBus } from '../../core/EventBus'; import type { RngStream, SeededRNG } from '../../core/SeededRNG'; import { generatePatron } from '../../patrons/generator'; import type { Patron } from '../../data/types'; import { nextArrivalGapMin } from './arrivalCurve'; import { assignListedName, generateGuestList, type GuestList } from '../../rules/guestList'; import { adoptCastIdentity } from '../../patrons/regularCast'; import { disguisedFromMemory } from '../../patrons/memory'; import type { RegularMemory } from '../../data/types'; import { ENCOUNTERS, encounterById, scheduleEncounters, twinSibling, type EncounterId, } from '../../rules/encounters'; /** Second patron of the night lands here regardless of the curve's opinion. */ const OPENING_ARRIVAL_MIN = 3; /** 2:45 AM. Last drinks is 3:00; the line has to stop growing before then. */ const DOORS_SHUT_MIN = 345; // Pure queue logic — no Phaser. DoorScene renders whatever this says is true. // Owning the queue outside the scene keeps the "how much does neglect cost?" // tuning in one testable place instead of smeared across render code. export const QUEUE_TUNING = { /** dolls drawn on the footpath; everyone past this is an offscreen count */ visibleSlots: 8, /** hard cap on people waiting — past this they wander off to the kebab shop */ maxQueue: 22, /** aggro per real second, per waiting patron, before the impatience ramp */ aggroPerSecPerWaiter: 0.06, /** the ramp: waiting gets worse the longer it goes on (design §2: Aggro punishes slowness) */ impatienceRampSec: 15, /** hype per real second of making people wait — the theatre bonus */ hypePerSec: 0.035, /** hype earned per single patron-wait, so you cannot farm one punter forever */ hypePerWaitCap: 0.5, /** * A queue this short reads as "under control" and bleeds aggro. Above it, only * the idle penalty applies. Relief has to scale with queue LENGTH rather than * only firing on an empty street: during the 11 PM slam the queue is never * empty, so an empty-only rule meant a perfectly-played night still rioted. */ comfortableQueue: 4, /** aggro bled off per real second with nobody waiting at all * (tuning pass 2026-07-19: was 0.9 — relief refunded ~10:1 and aggro never moved) */ calmPerSec: 0.35, /** * Hype decays toward x1 while a patron is actually being served. Theatre * fades the moment you get back to work — without this, hype was a pure * ratchet and every night ended at x3 regardless of play. */ hypeDecayPerSec: 0.045, /** looking at your phone while someone waits: pure theatre, pure profit */ phoneHype: 0.12, phoneCooldownMs: 2500, /** a denied regular comes back to try again this many in-game minutes later */ regularRetryMin: [25, 60] as const, /** * ...but only this many times. Uncapped, one stubborn regular reappeared six * times in a single night and ate a third of the night's denials on his own. */ regularMaxComebacks: 2, /** chance a new arrival brings a hand-holding partner (feeds noHandHolders) */ coupleChance: 0.12, /** scripted moral encounters injected per night (design §4.3) */ encountersPerNight: 3, } as const; // The red carpet (design §2 queue theatre, escalated): a velvet holding pen // where you park somebody gorgeous and make them WAIT, visibly, at length. // Every body on display sells the room; every body has a limit. export const CARPET_TUNING = { /** brass posts only stretch so far */ slots: 3, /** hype per real second per body on display */ hypePerSecPerBody: 0.02, /** the entrance: admitting off the carpet pays hype scaled by the stand */ entranceHypePerSec: 0.012, entranceHypeCap: 0.6, entranceVibe: 2, /** patience in real seconds, before the archetype scale */ patienceSecBase: [30, 55] as const, /** * Who tolerates being furniture. Influencers LIVE for it; a regular gives * it thirty seconds and tells the whole suburb. */ patienceScale: { influencer: 1.8, financeBro: 1.3, ownersMate: 1.2, fresh18: 1.1, almost18: 1.0, inspector: 1.0, punter: 0.9, quietMessy: 0.7, regular: 0.5, } as Readonly>, /** storming off the carpet is a scene the whole queue watches */ stormAggro: 3, } as const; /** The entrance dividend for `standMs` on the carpet, capped. */ export function entranceBonus(standMs: number): number { return Math.min(CARPET_TUNING.entranceHypeCap, (standMs / 1000) * CARPET_TUNING.entranceHypePerSec); } export interface CarpetSlot { patron: Patron; standMs: number; patienceMs: number; } export interface QueueSnapshot { waiting: Patron[]; visible: Patron[]; offscreen: number; up: Patron | null; /** real ms the front of the queue has been left standing with nobody up */ waitMs: number; } export class QueueManager { private waiting: Patron[] = []; private up: Patron | null = null; private waitMs = 0; private hypeEarnedThisWait = 0; private phoneCooldownMs = 0; private nextArrivalMin = 0; private clockMin = 0; private readonly arrivalRng: RngStream; private readonly coupleRng: RngStream; private readonly comebackRng: RngStream; 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 */ private pendingEncounters: Array<{ atMin: number; id: EncounterId }> = []; /** patronId -> the script they are playing, for the door to look up */ private readonly encounterOf = new Map(); /** patrons queued to re-enter the queue: [clockMin, patron] */ private readonly comebacks: Array<{ atMin: number; patron: Patron }> = []; /** Twins whose sibling has already been sent for — one brother each. */ private readonly twinsSpawned = new Set(); /** everyone who reached the front tonight, for the summary screen */ readonly seen: Patron[] = []; /** bodies currently on display between the brass posts */ private readonly carpet: CarpetSlot[] = []; /** storm-offs since the scene last asked — drained by takeStorms() */ private readonly storms: Patron[] = []; /** patronId -> how long they stood, recorded at recall (the entrance maths) */ private readonly carpetStand = new Map(); private readonly carpetRng: RngStream; constructor( private readonly bus: EventBus, private readonly rng: SeededRNG, private readonly nightDate: Date, /** Encounters already met this run — the scheduler prefers new people. */ seenEncounters: readonly string[] = [], /** Venue arrival multiplier (ladder, design §6). */ private readonly arrivalScale: number = 1, /** Venue crowd identity — archetype weight overrides (ladder, design §6). */ private readonly archetypeWeights?: Partial>, /** The run's stable regulars + what they remember (design §4.1). */ private readonly regulars?: { cast: readonly import('../../data/types').Patron[]; memory: Readonly>; }, ) { this.arrivalRng = rng.stream('arrivals'); this.coupleRng = rng.stream('couples'); // Its own stream on purpose. Drawing the comeback delay from 'arrivals' // meant one player decision — denying a regular — shifted every remaining // arrival time for the rest of the night, which is exactly the cross-system // perturbation CONTRACTS.md §6 gives named streams to prevent. this.comebackRng = rng.stream('comebacks'); this.listRng = rng.stream('guestList'); this.encounterRng = rng.stream('encounters'); this.castRng = rng.stream('castPick'); this.passbackRng = rng.stream('passback'); this.carpetRng = rng.stream('carpet'); bus.on('door:verdict', ({ patron, verdict }) => { // Twin B arrives a few minutes after A's verdict, WHATEVER it was — he // was parking the car either way. A plain patron, no script: his own // real name, his brother's face. The photo check is the thing on trial. // ...but only on a FINAL ruling, and only once. `door:verdict` also fires // for 'sobrietyTest' and 'patDown', so a doorperson who tested the twin // AND patted him down summoned three of his brother. if ( (verdict === 'admit' || verdict === 'deny') && this.encounterOf.get(patron.id) === 'twins' && !this.twinsSpawned.has(patron.id) ) { this.twinsSpawned.add(patron.id); this.comebacks.push({ atMin: this.clockMin + this.comebackRng.int(2, 5), patron: twinSibling(patron), }); } 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)), ); this.pendingEncounters = scheduleEncounters( this.encounterRng, QUEUE_TUNING.encountersPerNight, new Set(seenEncounters), ); // Somebody is ALWAYS at the rope when the shift starts. A game that opens on // an empty street reads as broken, however accurate a 9 PM lull would be. this.enqueue(this.make()); this.nextArrivalMin = OPENING_ARRIVAL_MIN; } get snapshot(): QueueSnapshot { const visible = this.waiting.slice(0, QUEUE_TUNING.visibleSlots); return { waiting: [...this.waiting], visible, offscreen: Math.max(0, this.waiting.length - visible.length), up: this.up, waitMs: this.waitMs, }; } get queueLength(): number { return this.waiting.length; } get patronUp(): Patron | null { return this.up; } get isPhoneReady(): boolean { return this.phoneCooldownMs <= 0; } /** Called on every clock:tick — arrivals and comebacks run on in-game minutes. */ onClockMinute(clockMin: number): void { this.clockMin = clockMin; // Doors shut at 2:45. Whoever is already in the line still gets served, but // nobody joins it after this — including a denied regular whose comeback // happens to be due. Shut means shut. if (clockMin >= DOORS_SHUT_MIN) return; // Scripted arrivals jump the curve — they are people who turned up at a // particular moment, not draws from a distribution. while (this.pendingEncounters.length > 0 && clockMin >= this.pendingEncounters[0]!.atMin) { const due = this.pendingEncounters.shift()!; const scripted = this.makeScripted(due.id); if (scripted) this.enqueue(scripted); } for (let i = this.comebacks.length - 1; i >= 0; i--) { const c = this.comebacks[i]!; if (clockMin >= c.atMin) { this.comebacks.splice(i, 1); this.enqueue(c.patron); } } while (clockMin >= this.nextArrivalMin) { this.spawnArrival(); const gap = nextArrivalGapMin(this.nextArrivalMin, this.arrivalRng.next(), this.arrivalScale); this.nextArrivalMin += Math.max(1, Math.round(gap)); } } /** Called every frame with real elapsed ms. Owns the impatience economy. */ update(deltaMs: number): void { const sec = deltaMs / 1000; this.phoneCooldownMs = Math.max(0, this.phoneCooldownMs - deltaMs); this.tickCarpet(deltaMs); const idle = this.up === null && this.waiting.length > 0; if (idle) { this.waitMs += deltaMs; const ramp = 1 + this.waitMs / 1000 / QUEUE_TUNING.impatienceRampSec; const aggro = QUEUE_TUNING.aggroPerSecPerWaiter * this.waiting.length * ramp * sec; // Hype is capped per wait; aggro is not. Theatre has diminishing returns, // resentment does not. const room = Math.max(0, QUEUE_TUNING.hypePerWaitCap - this.hypeEarnedThisWait); const hype = Math.min(room, QUEUE_TUNING.hypePerSec * sec); this.hypeEarnedThisWait += hype; this.bus.emit('meters:delta', { aggro, ...(hype > 0 ? { hype } : {}) }); return; } if (this.waiting.length === 0) this.waitMs = 0; // Somebody is being served, or nobody is waiting: the line relaxes in // proportion to how short it is. This is the pressure valve that makes the // night survivable — keep it moving and the aggro you earn bleeds back off. const slack = QUEUE_TUNING.comfortableQueue - this.waiting.length; if (slack > 0) { const relief = (QUEUE_TUNING.calmPerSec * slack) / QUEUE_TUNING.comfortableQueue; this.bus.emit('meters:delta', { aggro: -relief * sec }); } // Somebody is at the rope being dealt with: the theatre wears off. if (this.up !== null) { this.bus.emit('meters:delta', { hype: -QUEUE_TUNING.hypeDecayPerSec * sec }); } } /** The carpet ticks whether or not the rope is busy — display doesn't pause. */ private tickCarpet(deltaMs: number): void { if (this.carpet.length === 0) return; const sec = deltaMs / 1000; this.bus.emit('meters:delta', { hype: CARPET_TUNING.hypePerSecPerBody * this.carpet.length * sec }); for (let i = this.carpet.length - 1; i >= 0; i--) { const slot = this.carpet[i]!; slot.standMs += deltaMs; if (slot.standMs < slot.patienceMs) continue; this.carpet.splice(i, 1); this.storms.push(slot.patron); this.bus.emit('meters:delta', { aggro: CARPET_TUNING.stormAggro }); } } get carpetSnapshot(): readonly CarpetSlot[] { return this.carpet; } /** Storm-offs since last asked. The scene narrates; the meters already paid. */ takeStorms(): Patron[] { return this.storms.splice(0, this.storms.length); } /** * The patron at the rope takes position between the brass posts. Frees the * rope — the theatre is that you keep working while they stand there. */ toCarpet(): Patron | null { const p = this.up; if (!p || this.carpet.length >= CARPET_TUNING.slots) return null; this.up = null; this.waitMs = 0; this.hypeEarnedThisWait = 0; const [lo, hi] = CARPET_TUNING.patienceSecBase; const scale = CARPET_TUNING.patienceScale[p.archetype] ?? 1; this.carpet.push({ patron: p, standMs: 0, patienceMs: this.carpetRng.int(lo, hi) * scale * 1000, }); return p; } /** Wave them off the carpet and up to the rope. Only while the rope is free. */ recallFromCarpet(patronId: string): Patron | null { if (this.up !== null) return null; const i = this.carpet.findIndex((s) => s.patron.id === patronId); if (i < 0) return null; const slot = this.carpet.splice(i, 1)[0]!; this.carpetStand.set(slot.patron.id, slot.standMs); this.up = slot.patron; this.waitMs = 0; this.hypeEarnedThisWait = 0; this.bus.emit('door:patronUp', { patron: slot.patron }); return slot.patron; } /** * How long this patron stood on the carpet before their recall, in ms — * CONSUMED on read. The dividend is paid for one stand, once: a regular who * was parked, recalled and knocked back comes round again later through the * ordinary queue, and without this the door paid them a second entrance bonus * for a stand that had already been cashed. */ carpetMsFor(patronId: string): number { const ms = this.carpetStand.get(patronId) ?? 0; this.carpetStand.delete(patronId); return ms; } /** The Rope. Nothing steps up until the player says so. */ callNext(): Patron | null { if (this.up !== null) return null; const next = this.waiting.shift(); if (!next) return null; this.up = next; this.waitMs = 0; this.hypeEarnedThisWait = 0; this.seen.push(next); this.bus.emit('door:patronUp', { patron: next }); return next; } /** Player checked their phone while someone waits — reward the theatre. */ lookAtPhone(): boolean { if (!this.isPhoneReady || this.waiting.length === 0) return false; this.phoneCooldownMs = QUEUE_TUNING.phoneCooldownMs; this.bus.emit('meters:delta', { hype: QUEUE_TUNING.phoneHype }); return true; } /** * The patron at the rope has been dealt with. Denied regulars nurse the grudge * and try again later in the night (design §4.1 — regulars have memory). */ resolveUp(denied: boolean): void { const p = this.up; this.up = null; this.waitMs = 0; this.hypeEarnedThisWait = 0; if (!p || !denied) return; const priorDenials = p.flags.timesDeniedBefore ?? 0; if (p.archetype === 'regular' && this.clockMin < 300 && priorDenials < QUEUE_TUNING.regularMaxComebacks) { const [lo, hi] = QUEUE_TUNING.regularRetryMin; const back: Patron = { ...p, flags: { ...p.flags, timesDeniedBefore: (p.flags.timesDeniedBefore ?? 0) + 1 }, }; this.comebacks.push({ atMin: this.clockMin + this.comebackRng.int(lo, hi), patron: back }); } } private spawnArrival(): void { // EVERY draw here happens unconditionally, before anything looks at whether // the queue had room. Queue length is player-controlled, so a draw taken // behind an `if (enqueued)` would make the rest of the night's patrons // depend on how the player was playing — CONTRACTS.md §6 says same seed, // same night, full stop. const first = this.make(); const isCouple = this.coupleRng.chance(QUEUE_TUNING.coupleChance); const partner = isCouple ? this.make() : null; const p = this.enqueue(first); if (p && partner) { p.flags.handHolding = true; partner.flags.handHolding = true; this.enqueue(partner); } } /** Which scripted encounter this patron is, if any. */ encounterFor(patron: Patron): EncounterId | undefined { return this.encounterOf.get(patron.id); } private make(): Patron { let p = generatePatron( { rng: this.rng, nightDate: this.nightDate, ...(this.archetypeWeights ? { archetypeWeights: this.archetypeWeights } : {}) }, this.clockMin, ); // A rolled 'regular' is one of THE regulars — the run's stable cast. Same // face every night; the grudge (and the moustache) rides the run save. if (p.archetype === 'regular' && this.regulars && this.regulars.cast.length > 0) { const member = this.regulars.cast[this.castRng.int(0, this.regulars.cast.length - 1)]!; const mem = this.regulars.memory[member.flags.regularId ?? '']; 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 // an exact match is the suspicious one. if (p.flags.claimsGuestList) { p.idCard.name = assignListedName(this.listRng, this.guestList, p.flags.onGuestList === true); } return p; } /** * A scripted patron, if one is due. Generated through the SAME generator so * their doll, ID and outfit are ordinary — `dress()` only changes what the * script needs, which is why they don't announce themselves before speaking. */ private makeScripted(id: EncounterId): Patron | null { const enc = encounterById(id); if (!enc) return null; const p = generatePatron({ rng: this.rng, nightDate: this.nightDate }, this.clockMin, enc.archetype); enc.dress(p, this.encounterRng); if (p.flags.claimsGuestList) { p.idCard.name = assignListedName(this.listRng, this.guestList, p.flags.onGuestList === true); } this.encounterOf.set(p.id, id); return p; } private enqueue(p: Patron): Patron | null { if (this.waiting.length >= QUEUE_TUNING.maxQueue) return null; // they've gone for a kebab this.waiting.push(p); return p; } }