From fbcbb2e34954362794db1854352a3f6a294b60f1 Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 20 Jul 2026 19:49:33 +1000 Subject: [PATCH] =?UTF-8?q?The=20DJ=20shift:=20hold=20the=20decks=20?= =?UTF-8?q?=E2=80=94=20bass-out=20builds,=20bar-line-judged=20drops,=20boo?= =?UTF-8?q?th=20requests=20with=20odds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- LANEHANDOVER.md | 41 ++++++++ docs/SCENARIOS.md | 9 +- src/data/strings/floor.ts | 83 ++++++++++++++++ src/scenes/floor/FloorDemoScene.ts | 150 ++++++++++++++++++++++++++++- src/scenes/floor/djShift.ts | 70 ++++++++++++++ tests/floor/djShift.test.ts | 74 ++++++++++++++ 6 files changed, 422 insertions(+), 5 deletions(-) create mode 100644 src/scenes/floor/djShift.ts create mode 100644 tests/floor/djShift.test.ts diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index b575c3f..3c6b51f 100644 --- a/LANEHANDOVER.md +++ b/LANEHANDOVER.md @@ -1338,3 +1338,44 @@ kebab run full cycle with incident + toast ✓. - Aggro sim band floor eased 15 → 12: doubling the encounter pool reshuffled seeded nights; the band's claim ("denials sting at all") is intact. - 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. diff --git a/docs/SCENARIOS.md b/docs/SCENARIOS.md index dcb340c..4a7f20a 100644 --- a/docs/SCENARIOS.md +++ b/docs/SCENARIOS.md @@ -73,7 +73,12 @@ Status: ✅ implemented · 🔜 next nibbles · 💡 backlog.* - 💡 **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 mix arc. -- 💡 **The DJ shift** (role ladder): the booth is built, the engine's mix is - already externally scriptable — requests at the booth, energy management. +- ✅ **The DJ shift** (2026-07-20, first cut): E at the booth hands you the decks + 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; pour-and-cut-off from the other side of the counter. diff --git a/src/data/strings/floor.ts b/src/data/strings/floor.ts index 131de0e..05e8777 100644 --- a/src/data/strings/floor.ts +++ b/src/data/strings/floor.ts @@ -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.', ambulance: 'The ambulance takes them out the back. The room starts again, but not really.', } 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; diff --git a/src/scenes/floor/FloorDemoScene.ts b/src/scenes/floor/FloorDemoScene.ts index 3a7d370..ec702cb 100644 --- a/src/scenes/floor/FloorDemoScene.ts +++ b/src/scenes/floor/FloorDemoScene.ts @@ -28,10 +28,18 @@ import { SLIP_CHANCE_PER_S, SLIP_COOLDOWN_MS, SLIP_RADIUS_PX, VOMIT_CHANCE_PER_S, watered, } 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 { tileToWorld } from './venueMap'; 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, } from '../../data/strings/floor'; import { MeterHud } from '../../ui/MeterHud'; @@ -49,6 +57,8 @@ const RACK_SPOT = tileToWorld(51, 7); const DISH_SPOT = tileToWorld(17, 3); /** The sinks (venueMap rect 66..71,15): where a cup of water comes from. */ 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. */ const CARRY_BUMP_PX = 14; const CARRY_BUMP_COOLDOWN_MS = 350; @@ -60,7 +70,7 @@ const STAGE_RANK: Readonly> = { /** What pressing E on the current target would do. */ type InteractKind = | 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'exit' | 'joint' | 'rack' | 'dishwasher' - | 'puddle' | 'water' | 'filming' | 'fainter'; + | 'puddle' | 'water' | 'filming' | 'fainter' | 'decks'; interface InteractTarget { 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 fainterAtMin: number | null = null; 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). */ private jumperTimes: number[] = []; 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.meters = null; // NightScene owns the single writer 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.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; }), this.bus.on('beat:tick', ({ beatIndex }) => { + this.lastBeat = { index: beatIndex, atMs: this.elapsedMs }; this.crowd.onBeat(beatIndex); this.view.onBeat(beatIndex); }), @@ -303,12 +320,14 @@ export class FloorDemoScene extends Phaser.Scene { this.night.location = 'floor'; this.meters = new Meters(this.bus, this.night); this.hud = new HudStub(this, this.bus); + this.beatMs = 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.fights?.destroy(); this.fights = new FightDirector({ bus: this.bus, rng: this.rng.stream('fights'), crowd: this.crowd }); this.bus.on('beat:tick', ({ beatIndex }) => { + this.lastBeat = { index: beatIndex, atMs: this.elapsedMs }; this.crowd.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.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') { ev.preventDefault(); this.uvMode = !this.uvMode; @@ -447,7 +473,7 @@ export class FloorDemoScene extends Phaser.Scene { // Escorting costs you the walk: no interacting, slower going. 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) { // Demo mode stands in for the door with a synthetic spawner. @@ -471,6 +497,7 @@ export class FloorDemoScene extends Phaser.Scene { this.trackMaggots(); this.tickHazards(dt); this.aid.update(dt); + this.tickDecks(dt); // Mopping: feet planted until it's done. The commitment IS the mechanic. if (this.mop) { @@ -630,6 +657,15 @@ export class FloorDemoScene extends Phaser.Scene { 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. for (const s of VENUE.stalls) { 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 === 'rack') return this.takeRack(); if (t.kind === 'dishwasher') return this.deliverRack(); + if (t.kind === 'decks') return this.toggleDecks(); if (t.kind === 'fainter') return this.openAid(); if (t.kind === 'puddle') return this.interactPuddle(); if (t.kind === 'water') { @@ -991,6 +1028,113 @@ export class FloorDemoScene extends Phaser.Scene { // ---- 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 * player: vomit + slips, the filming influencer's timer, the fainter. Runs diff --git a/src/scenes/floor/djShift.ts b/src/scenes/floor/djShift.ts new file mode 100644 index 0000000..401f4b2 --- /dev/null +++ b/src/scenes/floor/djShift.ts @@ -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 = { + 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 = { + 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; diff --git a/tests/floor/djShift.test.ts b/tests/floor/djShift.test.ts new file mode 100644 index 0000000..2377216 --- /dev/null +++ b/tests/floor/djShift.test.ts @@ -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); + } + }); +});