barkour/test/smoke.mjs
w1n5t0n 99468cd379 fix: address all 21 confirmed findings from multi-agent review + live P2P testing
Sim: clamp knockback under tile size (wall tunneling), bomb/ball terminal
velocity, grapple constraint no longer drags players into terrain, bite
window ends on getting hit, shield/boost cleared on death, spawn invuln
preserved through countdown, spike/saw events only on real damage.
Mapgen: spawn footprint validation (was embedding dogs in 58% of seeds),
saw paths rejected if they grind terrain or sweep spawns, towers no longer
hang slabs over pits. Net: heartbeat+watchdog for dead peers, broker-error
tolerance once P2P link is up, join/host reentrancy guards. UI: text-field
keystrokes no longer hit game hotkeys, audio unlocks on mouse, textBaseline
leak, snapshot carries onGround/landT so client dogs animate.
2026-06-10 14:47:40 +02:00

136 lines
6.1 KiB
JavaScript

// Headless smoke test for the DOM-free sim core.
// Runs full matches with random inputs across many seeds and asserts sanity.
import { createGame, step, snapshot, applySnapshot } from '../js/sim.js';
import { generateMap, sawPos } from '../js/mapgen.js';
import { mulberry32 } from '../js/rng.js';
import { boxFree, solidAtPx } from '../js/physics.js';
import { GW, GH, T, B } from '../js/consts.js';
let failures = 0;
const fail = (msg) => { failures++; console.error('FAIL:', msg); };
const ok = (msg) => console.log(' ok:', msg);
// --- mapgen across many seeds ---
for (let s = 1; s <= 40; s++) {
const seed = s * 2654435761 >>> 0;
const map = generateMap(seed);
if (map.spawns.length < 2) fail(`seed ${seed}: only ${map.spawns.length} spawns`);
if (map.tiles.length !== GW * GH) fail(`seed ${seed}: bad tile count`);
let solids = 0, crystals = 0;
for (const t of map.tiles) { if (t === T.SOLID) solids++; if (t === T.CRYSTAL) crystals++; }
if (solids < 200) fail(`seed ${seed}: suspiciously empty map (${solids} solids)`);
if (crystals < 1) fail(`seed ${seed}: no crystals`);
for (const sp of map.spawns) {
if (!Number.isFinite(sp.x) || !Number.isFinite(sp.y)) fail(`seed ${seed}: NaN spawn`);
if (sp.y < 0 || sp.y > GH * 12) fail(`seed ${seed}: spawn off map (${sp.x},${sp.y})`);
}
// determinism: same seed → identical map
const map2 = generateMap(seed);
for (let i = 0; i < map.tiles.length; i++) {
if (map.tiles[i] !== map2.tiles[i]) { fail(`seed ${seed}: non-deterministic tiles`); break; }
}
for (const saw of map.saws) {
const p = sawPos(saw, 0.37);
if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) fail(`seed ${seed}: NaN saw pos`);
}
// spawn footprints must be clear of solids (review finding: 58% of seeds embedded players)
for (const sp of map.spawns) {
if (!boxFree(map, sp.x, sp.y, 18, 14)) fail(`seed ${seed}: spawn embedded in terrain at (${sp.x},${sp.y})`);
}
// saw paths must not grind through terrain or sweep spawn points
for (const saw of map.saws) {
for (let f = 0; f < 1; f += 0.02) {
const p = sawPos(saw, f);
if (solidAtPx(map, p.x, p.y)) { fail(`seed ${seed}: saw path inside solid at (${p.x | 0},${p.y | 0})`); break; }
for (const sp of map.spawns) {
if (Math.hypot(sp.x - p.x, sp.y - p.y) < 25) { fail(`seed ${seed}: saw sweeps spawn point`); f = 2; break; }
}
}
}
}
ok('mapgen: 40 seeds, deterministic, sane geometry, clear spawns, clean saw paths');
// --- full-match simulation with chaotic inputs ---
const ALL_BITS = [0, B.L, B.R, B.L | B.JUMP | B.U, B.R | B.JUMP | B.U, B.ATK, B.SPC, B.R | B.SPC,
B.L | B.ATK, B.D, B.D | B.JUMP, B.ITM, B.R | B.JUMP | B.SPC | B.U, B.U | B.SPC, B.L | B.D | B.SPC];
for (let trial = 0; trial < 12; trial++) {
const seed = (trial * 48271 + 7) >>> 0;
const rng = mulberry32(seed ^ 0x5151);
const charA = trial % 5, charB = (trial + 3) % 5;
const g = createGame(seed, [{ charId: charA }, { charId: charB }]);
let kos = 0, ticks = 0;
const inputs = [0, 0];
for (let t = 0; t < 12000 && !g.over; t++) {
ticks++;
if (t % 7 === 0) inputs[0] = ALL_BITS[Math.floor(rng() * ALL_BITS.length)];
if (t % 5 === 0) inputs[1] = ALL_BITS[Math.floor(rng() * ALL_BITS.length)];
const evs = step(g, inputs);
for (const ev of evs) if (ev.t === 'ko') kos++;
for (const p of g.players) {
if (!Number.isFinite(p.x) || !Number.isFinite(p.y) || !Number.isFinite(p.vx) || !Number.isFinite(p.vy)) {
fail(`trial ${trial} (chars ${charA}v${charB}) tick ${t}: non-finite player state`);
t = 1e9; break;
}
}
for (const e of g.ents) {
if (!Number.isFinite(e.x) || !Number.isFinite(e.y)) { fail(`trial ${trial} tick ${t}: non-finite entity`); t = 1e9; break; }
}
// snapshot round-trip every so often
if (t % 500 === 250) {
const snap = JSON.parse(JSON.stringify(snapshot(g, evs)));
const g2 = createGame(seed, [{ charId: charA }, { charId: charB }]);
applySnapshot(g2, snap);
if (Math.abs(g2.players[0].x - g.players[0].x) > 0.2) fail(`trial ${trial}: snapshot round-trip diverged`);
if (g2.tick !== g.tick) fail(`trial ${trial}: snapshot tick mismatch`);
}
}
if (!g.over && kos === 0) {
// chaotic play that never produced a single KO in 12k ticks would be odd but
// not impossible; only flag it, don't fail
console.log(` note: trial ${trial} (chars ${charA} vs ${charB}) — no KOs in ${ticks} ticks`);
}
}
ok('sim: 12 chaotic matches, all character matchups, finite state throughout');
// --- targeted: mining drops a pickup ---
{
// build a controlled scenario: place a crystal next to player 0 and bite it
const g = createGame(12345, [{ charId: 1 }, { charId: 1 }]);
g.freeze = 0;
const p = g.players[0];
const sx = p.x, sy = p.y;
const tx = Math.floor(p.x / 12) + 2, ty = Math.floor(p.y / 12);
g.map.tiles[ty * GW + tx] = T.CRYSTAL;
g.map.hp[ty * GW + tx] = 3;
let dropped = false;
for (let i = 0; i < 200 && !dropped; i++) {
// pin the dog next to the crystal — the bite's forward lunge would otherwise
// shove it off terrain steps and out of reach
p.x = sx; p.y = sy; p.vx = 0; p.vy = 0; p.face = 1;
step(g, [i % 30 === 0 ? B.ATK : 0, 0]);
if (g.ents.some(e => e.k === 'pickup')) dropped = true;
}
if (!dropped) fail('mining a crystal never dropped a pickup');
else ok('mining: crystal breaks into a powerup pickup');
}
// --- targeted: KO + stocks → match over ---
{
const g = createGame(999, [{ charId: 0 }, { charId: 4 }]);
g.freeze = 0;
// hurl player 1 into the abyss repeatedly
for (let n = 0; n < 5 && !g.over; n++) {
g.players[1].x = -2000; // past blast zone
for (let i = 0; i < 200 && !g.over; i++) {
step(g, [0, 0]);
if (g.players[1].respawn > 0 && g.players[1].stocks > 0) g.players[1].respawn = 1;
}
}
if (!g.over) fail('match never ended after repeated KOs');
else if (g.over.winner !== 0) fail(`wrong winner: ${g.over.winner}`);
else ok('stocks: 3 KOs end the match with the right winner');
}
console.log(failures ? `\n${failures} FAILURE(S)` : '\nAll smoke tests passed.');
process.exit(failures ? 1 : 0);