// test/knifeedge.test.mjs -- S-3 regression: knife-edge overhang. // A board whose COM is a hair (5px) past the support edge, with the pivot too // high for either end to reach the ground, is a pendulum. It must tip ONCE, // swing back, and REST (damped resolution) -- it must not re-tip in a // livelock loop. This regression was found on real level L3 during the golden // audit: 14 re-tips in 0.5s (gaps 0.01-0.04s) at one position; the v1 // anti-livelock flag was defeated by a velocity re-arm (the pendulum's own // swing-back speed cleared the flag), so the board re-tipped forever. 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: 'knife-test', sling: { x: 260, y: 560 }, birds: [], blocks: [], enemies: [] }, def); return ISLE.createGame(lvl); } // pillar top y=700, right edge x=1100 (pillar 200x200 @1000,800). // Board 300x30 center (1105,685): COM at x=1105, only 5px past the pivot edge // x=1100. Far corner radius ~158 < 200 (pivot-to-ground) -> the ends can never // reach the ground -> pendulum case (settle-back, not a ground landing). const PILLAR = { x: 1000, y: 800, w: 200, h: 200, mat: 'stone' }; const KNIFE = { x: 1105, y: 685, w: 300, h: 30, mat: 'wood' }; const g = game({ blocks: [PILLAR, KNIFE] }); const board = g.blocks[1]; let tips = 0; const origPush = g.events.push.bind(g.events); g.events.push = function (ev) { if (ev.type === 'topple_start') tips++; return origPush(ev); }; // run 10s (covers ~9 pendulum periods; a livelock would re-tip every period) const N = 10 / DT; for (let i = 0; i < N; i++) ISLE.step(g, DT); check('knife-edge board tips at all (COM 5px past edge)', tips >= 1, 'tips=' + tips); check('NO livelock: a pendulum overhang tips at most ONCE in 10s', tips <= 2, 'tips=' + tips); check('board ends at rest (not pivoting)', board.pivoting === false, 'pivoting=' + board.pivoting); check('board rests flat after settle-back', Math.abs(board.ang || 0) < 0.02, 'ang=' + (board.ang * 180 / Math.PI).toFixed(1) + 'deg'); check('board snapped back to its rest position (no drift)', Math.abs(board.x - 1105) < 0.5 && Math.abs(board.y - 685) < 0.5, 'x=' + board.x.toFixed(2) + ' y=' + board.y.toFixed(2)); // a REAL knock (displacement >= 6px, COM pushed further past the edge) must // re-arm the board to tip again { let tips2 = 0; const g2 = game({ blocks: [PILLAR, KNIFE] }); const b2 = g2.blocks[1]; const push2 = g2.events.push.bind(g2.events); g2.events.push = function (ev) { if (ev.type === 'topple_start') tips2++; return push2(ev); }; for (let i = 0; i < 2 / DT; i++) ISLE.step(g2, DT); // settle: tipped once, swung back, resting // knock: shove it 20px PAST the edge (COM 1105 -> 1125, well outside span [1000,1100]) b2.x += 20; b2.vx = 60; for (let i = 0; i < 3 / DT; i++) ISLE.step(g2, DT); check('a real knock (past the edge) re-arms a settled board to tip again', tips2 >= 2, 'tips=' + tips2); } console.log('== ' + passed + ' passed, ' + failed + ' failed =='); process.exit(failed ? 1 : 0);