BLOBBO/src/paint/coverage-math.test.ts
type-two 3699a1635a Lane G: colour zones, paint puddles, MEGA/MINI scale buffs
Fixes the live finding that a racer can finish with 0% paint. Paint is now
ROUTE-guaranteed by ground puddles, and colours come in single-colour zones so
buffs actually cross the 20% threshold.

- paint/puddles.ts: PaintPuddle + installPuddles. Flat coloured strips (no
  collider) that stamp the blob's underside via skin.splatAtPoint on a ~6/s
  cadence while rolling through, emitting paint:splatted. The body doesn't
  physically tumble (rotations locked), so an accumulated roll phase walks the
  contact point along the travel great circle — a straight run paints a stripe
  and coverage climbs instead of piling on the south pole.
- paint/coverage-math.ts: extend computeModifiers with purple MEGA (size→1.45 +
  heavier) and pink MINI (size→0.62 + lighter than clean at high %). They fight:
  net size uses (purple − pink) strength; mass floored so MINI stays positive.
  (Lives here, not buffs.ts, because the pure mapping must stay THREE-free for
  the bare-node test; BuffSystem already forwards mods.size/massMul unchanged.)
- paint/coverage-math.test.ts: +MEGA/MINI/fight cases (48 assertions pass).
- course/zones.ts: installZones lays RED/GREEN/BLUE puddles down the centre line
  and the PURPLE(left)/PINK(right) fork onto the machine district — a pressure
  plate (baseMass*1.25) + boot on the MEGA lane, a visibly-low pink tunnel
  (underside y≈1.12) on the MINI lane. Returns cannon configs for integration.
- demo (demos/lane-g.html + src/demo/lane-g.ts): drive the zones, F = hands-free
  hold-forward, 1-4 teleport to each zone, live coverage/size/mass + plate HUD.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 12:00:54 +10:00

179 lines
6.5 KiB
TypeScript

