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
|
**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
|
human passes (ear, economy feel) still open; then venue 2 planning can reuse
|
||||||
the whole PROPS/LIGHTS system as-is.
|
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.rng,
|
||||||
this.night.nightDate,
|
this.night.nightDate,
|
||||||
this.night.seenEncounters,
|
this.night.seenEncounters,
|
||||||
|
this.night.venue.arrivalScale,
|
||||||
);
|
);
|
||||||
this.up = new PatronUpView(this, UP_X, UP_FOOT_Y);
|
this.up = new PatronUpView(this, UP_X, UP_FOOT_Y);
|
||||||
this.idCard = new IdCardView(this, this.night.nightDate);
|
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 { DeferredHit } from '../../rules/doorTypes';
|
||||||
import type { GameState, HeatStrike, NightState, Patron, Verdict, VerdictOutcome } from '../../data/types';
|
import type { GameState, HeatStrike, NightState, Patron, Verdict, VerdictOutcome } from '../../data/types';
|
||||||
import { nextDazza } from './dazzaSchedule';
|
import { nextDazza } from './dazzaSchedule';
|
||||||
|
import { TOTAL_VENUES, nightDateFor, venueByIndex, type VenueDef } from '../../data/venues';
|
||||||
import { observe, startWatch, type InspectorWatch, type VenueBreaches } from '../../rules/inspector';
|
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;
|
// 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
|
// 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.
|
// 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;
|
export const LICENSED_CAPACITY = 80;
|
||||||
const VIBE_SAMPLE_EVERY_MIN = 5;
|
const VIBE_SAMPLE_EVERY_MIN = 5;
|
||||||
|
|
||||||
@ -66,10 +68,15 @@ export interface RunInfo {
|
|||||||
game: GameState;
|
game: GameState;
|
||||||
nightIndex: number;
|
nightIndex: number;
|
||||||
totalNights: 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 */
|
/** licence pulled — the run is dead, only a fresh one remains */
|
||||||
runOver: boolean;
|
runOver: boolean;
|
||||||
/** survived all three nights */
|
/** survived all three nights */
|
||||||
weekDone: boolean;
|
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. */
|
/** Everything the door and floor need from the night. Passed by reference. */
|
||||||
@ -87,6 +94,8 @@ export interface NightContext {
|
|||||||
reportQueue(length: number): void;
|
reportQueue(length: number): void;
|
||||||
/** Scripted encounters this RUN has already met (cross-night repeat gating). */
|
/** Scripted encounters this RUN has already met (cross-night repeat gating). */
|
||||||
seenEncounters: readonly string[];
|
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. */
|
/** Door/Floor ask to move the player; the night decides and flips the scenes. */
|
||||||
requestLocation(loc: 'door' | 'floor'): void;
|
requestLocation(loc: 'door' | 'floor'): void;
|
||||||
}
|
}
|
||||||
@ -104,6 +113,7 @@ export class NightScene extends Phaser.Scene {
|
|||||||
private run!: GameState;
|
private run!: GameState;
|
||||||
private seed = 4207;
|
private seed = 4207;
|
||||||
private nightIndex = 0;
|
private nightIndex = 0;
|
||||||
|
private venue!: VenueDef;
|
||||||
private ended = false;
|
private ended = false;
|
||||||
private deferred: DeferredHit[] = [];
|
private deferred: DeferredHit[] = [];
|
||||||
private readonly firedDazza = new Set<string>();
|
private readonly firedDazza = new Set<string>();
|
||||||
@ -134,8 +144,10 @@ export class NightScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
this.nightIndex = data.nightIndex ?? this.run.nightIndex;
|
this.nightIndex = data.nightIndex ?? this.run.nightIndex;
|
||||||
if (this.nightIndex >= TOTAL_NIGHTS) this.nightIndex = 0;
|
if (this.nightIndex >= TOTAL_NIGHTS) this.nightIndex = 0;
|
||||||
// Each night is its own deterministic world derived from the run seed.
|
this.venue = venueByIndex(this.run.venueIndex);
|
||||||
this.seed = this.run.seed + this.nightIndex * 101;
|
// 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 {
|
create(): void {
|
||||||
@ -150,9 +162,12 @@ export class NightScene extends Phaser.Scene {
|
|||||||
|
|
||||||
this.bus = new EventBus();
|
this.bus = new EventBus();
|
||||||
this.rng = new SeededRNG(this.seed);
|
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.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.
|
// Heat is run-scoped: strikes from earlier nights are already on the licence.
|
||||||
this.state.heatStrikes.push(...this.run.heatStrikes);
|
this.state.heatStrikes.push(...this.run.heatStrikes);
|
||||||
this.meters = new Meters(this.bus, this.state);
|
this.meters = new Meters(this.bus, this.state);
|
||||||
@ -237,6 +252,7 @@ export class NightScene extends Phaser.Scene {
|
|||||||
this.queueLength = length;
|
this.queueLength = length;
|
||||||
},
|
},
|
||||||
seenEncounters: this.run.seenEncounters ?? [],
|
seenEncounters: this.run.seenEncounters ?? [],
|
||||||
|
venue: this.venue,
|
||||||
requestLocation: (loc) => this.setLocation(loc),
|
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);
|
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
|
// 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).
|
// 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);
|
this.inspector = startWatch(patron.id, this.state.clockMin);
|
||||||
}
|
}
|
||||||
if (patron.age < 18) this.minorsInside++;
|
if (patron.age < 18) this.minorsInside++;
|
||||||
@ -405,6 +422,7 @@ export class NightScene extends Phaser.Scene {
|
|||||||
queueLength: this.queueLength,
|
queueLength: this.queueLength,
|
||||||
insideCount: this.state.capacity.inside,
|
insideCount: this.state.capacity.inside,
|
||||||
fired: this.firedDazza,
|
fired: this.firedDazza,
|
||||||
|
ruleLimit: this.venue.ruleLimit,
|
||||||
});
|
});
|
||||||
if (!line) return;
|
if (!line) return;
|
||||||
this.firedDazza.add(line.id);
|
this.firedDazza.add(line.id);
|
||||||
@ -437,12 +455,21 @@ export class NightScene extends Phaser.Scene {
|
|||||||
const runOver = reason === 'licence';
|
const runOver = reason === 'licence';
|
||||||
const survived = reason === 'clock';
|
const survived = reason === 'clock';
|
||||||
const weekDone = survived && this.nightIndex >= TOTAL_NIGHTS - 1;
|
const weekDone = survived && this.nightIndex >= TOTAL_NIGHTS - 1;
|
||||||
|
const seasonDone = weekDone && this.run.venueIndex >= TOTAL_VENUES - 1;
|
||||||
this.run.heatStrikes = [...this.state.heatStrikes];
|
this.run.heatStrikes = [...this.state.heatStrikes];
|
||||||
// pastReports is written by IncidentReportScene now, not here: Phase 4
|
// pastReports is written by IncidentReportScene now, not here: Phase 4
|
||||||
// audits what the player CLAIMED happened, and a raw truth dump would have
|
// audits what the player CLAIMED happened, and a raw truth dump would have
|
||||||
// nothing to catch them out with.
|
// nothing to catch them out with.
|
||||||
if (survived) this.run.nightIndex = this.nightIndex + 1;
|
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();
|
clearSave();
|
||||||
} else {
|
} else {
|
||||||
saveGame(this.run);
|
saveGame(this.run);
|
||||||
@ -451,8 +478,11 @@ export class NightScene extends Phaser.Scene {
|
|||||||
game: this.run,
|
game: this.run,
|
||||||
nightIndex: this.nightIndex,
|
nightIndex: this.nightIndex,
|
||||||
totalNights: TOTAL_NIGHTS,
|
totalNights: TOTAL_NIGHTS,
|
||||||
|
venue: this.venue,
|
||||||
|
promotedTo: weekDone && !seasonDone ? venueByIndex(this.run.venueIndex) : null,
|
||||||
runOver,
|
runOver,
|
||||||
weekDone,
|
weekDone,
|
||||||
|
seasonDone,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.time.delayedCall(reason === 'clock' ? 600 : 1400, () => {
|
this.time.delayedCall(reason === 'clock' ? 600 : 1400, () => {
|
||||||
|
|||||||
@ -63,7 +63,7 @@ export class NightSummaryScene extends Phaser.Scene {
|
|||||||
W / 2,
|
W / 2,
|
||||||
46,
|
46,
|
||||||
failed
|
failed
|
||||||
? `${this.state.venueId} · ${nightName} · night over early`
|
? `${this.run?.venue.name ?? this.state.venueId} · ${nightName} · night over early`
|
||||||
: `${SUMMARY_UI.subtitle}${nightName ? ` · ${nightName}` : ''}`,
|
: `${SUMMARY_UI.subtitle}${nightName ? ` · ${nightName}` : ''}`,
|
||||||
{
|
{
|
||||||
fontFamily: MONO,
|
fontFamily: MONO,
|
||||||
@ -115,12 +115,20 @@ export class NightSummaryScene extends Phaser.Scene {
|
|||||||
next: () => this.scene.start('Night', { freshRun: true, seed: this.log.seed + 13 }),
|
next: () => this.scene.start('Night', { freshRun: true, seed: this.log.seed + 13 }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (r.weekDone) {
|
if (r.seasonDone) {
|
||||||
return {
|
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 }),
|
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 failedNight = this.log.endReason !== 'clock';
|
||||||
const nextName = failedNight
|
const nextName = failedNight
|
||||||
? (NIGHT_NAMES[r.nightIndex] ?? 'the same night')
|
? (NIGHT_NAMES[r.nightIndex] ?? 'the same night')
|
||||||
|
|||||||
@ -105,6 +105,8 @@ export class QueueManager {
|
|||||||
private readonly nightDate: Date,
|
private readonly nightDate: Date,
|
||||||
/** Encounters already met this run — the scheduler prefers new people. */
|
/** Encounters already met this run — the scheduler prefers new people. */
|
||||||
seenEncounters: readonly string[] = [],
|
seenEncounters: readonly string[] = [],
|
||||||
|
/** Venue arrival multiplier (ladder, design §6). */
|
||||||
|
private readonly arrivalScale: number = 1,
|
||||||
) {
|
) {
|
||||||
this.arrivalRng = rng.stream('arrivals');
|
this.arrivalRng = rng.stream('arrivals');
|
||||||
this.coupleRng = rng.stream('couples');
|
this.coupleRng = rng.stream('couples');
|
||||||
@ -180,7 +182,7 @@ export class QueueManager {
|
|||||||
|
|
||||||
while (clockMin >= this.nextArrivalMin) {
|
while (clockMin >= this.nextArrivalMin) {
|
||||||
this.spawnArrival();
|
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));
|
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
|
* an RngStream; the exponential draw is what makes arrivals clump — three at once
|
||||||
* then a lull — instead of ticking over like a metronome.
|
* then a lull — instead of ticking over like a metronome.
|
||||||
*/
|
*/
|
||||||
export function nextArrivalGapMin(clockMin: number, roll: number): number {
|
export function nextArrivalGapMin(clockMin: number, roll: number, scale = 1): number {
|
||||||
const rate = arrivalsPerMin(clockMin);
|
// `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;
|
if (!(rate > 0)) return NO_ARRIVALS_GAP_MIN;
|
||||||
|
|
||||||
const safeRoll = Number.isFinite(roll) ? Math.min(Math.max(roll, 0), 0.999999) : 0;
|
const safeRoll = Number.isFinite(roll) ? Math.min(Math.max(roll, 0), 0.999999) : 0;
|
||||||
|
|||||||
@ -11,6 +11,11 @@ export interface DazzaWorld {
|
|||||||
queueLength: number;
|
queueLength: number;
|
||||||
insideCount: number;
|
insideCount: number;
|
||||||
fired: ReadonlySet<string>;
|
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. */
|
/** 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[] {
|
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[] = [];
|
const due: DazzaLine[] = [];
|
||||||
for (const line of DAZZA_TEXTS) {
|
for (const line of DAZZA_TEXTS) {
|
||||||
if (world.fired.has(line.id)) continue;
|
if (world.fired.has(line.id)) continue;
|
||||||
|
if (line.ruleId !== undefined && !allowedRules.has(line.id)) continue;
|
||||||
if (clockMin < line.fromMin) continue;
|
if (clockMin < line.fromMin) continue;
|
||||||
const gate = MOOD[line.id];
|
const gate = MOOD[line.id];
|
||||||
if (gate && !gate(world)) continue;
|
if (gate && !gate(world)) continue;
|
||||||
|
|||||||
@ -86,6 +86,9 @@ export class FloorView {
|
|||||||
/** glow images that flicker (dunny fluoro, booth lamp) with their base alpha */
|
/** 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 readonly flickerLights: Array<{ img: Phaser.GameObjects.Image; base: number; phase: number }> = [];
|
||||||
private beatKick = 0;
|
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;
|
// 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
|
// syncAgents adds this on top so a shoving pair jitters without the crowd
|
||||||
// simulation ever hearing about it.
|
// simulation ever hearing about it.
|
||||||
@ -142,6 +145,8 @@ export class FloorView {
|
|||||||
|
|
||||||
this.buildProps();
|
this.buildProps();
|
||||||
this.buildLights();
|
this.buildLights();
|
||||||
|
this.buildYardRain();
|
||||||
|
this.buildDj();
|
||||||
|
|
||||||
this.maskG = scene.make.graphics({}, false);
|
this.maskG = scene.make.graphics({}, false);
|
||||||
this.mask = this.maskG.createGeometryMask();
|
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. */
|
/** Call from the scene's beat:tick handler — the dance floor breathes in time. */
|
||||||
onBeat(beatIndex: number): void {
|
onBeat(beatIndex: number): void {
|
||||||
this.beatKick = beatIndex % 4 === 0 ? 1 : 0.6;
|
this.beatKick = beatIndex % 4 === 0 ? 1 : 0.6;
|
||||||
|
this.djBob = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Per-frame light life: beat decay + fluoro flicker. */
|
/** Per-frame light life: beat decay + fluoro flicker. */
|
||||||
@ -225,6 +280,10 @@ export class FloorView {
|
|||||||
this.beatKick = Math.max(0, this.beatKick - dtMs / 260);
|
this.beatKick = Math.max(0, this.beatKick - dtMs / 260);
|
||||||
for (const img of this.beatLights) img.setAlpha(0.22 + 0.3 * this.beatKick);
|
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;
|
const t = this.scene.time.now;
|
||||||
for (const f of this.flickerLights) {
|
for (const f of this.flickerLights) {
|
||||||
// Two incommensurate sines + a hard dropout band reads as dying fluoro.
|
// Two incommensurate sines + a hard dropout band reads as dying fluoro.
|
||||||
@ -380,6 +439,10 @@ export class FloorView {
|
|||||||
this.dark.destroy();
|
this.dark.destroy();
|
||||||
this.tileImg.destroy();
|
this.tileImg.destroy();
|
||||||
for (const n of this.neon) n.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();
|
for (const pr of this.propImgs) pr.destroy();
|
||||||
this.propImgs.length = 0;
|
this.propImgs.length = 0;
|
||||||
this.beatLights.length = 0;
|
this.beatLights.length = 0;
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { CAREFUL, DAWDLER, SLOPPY, simulateNight, type NightMetrics } from './economy';
|
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:
|
// 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
|
// 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);
|
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;
|
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();
|
_resetPatronSerial();
|
||||||
const bus = new EventBus();
|
const bus = new EventBus();
|
||||||
const rng = new SeededRNG(seed);
|
const rng = new SeededRNG(seed);
|
||||||
const clock = new GameClock(bus, DEFAULT_CLOCK);
|
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 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 deferred: DeferredHit[] = [];
|
||||||
const lapRng = rng.stream('dazzaLaps');
|
const lapRng = rng.stream('dazzaLaps');
|
||||||
// Mirrors CrowdSim's natural churn: admitted patrons go home after 50-140
|
// Mirrors CrowdSim's natural churn: admitted patrons go home after 50-140
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user