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>
179 lines
6.5 KiB
TypeScript
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 ✓`)
|