Venue ladder: Royal→Voltage→Elevate with promotion flow, per-venue knobs, sim coverage; yard rain + booth DJ

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-20 15:53:52 +10:00
parent b1e2f038bc
commit 9827e4ebe6
11 changed files with 267 additions and 15 deletions

View File

@ -1039,3 +1039,39 @@ space; bar run with amber shelf/taps/glassie racks/posters.
**Next:** John runs the §5 queue (I process + deploy as they land); the two
human passes (ear, economy feel) still open; then venue 2 planning can reuse
the whole PROPS/LIGHTS system as-is.
---
### SESSION — VENUE LADDER (Fable, solo) — 2026-07-20 17:10
**Branch/commits:** main; deployed live.
**Done — "WEEK SURVIVED" finally leads somewhere:**
- **`data/venues.ts`**: The Royal → Voltage → Elevate (design §6), each a week
of Thu→Sat expressed as knobs on existing systems: licensed capacity
(80/110/70), arrivalScale on the curve (0.8/1.0/1.1), Dazza ruleLimit
(4/8/8 — a dive doesn't run the full book), inspector (off at the Royal).
- **Promotion flow**: survive the week → promoted, next venue, dates advance a
calendar week, heat strikes CLEAR (new venue, new licence — regulars and
met-encounters carry: same town). Survive Elevate → THE SEASON IS DONE.
Licence pulled anywhere still kills the run. Each venue-week gets its own
seed block (runSeed + venueIndex*1009 + night*101).
- Knobs threaded cleanly: arrivalScale → QueueManager → nextArrivalGapMin;
ruleLimit → dazzaSchedule (chronological prefix of the rule book, live-rule
cap applies on top); inspector gated in NightScene.onVerdict. Summary names
venues and sells the promotion with Dazza's pitch line.
- **Sim knows venues**: simulateNight takes {arrivalScale, licensed}; new band
asserts The Royal is a measurably gentler week than Voltage for the same
careful hands (and still clock-survivable).
- **Floor garnish**: rain now falls inside the smoking yard (and only there —
it keys off 'yard' tiles), and there is a DJ in the booth: headphones,
hoodie, bobbing on the downbeat, paid in exposure.
**Verified in-browser end-to-end**: 3 Royal nights → promotion → Voltage boots
with capacity 110 on 2026-07-23; booth screenshot shows decks/mixer/DJ under
the torch. **Tests: 638. Deployed + live 200.**
**Broke / known-wonky:**
- Elevate is currently "Voltage but tighter" (smaller room, hotter curve). Its
design identity (VIP politics, influencer density) wants a content pass —
archetype weights per venue would be the natural next knob.
- NIGHT_DATES base stays exported for tests; live dates derive via nightDateFor.
- The bot-run paperwork in verification clicked through reports without
reading them, exactly like Dazza.
**Next:** venue-flavoured archetype mixes (Elevate's identity), John's art
queue (SPACES §5), the two human passes (ear, economy feel) still open.

65
src/data/venues.ts Normal file
View File

@ -0,0 +1,65 @@
// The venue ladder (design §6): each venue is a week of Thu→Fri→Sat, and a
// difficulty tier expressed as knobs on systems that already exist. Survive a
// week, get the next venue's door. Three strikes anywhere, the run dies.
export interface VenueDef {
id: string;
name: string;
/** Dazza's blurb on the promotion screen. */
pitch: string;
licensed: number;
/** Multiplies the arrival curve — how hard the queue slams. */
arrivalScale: number;
/**
* How many of Dazza's rule texts fire this week (chronological prefix).
* The live-rule cap (dressCode.ACTIVE_RULE_CAP) applies on top.
*/
ruleLimit: number;
/** Does the plainclothes inspector work this venue? */
inspector: boolean;
}
export const VENUES: readonly VenueDef[] = [
{
id: 'theRoyal',
name: 'The Royal',
pitch: 'a dive with a dance floor. nobody checks anything. u will',
licensed: 80,
arrivalScale: 0.8,
ruleLimit: 4,
inspector: false,
},
{
id: 'voltage',
name: 'Voltage',
pitch: 'valley institution. full rulebook. the inspector drinks here. lemonade',
licensed: 110,
arrivalScale: 1.0,
ruleLimit: 8,
inspector: true,
},
{
id: 'elevate',
name: 'Elevate',
pitch: 'rooftop. small room, big opinions. every second person is a VIP apparently',
licensed: 70,
arrivalScale: 1.1,
ruleLimit: 8,
inspector: true,
},
];
export const TOTAL_VENUES = VENUES.length;
export function venueByIndex(i: number): VenueDef {
return VENUES[Math.max(0, Math.min(VENUES.length - 1, i))]!;
}
/** Week `w` (venue index) shifts the base night dates forward 7 days per week. */
export function nightDateFor(baseIso: string, venueIndex: number): string {
const [y = '2026', m = '07', d = '16'] = baseIso.split('-');
const date = new Date(Number(y), Number(m) - 1, Number(d) + venueIndex * 7);
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
return `${date.getFullYear()}-${mm}-${dd}`;
}

View File

@ -107,6 +107,7 @@ export class DoorScene extends Phaser.Scene {
this.night.rng,
this.night.nightDate,
this.night.seenEncounters,
this.night.venue.arrivalScale,
);
this.up = new PatronUpView(this, UP_X, UP_FOOT_Y);
this.idCard = new IdCardView(this, this.night.nightDate);

View File

@ -13,6 +13,7 @@ import { Sfx } from '../../audio/Sfx';
import type { DeferredHit } from '../../rules/doorTypes';
import type { GameState, HeatStrike, NightState, Patron, Verdict, VerdictOutcome } from '../../data/types';
import { nextDazza } from './dazzaSchedule';
import { TOTAL_VENUES, nightDateFor, venueByIndex, type VenueDef } from '../../data/venues';
import { observe, startWatch, type InspectorWatch, type VenueBreaches } from '../../rules/inspector';
// Phase-2: the night-flow machine. Door and Floor both run for the whole night;
@ -20,6 +21,7 @@ import { observe, startWatch, type InspectorWatch, type VenueBreaches } from '..
// meters, the audio engine (and the location filter that goes with it), Kayden's
// stamp accuracy, the radio, and the run-level save. It still renders nothing.
/** The Royal's licence — kept for tests/back-compat; live nights use the venue def. */
export const LICENSED_CAPACITY = 80;
const VIBE_SAMPLE_EVERY_MIN = 5;
@ -66,10 +68,15 @@ export interface RunInfo {
game: GameState;
nightIndex: number;
totalNights: number;
venue: VenueDef;
/** the week is done and a HARDER venue wants you (venue ladder, design §6) */
promotedTo: VenueDef | null;
/** licence pulled — the run is dead, only a fresh one remains */
runOver: boolean;
/** survived all three nights */
weekDone: boolean;
/** survived every week at every venue — the season is complete */
seasonDone: boolean;
}
/** Everything the door and floor need from the night. Passed by reference. */
@ -87,6 +94,8 @@ export interface NightContext {
reportQueue(length: number): void;
/** Scripted encounters this RUN has already met (cross-night repeat gating). */
seenEncounters: readonly string[];
/** This week's venue — capacity/pressure knobs (venue ladder, design §6). */
venue: VenueDef;
/** Door/Floor ask to move the player; the night decides and flips the scenes. */
requestLocation(loc: 'door' | 'floor'): void;
}
@ -104,6 +113,7 @@ export class NightScene extends Phaser.Scene {
private run!: GameState;
private seed = 4207;
private nightIndex = 0;
private venue!: VenueDef;
private ended = false;
private deferred: DeferredHit[] = [];
private readonly firedDazza = new Set<string>();
@ -134,8 +144,10 @@ export class NightScene extends Phaser.Scene {
}
this.nightIndex = data.nightIndex ?? this.run.nightIndex;
if (this.nightIndex >= TOTAL_NIGHTS) this.nightIndex = 0;
// Each night is its own deterministic world derived from the run seed.
this.seed = this.run.seed + this.nightIndex * 101;
this.venue = venueByIndex(this.run.venueIndex);
// Each night is its own deterministic world derived from the run seed —
// and each venue-week its own block of them.
this.seed = this.run.seed + this.run.venueIndex * 1009 + this.nightIndex * 101;
}
create(): void {
@ -150,9 +162,12 @@ export class NightScene extends Phaser.Scene {
this.bus = new EventBus();
this.rng = new SeededRNG(this.seed);
const clockCfg: ClockConfig = { ...DEFAULT_CLOCK, nightDate: NIGHT_DATES[this.nightIndex]! };
const clockCfg: ClockConfig = {
...DEFAULT_CLOCK,
nightDate: nightDateFor(NIGHT_DATES[this.nightIndex]!, this.run.venueIndex),
};
this.clock = new GameClock(this.bus, clockCfg);
this.state = freshNightState('theRoyal', this.nightIndex, LICENSED_CAPACITY);
this.state = freshNightState(this.venue.id, this.nightIndex, this.venue.licensed);
// Heat is run-scoped: strikes from earlier nights are already on the licence.
this.state.heatStrikes.push(...this.run.heatStrikes);
this.meters = new Meters(this.bus, this.state);
@ -237,6 +252,7 @@ export class NightScene extends Phaser.Scene {
this.queueLength = length;
},
seenEncounters: this.run.seenEncounters ?? [],
venue: this.venue,
requestLocation: (loc) => this.setLocation(loc),
};
@ -281,7 +297,8 @@ export class NightScene extends Phaser.Scene {
this.state.location === 'door' ? true : this.rng.stream('kaydenStamp').chance(KAYDEN_STAMP_CHANCE);
// The inspector reads as a boring punter and is scored as one — the ONLY
// thing that marks them is that admitting one starts a clock (§4.2).
if (patron.archetype === 'inspector' && this.inspector === null) {
// ...and only at venues the inspector actually works (venue ladder).
if (patron.archetype === 'inspector' && this.inspector === null && this.venue.inspector) {
this.inspector = startWatch(patron.id, this.state.clockMin);
}
if (patron.age < 18) this.minorsInside++;
@ -405,6 +422,7 @@ export class NightScene extends Phaser.Scene {
queueLength: this.queueLength,
insideCount: this.state.capacity.inside,
fired: this.firedDazza,
ruleLimit: this.venue.ruleLimit,
});
if (!line) return;
this.firedDazza.add(line.id);
@ -437,12 +455,21 @@ export class NightScene extends Phaser.Scene {
const runOver = reason === 'licence';
const survived = reason === 'clock';
const weekDone = survived && this.nightIndex >= TOTAL_NIGHTS - 1;
const seasonDone = weekDone && this.run.venueIndex >= TOTAL_VENUES - 1;
this.run.heatStrikes = [...this.state.heatStrikes];
// pastReports is written by IncidentReportScene now, not here: Phase 4
// audits what the player CLAIMED happened, and a raw truth dump would have
// nothing to catch them out with.
if (survived) this.run.nightIndex = this.nightIndex + 1;
if (runOver || weekDone) {
if (weekDone && !seasonDone) {
// PROMOTED: next venue, fresh week, fresh licence. Heat is per-venue —
// the new place has not heard of you (yet). Regulars and met-encounters
// carry across: it is the same town.
this.run.venueIndex += 1;
this.run.nightIndex = 0;
this.run.heatStrikes = [];
}
if (runOver || seasonDone) {
clearSave();
} else {
saveGame(this.run);
@ -451,8 +478,11 @@ export class NightScene extends Phaser.Scene {
game: this.run,
nightIndex: this.nightIndex,
totalNights: TOTAL_NIGHTS,
venue: this.venue,
promotedTo: weekDone && !seasonDone ? venueByIndex(this.run.venueIndex) : null,
runOver,
weekDone,
seasonDone,
};
this.time.delayedCall(reason === 'clock' ? 600 : 1400, () => {

View File

@ -63,7 +63,7 @@ export class NightSummaryScene extends Phaser.Scene {
W / 2,
46,
failed
? `${this.state.venueId} · ${nightName} · night over early`
? `${this.run?.venue.name ?? this.state.venueId} · ${nightName} · night over early`
: `${SUMMARY_UI.subtitle}${nightName ? ` · ${nightName}` : ''}`,
{
fontFamily: MONO,
@ -115,12 +115,20 @@ export class NightSummaryScene extends Phaser.Scene {
next: () => this.scene.start('Night', { freshRun: true, seed: this.log.seed + 13 }),
};
}
if (r.weekDone) {
if (r.seasonDone) {
return {
prompt: 'WEEK SURVIVED. click for a new week (new seed, same rain)',
prompt: 'THE SEASON IS DONE. every door in the valley, held. click for a fresh run',
next: () => this.scene.start('Night', { freshRun: true, seed: this.log.seed + 7 }),
};
}
if (r.weekDone && r.promotedTo) {
const v = r.promotedTo;
return {
// Dazza's pitch sells the next rung — the ladder is the reward.
prompt: `WEEK SURVIVED — PROMOTED: ${v.name.toUpperCase()}. "${v.pitch}"`,
next: () => this.scene.start('Night', {}),
};
}
const failedNight = this.log.endReason !== 'clock';
const nextName = failedNight
? (NIGHT_NAMES[r.nightIndex] ?? 'the same night')

View File

@ -105,6 +105,8 @@ export class QueueManager {
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,
) {
this.arrivalRng = rng.stream('arrivals');
this.coupleRng = rng.stream('couples');
@ -180,7 +182,7 @@ export class QueueManager {
while (clockMin >= this.nextArrivalMin) {
this.spawnArrival();
const gap = nextArrivalGapMin(this.nextArrivalMin, this.arrivalRng.next());
const gap = nextArrivalGapMin(this.nextArrivalMin, this.arrivalRng.next(), this.arrivalScale);
this.nextArrivalMin += Math.max(1, Math.round(gap));
}
}

View File

@ -63,8 +63,11 @@ export function arrivalsPerMin(clockMin: number): number {
* an RngStream; the exponential draw is what makes arrivals clump three at once
* then a lull instead of ticking over like a metronome.
*/
export function nextArrivalGapMin(clockMin: number, roll: number): number {
const rate = arrivalsPerMin(clockMin);
export function nextArrivalGapMin(clockMin: number, roll: number, scale = 1): number {
// `scale` is the venue's arrival multiplier (ladder, design §6): a dive runs
// at 0.8x this curve, the rooftop above 1x. Guarded so a junk scale reads as 1.
const s = Number.isFinite(scale) && scale > 0 ? scale : 1;
const rate = arrivalsPerMin(clockMin) * s;
if (!(rate > 0)) return NO_ARRIVALS_GAP_MIN;
const safeRoll = Number.isFinite(roll) ? Math.min(Math.max(roll, 0), 0.999999) : 0;

View File

@ -11,6 +11,11 @@ export interface DazzaWorld {
queueLength: number;
insideCount: number;
fired: ReadonlySet<string>;
/**
* How many rule-bearing texts this venue's week uses (chronological prefix
* of the rule book venue ladder, design §6). Omitted = all of them.
*/
ruleLimit?: number;
}
/** Conditions for the non-rule texts, keyed by DazzaLine id. */
@ -23,9 +28,18 @@ const MOOD: Record<string, (w: DazzaWorld) => boolean> = {
};
export function dazzaDue(clockMin: number, world: DazzaWorld): DazzaLine[] {
// The venue's rule book: the first N rule texts by fromMin. A dive doesn't
// run the full eight; the ladder's later venues do.
const limit = world.ruleLimit ?? Number.POSITIVE_INFINITY;
const ruleOrder = DAZZA_TEXTS.filter((l) => l.ruleId !== undefined)
.slice()
.sort((a, b) => a.fromMin - b.fromMin);
const allowedRules = new Set(ruleOrder.slice(0, limit).map((l) => l.id));
const due: DazzaLine[] = [];
for (const line of DAZZA_TEXTS) {
if (world.fired.has(line.id)) continue;
if (line.ruleId !== undefined && !allowedRules.has(line.id)) continue;
if (clockMin < line.fromMin) continue;
const gate = MOOD[line.id];
if (gate && !gate(world)) continue;

View File

@ -86,6 +86,9 @@ export class FloorView {
/** glow images that flicker (dunny fluoro, booth lamp) with their base alpha */
private readonly flickerLights: Array<{ img: Phaser.GameObjects.Image; base: number; phase: number }> = [];
private beatKick = 0;
private yardRain: Phaser.GameObjects.Particles.ParticleEmitter | null = null;
private dj: Phaser.GameObjects.Image | null = null;
private djBob = 0;
// Render-only shove displacement by patron id. The sim owns agent.x/agent.y;
// syncAgents adds this on top so a shoving pair jitters without the crowd
// simulation ever hearing about it.
@ -142,6 +145,8 @@ export class FloorView {
this.buildProps();
this.buildLights();
this.buildYardRain();
this.buildDj();
this.maskG = scene.make.graphics({}, false);
this.mask = this.maskG.createGeometryMask();
@ -214,9 +219,59 @@ export class FloorView {
}
}
/** Rain falls INSIDE the smoking yard — it is the one open-sky pocket. */
private buildYardRain(): void {
const yard = { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity };
for (let ty = 0; ty < this.map.height; ty++) {
for (let tx = 0; tx < this.map.width; tx++) {
if (tileAt(this.map, tx, ty) !== 'yard') continue;
yard.x0 = Math.min(yard.x0, tx * TILE);
yard.y0 = Math.min(yard.y0, ty * TILE);
yard.x1 = Math.max(yard.x1, (tx + 1) * TILE);
yard.y1 = Math.max(yard.y1, (ty + 1) * TILE);
}
}
if (!Number.isFinite(yard.x0)) return; // no yard on this map
const g = this.scene.add.graphics();
g.fillStyle(0x8899bb, 1);
g.fillRect(0, 0, 1, 3);
g.generateTexture('floor:rain', 1, 3);
g.destroy();
this.yardRain = this.scene.add.particles(0, 0, 'floor:rain', {
x: { min: yard.x0, max: yard.x1 },
y: yard.y0 - 4,
lifespan: 700,
speedY: { min: 90, max: 130 },
speedX: { min: -8, max: -2 },
quantity: 1,
alpha: { start: 0.5, end: 0.1 },
});
this.yardRain.setDepth(D_NEON);
}
/** The DJ. Sealed in the booth, bobbing on the downbeat, paid in exposure. */
private buildDj(): void {
const key = 'floor:dj';
if (!this.scene.textures.exists(key)) {
const canvas = this.scene.textures.createCanvas(key, 12, 14);
if (canvas) {
const c = canvas.getContext();
c.fillStyle = '#1c1c26'; c.fillRect(2, 6, 8, 8); // hoodie shoulders
c.fillStyle = '#d9a066'; c.fillRect(4, 2, 4, 5); // head
c.fillStyle = '#15151c'; c.fillRect(2, 2, 8, 2); // headphones band
c.fillStyle = '#3060c0'; c.fillRect(2, 3, 2, 3); c.fillRect(8, 3, 2, 3); // cans
canvas.refresh();
}
}
// Behind the decks (PROPS puts the gear on the booth's west edge at tx 53;
// the DJ stands one tile east of it, centred on the mixer row).
this.dj = this.scene.add.image(55.5 * TILE, 22.5 * TILE, key).setDepth(D_TILES + 2);
}
/** Call from the scene's beat:tick handler — the dance floor breathes in time. */
onBeat(beatIndex: number): void {
this.beatKick = beatIndex % 4 === 0 ? 1 : 0.6;
this.djBob = 1;
}
/** Per-frame light life: beat decay + fluoro flicker. */
@ -225,6 +280,10 @@ export class FloorView {
this.beatKick = Math.max(0, this.beatKick - dtMs / 260);
for (const img of this.beatLights) img.setAlpha(0.22 + 0.3 * this.beatKick);
}
if (this.dj && this.djBob > 0) {
this.djBob = Math.max(0, this.djBob - dtMs / 200);
this.dj.setY(22.5 * TILE - 2 * this.djBob);
}
const t = this.scene.time.now;
for (const f of this.flickerLights) {
// Two incommensurate sines + a hard dropout band reads as dying fluoro.
@ -380,6 +439,10 @@ export class FloorView {
this.dark.destroy();
this.tileImg.destroy();
for (const n of this.neon) n.destroy();
this.yardRain?.destroy();
this.yardRain = null;
this.dj?.destroy();
this.dj = null;
for (const pr of this.propImgs) pr.destroy();
this.propImgs.length = 0;
this.beatLights.length = 0;

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { CAREFUL, DAWDLER, SLOPPY, simulateNight, type NightMetrics } from './economy';
import { VENUES } from '../../src/data/venues';
// The feel, as numbers. Phase-1/Phase-3 both measured the same failures:
// vibe pinned at ~100 by 10 PM, aggro flat at 0, hype a pure ratchet, dawdling
@ -75,3 +76,20 @@ describe('door economy (bot nights, 5 seeds)', () => {
expect(avg(careful.map((r) => r.heatStrikes))).toBeLessThan(1);
});
});
describe('the venue ladder changes the pressure (design §6)', () => {
it('The Royal is a gentler week than Voltage for the same careful hands', () => {
const royal = VENUES[0]!;
const seeds = [4207, 4208, 4209];
const atRoyal = seeds.map((s) =>
simulateNight(s, CAREFUL, { arrivalScale: royal.arrivalScale, licensed: royal.licensed }),
);
const atVoltage = seeds.map((s) => simulateNight(s, CAREFUL));
const avgOf = (xs: number[]): number => xs.reduce((a, b) => a + b, 0) / xs.length;
// Fewer arrivals -> a visibly calmer slam and a survivable night.
expect(avgOf(atRoyal.map((r) => r.slamQueueAvg))).toBeLessThan(
avgOf(atVoltage.map((r) => r.slamQueueAvg)),
);
for (const r of atRoyal) expect(r.endReason).toBe('clock');
});
});

View File

@ -50,14 +50,26 @@ export interface NightMetrics {
const TICK_MS = 100;
export function simulateNight(seed: number, policy: BotPolicy): NightMetrics {
export interface SimVenue {
arrivalScale: number;
licensed: number;
}
/** Voltage-equivalent knobs — the tier the feel-bands are calibrated to. */
export const SIM_DEFAULT_VENUE: SimVenue = { arrivalScale: 1.0, licensed: 80 };
export function simulateNight(
seed: number,
policy: BotPolicy,
venue: SimVenue = SIM_DEFAULT_VENUE,
): NightMetrics {
_resetPatronSerial();
const bus = new EventBus();
const rng = new SeededRNG(seed);
const clock = new GameClock(bus, DEFAULT_CLOCK);
const state = freshNightState('theRoyal', 0, 80);
const state = freshNightState('sim', 0, venue.licensed);
const meters = new Meters(bus, state);
const queue = new QueueManager(bus, rng, clock.nightDate);
const queue = new QueueManager(bus, rng, clock.nightDate, [], venue.arrivalScale);
const deferred: DeferredHit[] = [];
const lapRng = rng.stream('dazzaLaps');
// Mirrors CrowdSim's natural churn: admitted patrons go home after 50-140