Fixes applied and verified (Hermies Qwen, RC-0D193E): - Rubber band no longer tracks the bird after launch (bug #14, renderer.js) - Launch direction fixed: toWorld divides by camera zoom (bug #15, main.js) - Balance retune: stone hp 3400->1400, dmgFactor 0.35->0.8, plus an enemy-hit damage floor so soft lobs still kill (core.js) - New test/balance.test.mjs: RED on pre-fix constants, GREEN after Suite: 27/27 headless + 10/10 real-browser (real mouse events, real canvas). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
143 lines
5.3 KiB
JavaScript
143 lines
5.3 KiB
JavaScript
/* ISLE SLINGERS — balance spec (Commander RC-0D193E 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/core.js';
|
|
import levelsModule from '../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);
|
|
}
|