The DJ shift: hold the decks — bass-out builds, bar-line-judged drops, booth requests with odds
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
1b0842024b
commit
fbcbb2e349
@ -1338,3 +1338,44 @@ kebab run full cycle with incident + toast ✓.
|
|||||||
- Aggro sim band floor eased 15 → 12: doubling the encounter pool reshuffled
|
- Aggro sim band floor eased 15 → 12: doubling the encounter pool reshuffled
|
||||||
seeded nights; the band's claim ("denials sting at all") is intact.
|
seeded nights; the band's claim ("denials sting at all") is intact.
|
||||||
- SCENARIOS.md: everything except the three phase-scale systems is now ✅.
|
- SCENARIOS.md: everything except the three phase-scale systems is now ✅.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SESSION — FABLE-SOLO-11 · 2026-07-20
|
||||||
|
|
||||||
|
**Branch:** main (solo session, Fable) · **Gate:** lint ✓ build ✓ test ✓ (674 tests, 41 files)
|
||||||
|
**Deployed:** https://monsterrobot.games/not-tonight/ (rsync, curl 200)
|
||||||
|
|
||||||
|
### The DJ shift (design §6 role ladder — first playable cut)
|
||||||
|
|
||||||
|
`scenes/floor/djShift.ts` (pure, 9 tests) + FloorDemoScene wiring + strings.
|
||||||
|
- **Enter/exit**: E at the booth (`DECKS_SPOT` = tile 55,22). Movement locks;
|
||||||
|
B/SPACE go live; every other key is eaten ("the decks eat every other key").
|
||||||
|
E steps off; an armed build fizzles on handback.
|
||||||
|
- **BUILD (B)**: `getActiveEngine()?.setVoiceEnabled('bass', false)` — the
|
||||||
|
actual bass leaves the actual mix — hype +0.1. Held past 4 bars it fizzles
|
||||||
|
(vibe −2, bass returns unannounced).
|
||||||
|
- **DROP (SPACE)**: judged against the LIVE bar line. Scene tracks
|
||||||
|
`lastBeat {index, atMs}` off `beat:tick` (both modes) + `beatMs` from the
|
||||||
|
night's beatIntervalMs; `msToNearestBar(beatInBar, msSinceBeat, beatMs)` →
|
||||||
|
`judgeDrop`: ≤140ms clean (vibe +4, hype +0.4, crowd-ooh sfx), ≤320ms late
|
||||||
|
(+1), else clang (vibe −2, aggro +1). Early and late are the same offence.
|
||||||
|
- **Requests**: while on the decks, stream `'djRequests'` (~1 per 80s) opens
|
||||||
|
ChoiceOverlay with a real dancer's doll: PLAY IT (per-request hit odds
|
||||||
|
0.4–0.75, `REQUEST_HIT`; hit vibe +3/hype +0.2, flop vibe −3), NAH (aggro
|
||||||
|
+1), AFTER THIS ONE (40% deferred aggro +2 in 8–20 min — "they remembered").
|
||||||
|
Six requests: dnb, nan, birthday, shazam, slower, the DUN DUN one. A request
|
||||||
|
needs a live body on the floor — empty room sends nobody to the glass.
|
||||||
|
- Incident kinds `djShift`/`djDrop`/`djRequest` feed the report system.
|
||||||
|
|
||||||
|
### Verified in the pane (frame-stepped)
|
||||||
|
Enter prompt + movement lock + help line ✓ · build arms, bass voice off ✓ ·
|
||||||
|
drop AT bar boundary → clean, vibe +5.5 hype-scaled, incident ✓ · mid-bar drop
|
||||||
|
→ clang ✓ · 4-bar fizzle ✓ · rigged-stream request → overlay with asker doll,
|
||||||
|
PLAY IT → "It landed." incident ✓ · organic request appeared during screenshot
|
||||||
|
✓ · step-off clears an armed build ✓.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- Tween-leak hunt (DoorScene, ~1.1k live 800ms tweens) is running in a separate
|
||||||
|
session — this session deliberately avoided DoorScene.
|
||||||
|
- Shelf remaining: ROOM boss week, bartender shift.
|
||||||
|
|||||||
@ -73,7 +73,12 @@ Status: ✅ implemented · 🔜 next nibbles · 💡 backlog.*
|
|||||||
- 💡 **Venue 4 / ROOM boss week** (design §6): dress code re-randomises every
|
- 💡 **Venue 4 / ROOM boss week** (design §6): dress code re-randomises every
|
||||||
15 in-game minutes; dialogue rendered as lip-read guesses under the loudest
|
15 in-game minutes; dialogue rendered as lip-read guesses under the loudest
|
||||||
mix arc.
|
mix arc.
|
||||||
- 💡 **The DJ shift** (role ladder): the booth is built, the engine's mix is
|
- ✅ **The DJ shift** (2026-07-20, first cut): E at the booth hands you the decks
|
||||||
already externally scriptable — requests at the booth, energy management.
|
while the resident steps out. B pulls the bass (the room leans in); SPACE
|
||||||
|
drops, judged against the LIVE bar line (clean/late/clang — early and late
|
||||||
|
are the same offence); requests arrive at the booth glass with per-request
|
||||||
|
odds ("got any dnb", nan's song, the DUN DUN one) and three answers that all
|
||||||
|
cost something. A build held too long runs out of stairs. Movement locks
|
||||||
|
while you hold them; the floor and the door keep happening without you.
|
||||||
- 💡 **Bartender shift**: the bar geometry and RSA scoring already exist;
|
- 💡 **Bartender shift**: the bar geometry and RSA scoring already exist;
|
||||||
pour-and-cut-off from the other side of the counter.
|
pour-and-cut-off from the other side of the counter.
|
||||||
|
|||||||
@ -196,3 +196,86 @@ export const AID_UI = {
|
|||||||
ok: 'They come around on their side. Walked out the back to a taxi. The room starts again, quieter.',
|
ok: 'They come around on their side. Walked out the back to a taxi. The room starts again, quieter.',
|
||||||
ambulance: 'The ambulance takes them out the back. The room starts again, but not really.',
|
ambulance: 'The ambulance takes them out the back. The room starts again, but not really.',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/** The DJ shift (docs/SCENARIOS.md, design §6). Holding the decks, briefly. */
|
||||||
|
export const DJ_UI = {
|
||||||
|
enter: 'E — hold the decks for a minute',
|
||||||
|
exit: 'E — step off the decks',
|
||||||
|
handover: 'the resident points at the crossfader, then at you, then leaves at pace toward the toilets.',
|
||||||
|
handback: 'the resident returns smelling of the yard. "good hands." highest honour.',
|
||||||
|
help: 'B build \u00b7 SPACE drop \u00b7 E step off',
|
||||||
|
buildStart: 'the bass ducks out. the room leans in.',
|
||||||
|
fizzle: 'the riser runs out of stairs. the bass slinks back in unannounced.',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const DJ_DROP_RESULTS = {
|
||||||
|
clean: 'ON the bar. the floor detonates. somebody loses a shoe and does not care.',
|
||||||
|
late: 'near enough. the floor forgives. the floor wanted to forgive.',
|
||||||
|
clang: 'clanged it. a bartender winces audibly over the PA somehow.',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Booth requests: id must match REQUEST_HIT in scenes/floor/djShift.ts. */
|
||||||
|
export interface DjRequestScript {
|
||||||
|
id: string;
|
||||||
|
line: string;
|
||||||
|
hit: string;
|
||||||
|
flop: string;
|
||||||
|
nah: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DJ_REQUESTS: readonly DjRequestScript[] = [
|
||||||
|
{
|
||||||
|
id: 'dnb',
|
||||||
|
line: 'got any dnb. any at all. i can wait',
|
||||||
|
hit: 'the dnb lands. three blokes materialise from nowhere doing the washing machine.',
|
||||||
|
flop: 'the dnb clears the middle of the floor like a fire drill.',
|
||||||
|
nah: 'he nods slowly and stays exactly where he is. he can wait.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nan',
|
||||||
|
line: 'can u play something my nan would know. shes here. shes ninety',
|
||||||
|
hit: 'nan goes OFF. the entire floor orbits her. this is her venue now.',
|
||||||
|
flop: 'nan is polite about it. the floor is not.',
|
||||||
|
nah: 'nan waves at the booth anyway. no hard feelings from nan.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'birthday',
|
||||||
|
line: 'its my birthday. this is a fact u can verify',
|
||||||
|
hit: 'the birthday track hits and the whole room sings at a stranger. he weeps.',
|
||||||
|
flop: 'the birthday track dies. he stands in the middle of it, aging.',
|
||||||
|
nah: 'he shows you his licence to prove the birthday. you have seen worse proof tonight.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'shazam',
|
||||||
|
line: '*holds phone against the booth glass* play THIS',
|
||||||
|
hit: 'phone song works. she points at you like you invented it.',
|
||||||
|
flop: 'the phone song is 34 seconds of someone talking over a boat engine.',
|
||||||
|
nah: 'she lowers the phone one centimetre at a time, maintaining eye contact.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'slower',
|
||||||
|
line: 'somethin slower?? my feet r cooked',
|
||||||
|
hit: 'the slow one lands and the couples appear. where were the couples STANDING.',
|
||||||
|
flop: 'the energy drops through the floor and into the carpark below.',
|
||||||
|
nah: 'she sits on the subwoofer to rest instead. security problem, later, probably.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'again',
|
||||||
|
line: 'play the one from before again. the DUN DUN one',
|
||||||
|
hit: 'the DUN DUN one, again. somehow BIGGER. lightning does strike twice.',
|
||||||
|
flop: 'the same song twice reads less like a victory lap and more like a cry for help.',
|
||||||
|
nah: 'he hums it to himself on the way back to the floor. dun dun. dun dun.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DJ_REQUEST_UI = {
|
||||||
|
title: 'BOOTH REQUEST',
|
||||||
|
play: 'PLAY IT',
|
||||||
|
playDetail: 'give the floor to them. see what it does with it',
|
||||||
|
nah: 'NAH',
|
||||||
|
nahDetail: 'the set has a plan. the plan is not this',
|
||||||
|
later: 'AFTER THIS ONE',
|
||||||
|
laterDetail: 'the diplomatic no. sometimes they remember',
|
||||||
|
laterQuiet: 'they nod and drift off satisfied. for now.',
|
||||||
|
laterGrudge: 'they came back. they remembered. they brought a mood.',
|
||||||
|
} as const;
|
||||||
|
|||||||
@ -28,10 +28,18 @@ import {
|
|||||||
SLIP_CHANCE_PER_S, SLIP_COOLDOWN_MS, SLIP_RADIUS_PX,
|
SLIP_CHANCE_PER_S, SLIP_COOLDOWN_MS, SLIP_RADIUS_PX,
|
||||||
VOMIT_CHANCE_PER_S, watered,
|
VOMIT_CHANCE_PER_S, watered,
|
||||||
} from './hazards';
|
} from './hazards';
|
||||||
|
import {
|
||||||
|
BUILD_FIZZLE_BARS, BUILD_HYPE, DROP_PAYOUT, FIZZLE_VIBE,
|
||||||
|
REQUEST_HIT, REQUEST_LATER_GRUDGE, REQUEST_NAH_AGGRO,
|
||||||
|
REQUEST_PLAY_FLOP, REQUEST_PLAY_HIT,
|
||||||
|
judgeDrop, msToNearestBar,
|
||||||
|
} from './djShift';
|
||||||
|
import { getActiveEngine } from '../../audio/TechnoEngine';
|
||||||
import { RACK_RESPAWN_MIN, freshRack, stepRack, turnSharpness, type RackState } from './glassie';
|
import { RACK_RESPAWN_MIN, freshRack, stepRack, turnSharpness, type RackState } from './glassie';
|
||||||
import { tileToWorld } from './venueMap';
|
import { tileToWorld } from './venueMap';
|
||||||
import {
|
import {
|
||||||
AID_UI, HELP_TEXT, JOINT_RESULTS, JOINT_STING, JOINT_UI, NO_STAMP_RADIO,
|
AID_UI, DJ_DROP_RESULTS, DJ_REQUESTS, DJ_REQUEST_UI, DJ_UI,
|
||||||
|
HELP_TEXT, JOINT_RESULTS, JOINT_STING, JOINT_UI, NO_STAMP_RADIO,
|
||||||
PHONE_POSTED, PHONE_UI, VOMIT_UI, WATER_UI,
|
PHONE_POSTED, PHONE_UI, VOMIT_UI, WATER_UI,
|
||||||
} from '../../data/strings/floor';
|
} from '../../data/strings/floor';
|
||||||
import { MeterHud } from '../../ui/MeterHud';
|
import { MeterHud } from '../../ui/MeterHud';
|
||||||
@ -49,6 +57,8 @@ const RACK_SPOT = tileToWorld(51, 7);
|
|||||||
const DISH_SPOT = tileToWorld(17, 3);
|
const DISH_SPOT = tileToWorld(17, 3);
|
||||||
/** The sinks (venueMap rect 66..71,15): where a cup of water comes from. */
|
/** The sinks (venueMap rect 66..71,15): where a cup of water comes from. */
|
||||||
const WATER_SPOT = tileToWorld(68, 15);
|
const WATER_SPOT = tileToWorld(68, 15);
|
||||||
|
/** The booth (venueMap rect 52..58,19..25): where you hold the decks. */
|
||||||
|
const DECKS_SPOT = tileToWorld(55, 22);
|
||||||
/** Crowd contact distance while carrying, and its debounce. */
|
/** Crowd contact distance while carrying, and its debounce. */
|
||||||
const CARRY_BUMP_PX = 14;
|
const CARRY_BUMP_PX = 14;
|
||||||
const CARRY_BUMP_COOLDOWN_MS = 350;
|
const CARRY_BUMP_COOLDOWN_MS = 350;
|
||||||
@ -60,7 +70,7 @@ const STAGE_RANK: Readonly<Record<DrunkStage, number>> = {
|
|||||||
/** What pressing E on the current target would do. */
|
/** What pressing E on the current target would do. */
|
||||||
type InteractKind =
|
type InteractKind =
|
||||||
| 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'exit' | 'joint' | 'rack' | 'dishwasher'
|
| 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'exit' | 'joint' | 'rack' | 'dishwasher'
|
||||||
| 'puddle' | 'water' | 'filming' | 'fainter';
|
| 'puddle' | 'water' | 'filming' | 'fainter' | 'decks';
|
||||||
|
|
||||||
interface InteractTarget {
|
interface InteractTarget {
|
||||||
kind: InteractKind;
|
kind: InteractKind;
|
||||||
@ -116,6 +126,11 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
private fainter: { agentId: string; x: number; y: number; msLeft: number } | null = null;
|
private fainter: { agentId: string; x: number; y: number; msLeft: number } | null = null;
|
||||||
private fainterAtMin: number | null = null;
|
private fainterAtMin: number | null = null;
|
||||||
private aid!: AidOverlay;
|
private aid!: AidOverlay;
|
||||||
|
/** On the decks: movement locked, B/SPACE live, requests arrive. */
|
||||||
|
private djMode = false;
|
||||||
|
private djBuild: { armedAtMs: number } | null = null;
|
||||||
|
private lastBeat = { index: 0, atMs: 0 };
|
||||||
|
private beatMs = 500;
|
||||||
/** Clock minutes at which somebody comes OVER the yard fence (night mode). */
|
/** Clock minutes at which somebody comes OVER the yard fence (night mode). */
|
||||||
private jumperTimes: number[] = [];
|
private jumperTimes: number[] = [];
|
||||||
private jumperRng: SeededRNG | null = null;
|
private jumperRng: SeededRNG | null = null;
|
||||||
@ -226,6 +241,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
this.beat = null; // beat:tick arrives on the shared bus (stub, then TechnoEngine)
|
this.beat = null; // beat:tick arrives on the shared bus (stub, then TechnoEngine)
|
||||||
this.meters = null; // NightScene owns the single writer
|
this.meters = null; // NightScene owns the single writer
|
||||||
this.hud = new MeterHud(this, this.bus, { initialHeat: this.night.heatStrikes.length });
|
this.hud = new MeterHud(this, this.bus, { initialHeat: this.night.heatStrikes.length });
|
||||||
|
this.beatMs = ctx.beatIntervalMs;
|
||||||
this.stall = new StallOverlay(this, this.bus, ctx.beatIntervalMs, ctx.sfx);
|
this.stall = new StallOverlay(this, this.bus, ctx.beatIntervalMs, ctx.sfx);
|
||||||
|
|
||||||
this.crowd = new CrowdSim({ map: VENUE, bus: this.bus, rng: this.rng.stream('crowd') });
|
this.crowd = new CrowdSim({ map: VENUE, bus: this.bus, rng: this.rng.stream('crowd') });
|
||||||
@ -272,6 +288,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
if (this.present) this.target = null;
|
if (this.present) this.target = null;
|
||||||
}),
|
}),
|
||||||
this.bus.on('beat:tick', ({ beatIndex }) => {
|
this.bus.on('beat:tick', ({ beatIndex }) => {
|
||||||
|
this.lastBeat = { index: beatIndex, atMs: this.elapsedMs };
|
||||||
this.crowd.onBeat(beatIndex);
|
this.crowd.onBeat(beatIndex);
|
||||||
this.view.onBeat(beatIndex);
|
this.view.onBeat(beatIndex);
|
||||||
}),
|
}),
|
||||||
@ -303,12 +320,14 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
this.night.location = 'floor';
|
this.night.location = 'floor';
|
||||||
this.meters = new Meters(this.bus, this.night);
|
this.meters = new Meters(this.bus, this.night);
|
||||||
this.hud = new HudStub(this, this.bus);
|
this.hud = new HudStub(this, this.bus);
|
||||||
|
this.beatMs = this.beat.beatIntervalMs;
|
||||||
this.stall = new StallOverlay(this, this.bus, this.beat.beatIntervalMs);
|
this.stall = new StallOverlay(this, this.bus, this.beat.beatIntervalMs);
|
||||||
|
|
||||||
this.crowd = new CrowdSim({ map: VENUE, bus: this.bus, rng: this.rng.stream('crowd') });
|
this.crowd = new CrowdSim({ map: VENUE, bus: this.bus, rng: this.rng.stream('crowd') });
|
||||||
this.fights?.destroy();
|
this.fights?.destroy();
|
||||||
this.fights = new FightDirector({ bus: this.bus, rng: this.rng.stream('fights'), crowd: this.crowd });
|
this.fights = new FightDirector({ bus: this.bus, rng: this.rng.stream('fights'), crowd: this.crowd });
|
||||||
this.bus.on('beat:tick', ({ beatIndex }) => {
|
this.bus.on('beat:tick', ({ beatIndex }) => {
|
||||||
|
this.lastBeat = { index: beatIndex, atMs: this.elapsedMs };
|
||||||
this.crowd.onBeat(beatIndex);
|
this.crowd.onBeat(beatIndex);
|
||||||
this.view.onBeat(beatIndex);
|
this.view.onBeat(beatIndex);
|
||||||
});
|
});
|
||||||
@ -344,6 +363,13 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
if (this.patDown.isOpen) return this.patDown.handleKey(key);
|
if (this.patDown.isOpen) return this.patDown.handleKey(key);
|
||||||
if (this.choice.isOpen) return this.choice.handleKey(key);
|
if (this.choice.isOpen) return this.choice.handleKey(key);
|
||||||
|
|
||||||
|
if (this.djMode) {
|
||||||
|
if (key === 'B') return this.djStartBuild();
|
||||||
|
if (key === 'SPACE') return this.djDrop();
|
||||||
|
if (key === 'E') return this.interact();
|
||||||
|
return; // the decks eat every other key; the floor can wait
|
||||||
|
}
|
||||||
|
|
||||||
if (key === 'TAB') {
|
if (key === 'TAB') {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
this.uvMode = !this.uvMode;
|
this.uvMode = !this.uvMode;
|
||||||
@ -447,7 +473,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// Escorting costs you the walk: no interacting, slower going.
|
// Escorting costs you the walk: no interacting, slower going.
|
||||||
const input = this.readInput();
|
const input = this.readInput();
|
||||||
this.player = stepPlayer(this.player, input, dt, VENUE);
|
this.player = this.djMode ? this.player : stepPlayer(this.player, input, dt, VENUE);
|
||||||
|
|
||||||
if (!this.nightCtx) {
|
if (!this.nightCtx) {
|
||||||
// Demo mode stands in for the door with a synthetic spawner.
|
// Demo mode stands in for the door with a synthetic spawner.
|
||||||
@ -471,6 +497,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
this.trackMaggots();
|
this.trackMaggots();
|
||||||
this.tickHazards(dt);
|
this.tickHazards(dt);
|
||||||
this.aid.update(dt);
|
this.aid.update(dt);
|
||||||
|
this.tickDecks(dt);
|
||||||
|
|
||||||
// Mopping: feet planted until it's done. The commitment IS the mechanic.
|
// Mopping: feet planted until it's done. The commitment IS the mechanic.
|
||||||
if (this.mop) {
|
if (this.mop) {
|
||||||
@ -630,6 +657,15 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
if (d <= INTERACT_RANGE) return { kind: 'water', prompt: WATER_UI.pickup };
|
if (d <= INTERACT_RANGE) return { kind: 'water', prompt: WATER_UI.pickup };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On the decks the only interaction is stepping off them.
|
||||||
|
if (this.djMode) return { kind: 'decks', prompt: DJ_UI.exit };
|
||||||
|
|
||||||
|
// The booth: hold the decks. Blocked while your hands are otherwise full.
|
||||||
|
if (!this.carryingCup) {
|
||||||
|
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, DECKS_SPOT.x, DECKS_SPOT.y);
|
||||||
|
if (d <= INTERACT_RANGE) return { kind: 'decks', prompt: DJ_UI.enter };
|
||||||
|
}
|
||||||
|
|
||||||
// Stall doors first — you interact with the door, not a person.
|
// Stall doors first — you interact with the door, not a person.
|
||||||
for (const s of VENUE.stalls) {
|
for (const s of VENUE.stalls) {
|
||||||
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, s.door.x, s.door.y);
|
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, s.door.x, s.door.y);
|
||||||
@ -704,6 +740,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
if (t.kind === 'exit') return this.nightCtx?.requestLocation('door');
|
if (t.kind === 'exit') return this.nightCtx?.requestLocation('door');
|
||||||
if (t.kind === 'rack') return this.takeRack();
|
if (t.kind === 'rack') return this.takeRack();
|
||||||
if (t.kind === 'dishwasher') return this.deliverRack();
|
if (t.kind === 'dishwasher') return this.deliverRack();
|
||||||
|
if (t.kind === 'decks') return this.toggleDecks();
|
||||||
if (t.kind === 'fainter') return this.openAid();
|
if (t.kind === 'fainter') return this.openAid();
|
||||||
if (t.kind === 'puddle') return this.interactPuddle();
|
if (t.kind === 'puddle') return this.interactPuddle();
|
||||||
if (t.kind === 'water') {
|
if (t.kind === 'water') {
|
||||||
@ -991,6 +1028,113 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// ---- chrome ------------------------------------------------------------
|
// ---- chrome ------------------------------------------------------------
|
||||||
|
|
||||||
|
// ---- the DJ shift (docs/SCENARIOS.md, design §6) -----------------------
|
||||||
|
|
||||||
|
/** E at the booth: take the decks, or hand them back. */
|
||||||
|
private toggleDecks(): void {
|
||||||
|
if (!this.djMode) {
|
||||||
|
this.djMode = true;
|
||||||
|
this.player.x = DECKS_SPOT.x;
|
||||||
|
this.player.y = DECKS_SPOT.y;
|
||||||
|
this.toast(DJ_UI.handover);
|
||||||
|
this.promptText.setText(DJ_UI.help);
|
||||||
|
this.logIncident('djShift', undefined, 'Held the decks while the resident stepped out.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Handing back mid-build: the bass comes home unsupervised.
|
||||||
|
if (this.djBuild) this.djResolveBuild(null);
|
||||||
|
this.djMode = false;
|
||||||
|
this.toast(DJ_UI.handback);
|
||||||
|
}
|
||||||
|
|
||||||
|
private djStartBuild(): void {
|
||||||
|
if (this.djBuild || this.overlayOpen) return;
|
||||||
|
this.djBuild = { armedAtMs: this.elapsedMs };
|
||||||
|
getActiveEngine()?.setVoiceEnabled('bass', false);
|
||||||
|
this.bus.emit('meters:delta', { hype: BUILD_HYPE });
|
||||||
|
this.toast(DJ_UI.buildStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
private djDrop(): void {
|
||||||
|
if (!this.djBuild || this.overlayOpen) return;
|
||||||
|
const sinceBeat = this.elapsedMs - this.lastBeat.atMs;
|
||||||
|
const verdict = judgeDrop(msToNearestBar(this.lastBeat.index % 4, sinceBeat, this.beatMs));
|
||||||
|
this.djResolveBuild(verdict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** null = fizzle/abandon; otherwise a judged drop. Either way the bass returns. */
|
||||||
|
private djResolveBuild(verdict: 'clean' | 'late' | 'clang' | null): void {
|
||||||
|
this.djBuild = null;
|
||||||
|
getActiveEngine()?.setVoiceEnabled('bass', true);
|
||||||
|
if (verdict === null) {
|
||||||
|
this.bus.emit('meters:delta', { vibe: FIZZLE_VIBE });
|
||||||
|
this.toast(DJ_UI.fizzle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pay = DROP_PAYOUT[verdict];
|
||||||
|
const delta: { vibe?: number; aggro?: number; hype?: number } = {};
|
||||||
|
if (pay.vibe) delta.vibe = pay.vibe;
|
||||||
|
if (pay.aggro) delta.aggro = pay.aggro;
|
||||||
|
if (pay.hype) delta.hype = pay.hype;
|
||||||
|
this.bus.emit('meters:delta', delta);
|
||||||
|
this.logIncident('djDrop', undefined, `Drop: ${verdict}.`);
|
||||||
|
this.toast(DJ_DROP_RESULTS[verdict]);
|
||||||
|
if (verdict === 'clean') this.nightCtx?.sfx?.play('denyCrowdOoh');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build fizzle + booth requests. Only while the player is on the decks. */
|
||||||
|
private tickDecks(dt: number): void {
|
||||||
|
if (!this.djMode) return;
|
||||||
|
if (this.djBuild && this.elapsedMs - this.djBuild.armedAtMs > BUILD_FIZZLE_BARS * 4 * this.beatMs) {
|
||||||
|
this.djResolveBuild(null);
|
||||||
|
}
|
||||||
|
if (this.overlayOpen) return;
|
||||||
|
const rq = this.rng.stream('djRequests');
|
||||||
|
if (!rq.chance((dt / 1000) * 0.012)) return;
|
||||||
|
const script = DJ_REQUESTS[rq.int(0, DJ_REQUESTS.length - 1)]!;
|
||||||
|
// The overlay shows the asker's doll, so a request needs an actual body —
|
||||||
|
// an empty floor sends nobody to the booth glass.
|
||||||
|
const asker =
|
||||||
|
this.crowd.agents.find((a) => !a.gone && a.activity === 'dancing') ??
|
||||||
|
this.crowd.agents.find((a) => !a.gone);
|
||||||
|
if (!asker) return;
|
||||||
|
this.choice.open(
|
||||||
|
{
|
||||||
|
title: DJ_REQUEST_UI.title,
|
||||||
|
line: script.line,
|
||||||
|
patron: asker.patron,
|
||||||
|
choices: [
|
||||||
|
{ id: 'play', label: DJ_REQUEST_UI.play, detail: DJ_REQUEST_UI.playDetail },
|
||||||
|
{ id: 'nah', label: DJ_REQUEST_UI.nah, detail: DJ_REQUEST_UI.nahDetail },
|
||||||
|
{ id: 'later', label: DJ_REQUEST_UI.later, detail: DJ_REQUEST_UI.laterDetail },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
(id) => {
|
||||||
|
if (id === null) return; // you stared at the crossfader until they left
|
||||||
|
if (id === 'play') {
|
||||||
|
const hit = rq.chance(REQUEST_HIT[script.id] ?? 0.5);
|
||||||
|
this.bus.emit('meters:delta', hit ? { ...REQUEST_PLAY_HIT } : { ...REQUEST_PLAY_FLOP });
|
||||||
|
this.logIncident('djRequest', asker.patron.id, `Played the ${script.id} request. ${hit ? 'It landed.' : 'It did not.'}`);
|
||||||
|
this.toast(hit ? script.hit : script.flop);
|
||||||
|
} else if (id === 'nah') {
|
||||||
|
this.bus.emit('meters:delta', { aggro: REQUEST_NAH_AGGRO });
|
||||||
|
this.logIncident('djRequest', asker.patron.id, `Declined the ${script.id} request.`);
|
||||||
|
this.toast(script.nah);
|
||||||
|
} else {
|
||||||
|
if (this.nightCtx && rq.chance(REQUEST_LATER_GRUDGE)) {
|
||||||
|
this.nightCtx.scheduleDeferred([{
|
||||||
|
atClockMin: this.clock.clockMin + 8 + rq.int(0, 12),
|
||||||
|
aggroDelta: 2,
|
||||||
|
reason: 'a deferred booth request came back with interest',
|
||||||
|
patronId: asker.patron.id,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
this.toast(DJ_REQUEST_UI.laterQuiet);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Everything on the floor that happens TO the room rather than through the
|
* Everything on the floor that happens TO the room rather than through the
|
||||||
* player: vomit + slips, the filming influencer's timer, the fainter. Runs
|
* player: vomit + slips, the filming influencer's timer, the fainter. Runs
|
||||||
|
|||||||
70
src/scenes/floor/djShift.ts
Normal file
70
src/scenes/floor/djShift.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
// The DJ shift (design §6 role ladder, previewed as a floor interaction the way
|
||||||
|
// the glassie run was). Pure numbers + judgement here, tested; the scene owns
|
||||||
|
// sprites, keys and the TechnoEngine hookup.
|
||||||
|
//
|
||||||
|
// The shape of the fantasy: the resident needs a minute, you hold the decks.
|
||||||
|
// BUILD pulls the bass out and the room leans in; DROP is judged against the
|
||||||
|
// bar line the engine is actually playing. Requests arrive mid-set and every
|
||||||
|
// answer costs something (§4.3 — being the fun DJ and being the good DJ are
|
||||||
|
// different jobs).
|
||||||
|
|
||||||
|
export type DropVerdict = 'clean' | 'late' | 'clang';
|
||||||
|
|
||||||
|
/** Inside this of a bar boundary is a clean drop. */
|
||||||
|
export const DROP_CLEAN_MS = 140;
|
||||||
|
/** Beyond clean but inside this is a stumble the room forgives. */
|
||||||
|
export const DROP_LATE_MS = 320;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Judge a drop from its distance to the NEAREST bar line (early or late are the
|
||||||
|
* same offence — the room only hears wrong).
|
||||||
|
*/
|
||||||
|
export function judgeDrop(msToNearestBar: number): DropVerdict {
|
||||||
|
const d = Math.abs(msToNearestBar);
|
||||||
|
if (d <= DROP_CLEAN_MS) return 'clean';
|
||||||
|
if (d <= DROP_LATE_MS) return 'late';
|
||||||
|
return 'clang';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distance to the nearest bar boundary, from the current beat index within the
|
||||||
|
* bar and ms elapsed since that beat. Bars are 4 beats.
|
||||||
|
*/
|
||||||
|
export function msToNearestBar(beatInBar: number, msSinceBeat: number, beatMs: number): number {
|
||||||
|
const intoBar = beatInBar * beatMs + msSinceBeat;
|
||||||
|
const barMs = 4 * beatMs;
|
||||||
|
const behind = intoBar; // how late past the last bar line
|
||||||
|
const ahead = barMs - intoBar; // how early before the next
|
||||||
|
return behind <= ahead ? behind : -ahead;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A build that never drops runs out of stairs after this many bars. */
|
||||||
|
export const BUILD_FIZZLE_BARS = 4;
|
||||||
|
|
||||||
|
// ---- outcomes (§4.3: priced, never graded) ----------------------------------
|
||||||
|
|
||||||
|
export const DROP_PAYOUT: Record<DropVerdict, { vibe: number; hype: number; aggro: number }> = {
|
||||||
|
clean: { vibe: 4, hype: 0.4, aggro: 0 },
|
||||||
|
late: { vibe: 1, hype: 0.1, aggro: 0 },
|
||||||
|
clang: { vibe: -2, hype: 0, aggro: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BUILD_HYPE = 0.1;
|
||||||
|
export const FIZZLE_VIBE = -2;
|
||||||
|
|
||||||
|
/** Per-request odds the floor is WITH it. The rest of the request lives in
|
||||||
|
* data/strings/floor.ts (DJ_REQUESTS) keyed by the same id. */
|
||||||
|
export const REQUEST_HIT: Record<string, number> = {
|
||||||
|
dnb: 0.75,
|
||||||
|
nan: 0.5,
|
||||||
|
birthday: 0.65,
|
||||||
|
shazam: 0.55,
|
||||||
|
slower: 0.4,
|
||||||
|
again: 0.7,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REQUEST_PLAY_HIT = { vibe: 3, hype: 0.2 };
|
||||||
|
export const REQUEST_PLAY_FLOP = { vibe: -3 };
|
||||||
|
export const REQUEST_NAH_AGGRO = 1;
|
||||||
|
/** "after this one": chance they come back later, having remembered. */
|
||||||
|
export const REQUEST_LATER_GRUDGE = 0.4;
|
||||||
74
tests/floor/djShift.test.ts
Normal file
74
tests/floor/djShift.test.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
BUILD_FIZZLE_BARS,
|
||||||
|
DROP_CLEAN_MS,
|
||||||
|
DROP_LATE_MS,
|
||||||
|
DROP_PAYOUT,
|
||||||
|
REQUEST_HIT,
|
||||||
|
judgeDrop,
|
||||||
|
msToNearestBar,
|
||||||
|
} from '../../src/scenes/floor/djShift';
|
||||||
|
import { DJ_REQUESTS } from '../../src/data/strings/floor';
|
||||||
|
|
||||||
|
describe('judgeDrop', () => {
|
||||||
|
it('rewards the bar line, forgives near misses, punishes the clang', () => {
|
||||||
|
expect(judgeDrop(0)).toBe('clean');
|
||||||
|
expect(judgeDrop(DROP_CLEAN_MS)).toBe('clean');
|
||||||
|
expect(judgeDrop(-DROP_CLEAN_MS)).toBe('clean');
|
||||||
|
expect(judgeDrop(DROP_CLEAN_MS + 1)).toBe('late');
|
||||||
|
expect(judgeDrop(-DROP_LATE_MS)).toBe('late');
|
||||||
|
expect(judgeDrop(DROP_LATE_MS + 1)).toBe('clang');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('early and late are the same offence — the room only hears wrong', () => {
|
||||||
|
for (const d of [50, 200, 500]) expect(judgeDrop(d)).toBe(judgeDrop(-d));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('msToNearestBar', () => {
|
||||||
|
const BEAT = 500; // 120bpm
|
||||||
|
it('is zero exactly on the bar', () => {
|
||||||
|
expect(msToNearestBar(0, 0, BEAT)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measures behind the last bar early in the bar', () => {
|
||||||
|
expect(msToNearestBar(0, 120, BEAT)).toBe(120);
|
||||||
|
expect(msToNearestBar(1, 0, BEAT)).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measures ahead (negative) late in the bar', () => {
|
||||||
|
expect(msToNearestBar(3, 400, BEAT)).toBe(-(4 * BEAT - (3 * BEAT + 400)));
|
||||||
|
expect(msToNearestBar(3, 499, BEAT)).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a mid-bar drop is maximally wrong from either side', () => {
|
||||||
|
expect(Math.abs(msToNearestBar(2, 0, BEAT))).toBe(2 * BEAT);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the shift is priced, not graded', () => {
|
||||||
|
it('a clean drop pays, a clang costs — and neither is free', () => {
|
||||||
|
expect(DROP_PAYOUT.clean.vibe).toBeGreaterThan(0);
|
||||||
|
expect(DROP_PAYOUT.clang.vibe).toBeLessThan(0);
|
||||||
|
expect(DROP_PAYOUT.clang.aggro).toBeGreaterThan(0);
|
||||||
|
expect(DROP_PAYOUT.late.vibe).toBeLessThan(DROP_PAYOUT.clean.vibe);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a build cannot be held forever', () => {
|
||||||
|
expect(BUILD_FIZZLE_BARS).toBeLessThanOrEqual(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('booth requests', () => {
|
||||||
|
it('every scripted request has odds, and every odds entry has a script', () => {
|
||||||
|
const scriptIds = DJ_REQUESTS.map((r) => r.id);
|
||||||
|
expect(new Set(scriptIds).size).toBe(scriptIds.length);
|
||||||
|
for (const id of scriptIds) {
|
||||||
|
expect(REQUEST_HIT[id], `no odds for '${id}'`).toBeGreaterThan(0);
|
||||||
|
expect(REQUEST_HIT[id]).toBeLessThan(1); // nothing is a sure thing at the booth
|
||||||
|
}
|
||||||
|
for (const id of Object.keys(REQUEST_HIT)) {
|
||||||
|
expect(scriptIds).toContain(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user