From 9827e4ebe6ff50f7d8ab28ef279623224fe49935 Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 20 Jul 2026 15:53:52 +1000 Subject: [PATCH] =?UTF-8?q?Venue=20ladder:=20Royal=E2=86=92Voltage?= =?UTF-8?q?=E2=86=92Elevate=20with=20promotion=20flow,=20per-venue=20knobs?= =?UTF-8?q?,=20sim=20coverage;=20yard=20rain=20+=20booth=20DJ?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- LANEHANDOVER.md | 36 +++++++++++++++ src/data/venues.ts | 65 ++++++++++++++++++++++++++++ src/scenes/door/DoorScene.ts | 1 + src/scenes/door/NightScene.ts | 42 +++++++++++++++--- src/scenes/door/NightSummaryScene.ts | 14 ++++-- src/scenes/door/QueueManager.ts | 4 +- src/scenes/door/arrivalCurve.ts | 7 ++- src/scenes/door/dazzaSchedule.ts | 14 ++++++ src/scenes/floor/FloorView.ts | 63 +++++++++++++++++++++++++++ tests/sim/economy.test.ts | 18 ++++++++ tests/sim/economy.ts | 18 ++++++-- 11 files changed, 267 insertions(+), 15 deletions(-) create mode 100644 src/data/venues.ts diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index b88e2b0..a510e90 100644 --- a/LANEHANDOVER.md +++ b/LANEHANDOVER.md @@ -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. diff --git a/src/data/venues.ts b/src/data/venues.ts new file mode 100644 index 0000000..0511fed --- /dev/null +++ b/src/data/venues.ts @@ -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}`; +} diff --git a/src/scenes/door/DoorScene.ts b/src/scenes/door/DoorScene.ts index 0fc75cc..d1d53a6 100644 --- a/src/scenes/door/DoorScene.ts +++ b/src/scenes/door/DoorScene.ts @@ -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); diff --git a/src/scenes/door/NightScene.ts b/src/scenes/door/NightScene.ts index b90347f..645a961 100644 --- a/src/scenes/door/NightScene.ts +++ b/src/scenes/door/NightScene.ts @@ -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(); @@ -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, () => { diff --git a/src/scenes/door/NightSummaryScene.ts b/src/scenes/door/NightSummaryScene.ts index 4b98198..3574b40 100644 --- a/src/scenes/door/NightSummaryScene.ts +++ b/src/scenes/door/NightSummaryScene.ts @@ -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') diff --git a/src/scenes/door/QueueManager.ts b/src/scenes/door/QueueManager.ts index 721bf67..4d7f9b2 100644 --- a/src/scenes/door/QueueManager.ts +++ b/src/scenes/door/QueueManager.ts @@ -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)); } } diff --git a/src/scenes/door/arrivalCurve.ts b/src/scenes/door/arrivalCurve.ts index bdd1d68..f36ec87 100644 --- a/src/scenes/door/arrivalCurve.ts +++ b/src/scenes/door/arrivalCurve.ts @@ -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; diff --git a/src/scenes/door/dazzaSchedule.ts b/src/scenes/door/dazzaSchedule.ts index a0a4795..b5ce2b9 100644 --- a/src/scenes/door/dazzaSchedule.ts +++ b/src/scenes/door/dazzaSchedule.ts @@ -11,6 +11,11 @@ export interface DazzaWorld { queueLength: number; insideCount: number; fired: ReadonlySet; + /** + * 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 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; diff --git a/src/scenes/floor/FloorView.ts b/src/scenes/floor/FloorView.ts index 169883a..c7ab8e8 100644 --- a/src/scenes/floor/FloorView.ts +++ b/src/scenes/floor/FloorView.ts @@ -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; diff --git a/tests/sim/economy.test.ts b/tests/sim/economy.test.ts index a9db954..ea1e0e9 100644 --- a/tests/sim/economy.test.ts +++ b/tests/sim/economy.test.ts @@ -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'); + }); +}); diff --git a/tests/sim/economy.ts b/tests/sim/economy.ts index 1a5a7db..b32eb8e 100644 --- a/tests/sim/economy.ts +++ b/tests/sim/economy.ts @@ -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