Isle-Slingers/test/fallingkill.test.mjs

122 lines
6.2 KiB
JavaScript
Raw Normal View History

2026-09-29 09:29:05 -05:00
// test/fallingkill.test.mjs -- S-4: falling-object impact damage.
// "falling objects need to be able to kill the
// enemy's as well not just the birds". Tuned balance
// (block maxVel clamps: stone 480, ice 560, wood 520) blocks kill a mogg in
// one impact (1.07-1.45) and a grum (hp 2) in two impacts (1.07 x 2 = 2.14).
// S-4 fixes the two gaps that made falling blocks unable to kill, plus a
// double-fire defect the fix exposed:
// (a) crush direction gate -- the old "ny < -0.3" (enemy ABOVE the box) meant
// a block dropped STRAIGHT DOWN onto an enemy never registered. Now the
// box must simply be the body moving into the enemy (boxAlong > 30).
// (b) chain momentum -- resolveBoxPair used a mass-less (a.vy+c.vy)/2, so a
// heavy stone landing on a light plank handed the plank only half the
// speed (240 px/s, UNDER the 330 mogg threshold). Mass-weighted inelastic
// contact transfers the real momentum (plank -> 466 px/s, OVER), so a
// knocked plank is itself a live projectile that can kill.
// (c) crush cooldown + inelastic transfer -- with (a) in place a box resting
// on an enemy at terminal velocity would otherwise re-fire damage every
// frame (4 frames = a king gone). One landing = one crush instance
// (0.12s per-enemy cooldown, same idea as the bird-hit gate); the box
// also sheds a mass-scaled share of its normal velocity so it does not
// phase through the enemy.
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const ISLE = require('../js/physics/index.js');
const DT = 1 / 120;
let passed = 0, failed = 0;
function check(name, cond, detail) {
if (cond) { passed++; console.log(' PASS ' + name); }
else { failed++; console.log(' FAIL ' + name + (detail ? ' -- ' + detail : '')); }
}
function game(def) {
const lvl = Object.assign({ name: 'fk-test', sling: { x: 260, y: 560 }, birds: [], blocks: [], enemies: [] }, def);
return ISLE.createGame(lvl);
}
function step(g, secs) {
const n = Math.round(secs / DT);
for (let i = 0; i < n; i++) ISLE.step(g, DT);
}
// arm 1 (chain kill): heavy stone 200x200 (mass 62.9) dropped onto an ice
// plank 120x30 (mass 1.8) that overhangs a mogg. Mass-weighted contact drives
// the plank to ~466 px/s (the old mass-less average would give ~240, UNDER the
// 330 threshold) -> the plank crushes the mogg for 1.01 >= hp 1. KILL through
// the stack: the falling stone kills the enemy via the plank it knocks.
{
const g = game({
blocks: [
{ x: 1300, y: 300, w: 200, h: 200, mat: 'stone' }, // bottom 400
{ x: 1300, y: 767, w: 120, h: 30, mat: 'ice' } // 752..782, overhangs mogg at 1340
],
enemies: [{ x: 1340, y: 876, kind: 'mogg' }]
});
step(g, 5.0);
const e = g.enemies[0];
check('chain: heavy stone drives a plank fast enough to KILL the mogg it sweeps', e.dead,
'mogg hp=' + e.hp + ' dead=' + e.dead);
}
// arm 2 (direct one-hit kill): stone dropped straight onto a mogg.
{
const g = game({ blocks: [{ x: 1200, y: 280, w: 120, h: 120, mat: 'stone' }],
enemies: [{ x: 1200, y: 876, kind: 'mogg' }] });
step(g, 4.0);
const e = g.enemies[0];
check('direct: a falling stone kills the mogg it lands on (one hit)', e.dead,
'mogg hp=' + e.hp + ' dead=' + e.dead);
}
// arm 3 (repeated-impact kill): a grum (hp 2) dies to two ice drops landing at
// DISJOINT spots (offset 90px, blocks are 120 wide so they never touch each
// other). 1.07 x 2 = 2.14 >= 2.
{
const g = game({ enemies: [{ x: 1200, y: 868, kind: 'grum' }] });
g.blocks.push({ id: 90, x: 1160, y: 280, w: 120, h: 120, mat: 'ice', hp: 480, maxHp: 480, vx: 0, vy: 0, rest: 1, ang: 0, angVel: 0, pivoting: false, mass: 0.7 * 120 * 120 / 1400 });
step(g, 4.0); // first impact at x=1160: 1.07
const afterFirst = g.enemies[0].hp;
g.blocks.push({ id: 91, x: 1250, y: 280, w: 120, h: 120, mat: 'ice', hp: 480, maxHp: 480, vx: 0, vy: 0, rest: 1, ang: 0, angVel: 0, pivoting: false, mass: 0.7 * 120 * 120 / 1400 });
step(g, 4.0); // second impact at x=1250: 1.07
const e = g.enemies[0];
check('repeated: a grum dies to two disjoint ice drops (1.07 x 2 >= 2)', e.dead,
'after 1st=' + afterFirst.toFixed(2) + ' final hp=' + e.hp + ' dead=' + e.dead);
}
// arm 4 (one impact, no machine-gun): a LOW stone drop (70px fall -> v=458,
// below stone's 480 clamp) deals 0.96 < 1: the mogg survives with EXACTLY one
// damage instance even though the stone rests on it for the whole window.
// Without the cooldown, the resting box (still registered at terminal speed)
// would re-fire every frame and finish it.
{
const g = game({ blocks: [{ x: 1200, y: 722, w: 120, h: 120, mat: 'stone' }],
enemies: [{ x: 1200, y: 876, kind: 'mogg' }] });
let crushEvents = 0;
const origPush = g.events.push.bind(g.events);
g.events.push = function (ev) { if (ev.type === 'crush') crushEvents++; return origPush(ev); };
step(g, 4.0);
const e = g.enemies[0];
// impact ~462 px/s (70px fall + ~5px detection overlap): dmg ~0.99, survives
// with a hair of hp -- and the resting box must not re-fire (exactly 1 event).
check('one landing = one crush instance (no per-frame re-fire)',
crushEvents === 1 && !e.dead && e.hp > 0 && e.hp < 0.05,
'crushEvents=' + crushEvents + ' mogg hp=' + e.hp.toFixed(3));
}
// arm 5 (negative: under threshold): a wood block dropped only 36px
// (v=328 < the 330 mogg crushVel) must deal zero crush damage.
{
// A block resting ON the mogg with no impact velocity must never crush it:
// the crush gate is velocity-based (boxAlong > 30), so a box that is merely
// sitting there (boxAlong ~ 0) deals nothing. Guards the per-frame
// machine-gun failure mode from the other direction.
const g = game({ blocks: [{ x: 1200, y: 832, w: 60, h: 40, mat: 'wood', hp: 1300, maxHp: 1300, vx: 0, vy: 0, rest: 1, ang: 0, angVel: 0, pivoting: false, mass: 1.0 * 60 * 40 / 1400 }],
enemies: [{ x: 1200, y: 876, kind: 'mogg' }] });
step(g, 4.0);
const e = g.enemies[0];
check('negative: a block RESTING on the mogg (no impact velocity) never crushes it', e.hp === 1 && !e.dead,
'mogg hp=' + e.hp);
}
console.log('== ' + passed + ' passed, ' + failed + ' failed ==');
process.exit(failed ? 1 : 0);