/**
* Unit checks for the pure paint math. No test runner / no deps — run directly:
* node --experimental-strip-types src/paint/coverage-math.test.ts
* (Node ≥22; on Node ≥23 the flag is unnecessary.) Uses only console + throw so
* it also type-checks cleanly under the project's DOM tsconfig and is never
* bundled (no demo entry imports it).
*/
import type { CoverageReport, PaintColor } from '../contracts'
import {
ACTIVATE,
SUPER,
MEGA_SIZE,
MINI_SIZE,
MEGA_MASS,
MINI_MASS,
MASS_FLOOR,
buffStrength,
computeModifiers,
countBuckets,
coverageReportFromCounts,
colorIndex,
} from './coverage-math'
let passed = 0
function ok(cond: boolean, msg: string): void {
if (!cond) throw new Error('FAIL: ' + msg)
passed++
}
function near(a: number, b: number, msg: string, eps = 1e-9): void {
ok(Math.abs(a - b) <= eps, `${msg} (got ${a}, want ${b})`)
}
// helper: build a CoverageReport from a partial byColor map
function cov(part: Partial<Record<PaintColor, number>>): CoverageReport {
const byColor = {
red: 0, orange: 0, yellow: 0, green: 0, blue: 0, purple: 0, pink: 0,
...part,
}
const total = Object.values(byColor).reduce((a, b) => a + b, 0)
return { total, byColor }
}
// ---- colorIndex ----------------------------------------------------------
ok(colorIndex('red') === 1, 'red index is 1')
ok(colorIndex('pink') === 7, 'pink index is 7')
// ---- countBuckets --------------------------------------------------------
{
const mask = new Uint8Array(100) // all base (0)
for (let i = 0; i < 30; i++) mask[i] = 1 // red
for (let i = 30; i < 50; i++) mask[i] = 4 // green
const c = countBuckets(mask, 7)
ok(c[0] === 50, 'base count 50')
ok(c[1] === 30, 'red count 30')
ok(c[4] === 20, 'green count 20')
ok(c[7] === 0, 'pink count 0')
ok(c.length === 8, 'counts length 8 (base + 7)')
}
// ---- coverageReportFromCounts --------------------------------------------
{
// counts: [base, red, orange, yellow, green, blue, purple, pink]
const counts = [50, 30, 0, 0, 20, 0, 0, 0]
const r = coverageReportFromCounts(counts, 100)
near(r.byColor.red, 0.3, 'red fraction')
near(r.byColor.green, 0.2, 'green fraction')
near(r.total, 0.5, 'total painted fraction')
// empty texture → all zeros, no NaN
const z = coverageReportFromCounts([0, 0, 0, 0, 0, 0, 0, 0], 0)
near(z.total, 0, 'empty total 0')
near(z.byColor.blue, 0, 'empty blue 0')
}
// ---- buffStrength --------------------------------------------------------
near(buffStrength(0.0), 0, 'strength below threshold is 0')
near(buffStrength(0.199), 0, 'strength just below 20% is 0')
near(buffStrength(ACTIVATE), 0.15, 'strength at 20% is the floor')
near(buffStrength(SUPER), 1, 'strength at 70% is 1')
near(buffStrength(0.9), 1, 'strength above 70% pinned at 1')
near(buffStrength(0.45), 0.15 + 0.85 * 0.5, 'strength ramps linearly at midpoint')
// ---- computeModifiers ----------------------------------------------------
{
const clean = computeModifiers(cov({}))
near(clean.speedMul, 1, 'clean speedMul 1')
near(clean.grip, 0, 'clean grip 0')
ok(clean.waterproof === false, 'clean not waterproof')
near(clean.massMul, 1, 'clean massMul 1')
near(clean.glow, 0, 'clean no glow')
near(clean.size, 1, 'clean size 1 (no grow/shrink in MVP)')
}
{
// RED just under activation: no buff yet
const m = computeModifiers(cov({ red: 0.19 }))
near(m.speedMul, 1, 'red 19% → no speed buff')
}
{
// RED at 45% → speedMul = 1 + 0.6 * buffStrength(0.45)
const s = 0.15 + 0.85 * 0.5
const m = computeModifiers(cov({ red: 0.45 }))
near(m.speedMul, 1 + 0.6 * s, 'red 45% speedMul')
near(m.massMul, 1 + 0.8 * 0.45, 'massMul tracks total 45%')
near(m.glow, 0, 'red 45% not super')
}
{
// GREEN super (70%) → grip maxed + glow
const m = computeModifiers(cov({ green: 0.7 }))
near(m.grip, 1, 'green 70% grip maxed')
near(m.glow, 1, 'green 70% is super (glow)')
}
{
// BLUE ≥20% → waterproof
const m = computeModifiers(cov({ blue: 0.25 }))
ok(m.waterproof === true, 'blue 25% waterproof')
}
{
// massMul clamps at 1.8 when fully painted (no purple/pink → base only)
const m = computeModifiers(cov({ red: 0.5, blue: 0.5 }))
near(m.massMul, 1.8, 'full coverage massMul 1.8')
}
// ---- purple MEGA (grow + heavy) ------------------------------------------
{
// below 20% purple → no size change, no extra mass
const m = computeModifiers(cov({ purple: 0.19 }))
near(m.size, 1, 'purple 19% → size unchanged')
near(m.massMul, 1 + 0.8 * 0.19, 'purple 19% → base mass only')
}
{
// purple super (70%) → size maxed to 1.45, MEGA mass added, glow
const m = computeModifiers(cov({ purple: 0.7 }))
near(m.size, 1 + MEGA_SIZE, 'purple 70% → size 1.45')
near(m.massMul, 1 + 0.8 * 0.7 + MEGA_MASS, 'purple 70% → base + full MEGA mass')
near(m.glow, 1, 'purple 70% is super (glow)')
ok(m.massMul > 1.25, 'MEGA is heavy enough to trip a *1.25 plate')
}
{
// purple mid (45%) → partial grow via buffStrength
const s = 0.15 + 0.85 * 0.5
const m = computeModifiers(cov({ purple: 0.45 }))
near(m.size, 1 + MEGA_SIZE * s, 'purple 45% → partial grow')
}
// ---- pink MINI (shrink + light) ------------------------------------------
{
// below 20% pink → no size change
const m = computeModifiers(cov({ pink: 0.19 }))
near(m.size, 1, 'pink 19% → size unchanged')
}
{
// pink super (70%) → size shrunk to 0.62, lighter than clean
const m = computeModifiers(cov({ pink: 0.7 }))
near(m.size, 1 - MINI_SIZE, 'pink 70% → size 0.62')
ok(m.size < 1, 'pink shrinks the blob')
near(m.massMul, 1 + 0.8 * 0.7 - MINI_MASS, 'pink 70% → base mass minus full MINI reduction')
ok(m.massMul < 1, 'MINI is lighter than a clean blob at high %')
}
{
// pink can never drive mass to/below zero (MASS_FLOOR guards it)
const m = computeModifiers(cov({ pink: 1 }))
ok(m.massMul >= MASS_FLOOR, 'pink mass floored at MASS_FLOOR')
}
// ---- purple vs pink fight -------------------------------------------------
{
// equal purple + pink strength cancel → net normal size
const m = computeModifiers(cov({ purple: 0.5, pink: 0.5 }))
near(m.size, 1, 'equal purple+pink → size nets to 1 (they fight)')
}
{
// more purple than pink → still grows, but less than pure purple
const strong = computeModifiers(cov({ purple: 0.6 }))
const fought = computeModifiers(cov({ purple: 0.6, pink: 0.3 }))
ok(fought.size > 1, 'purple-dominant mix still grows')
ok(fought.size < strong.size, 'pink drags the grow down')
}
console.log(`coverage-math.test: ${passed} assertions passed ✓`)