Audio (src/audio/), raw WebAudio, no samples or libraries: - TechnoEngine: 128 BPM four-on-the-floor kick, off-beat hats, 2-bar 16th bassline, 8-bar pad. Look-ahead scheduler (25ms timer, 100ms horizon) is the beat authority — emits beat:tick with the SCHEDULED audio time, so consumers can compare honestly. Replaces core/StubBeatClock at integration; same event. Stale beats are resynced to the next downbeat rather than caught up: a throttled/hidden tab otherwise dumps a 64-beat backlog onto one sample. Music runs through the location lowpass; SFX bypass it and stay crisp. - Sfx: 10 procedural one-shots + rain bed, one shared noise buffer, voices self-disconnect on ended so a six-hour night doesn't accumulate nodes. UI (src/ui/), reusable Phaser containers, no door/floor imports: - Stamp (the signature interaction), Phone (Dazza thread + phoneTheatre), DialogueBox (typed-out, drunk-typo render), Clicker (digit roll), MeterHud (neon vibe / crowd-temp aggro / hype badge / 3 heat slots). - JuiceDemoScene exercises every widget and SFX, with a log-mapped cutoff slider and a DOOR/FLOOR toggle that routes through the bus. Verified in-browser: the door->floor sweep ramps 250Hz->18kHz over 600ms, monotonic and exponential (halfway lands ~2.1kHz, not the ~9kHz a linear ramp would give) — that late bloom is what sells the door opening. Under a real hidden tab, 52 consecutive beats scheduled with zero in the past, 75-98ms lead, max one beat per wake. main.ts touched to route J / #juice to the demo — outside lane ownership, flagged for sign-off in LANEHANDOVER.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
212 lines
8.2 KiB
TypeScript
212 lines
8.2 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { SeededRNG } from '../src/core/SeededRNG';
|
|
import { drunkify, typedLength, wobbleAt, type RngLike } from '../src/ui/typo';
|
|
|
|
/** Fresh stream every call — drunkify consumes draws, so tests must not share state. */
|
|
function stream(seed: number, name = 'typo'): RngLike {
|
|
return new SeededRNG(seed).stream(name);
|
|
}
|
|
|
|
const LINE =
|
|
"Yeah nah mate the line's round the corner, hop in and grab a schooner before last drinks at half two.";
|
|
|
|
// Long enough that two different seeds colliding on an identical mangle is not plausible.
|
|
const CORPUS = Array.from({ length: 6 }, () => LINE).join(' ');
|
|
|
|
function levenshtein(a: string, b: string): number {
|
|
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
for (let i = 1; i <= a.length; i++) {
|
|
const row = [i];
|
|
for (let j = 1; j <= b.length; j++) {
|
|
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
row.push(Math.min(row[j - 1]! + 1, prev[j]! + 1, prev[j - 1]! + cost));
|
|
}
|
|
prev = row;
|
|
}
|
|
return prev[b.length]!;
|
|
}
|
|
|
|
const nonLetters = (s: string): string => s.replace(/[a-zA-Z]/g, '');
|
|
|
|
/**
|
|
* Matches exactly those strings that keep `input`'s non-letter skeleton verbatim
|
|
* while every letter run has become zero or more *letters*. Comparing against this
|
|
* — rather than stripping non-letters first, which makes the assertion vacuous —
|
|
* is what catches a letter position emitting something that is not a letter.
|
|
*/
|
|
function letterShape(input: string): RegExp {
|
|
const MARK = '\u0000'; // cannot occur in dialogue text, so it is a safe placeholder
|
|
const src = input
|
|
.replace(/[a-zA-Z]+/g, MARK)
|
|
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
.split(MARK)
|
|
.join('[a-zA-Z]*');
|
|
return new RegExp(`^${src}$`);
|
|
}
|
|
|
|
describe('drunkify', () => {
|
|
it('is byte-identical for the same text, drunkenness and seed', () => {
|
|
const a = drunkify(CORPUS, { drunkenness: 0.8, rng: stream(1234) });
|
|
const b = drunkify(CORPUS, { drunkenness: 0.8, rng: stream(1234) });
|
|
expect(a).toBe(b);
|
|
expect(a).not.toBe(CORPUS); // sanity: 0.8 actually mangles something
|
|
});
|
|
|
|
it('diverges between seeds', () => {
|
|
const a = drunkify(CORPUS, { drunkenness: 0.8, rng: stream(1) });
|
|
const b = drunkify(CORPUS, { drunkenness: 0.8, rng: stream(2) });
|
|
expect(a).not.toBe(b);
|
|
});
|
|
|
|
it('is stream-scoped, not seed-scoped', () => {
|
|
const a = drunkify(CORPUS, { drunkenness: 0.8, rng: stream(7, 'patron:dialogue') });
|
|
const b = drunkify(CORPUS, { drunkenness: 0.8, rng: stream(7, 'patron:heckle') });
|
|
expect(a).not.toBe(b);
|
|
});
|
|
|
|
it('leaves text untouched at drunkenness 0, without consuming draws', () => {
|
|
const rng = stream(99);
|
|
expect(drunkify(LINE, { drunkenness: 0, rng })).toBe(LINE);
|
|
expect(rng.next()).toBe(stream(99).next());
|
|
});
|
|
|
|
it('leaves text untouched at or below the 0.25 sober floor', () => {
|
|
for (const d of [0.05, 0.1, 0.2, 0.249, 0.25]) {
|
|
expect(drunkify(CORPUS, { drunkenness: d, rng: stream(5) })).toBe(CORPUS);
|
|
}
|
|
// Just above the floor the odds per letter are tiny, so check across seeds
|
|
// that *something* slips — the threshold is a ramp, not a hard cut.
|
|
const seeds = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
|
const mangled = seeds.filter((s) => drunkify(CORPUS, { drunkenness: 0.3, rng: stream(s) }) !== CORPUS);
|
|
expect(mangled.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('clamps drunkenness into 0..1', () => {
|
|
expect(drunkify(CORPUS, { drunkenness: 4, rng: stream(42) })).toBe(
|
|
drunkify(CORPUS, { drunkenness: 1, rng: stream(42) }),
|
|
);
|
|
expect(drunkify(CORPUS, { drunkenness: -3, rng: stream(42) })).toBe(CORPUS);
|
|
});
|
|
|
|
it('treats a non-finite drunkenness as sober, without consuming draws', () => {
|
|
for (const d of [NaN, Infinity, -Infinity]) {
|
|
const rng = stream(77);
|
|
expect(drunkify(CORPUS, { drunkenness: d, rng })).toBe(CORPUS);
|
|
expect(rng.next()).toBe(stream(77).next());
|
|
}
|
|
});
|
|
|
|
it('never substitutes or drops non-letters, even at maximum', () => {
|
|
const punctuated = 'Oi! 3 IDs, 2 mates — $15 cover (cash only)?? "no wuckas"...\tline 4\n';
|
|
for (const seed of [11, 12, 13, 14, 15]) {
|
|
const out = drunkify(punctuated, { drunkenness: 1, rng: stream(seed) });
|
|
expect(nonLetters(out)).toBe(nonLetters(punctuated));
|
|
}
|
|
});
|
|
|
|
it('only ever emits letters where letters were', () => {
|
|
const punctuated = 'Oi! 3 IDs, 2 mates — $15 cover (cash only)?? "no wuckas"...\tline 4\n';
|
|
for (const source of [CORPUS, punctuated]) {
|
|
const shape = letterShape(source);
|
|
for (const seed of [808, 809, 810]) {
|
|
expect(drunkify(source, { drunkenness: 1, rng: stream(seed) })).toMatch(shape);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('mangles substantially more the drunker the speaker is', () => {
|
|
// Statistical, so average over several seeds and assert a loose ratio.
|
|
let tipsy = 0;
|
|
let maggot = 0;
|
|
for (const seed of [1, 2, 3, 4, 5, 6, 7, 8]) {
|
|
tipsy += levenshtein(CORPUS, drunkify(CORPUS, { drunkenness: 0.4, rng: stream(seed) }));
|
|
maggot += levenshtein(CORPUS, drunkify(CORPUS, { drunkenness: 1, rng: stream(seed) }));
|
|
}
|
|
expect(tipsy).toBeGreaterThan(0);
|
|
expect(maggot).toBeGreaterThan(tipsy * 2);
|
|
});
|
|
});
|
|
|
|
describe('typedLength', () => {
|
|
it('reveals nothing at zero elapsed', () => {
|
|
expect(typedLength(LINE, 0, 28)).toBe(0);
|
|
expect(typedLength(LINE, 0, 28, 1)).toBe(0);
|
|
});
|
|
|
|
it('reaches but never exceeds the full length', () => {
|
|
expect(typedLength(LINE, LINE.length * 28, 28)).toBe(LINE.length);
|
|
expect(typedLength(LINE, 10_000_000, 28, 1)).toBe(LINE.length);
|
|
expect(typedLength('', 10_000, 28)).toBe(0);
|
|
});
|
|
|
|
it('is monotonic in elapsed time', () => {
|
|
let prev = -1;
|
|
for (let ms = 0; ms <= 4000; ms += 17) {
|
|
const n = typedLength(LINE, ms, 28, 0.7);
|
|
expect(n).toBeGreaterThanOrEqual(prev);
|
|
prev = n;
|
|
}
|
|
});
|
|
|
|
it('makes drunk speakers type slower', () => {
|
|
const sober = typedLength(LINE, 900, 28, 0);
|
|
const drunk = typedLength(LINE, 900, 28, 1);
|
|
expect(drunk).toBeLessThan(sober);
|
|
expect(typedLength(LINE, 900, 28, 0.5)).toBeLessThan(sober);
|
|
});
|
|
|
|
it('clamps drunkenness like drunkify does', () => {
|
|
expect(typedLength(LINE, 900, 28, 5)).toBe(typedLength(LINE, 900, 28, 1));
|
|
expect(typedLength(LINE, 900, 28, -5)).toBe(typedLength(LINE, 900, 28, 0));
|
|
});
|
|
|
|
it('short-circuits to full length when charMs is non-positive', () => {
|
|
expect(typedLength(LINE, 0, 0)).toBe(LINE.length);
|
|
expect(typedLength(LINE, 0, -5, 1)).toBe(LINE.length);
|
|
});
|
|
|
|
// DialogueBox accumulates `elapsed += delta`, so a NaN that escaped here would
|
|
// stall the reveal for the rest of the night rather than for one frame.
|
|
it('treats a non-finite elapsed or drunkenness as zero', () => {
|
|
expect(typedLength(LINE, NaN, 28)).toBe(0);
|
|
expect(typedLength(LINE, NaN, 28, 1)).toBe(0);
|
|
expect(typedLength(LINE, 900, 28, NaN)).toBe(typedLength(LINE, 900, 28, 0));
|
|
});
|
|
});
|
|
|
|
describe('wobbleAt', () => {
|
|
it('is flat when sober', () => {
|
|
for (let i = 0; i < 20; i++) expect(wobbleAt(i, 0, i * 0.3)).toBe(0);
|
|
expect(wobbleAt(3, -1, 1.2)).toBe(0);
|
|
});
|
|
|
|
it('stays within the 1.6px amplitude at maximum', () => {
|
|
for (let i = 0; i < 200; i++) {
|
|
for (const phase of [0, 0.7, 2.5, 6.1]) {
|
|
expect(Math.abs(wobbleAt(i, 1, phase))).toBeLessThanOrEqual(1.6);
|
|
expect(Math.abs(wobbleAt(i, 4, phase))).toBeLessThanOrEqual(1.6); // clamped
|
|
}
|
|
}
|
|
});
|
|
|
|
it('scales with drunkenness', () => {
|
|
// Index/phase chosen so sin() is well away from a zero crossing.
|
|
const at = (d: number): number => Math.abs(wobbleAt(0, d, Math.PI / 2));
|
|
expect(at(0.5)).toBeGreaterThan(at(0.25));
|
|
expect(at(1)).toBeGreaterThan(at(0.5));
|
|
});
|
|
|
|
// Same reasoning as typedLength: phase is accumulated, and a NaN reaching
|
|
// text.y makes Phaser drop the glyph silently instead of erroring.
|
|
it('treats a non-finite phase or drunkenness as zero', () => {
|
|
expect(wobbleAt(0, 1, NaN)).toBe(0);
|
|
expect(wobbleAt(5, 1, Infinity)).toBe(0);
|
|
expect(wobbleAt(5, NaN, 1.2)).toBe(0);
|
|
});
|
|
|
|
it('is deterministic for the same index, drunkenness and phase', () => {
|
|
expect(wobbleAt(7, 0.6, 1.25)).toBe(wobbleAt(7, 0.6, 1.25));
|
|
expect(wobbleAt(8, 0.6, 1.25)).not.toBe(wobbleAt(7, 0.6, 1.25));
|
|
});
|
|
});
|