import { describe, expect, it } from 'vitest'; import { BUILD_FIZZLE_BARS, DROP_CLEAN_MS, DROP_LATE_MS, DROP_PAYOUT, REQUEST_HIT, TIP_AMOUNTS, TIP_OFFER_CHANCE, judgeDrop, msToNearestBar, rollTip, } from '../../src/scenes/floor/djShift'; import { SeededRNG } from '../../src/core/SeededRNG'; 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); } }); }); describe('the tip economy', () => { it('rollTip: sometimes cash, always a real amount, never help for the song', () => { const rng = new SeededRNG(7).stream('tips'); let offers = 0; for (let i = 0; i < 2000; i++) { const tip = rollTip(rng); if (tip === null) continue; offers++; expect(TIP_AMOUNTS).toContain(tip); } // Roughly TIP_OFFER_CHANCE of requests wave cash. expect(offers / 2000).toBeGreaterThan(TIP_OFFER_CHANCE - 0.06); expect(offers / 2000).toBeLessThan(TIP_OFFER_CHANCE + 0.06); }); });