Second game mode selectable at dog select (host-authoritative online): - js/sim_arena.js: top-down sim, same export surface as sim.js — x/y plane plus a z hop axis (Space / comma); KOs by knocking dogs into abyss pits - js/mapgen_arena.js: mirrored symmetric arenas — walls, pits, center clearing, guaranteed spawn-to-center corridors, crystals/spikes/launch pads, mirrored saw pairs; fallback pit pair since pits are the only KO mechanic - specials re-imagined per dog: zoom dash, ram + hop-slam, zip-leash slingshot, blink, hover (floats over pits/saws/spikes) - render: arena tiles with wall shadows + void pits, top-down dog drawing with z-lift over a ground shadow; new fall event/sfx - B.HOP input bit so 'up' can mean north in top-down (Space also jumps in side view); mode toggle UI + mode carried in start/rematch net messages Tested: arena mapgen determinism/symmetry/connectivity + chaotic matches + pit/hop scenarios headless; local chaos + full P2P arena match (host mode flip syncs to client) in two headless browsers with zero page errors.
260 lines
12 KiB
JavaScript
260 lines
12 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 * as arena from '../js/sim_arena.js';
|
|
import { generateMap, sawPos } from '../js/mapgen.js';
|
|
import { generateArena } from '../js/mapgen_arena.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');
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Top-down "Dogpit" arena mode
|
|
|
|
// --- arena mapgen: determinism, symmetry, connectivity ---
|
|
for (let s = 1; s <= 30; s++) {
|
|
const seed = (s * 1103515245 + 12345) >>> 0;
|
|
const map = generateArena(seed);
|
|
if (map.spawns.length !== 4) fail(`arena seed ${seed}: expected 4 spawns`);
|
|
const map2 = generateArena(seed);
|
|
for (let i = 0; i < map.tiles.length; i++) {
|
|
if (map.tiles[i] !== map2.tiles[i]) { fail(`arena seed ${seed}: non-deterministic`); break; }
|
|
}
|
|
let pits = 0, walls = 0, crystals = 0;
|
|
for (const t of map.tiles) { if (t === T.PIT) pits++; if (t === T.SOLID) walls++; if (t === T.CRYSTAL) crystals++; }
|
|
if (pits < 4) fail(`arena seed ${seed}: too few pit tiles (${pits})`);
|
|
if (crystals < 2) fail(`arena seed ${seed}: too few crystals (${crystals})`);
|
|
// mirror symmetry of walls and pits
|
|
for (let y = 0; y < GH; y++) for (let x = 0; x < GW; x++) {
|
|
const a = map.tiles[y * GW + x], b = map.tiles[y * GW + (GW - 1 - x)];
|
|
const cls = (t) => (t === T.SOLID ? 1 : t === T.PIT ? 2 : 0);
|
|
if (cls(a) !== cls(b)) { fail(`arena seed ${seed}: asymmetric at ${x},${y}`); y = GH; break; }
|
|
}
|
|
// spawns clear + all mutually reachable over walkable tiles (corridors guarantee it)
|
|
for (const sp of map.spawns) {
|
|
if (!boxFree(map, sp.x, sp.y, 18, 14)) fail(`arena seed ${seed}: spawn blocked`);
|
|
}
|
|
{
|
|
const walkable = (t) => t === T.EMPTY || t === T.SPIKE || t === T.PADTOP;
|
|
const seen = new Uint8Array(GW * GH);
|
|
const q = [[Math.floor(map.spawns[0].x / 12), Math.floor(map.spawns[0].y / 12)]];
|
|
seen[q[0][1] * GW + q[0][0]] = 1;
|
|
while (q.length) {
|
|
const [x, y] = q.pop();
|
|
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
|
const nx = x + dx, ny = y + dy;
|
|
if (nx < 0 || nx >= GW || ny < 0 || ny >= GH) continue;
|
|
const i = ny * GW + nx;
|
|
if (!seen[i] && walkable(map.tiles[i])) { seen[i] = 1; q.push([nx, ny]); }
|
|
}
|
|
}
|
|
for (const sp of map.spawns) {
|
|
if (!seen[Math.floor(sp.y / 12) * GW + Math.floor(sp.x / 12)]) fail(`arena seed ${seed}: spawn unreachable`);
|
|
}
|
|
}
|
|
}
|
|
ok('arena mapgen: 30 seeds, deterministic, mirrored, connected, clear spawns');
|
|
|
|
// --- arena chaotic matches (hop + specials in the input mix) ---
|
|
const ARENA_BITS = [0, B.L, B.R, B.U, B.D, B.L | B.U, B.R | B.D, B.HOP, B.R | B.HOP,
|
|
B.ATK, B.SPC, B.U | B.SPC, B.L | B.D | B.SPC, B.ITM, B.HOP | B.ATK, B.R | B.U | B.SPC | B.HOP];
|
|
for (let trial = 0; trial < 10; trial++) {
|
|
const seed = (trial * 69069 + 1) >>> 0;
|
|
const rng = mulberry32(seed ^ 0xA1A1);
|
|
const g = arena.createGame(seed, [{ charId: trial % 5 }, { charId: (trial + 2) % 5 }]);
|
|
const inputs = [0, 0];
|
|
for (let t = 0; t < 12000 && !g.over; t++) {
|
|
if (t % 7 === 0) inputs[0] = ARENA_BITS[Math.floor(rng() * ARENA_BITS.length)];
|
|
if (t % 5 === 0) inputs[1] = ARENA_BITS[Math.floor(rng() * ARENA_BITS.length)];
|
|
const evs = arena.step(g, inputs);
|
|
for (const p of g.players) {
|
|
if (!Number.isFinite(p.x) || !Number.isFinite(p.y) || !Number.isFinite(p.z)) {
|
|
fail(`arena trial ${trial} tick ${t}: non-finite player state`);
|
|
t = 1e9; break;
|
|
}
|
|
}
|
|
if (t % 500 === 250) {
|
|
const snap = JSON.parse(JSON.stringify(arena.snapshot(g, evs)));
|
|
const g2 = arena.createGame(seed, [{ charId: trial % 5 }, { charId: (trial + 2) % 5 }]);
|
|
arena.applySnapshot(g2, snap);
|
|
if (Math.abs(g2.players[0].x - g.players[0].x) > 0.2 || Math.abs((g2.players[0].z || 0) - g.players[0].z) > 0.2) {
|
|
fail(`arena trial ${trial}: snapshot round-trip diverged`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ok('arena sim: 10 chaotic matches, finite state, snapshot round-trips');
|
|
|
|
// --- targeted: walking onto a pit KOs after the fall animation ---
|
|
{
|
|
const g = arena.createGame(777, [{ charId: 0 }, { charId: 1 }]);
|
|
g.freeze = 0;
|
|
const p = g.players[0];
|
|
// find a pit tile and park the dog on it
|
|
let pit = -1;
|
|
for (let i = 0; i < g.map.tiles.length; i++) if (g.map.tiles[i] === T.PIT) { pit = i; break; }
|
|
if (pit < 0) fail('arena seed 777 has no pit');
|
|
else {
|
|
p.x = (pit % GW) * 12 + 6; p.y = Math.floor(pit / GW) * 12 + 6; p.z = 0;
|
|
const before = p.stocks;
|
|
let fell = false;
|
|
for (let i = 0; i < 120; i++) {
|
|
const evs = arena.step(g, [0, 0]);
|
|
if (evs.some(e => e.t === 'fall')) fell = true;
|
|
if (p.stocks < before) break;
|
|
}
|
|
if (!fell) fail('pit never triggered a fall');
|
|
if (p.stocks !== before - 1) fail('pit fall did not cost a stock');
|
|
else ok('arena pits: falling in costs a stock');
|
|
}
|
|
}
|
|
|
|
// --- targeted: hop crosses a pit ---
|
|
{
|
|
const g = arena.createGame(777, [{ charId: 0 }, { charId: 1 }]);
|
|
g.freeze = 0;
|
|
const p = g.players[0];
|
|
// 3-wide pit straight ahead; hop from the edge while running right
|
|
const ty = Math.floor(p.y / 12);
|
|
const tx = Math.floor(p.x / 12);
|
|
for (let i = 1; i <= 3; i++) g.map.tiles[ty * GW + tx + 1 + i] = T.PIT;
|
|
// run right to build speed, hop at the lip
|
|
let lost = false;
|
|
for (let i = 0; i < 120; i++) {
|
|
const overLip = Math.floor(p.x / 12) >= tx + 1;
|
|
arena.step(g, [B.R | (overLip && p.z <= 0 && p.falling <= 0 ? B.HOP : 0), 0]);
|
|
if (p.falling > 0 || p.stocks < 3) { lost = true; break; }
|
|
if (Math.floor(p.x / 12) > tx + 4) break; // made it across
|
|
}
|
|
if (lost) fail('hop could not cross a 3-tile pit');
|
|
else ok('arena hop: clears a 3-tile pit at speed');
|
|
}
|
|
|
|
console.log(failures ? `\n${failures} FAILURE(S)` : '\nAll smoke tests passed.');
|
|
process.exit(failures ? 1 : 0);
|