diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index f9d0f74..8cc2405 100644 --- a/LANEHANDOVER.md +++ b/LANEHANDOVER.md @@ -1498,3 +1498,36 @@ card aggro +2 + tabMisreturned ✓ · THE POUR screenshot. patrons ever share a full name. - No cash meter exists on purpose: tips/tab totals are incidents + toasts (the report is the moral ledger, per the joint bust). + +## SESSION — FABLE-SOLO-14 · 2026-07-21 + +**Branch:** main (solo, Fable, ultra) · **Gate:** lint ✓ build ✓ test ✓ (718 tests, 45 files) +**Deployed:** https://monsterrobot.games/not-tonight/ (rsync, curl 200) + +### The red carpet (John's ask: "make ppl wait ages") +QueueManager owns it (pure, 7 new tests); DoorScene renders it. +- **CARPET button** beside UNHOOK ROPE (rope shrank 192→130px). Sends the + patron at the rope to a 3-slot red strip between rope and door (brass posts + + velvet drawn in buildStreet; dolls clickable, impatience-sway ramps). +- **Economy** (`CARPET_TUNING`): 0.02 hype/s per body on display, ADDITIVE to + ordinary queue theatre (freeing the rope re-arms the per-wait hype cap — the + carpet is a hype engine, that's the point). Patience 30–55s × archetype + scale (influencer 1.8 … regular 0.5); past it they STORM (aggro +3, toast, + `carpetStorm` incident, drained via `takeStorms()` — no new bus event names, + EventMap is frozen contract). +- **Recall**: click the doll (rope must be free) → `recallFromCarpet` puts + them up through the shared `presentUp()` path (extracted from callNext — a + recalled patron gets passback checks, Franko tips, tray, the lot). +- **The entrance**: ADMIT off the carpet pays vibe +2 + hype 0.012/s stood + (cap 0.6), `carpetEntrance` incident. Deny pays nothing extra — you made + them wait for THAT. +- Verified in pane: park → rope keeps working → recall(4s) → admit paid 0.048 + hype + incident ✓ · storm at 25s with toast/incident ✓ · screenshots. + +### Notes +- Kayden never uses the carpet (he doesn't know what it's for). Bodies keep + ticking/storming while the player is inside — QueueManager.update runs on. +- Art audit for the next MODELBEAST batch (placeholder rects live for): + `monitor`, `boothLamp`, `dishwasher`, `poolTable`, `wetFloor`, `highTable`, + plus new candidates: red-carpet strip + brass bollards (street), the pour + glass, the card jar. Recipe in the Phase-3 deploy note. diff --git a/src/data/strings/door.ts b/src/data/strings/door.ts index 8676953..9b421ea 100644 --- a/src/data/strings/door.ts +++ b/src/data/strings/door.ts @@ -16,6 +16,16 @@ export const DOOR_UI = { idTrayEmpty: 'no ID handed over', dressCode: "DAZZA'S RULES", dressCodeEmpty: 'no rules yet.\nuse ur judgement\n(dont)', + carpet: 'CARPET', +} as const; + +/** The red carpet: a velvet holding pen for making the right people wait. */ +export const CARPET_UI = { + sent: 'they take position on the carpet like it was always the plan.', + full: 'the carpet is at capacity. it is a small carpet.', + storm: 'someone on the carpet is done being furniture. they leave, loudly, mid-pose.', + entrance: 'an ENTRANCE. the room parts. worth every minute of the wait.', + recallBusy: 'finish with the one at the rope first.', } as const; /** Shown once, first thirty seconds, then never again. A stranger must be able to play. */ diff --git a/src/scenes/door/DoorScene.ts b/src/scenes/door/DoorScene.ts index b3887bb..94dd7cf 100644 --- a/src/scenes/door/DoorScene.ts +++ b/src/scenes/door/DoorScene.ts @@ -15,11 +15,12 @@ import { DOOR_TUTORIAL, DOOR_UI, BOARD_SHUFFLE_TOASTS, + CARPET_UI, FRANKO_TIPS, PATDOWN_LINES, RULE_RETRACTIONS, } from '../../data/strings/door'; -import { QueueManager } from './QueueManager'; +import { CARPET_TUNING, QueueManager, entranceBonus } from './QueueManager'; import { PatronUpView } from './PatronUpView'; import { IdCardView } from './IdCardView'; import { PhoneWidget } from './PhoneWidget'; @@ -72,6 +73,7 @@ export class DoorScene extends Phaser.Scene { private sobriety!: SobrietyModal; private queueSprites: QueueSprite[] = []; + private carpetSprites: QueueSprite[] = []; private trayCard: Phaser.GameObjects.Container | null = null; private buttons: Record = {}; private vibeBar!: MeterBar; @@ -250,6 +252,17 @@ export class DoorScene extends Phaser.Scene { .text(DOOR_X, 184, 'step inside', { fontFamily: MONO, fontSize: '7px', color: '#6a4a5a' }) .setOrigin(0.5, 0); + // The red carpet: a strip of somewhere-better between rope and door, + // flanked by brass posts. Bodies parked here are rendered by + // syncCarpetSprites; the strip is set dressing. + this.add.rectangle(420, 232, 124, 12, 0x8a1428).setDepth(2); + this.add.rectangle(420, 227, 124, 2, 0xa8283c).setDepth(2); + for (const px of [356, 484]) { + this.add.rectangle(px, 216, 3, 22, 0xc8a24a).setDepth(2); + this.add.rectangle(px, 205, 7, 4, 0xe0c060).setDepth(2); + } + this.add.rectangle(420, 206, 126, 2, 0x7a2438).setDepth(2); // the velvet itself + this.buildRain(); this.buildRope(); } @@ -344,10 +357,15 @@ export class DoorScene extends Phaser.Scene { // verdicts this.buttons.rope = new Button(this, { - x: 442, y: 256, w: 192, h: 24, label: DOOR_UI.rope, size: 9, + x: 442, y: 256, w: 130, h: 24, label: DOOR_UI.rope, size: 9, fill: 0x5a2030, hover: 0x8a2038, onClick: () => this.callNext(), }); + this.buttons.carpet = new Button(this, { + x: 576, y: 256, w: 58, h: 24, label: DOOR_UI.carpet, size: 7, + fill: 0x6a1424, hover: 0x9a2038, + onClick: () => this.callCarpet(), + }); this.buttons.test = new Button(this, { x: 442, y: 284, w: 94, h: 22, label: DOOR_UI.test, size: 7, onClick: () => this.runSobriety(), @@ -446,6 +464,12 @@ export class DoorScene extends Phaser.Scene { this.showToast(DOOR_UI.ropeEmpty); return; } + this.presentUp(p); + } + + /** Everything that happens once SOMEBODY is at the rope — the rope and the + * carpet recall share this path, so a recalled patron is a full patron. */ + private presentUp(p: Patron): void { this.testedThisPatron = false; this.pattedThisPatron = false; // Passback: this exact card already went in tonight. The tell is set HERE — @@ -476,6 +500,79 @@ export class DoorScene extends Phaser.Scene { }); } + // ---- the red carpet ---------------------------------------------------- + + /** The patron at the rope takes position between the brass posts. */ + private callCarpet(): void { + const p = this.queue.patronUp; + if (!p || this.busy) return; + if (this.queue.toCarpet() === null) { + this.showToast(CARPET_UI.full); + return; + } + this.idCard.close(); + this.trayCard?.destroy(); + this.trayCard = null; + this.up.clear(); + this.setVerdictButtons(false); + this.night.sfx?.play('ropeUnhook'); + this.showToast(CARPET_UI.sent); + this.logIncidentDoor('carpet', p.id, 'Sent to the carpet to be seen waiting.'); + } + + /** Click a carpet doll: wave them up to the rope for the actual ruling. */ + private recallCarpet(patronId: string): void { + if (this.busy) return; + if (this.queue.patronUp) { + this.showToast(CARPET_UI.recallBusy); + return; + } + const p = this.queue.recallFromCarpet(patronId); + if (!p) return; + this.presentUp(p); + } + + private logIncidentDoor(kind: string, patronId: string | undefined, detail: string): void { + this.night.bus.emit('incident:log', { clockMin: this.night.state.clockMin, kind, patronId, detail }); + } + + /** The carpet is a view of QueueManager, same discipline as the queue line. */ + private syncCarpetSprites(): void { + const slots = this.queue.carpetSnapshot; + const same = + slots.length === this.carpetSprites.length && + slots.every((s, i) => this.carpetSprites[i]?.patron.id === s.patron.id); + if (!same) { + for (const s of this.carpetSprites) s.image.destroy(); + this.carpetSprites = slots.map((slot, i) => { + const x = 372 + i * 34; + const img = this.add + .image(x, QUEUE_FOOT_Y + 6, renderDoll(this, slot.patron, 'queue')) + .setOrigin(0.5, 1) + .setScale(1.5) + .setDepth(205) + .setInteractive({ useHandCursor: true }); + img.on('pointerdown', () => this.recallCarpet(slot.patron.id)); + img.setData('baseX', x); + return { patron: slot.patron, image: img, link: null }; + }); + } + // Impatience is visible: the sway grows as the patience runs out. + for (let i = 0; i < this.carpetSprites.length; i++) { + const s = this.carpetSprites[i]!; + const slot = slots[i]; + if (!slot) continue; + const fuse = Math.min(1, slot.standMs / slot.patienceMs); + s.image.x = s.image.getData('baseX') + Math.sin(this.elapsedMs / (300 - 140 * fuse)) * (1 + fuse * 3); + } + + for (const p of this.queue.takeStorms()) { + this.showToast(CARPET_UI.storm); + this.night.sfx?.play('doorBang'); + this.logIncidentDoor('carpetStorm', p.id, 'Ran out of patience on the carpet and left, loudly.'); + } + } + /** Nudge the player toward a tab that just became relevant, without switching it. */ private flashTab(tab: ClipboardTab): void { const b = this.tabButtons[tab]; @@ -565,6 +662,14 @@ export class DoorScene extends Phaser.Scene { } this.applyOutcome(p, verdict, outcome); this.applyEncounter(p, verdict); + // The entrance dividend: an admit off the carpet pays for every minute + // they were made to stand there being looked at. + const stoodMs = this.queue.carpetMsFor(p.id); + if (verdict === 'admit' && stoodMs > 0) { + this.night.bus.emit('meters:delta', { vibe: CARPET_TUNING.entranceVibe, hype: entranceBonus(stoodMs) }); + this.time.delayedCall(600, () => this.showToast(CARPET_UI.entrance)); + this.logIncidentDoor('carpetEntrance', p.id, `Admitted after ${Math.round(stoodMs / 1000)}s on the carpet.`); + } this.queue.resolveUp(verdict === 'deny'); // Highlight AFTER the call, never before. Lighting up the broken rules the @@ -923,6 +1028,10 @@ export class DoorScene extends Phaser.Scene { } this.syncQueueSprites(); + this.syncCarpetSprites(); + this.buttons.carpet?.setEnabled( + !this.busy && this.queue.patronUp !== null && this.queue.carpetSnapshot.length < CARPET_TUNING.slots, + ); const rope = this.buttons.rope; if (rope) { @@ -981,6 +1090,8 @@ export class DoorScene extends Phaser.Scene { } private teardown(): void { + for (const s of this.carpetSprites) s.image.destroy(); + this.carpetSprites = []; for (const off of this.offs) off(); this.offs = []; this.phone?.destroy(); diff --git a/src/scenes/door/QueueManager.ts b/src/scenes/door/QueueManager.ts index 099f03d..59481a9 100644 --- a/src/scenes/door/QueueManager.ts +++ b/src/scenes/door/QueueManager.ts @@ -69,6 +69,43 @@ export const QUEUE_TUNING = { 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[]; @@ -106,6 +143,13 @@ export class QueueManager { /** 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, @@ -134,6 +178,7 @@ export class QueueManager { 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 @@ -222,6 +267,7 @@ export class QueueManager { 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) { @@ -256,6 +302,69 @@ export class QueueManager { } } + /** 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. */ + carpetMsFor(patronId: string): number { + return this.carpetStand.get(patronId) ?? 0; + } + /** The Rope. Nothing steps up until the player says so. */ callNext(): Patron | null { if (this.up !== null) return null; diff --git a/tests/queueManager.test.ts b/tests/queueManager.test.ts index 3d0c419..d8a2027 100644 --- a/tests/queueManager.test.ts +++ b/tests/queueManager.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { EventBus } from '../src/core/EventBus'; import { SeededRNG } from '../src/core/SeededRNG'; import { _resetPatronSerial } from '../src/patrons/generator'; -import { QueueManager, QUEUE_TUNING } from '../src/scenes/door/QueueManager'; +import { CARPET_TUNING, QueueManager, QUEUE_TUNING, entranceBonus } from '../src/scenes/door/QueueManager'; import type { Patron } from '../src/data/types'; const NIGHT = new Date('2026-07-18T00:00:00'); @@ -289,3 +289,85 @@ describe('QueueManager — determinism', () => { expect(nightFingerprint(1)).not.toEqual(nightFingerprint(2)); }); }); + +describe('the red carpet', () => { + const carpetSetup = () => { + const s = setup(9); + fillTo(s.q, 6); + return s; + }; + + it('toCarpet parks the up patron and frees the rope', () => { + const { q } = carpetSetup(); + const p = q.callNext()!; + expect(q.toCarpet()?.id).toBe(p.id); + expect(q.patronUp).toBeNull(); + expect(q.carpetSnapshot.length).toBe(1); + expect(q.callNext()).not.toBeNull(); // the rope keeps working + }); + + it('the brass posts only stretch to CARPET_TUNING.slots', () => { + const { q } = carpetSetup(); + for (let i = 0; i < CARPET_TUNING.slots; i++) { + expect(q.callNext()).not.toBeNull(); + expect(q.toCarpet()).not.toBeNull(); + } + expect(q.callNext()).not.toBeNull(); + expect(q.toCarpet()).toBeNull(); // full — the fourth stays at the rope + expect(q.patronUp).not.toBeNull(); + }); + + it('bodies on display pay hype per second, on top of the queue theatre', () => { + const { q, total } = carpetSetup(); + q.callNext(); + q.toCarpet(); + // Rope free + line waiting: ordinary queue theatre still flows. The + // carpet's dividend is ADDITIVE — that is the whole point of the posts. + const before = total('hype'); + q.update(1000); + expect(total('hype') - before).toBeCloseTo( + CARPET_TUNING.hypePerSecPerBody + QUEUE_TUNING.hypePerSec, + 3, + ); + }); + + it('patience runs out: a storm-off, aggro, and the slot comes back', () => { + const { q, total } = carpetSetup(); + const p = q.callNext()!; + q.toCarpet(); + const before = total('aggro'); + // Worst-case patience is base-max × the biggest archetype scale. + q.update(CARPET_TUNING.patienceSecBase[1] * 1.8 * 1000 + 1000); + expect(q.carpetSnapshot.length).toBe(0); + expect(q.takeStorms().map((x) => x.id)).toEqual([p.id]); + expect(q.takeStorms()).toEqual([]); // drained means drained + expect(total('aggro') - before).toBeGreaterThanOrEqual(CARPET_TUNING.stormAggro); + }); + + it('recall puts them back up and remembers the stand for the entrance', () => { + const { q } = carpetSetup(); + const p = q.callNext()!; + q.toCarpet(); + q.update(3000); + expect(q.recallFromCarpet('nobody')).toBeNull(); + const back = q.recallFromCarpet(p.id); + expect(back?.id).toBe(p.id); + expect(q.patronUp?.id).toBe(p.id); + expect(q.carpetMsFor(p.id)).toBe(3000); + }); + + it('recall refuses while somebody else is at the rope', () => { + const { q } = carpetSetup(); + const p = q.callNext()!; + q.toCarpet(); + q.callNext(); + expect(q.recallFromCarpet(p.id)).toBeNull(); + expect(q.carpetSnapshot.length).toBe(1); + }); + + it('entranceBonus scales with the stand and caps', () => { + expect(entranceBonus(0)).toBe(0); + expect(entranceBonus(10_000)).toBeCloseTo(10 * CARPET_TUNING.entranceHypePerSec); + expect(entranceBonus(10_000_000)).toBe(CARPET_TUNING.entranceHypeCap); + }); +});