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:
type-two 2026-07-20 19:49:33 +10:00
parent 1b0842024b
commit fbcbb2e349
6 changed files with 422 additions and 5 deletions

View File

@ -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.40.75, `REQUEST_HIT`; hit vibe +3/hype +0.2, flop vibe 3), NAH (aggro
+1), AFTER THIS ONE (40% deferred aggro +2 in 820 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.

View File

@ -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.

View File

@ -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;

View File

@ -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

View 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;

View 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);
}
});
});