/* 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'; // RC-375D84 S-2: physics module aggregator (was js/core.js) import levelsModule from '../js/levels/index.js'; // RC-375D84 S-2: levels module aggregator (was js/levels.js) const { LEVELS } = levelsModule; const HERE = dirname(fileURLToPath(import.meta.url)); // RC-375D84 S-2: 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 (Commander's 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); }