Isle-Slingers/test/balance.test.mjs
2026-09-29 14:23:12 +00:00

143 lines
5.5 KiB
JavaScript

/* ISLE SLINGERS — balance spec (Commander bug #3:
"the blocks and pig bad guys too hard to kill").
*
* Measures with the REAL core (same integrator/colliders the game runs),
* repeated identical full-power DIRECT hits, and asserts hit-to-kill
* budgets. These numbers are what a human with a decent aim actually
* gets — not the suite's omniscient perfect archer.
*
* Pinned by this spec:
* wood breaks in <= 3 full-power direct hits
* ice breaks in <= 2 full-power direct hits
* stone breaks in <= 4 full-power direct hits (was ~11: unkillable)
* grum dies in <= 2 medium-speed direct hits (soft lobs were ~0.9 dmg)
* king must still SURVIVE one full-power wren hit (sim.test.mjs pins hp3)
*/
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.LEVELS;
let passed = 0, failed = 0;
const failures = [];
function test(name, fn) {
try { fn(); passed++; console.log(' ok ' + name); }
catch (err) {
failed++; failures.push({ name, err });
console.log(' FAIL ' + name + ' — ' + (err && err.message || err));
}
}
function ok(cond, msg) { if (!cond) throw new Error(msg); }
const DT = 1 / 120;
const SLING = LEVELS[0].sling;
const PULL = 150; // full power
// Full-power launch at `deg` above horizontal (toward +x).
function fire(g, deg) {
const r = deg * Math.PI / 180;
const v = PULL * ISLE.LAUNCH_POWER;
const b = ISLE.activeBird(g);
b.x = SLING.x; b.y = SLING.y;
b.vx = v * Math.cos(r); b.vy = -v * Math.sin(r);
b.state = 'flying'; b.launchT = g.time;
g.launched++;
let guard = 0;
while (g.status !== 'won' && g.status !== 'lost' && guard++ < 1600) {
ISLE.step(g, DT);
if (b.state === 'spent') break;
}
}
function settle(g, frames = 40) { for (let i = 0; i < frames; i++) ISLE.step(g, DT); }
// Best (most damaging) full-power direct angle against one isolated block.
function bestDirectAngle(mat) {
const mk = () => {
const g = ISLE.createGame({ ...LEVELS[0], enemies: [], blocks: [{ x: 1100, y: 900 - 35, w: 70, h: 70, mat }] });
settle(g);
return g;
};
let best = null;
for (let deg = 0; deg <= 25; deg += 1) {
const g = mk();
const hp0 = g.blocks[0].maxHp;
fire(g, deg);
const dmg = hp0 - g.blocks[0].hp;
if (!best || dmg > best.dmg) best = { deg, dmg };
}
return best;
}
// Hits-to-break: repeated identical direct hits, hp carried over between birds.
function hitsToBreak(mat, deg) {
const spec = ISLE.MATERIALS[mat];
let hp = spec.hp, hits = 0;
while (hp > 0 && hits < 20) {
const g = ISLE.createGame({ ...LEVELS[0], enemies: [], blocks: [{ x: 1100, y: 900 - 35, w: 70, h: 70, mat }] });
settle(g);
g.blocks[0].hp = hp;
fire(g, deg);
const dmg = hp - g.blocks[0].hp;
hp -= dmg; hits++;
if (dmg <= 0) break; // never connects — treat as unbreakable
}
return { hits, connected: hits > 0 && hp <= 0 };
}
// Enemy: repeated full-power direct hits from the sling (flat, 0° — the
// player's most reliable connection), hp carried over between birds.
function enemyHitsToDie(kind) {
const spec = ISLE.ENEMY_TYPES[kind];
let hp = spec.hp, hits = 0;
while (hp > 0 && hits < 10) {
const g = ISLE.createGame({ ...LEVELS[0], enemies: [{ x: 1100, y: 876, kind }], blocks: [], birds: ['wren'] });
settle(g, 10);
const e = g.enemies[0];
e.hp = hp;
fire(g, 0);
const dmg = hp - e.hp;
hp -= dmg; hits++;
if (dmg <= 0) break; // never connects — treat as unkillable
}
return { hits, connected: hp <= 0 };
}
console.log('\n== ISLE SLINGERS balance spec ==');
const W = bestDirectAngle('wood');
const I = bestDirectAngle('ice');
const S = bestDirectAngle('stone');
console.log(` (direct angles — wood ${W.deg}° ${W.dmg.toFixed(0)}dmg, ice ${I.deg}° ${I.dmg.toFixed(0)}dmg, stone ${S.deg}° ${S.dmg.toFixed(0)}dmg)`);
test('wood breaks in <= 3 full-power direct hits', () => {
const r = hitsToBreak('wood', W.deg);
ok(r.connected, 'wood block never broke');
ok(r.hits <= 3, `wood took ${r.hits} full-power direct hits (budget 3)`);
});
test('ice breaks in <= 2 full-power direct hits', () => {
const r = hitsToBreak('ice', I.deg);
ok(r.connected, 'ice block never broke');
ok(r.hits <= 2, `ice took ${r.hits} full-power direct hits (budget 2)`);
});
test('stone breaks in <= 4 full-power direct hits (was ~11: unkillable)', () => {
const r = hitsToBreak('stone', S.deg);
ok(r.connected, 'stone block never broke (unkillable wall)');
ok(r.hits <= 4, `stone took ${r.hits} full-power direct hits (budget 4)`);
});
test('grum dies in <= 2 full-power direct hits (soft lobs currently ~0.9 dmg)', () => {
const r = enemyHitsToDie('grum');
ok(r.connected, 'grum never died');
ok(r.hits <= 2, `grum took ${r.hits} full-power hits (budget 2)`);
});
test('king still survives one full-power flat wren hit (pinned hp3)', () => {
const h = ISLE.createGame({ ...LEVELS[0], enemies: [{ x: 1100, y: 876, kind: 'king' }], blocks: [], birds: ['wren'] });
settle(h, 10);
fire(h, 0); // full-power flat from the sling — the player's hardest reliable hit
const hp = h.enemies[0].hp;
ok(hp < 3, 'full-power flat wren shot did not connect with the king');
ok(hp > 0, `king died to a single full-power wren hit (hp ${hp}) — must survive (pinned by sim.test.mjs)`);
});
console.log(`\n== ${passed} passed, ${failed} failed ==`);
if (failed) {
process.exit(1);
}