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:
parent
b1e2f038bc
commit
9827e4ebe6
@ -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
65
src/data/venues.ts
Normal 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}`;
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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, () => {
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user