// 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);