The red carpet: 3-slot velvet holding pen — park them, they pay hype; patience by archetype, storms, and the entrance dividend on admit

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-21 02:32:15 +10:00
parent b7bb8f49ab
commit 5ebdfc61eb
5 changed files with 348 additions and 3 deletions

View File

@ -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 3055s × 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.

View File

@ -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. */

View File

@ -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<string, Button> = {};
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();

View File

@ -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<Record<import('../../data/types').Archetype, number>>,
/** 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<string, number>();
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;

View File

@ -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);
});
});