Isle-Slingers/test/sim.test.mjs

440 lines
20 KiB
JavaScript
Raw Normal View History

/* ISLE SLINGERS — headless simulation tests (node, zero dependencies).
* Run: node test/sim.test.mjs (or `npm test`)
*
* Verifies the pure core: syntax, level data integrity, determinism,
* numeric stability, trajectory-preview fidelity, ability behaviour,
* win/lose rules, and that every level is clearable by a perfect archer.
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import ISLE from '../js/physics/index.js'; // physics module aggregator (was js/core.js)
import levelsModule from '../js/levels/index.js'; // levels module aggregator (was js/levels.js)
const { LEVELS } = levelsModule;
const HERE = dirname(fileURLToPath(import.meta.url));
// recursive list of every .js under js/ (the modularized tree).
// Replaces the old hardcoded [core,levels,audio,renderer,main] file list so
// the syntax + DOM-wiring arms scan the real submodules, not orphaned monoliths.
function listJs(rootRel) {
const base = join(HERE, '..', rootRel);
const out = [];
(function walk(d) {
for (const name of readdirSync(d)) {
const fp = join(d, name);
if (statSync(fp).isDirectory()) walk(fp);
else if (name.endsWith('.js')) out.push(fp);
}
})(base);
return out.sort();
}
const JS_FILES = listJs('js');
let passed = 0, failed = 0;
const failures = [];
function test(name, fn) {
try {
fn();
passed++;
console.log(' PASS ' + name);
} catch (e) {
failed++;
failures.push({ name, err: e });
console.log(' FAIL ' + name + ' :: ' + e.message);
}
}
function ok(cond, msg) { if (!cond) throw new Error(msg || 'assertion failed'); }
function close(a, b, tol, msg) {
ok(Number.isFinite(a) && Number.isFinite(b), (msg || 'value') + ' not finite: ' + a + ' vs ' + b);
if (Math.abs(a - b) > tol) throw new Error((msg || 'close') + ': ' + a + ' != ' + b + ' (tol ' + tol + ')');
}
function noNaN(state, where) {
const check = (v, ctx) => {
if (v != null && !Number.isFinite(v)) throw new Error(where + ' non-finite at ' + ctx);
};
for (const b of state.birds) { check(b.x, 'bird.x'); check(b.y, 'bird.y'); check(b.vx, 'bird.vx'); check(b.vy, 'bird.vy'); }
for (const bl of state.blocks) { check(bl.x, 'block.x'); check(bl.y, 'block.y'); check(bl.vx, 'block.vx'); check(bl.vy, 'block.vy'); }
for (const e of state.enemies) { check(e.x, 'enemy.x'); check(e.y, 'enemy.y'); check(e.vx, 'enemy.vx'); check(e.vy, 'enemy.vy'); }
}
function simSeconds(state, secs, dt = 1 / 120) {
const n = Math.round(secs / dt);
for (let i = 0; i < n; i++) ISLE.step(state, dt);
}
// A greedy "skilled player" AI: for each living enemy, evaluate candidate
// shots through the REAL physics on a cloned state, pick the shot that
// advances the board (kill the target, or the best damage/position),
// commit it, repeat. Two-stage search: a coarse full-power angle sweep,
// then a fine angle x pull ladder around the best coarse candidates —
// the player can pull at any power and any angle, so a full-power-only
// 2°-grid search models a player much too weakly.
// If a level cannot be cleared this way it is not clearable by a player
// either — the AI sees exactly what the player sees.
function cloneState(s) { return JSON.parse(JSON.stringify(s)); }
function aliveCount(s) { let n = 0; for (const e of s.enemies) if (!e.dead) n++; return n; }
// cheap exposure metric: number of blocks the straight line sling->enemy
// crosses. Fewer = the enemy is more directly shootable.
function lineBlocked(s, e) {
const sx = s.level.sling.x, sy = s.level.sling.y;
let n = 0;
for (const b of s.blocks) {
if (b.hp <= 0) continue;
// slab test of segment against AABB (expanded by nothing; blocks are thin enough)
const dx = e.x - sx, dy = e.y - sy;
const tMinX = (b.x - b.w / 2 - sx) / dx, tMaxX = (b.x + b.w / 2 - sx) / dx;
const tMinY = (b.y - b.h / 2 - sy) / dy, tMaxY = (b.y + b.h / 2 - sy) / dy;
const t0 = Math.max(Math.min(tMinX, tMaxX), Math.min(tMinY, tMaxY));
const t1 = Math.min(Math.max(tMinX, tMaxX), Math.max(tMinY, tMaxY));
if (t1 > Math.max(t0, 0) && t1 < 1) n++;
}
return n;
}
function evalShot(state, deg, pull, targetId) {
const cl = cloneState(state);
const th = deg * Math.PI / 180;
const v = ISLE.MAX_PULL * ISLE.LAUNCH_POWER * pull;
const drag = {
x: cl.level.sling.x - (Math.cos(th) * v) / ISLE.LAUNCH_POWER,
y: cl.level.sling.y + (Math.sin(th) * v) / ISLE.LAUNCH_POWER
};
const bird = cl.birds[cl.activeBird];
if (!bird) return null;
// wait for the previous bird to finish before the next shot (mirrors the
// in-game flow where the next slinger loads once the last one is spent)
let wait = 0;
while (wait < 3 && cl.activeBird >= 0 && cl.birds[cl.activeBird].state === 'flying') {
ISLE.step(cl, 1 / 120);
wait += 1 / 120;
}
const bird2 = cl.birds[cl.activeBird];
if (!ISLE.launch(cl, drag) || !bird2) return null;
simSeconds(cl, 5);
const target = cl.enemies[targetId];
const destroyed = cl.blocks.reduce((a, b) => a + (b.hp <= 0 ? 1 : 0), 0) -
state.blocks.reduce((a, b) => a + (b.hp <= 0 ? 1 : 0), 0);
const score = (target && target.dead ? 1000 : 0) +
(aliveCount(state) - aliveCount(cl)) * 50 + destroyed * 5;
const endDist = Math.hypot(bird2.x - target.x, bird2.y - target.y);
return { score, cl, endDist, destroyed };
}
const PULL_LADDER = [1, 0.85, 0.7, 0.55, 0.4];
function bestShot(state, targetId, opts = {}) {
const scoreOf = (r) => opts.approach
? r.score - r.cl.enemies[targetId].hp * 500 - r.endDist
: r.score;
// coarse pass: 2° steps at full power
const coarse = [];
for (let deg = 4; deg <= 90; deg += 2) {
const r = evalShot(state, deg, 1, targetId);
if (r) coarse.push({ deg, r });
}
coarse.sort((a, b) => scoreOf(b.r) - scoreOf(a.r));
let best = null;
const consider = (r) => {
const s = scoreOf(r);
if (best === null || s > best.s) best = { s, r };
};
// refine the top-3 coarse angles: ±3° at 0.5°, each across the pull
// ladder (players pull partially all the time)
for (const c of coarse.slice(0, 3)) {
for (let deg = c.deg - 3; deg <= c.deg + 3 + 1e-9; deg += 0.5) {
for (const pull of PULL_LADDER) {
const r = evalShot(state, deg, pull, targetId);
if (r) consider(r);
}
}
}
if (best && best.s <= 0 && !opts.allowZero) best = null;
return best ? best.r : null;
}
function clearLevel(levelDef) {
let state = ISLE.createGame(levelDef);
let guard = 0;
while (aliveCount(state) > 0 && ISLE.activeBird(state) && guard++ < 10) {
// aim at the most exposed living enemy (fewest blocks in the line of fire)
let target = null;
let bestBlock = Infinity;
for (const e of state.enemies) {
if (e.dead) continue;
const n = lineBlocked(state, e);
if (n < bestBlock) { bestBlock = n; target = e; }
}
// prefer shots that kill the target; otherwise take the shot that gets
// closest to it (usually breaches the shield in its line of fire).
// Approach scores are negative by construction, so the <= 0 gate only
// applies to real kill/demolition scores.
let kill = bestShot(state, target.id, { allowZero: false });
// a "kill-branch" shot is only worth a bird if it actually advances
// the target or the board — a +5 score from an unrelated block break
// is a wasted bird (this is how L1 used to die: 3 birds spent, the
// mogg left at 0.06 hp for a 4th bird that does not exist)
if (kill) {
const t = kill.cl.enemies[target.id];
const othersKilled = aliveCount(state) - aliveCount(kill.cl) - (t.dead ? 1 : 0);
if (!(t.dead || t.hp < state.enemies[target.id].hp || othersKilled > 0)) kill = null;
}
const best = kill || bestShot(state, target.id, { allowZero: true, approach: true });
if (!best) break;
if (!kill && best.endDist > 300 && best.destroyed === 0) break; // can't even connect
state = best.cl;
}
return state;
}
console.log('\n== ISLE SLINGERS headless sim ==');
// ------------------------------------------------------------------ 1. syntax
test('all JS files parse', () => {
const files = JS_FILES.map((fp) => fp.slice(fp.indexOf('js/')));
ok(files.length >= 15, 'S-2: expected >=15 js files in the modularized tree, got ' + files.length);
for (const fp of files) {
const src = readFileSync(join(HERE, '..', fp), 'utf8');
new Function(src); // throws on syntax error
}
// every submodule that carries real logic must be more than a stub
for (const fp of files.filter((f) => /physics\/(state|step|collision)\//.test(f) || /physics\/index/.test(f))) {
const src = readFileSync(join(HERE, '..', fp), 'utf8');
ok(src.length > 800, fp + ' suspiciously small');
}
});
// --------------------------------------------- 1b. DOM wiring (page level)
// The physics tests import only core/levels and are blind to the page: this
// arm asserts every element id referenced by js/*.js (getElementById and
// querySelector('#id')) exists in index.html. This is the class that killed
// the boot on first browser load (null element -> TypeError -> dead page).
test('DOM wiring: every id referenced by js/ exists in index.html', () => {
const html = readFileSync(join(HERE, '..', 'index.html'), 'utf8');
const ids = new Set();
let m;
const idRe = /id="([^"]+)"/g;
while ((m = idRe.exec(html))) ids.add(m[1]);
const refs = [];
const refRe = /getElementById\(\s*['"]([^'"]+)['"]\s*\)|querySelector(?:All)?\(\s*['"]#([A-Za-z0-9_-]+)['"]\s*\)/g;
for (const fp of JS_FILES) {
const rel = fp.slice(fp.indexOf('js/'));
const src = readFileSync(fp, 'utf8');
let r;
while ((r = refRe.exec(src))) refs.push(rel + '->' + (r[1] || r[2]));
}
ok(refs.length > 10, 'no DOM references found — scan pattern is broken');
const missing = [...new Set(refs.filter((rf) => !ids.has(rf.split('->')[1])))].sort();
ok(missing.length === 0, 'ids referenced by js/ but missing from index.html: ' + missing.join(', '));
});
// ---------------------------------------------------------- 2. level integrity
test('level data integrity (5 levels, ordered difficulty)', () => {
ok(LEVELS.length >= 3, 'need at least 3 levels, got ' + LEVELS.length);
for (let i = 0; i < LEVELS.length; i++) {
const lv = LEVELS[i];
ok(lv.birds.length >= 2, `L${lv.id}: needs birds`);
ok(lv.enemies.length >= 2, `L${lv.id}: needs enemies`);
ok(lv.blocks.length >= 3, `L${lv.id}: needs blocks`);
ok(lv.stars.length === 3 && lv.stars[0] < lv.stars[1] < lv.stars[2], `L${lv.id}: star thresholds`);
for (const b of lv.blocks) {
ok(b.x > 200 && b.x < 2380, `L${lv.id} block x in world`);
ok(b.y - b.h / 2 >= 0, `L${lv.id} block above ground plane`);
ok(['wood', 'ice', 'stone'].includes(b.mat), `L${lv.id} block mat`);
}
for (const e of lv.enemies) {
const r = ISLE.ENEMY_TYPES[e.kind].r;
const bottom = e.y + r;
ok(bottom <= 902, `L${lv.id} enemy not below ground (${bottom})`);
// supported: either resting on the ground OR its bottom sits on a flat block top
const onGround = bottom >= 898;
const onBlock = lv.blocks.some((b) => Math.abs((b.y - b.h / 2) - bottom) < 6 &&
e.x > b.x - b.w / 2 - 4 && e.x < b.x + b.w / 2 + 4);
ok(onGround || onBlock, `L${lv.id} enemy unsupported at (${e.x},${e.y}) bottom=${bottom}`);
}
}
// increasing difficulty: total enemy hp should not decrease
const hp = LEVELS.map((l) => l.enemies.reduce((a, e) => a + ISLE.ENEMY_TYPES[e.kind].hp, 0));
for (let i = 1; i < hp.length; i++) ok(hp[i] >= hp[i - 1], 'enemy HP budget should not decrease');
});
// ------------------------------------------------------------- 3. determinism
test('determinism: identical inputs -> identical states', () => {
function run() {
const s = ISLE.createGame(LEVELS[0]);
const ab = ISLE.activeBird(s);
ab.x = s.level.sling.x; ab.y = s.level.sling.y;
ab.vx = 900; ab.vy = -700; ab.state = 'flying'; ab.launchT = s.time;
s.launched++;
simSeconds(s, 6);
return JSON.stringify(s.birds.map((b) => [b.x, b.y, b.state])) + '|' + s.score + '|' +
JSON.stringify(s.blocks.map((b) => [Math.round(b.x), Math.round(b.y), b.hp]));
}
ok(run() === run(), 'two runs diverged');
});
// ------------------------------------------------------- 4. numeric stability
test('stability: 15s of physics after a full-power 45deg shot stays finite', () => {
const s = ISLE.createGame(LEVELS[4]); // heaviest structure
const ab = ISLE.activeBird(s);
const v = ISLE.MAX_PULL * ISLE.LAUNCH_POWER * 0.7;
ab.x = s.level.sling.x; ab.y = s.level.sling.y;
ab.vx = v * Math.cos(Math.PI / 4); ab.vy = -v * Math.sin(Math.PI / 4);
ab.state = 'flying'; ab.launchT = s.time;
s.launched++;
simSeconds(s, 15);
noNaN(s, 'stability');
for (const bl of s.blocks) ok(bl.x >= -1 && bl.x <= 2401, 'block x escaped world: ' + bl.x);
});
// ---------------------------------------------------- 5. trajectory fidelity
test('trajectory preview tracks the real flight (empty air)', () => {
const bare = { ...LEVELS[0], blocks: [], enemies: [], birds: ['wren'] };
const s = ISLE.createGame(bare);
const v = { vx: 1150, vy: -760 };
const pts = ISLE.trajectory(s, s.level.sling.x, s.level.sling.y, v.vx, v.vy, 46);
ok(pts.length >= 40, 'preview too short');
const ab = ISLE.activeBird(s);
ab.x = s.level.sling.x; ab.y = s.level.sling.y;
ab.vx = v.vx; ab.vy = v.vy; ab.state = 'flying'; ab.launchT = s.time;
s.launched++;
// live sim runs at 1/120; preview at 1/60 -> sample live every 2 steps
for (let i = 0; i < pts.length - 1 && i < 40; i++) {
simSeconds(s, 2 / 120);
const p = pts[i];
const d = Math.hypot(ab.x - p.x, ab.y - p.y);
ok(d < 40, `preview drift at step ${i}: ${d.toFixed(1)}px`);
}
});
// ------------------------------------------------------------ 6. abilities
test('abilities: each slinger has a distinct, usable mid-flight move', () => {
const cases = [
{ kind: 'wren', check: (s, ab) => ab.vx > 1200 },
{ kind: 'zippy', check: (s, ab) => ab.vy > 600 },
];
for (const c of cases) {
const s = ISLE.createGame({ ...LEVELS[0], blocks: [], enemies: [], birds: [c.kind] });
const ab = ISLE.activeBird(s);
ab.x = s.level.sling.x; ab.y = s.level.sling.y;
ab.vx = 600; ab.vy = -500; ab.state = 'flying'; ab.launchT = s.time;
s.launched++;
simSeconds(s, 0.4); // past the ability cooldown
ok(ISLE.triggerAbility(s), c.kind + ': ability should trigger');
c.check(s, ab);
ok(!ISLE.triggerAbility(s), c.kind + ': ability is one-shot');
}
// boulder quake damages nearby enemies without a direct hit
const s2 = ISLE.createGame({
...LEVELS[0], blocks: [], birds: ['boulder'],
enemies: [{ x: 700, y: 850, kind: 'mogg' }]
});
const ab2 = ISLE.activeBird(s2);
ab2.x = 600; ab2.y = 800; ab2.vx = 300; ab2.vy = 0; ab2.state = 'flying'; ab2.launchT = s2.time;
s2.launched++;
simSeconds(s2, 0.4);
ISLE.triggerAbility(s2);
ok(s2.enemies[0].hp < 1, 'quake should damage the nearby mogg');
});
// ------------------------------------------------------- 7. rules & scoring
test('win/lose: clearing all enemies wins; running dry loses', () => {
const winLvl = { ...LEVELS[0], blocks: [], enemies: [{ x: 900, y: 876, kind: 'mogg' }], birds: ['wren', 'wren'] };
let s = ISLE.createGame(winLvl);
const winShot = bestShot(s, 0);
ok(winShot, 'win lvl: found a killing shot');
s = winShot.cl;
simSeconds(s, 2);
ok(s.status === 'won', 'should win, got ' + s.status);
ok(s.score >= ISLE.BIRD_BONUS, 'win should include unused-bird bonus (score ' + s.score + ')');
const loseLvl = { ...LEVELS[0], blocks: [], enemies: [{ x: 1400, y: 876, kind: 'king' }], birds: ['wren'] };
let s2 = ISLE.createGame(loseLvl);
// pick the single hardest-hitting angle (max damage to the king)
let hitShot = null;
for (let deg = 4; deg <= 90; deg += 2) {
const r = evalShot(s2, deg, 1, 0);
if (!r) continue;
if (!hitShot || r.cl.enemies[0].hp < hitShot.cl.enemies[0].hp) hitShot = r;
}
ok(hitShot && hitShot.cl.enemies[0].hp < 3, 'lose lvl: shot should connect with the king');
s2 = hitShot.cl;
simSeconds(s2, 2);
ok(s2.status === 'lost', 'king (hp3) should survive one wren and lose the level, got ' + s2.status);
ok(!s2.enemies[0].dead, 'king should survive a single wren hit');
});
// ------------------------------------------------- 8. perfect-archer clears
for (const lv of LEVELS) {
test(`level ${lv.id} "${lv.name}" is clearable by a perfect archer`, () => {
const s = clearLevel(lv);
const alive = s.enemies.filter((e) => !e.dead);
ok(alive.length === 0, 'enemies survived the perfect archer: ' + alive.map((e) => e.kind).join(','));
noNaN(s, `L${lv.id} clear`);
console.log(` (L${lv.id}: score ${s.score}, birds used ${s.launched}/${lv.birds.length}, stars ${ISLE.starsFor(s)}/3)`);
});
}
// --------------------------------- 9. rest & framing (reported bugs #12-13)
// Bug #12: every fort must sit STILL when no one touches it. The original
// box-pair solver pushed resting blocks toward each other instead of apart,
// so each level's structure self-destructed within seconds — "objects
// glitching like crazy, bouncing around on their own." (The pre-fix probe
// measured 21-108px/s positional drift and 34-112px/s block speeds on a
// level with zero input.)
test('fort rest: every level sits still when untouched (15s idle)', () => {
const dt = 1 / 120;
for (const lv of LEVELS) {
const s = ISLE.createGame(lv);
let maxV = 0;
for (let t = 0; t < 15 * 120; t++) {
ISLE.step(s, dt);
if (t % 120 === 119) {
for (const bl of s.blocks) if (bl.hp > 0) maxV = Math.max(maxV, Math.hypot(bl.vx, bl.vy));
for (const e of s.enemies) if (!e.dead) maxV = Math.max(maxV, Math.hypot(e.vx, e.vy));
}
}
ok(maxV < 20, `L${lv.id}: structure still moving at idle (max ${maxV.toFixed(1)} px/s)`);
const p0 = s.blocks.filter((b) => b.hp > 0).map((b) => [b.x, b.y]);
for (let t = 0; t < 120; t++) ISLE.step(s, dt);
let drift = 0;
s.blocks.forEach((b, i) => {
if (b.hp > 0) drift = Math.max(drift, Math.hypot(b.x - p0[i][0], b.y - p0[i][1]));
});
ok(drift < 0.5, `L${lv.id}: structure drifted ${drift.toFixed(2)}px in 1s of idle`);
}
});
// Bug #13: while aiming, the view must contain the sling AND the whole
// action zone. The old camera framed only the sling (fort off-screen) or,
// mid-flight, only the bird (sling off-screen) — no single framing showed
// launch point and target, so "have to guess where to sling the birds."
test('aim framing: sling and full action zone visible while aiming (all levels)', () => {
for (const lv of LEVELS) {
const s = ISLE.createGame(lv);
ISLE.step(s, 1 / 120); // first frame snaps the camera onto its target
const c = s.camera;
const halfW = (ISLE.VIEW.w / 2) / c.zoom;
const actionRight = Math.max(
400,
...s.blocks.filter((b) => b.hp > 0).map((b) => b.x + b.w / 2),
...s.enemies.filter((e) => !e.dead).map((e) => e.x + e.r)
);
ok(c.zoom > 0.3 && c.zoom <= 1, `L${lv.id}: zoom out of range (${c.zoom.toFixed(2)})`);
ok(lv.sling.x >= c.x - halfW && lv.sling.x <= c.x + halfW,
`L${lv.id}: sling (${lv.sling.x}) outside view [${(c.x - halfW).toFixed(0)}, ${(c.x + halfW).toFixed(0)}]`);
ok(actionRight <= c.x + halfW,
`L${lv.id}: action right edge (${actionRight.toFixed(0)}) outside view right (${(c.x + halfW).toFixed(0)})`);
}
});
// ------------------------------------------------------------------ summary
console.log(`\n== ${passed} passed, ${failed} failed ==`);
if (failed) {
console.log('FAILURES:');
for (const f of failures) console.log(' - ' + f.name + ': ' + f.err.message);
process.exit(1);
}