2026-09-28 17:20:17 -05:00
|
|
|
/* 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.
|
|
|
|
|
*/
|
Physics upgrade + repo modularization (RC-375D84)
Delivered by Hermies Qwen, gated and deployed by Relay.
- Board tip/topple: full continuous rotation when a board's center of
mass passes its support edge (Q1: smooth pivot swing, no discrete
snap). Falling boards deal impact damage and can kill enemies in
their fall path.
- Falling-object impact damage: blocks and birds can now kill enemies
from vertical drops and side plows (velocity-aware crush gate),
chain momentum transfers correctly through stacks (mass-weighted
contact), repeated impacts finish tougher enemies.
- Landed-bird physics: a bird resting on a board remains a real
physical body under continuous gravity (Q2: no lifetime timer). If
its platform is destroyed it falls, damages what it lands on
(chain reaction), and can kill enemies.
- Repo modularized: folders = modules, subfolders = submodules,
architecture.md + MODULES.md at root, an agents.md card in every
module and submodule (17 total).
- Test suite grown from 27 to 47 (20 new RED-watched tests for the
above behaviors), all green. Golden differential against the
original core confirmed the only behavioral changes are the
intended ones.
Live at http://10.244.136.121:8093/, independently re-verified by
Relay (full suite re-run, module structure check, headless Chromium
smoke) before this push.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 08:21:18 -05:00
|
|
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
2026-09-28 17:20:17 -05:00
|
|
|
import { dirname, join } from 'node:path';
|
|
|
|
|
import { fileURLToPath } from 'node:url';
|
Physics upgrade + repo modularization (RC-375D84)
Delivered by Hermies Qwen, gated and deployed by Relay.
- Board tip/topple: full continuous rotation when a board's center of
mass passes its support edge (Q1: smooth pivot swing, no discrete
snap). Falling boards deal impact damage and can kill enemies in
their fall path.
- Falling-object impact damage: blocks and birds can now kill enemies
from vertical drops and side plows (velocity-aware crush gate),
chain momentum transfers correctly through stacks (mass-weighted
contact), repeated impacts finish tougher enemies.
- Landed-bird physics: a bird resting on a board remains a real
physical body under continuous gravity (Q2: no lifetime timer). If
its platform is destroyed it falls, damages what it lands on
(chain reaction), and can kill enemies.
- Repo modularized: folders = modules, subfolders = submodules,
architecture.md + MODULES.md at root, an agents.md card in every
module and submodule (17 total).
- Test suite grown from 27 to 47 (20 new RED-watched tests for the
above behaviors), all green. Golden differential against the
original core confirmed the only behavioral changes are the
intended ones.
Live at http://10.244.136.121:8093/, independently re-verified by
Relay (full suite re-run, module structure check, headless Chromium
smoke) before this push.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 08:21:18 -05:00
|
|
|
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)
|
2026-09-28 17:20:17 -05:00
|
|
|
const { LEVELS } = levelsModule;
|
|
|
|
|
|
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
Physics upgrade + repo modularization (RC-375D84)
Delivered by Hermies Qwen, gated and deployed by Relay.
- Board tip/topple: full continuous rotation when a board's center of
mass passes its support edge (Q1: smooth pivot swing, no discrete
snap). Falling boards deal impact damage and can kill enemies in
their fall path.
- Falling-object impact damage: blocks and birds can now kill enemies
from vertical drops and side plows (velocity-aware crush gate),
chain momentum transfers correctly through stacks (mass-weighted
contact), repeated impacts finish tougher enemies.
- Landed-bird physics: a bird resting on a board remains a real
physical body under continuous gravity (Q2: no lifetime timer). If
its platform is destroyed it falls, damages what it lands on
(chain reaction), and can kill enemies.
- Repo modularized: folders = modules, subfolders = submodules,
architecture.md + MODULES.md at root, an agents.md card in every
module and submodule (17 total).
- Test suite grown from 27 to 47 (20 new RED-watched tests for the
above behaviors), all green. Golden differential against the
original core confirmed the only behavioral changes are the
intended ones.
Live at http://10.244.136.121:8093/, independently re-verified by
Relay (full suite re-run, module structure check, headless Chromium
smoke) before this push.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 08:21:18 -05:00
|
|
|
|
|
|
|
|
// 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');
|
2026-09-28 17:20:17 -05:00
|
|
|
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', () => {
|
Physics upgrade + repo modularization (RC-375D84)
Delivered by Hermies Qwen, gated and deployed by Relay.
- Board tip/topple: full continuous rotation when a board's center of
mass passes its support edge (Q1: smooth pivot swing, no discrete
snap). Falling boards deal impact damage and can kill enemies in
their fall path.
- Falling-object impact damage: blocks and birds can now kill enemies
from vertical drops and side plows (velocity-aware crush gate),
chain momentum transfers correctly through stacks (mass-weighted
contact), repeated impacts finish tougher enemies.
- Landed-bird physics: a bird resting on a board remains a real
physical body under continuous gravity (Q2: no lifetime timer). If
its platform is destroyed it falls, damages what it lands on
(chain reaction), and can kill enemies.
- Repo modularized: folders = modules, subfolders = submodules,
architecture.md + MODULES.md at root, an agents.md card in every
module and submodule (17 total).
- Test suite grown from 27 to 47 (20 new RED-watched tests for the
above behaviors), all green. Golden differential against the
original core confirmed the only behavioral changes are the
intended ones.
Live at http://10.244.136.121:8093/, independently re-verified by
Relay (full suite re-run, module structure check, headless Chromium
smoke) before this push.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 08:21:18 -05:00
|
|
|
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');
|
2026-09-28 17:20:17 -05:00
|
|
|
new Function(src); // throws on syntax error
|
Physics upgrade + repo modularization (RC-375D84)
Delivered by Hermies Qwen, gated and deployed by Relay.
- Board tip/topple: full continuous rotation when a board's center of
mass passes its support edge (Q1: smooth pivot swing, no discrete
snap). Falling boards deal impact damage and can kill enemies in
their fall path.
- Falling-object impact damage: blocks and birds can now kill enemies
from vertical drops and side plows (velocity-aware crush gate),
chain momentum transfers correctly through stacks (mass-weighted
contact), repeated impacts finish tougher enemies.
- Landed-bird physics: a bird resting on a board remains a real
physical body under continuous gravity (Q2: no lifetime timer). If
its platform is destroyed it falls, damages what it lands on
(chain reaction), and can kill enemies.
- Repo modularized: folders = modules, subfolders = submodules,
architecture.md + MODULES.md at root, an agents.md card in every
module and submodule (17 total).
- Test suite grown from 27 to 47 (20 new RED-watched tests for the
above behaviors), all green. Golden differential against the
original core confirmed the only behavioral changes are the
intended ones.
Live at http://10.244.136.121:8093/, independently re-verified by
Relay (full suite re-run, module structure check, headless Chromium
smoke) before this push.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 08:21:18 -05:00
|
|
|
}
|
|
|
|
|
// 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');
|
2026-09-28 17:20:17 -05:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// --------------------------------------------- 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;
|
Physics upgrade + repo modularization (RC-375D84)
Delivered by Hermies Qwen, gated and deployed by Relay.
- Board tip/topple: full continuous rotation when a board's center of
mass passes its support edge (Q1: smooth pivot swing, no discrete
snap). Falling boards deal impact damage and can kill enemies in
their fall path.
- Falling-object impact damage: blocks and birds can now kill enemies
from vertical drops and side plows (velocity-aware crush gate),
chain momentum transfers correctly through stacks (mass-weighted
contact), repeated impacts finish tougher enemies.
- Landed-bird physics: a bird resting on a board remains a real
physical body under continuous gravity (Q2: no lifetime timer). If
its platform is destroyed it falls, damages what it lands on
(chain reaction), and can kill enemies.
- Repo modularized: folders = modules, subfolders = submodules,
architecture.md + MODULES.md at root, an agents.md card in every
module and submodule (17 total).
- Test suite grown from 27 to 47 (20 new RED-watched tests for the
above behaviors), all green. Golden differential against the
original core confirmed the only behavioral changes are the
intended ones.
Live at http://10.244.136.121:8093/, independently re-verified by
Relay (full suite re-run, module structure check, headless Chromium
smoke) before this push.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 08:21:18 -05:00
|
|
|
for (const fp of JS_FILES) {
|
|
|
|
|
const rel = fp.slice(fp.indexOf('js/'));
|
|
|
|
|
const src = readFileSync(fp, 'utf8');
|
2026-09-28 17:20:17 -05:00
|
|
|
let r;
|
Physics upgrade + repo modularization (RC-375D84)
Delivered by Hermies Qwen, gated and deployed by Relay.
- Board tip/topple: full continuous rotation when a board's center of
mass passes its support edge (Q1: smooth pivot swing, no discrete
snap). Falling boards deal impact damage and can kill enemies in
their fall path.
- Falling-object impact damage: blocks and birds can now kill enemies
from vertical drops and side plows (velocity-aware crush gate),
chain momentum transfers correctly through stacks (mass-weighted
contact), repeated impacts finish tougher enemies.
- Landed-bird physics: a bird resting on a board remains a real
physical body under continuous gravity (Q2: no lifetime timer). If
its platform is destroyed it falls, damages what it lands on
(chain reaction), and can kill enemies.
- Repo modularized: folders = modules, subfolders = submodules,
architecture.md + MODULES.md at root, an agents.md card in every
module and submodule (17 total).
- Test suite grown from 27 to 47 (20 new RED-watched tests for the
above behaviors), all green. Golden differential against the
original core confirmed the only behavioral changes are the
intended ones.
Live at http://10.244.136.121:8093/, independently re-verified by
Relay (full suite re-run, module structure check, headless Chromium
smoke) before this push.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 08:21:18 -05:00
|
|
|
while ((r = refRe.exec(src))) refs.push(rel + '->' + (r[1] || r[2]));
|
2026-09-28 17:20:17 -05:00
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
|
}
|