not-tonight/tests/typo.test.ts
type-two 2eb3c94ccd LANE-JUICE: audio engine, SFX set, five UI widgets, JuiceDemoScene
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>
2026-07-19 18:52:43 +10:00

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