2026-09-29 09:24:13 -05:00
// test/topple.test.mjs -- S-3: board tip/topple physics
// Q1 (turn 2): FULL CONTINUOUS ROTATION -- boards
Physics upgrade + repo modularization (RC-375D84)
Delivered by Hermies Qwen, gated and deployed by Relay.
- Board tip/topple: full continuous rotation when a board's center of
mass passes its support edge (Q1: smooth pivot swing, no discrete
snap). Falling boards deal impact damage and can kill enemies in
their fall path.
- Falling-object impact damage: blocks and birds can now kill enemies
from vertical drops and side plows (velocity-aware crush gate),
chain momentum transfers correctly through stacks (mass-weighted
contact), repeated impacts finish tougher enemies.
- Landed-bird physics: a bird resting on a board remains a real
physical body under continuous gravity (Q2: no lifetime timer). If
its platform is destroyed it falls, damages what it lands on
(chain reaction), and can kill enemies.
- Repo modularized: folders = modules, subfolders = submodules,
architecture.md + MODULES.md at root, an agents.md card in every
module and submodule (17 total).
- Test suite grown from 27 to 47 (20 new RED-watched tests for the
above behaviors), all green. Golden differential against the
original core confirmed the only behavioral changes are the
intended ones.
Live at http://10.244.136.121:8093/, independently re-verified by
Relay (full suite re-run, module structure check, headless Chromium
smoke) before this push.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 08:21:18 -05:00
// rotate smoothly through the fall (no discrete settle-then-snap); COM past
// the support edge triggers the tip; the tip lands with impact damage and
// falling objects can kill enemies.
//
// Every arm was proven RED on the pre-S-3 tree: blocks carried no rotation
// degree of freedom, so nothing could ever tip.
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 step ( g , secs ) {
const n = Math . round ( secs / DT ) ;
for ( let i = 0 ; i < n ; i ++ ) ISLE . step ( g , DT ) ;
}
// Real createGame, custom level definition (no birds needed for these arms).
function game ( def ) {
const lvl = Object . assign ( { name : 'topple-test' , sling : { x : 260 , y : 560 } , birds : [ ] , blocks : [ ] , enemies : [ ] } , def ) ;
return ISLE . createGame ( lvl ) ;
}
// Geometry (verified against the pendulum prototype):
// ground y=900. Stone pillar 200x200 at (1000,800): x 900..1100, top y=700.
// Wood board 300x30 at (1200,685): x 1050..1350, COM at x=1200 -- 100px
// PAST the pivot edge (pillar top-right corner x=1100, y=700).
const PILLAR = { x : 1000 , y : 800 , w : 200 , h : 200 , mat : 'stone' } ;
const BOARD _OVER = { x : 1200 , y : 685 , w : 300 , h : 30 , mat : 'wood' } ;
const BOARD _CENTERED = { x : 1000 , y : 685 , w : 300 , h : 30 , mat : 'wood' } ; // COM at x=1000, inside span 900..1100
// impact victim: ice 200x30 at (1250,885) -- under the board's fall path
const VICTIM = { x : 1250 , y : 885 , w : 200 , h : 30 , mat : 'ice' } ;
console . log ( '== ISLE SLINGERS topple (S-3) ==' ) ;
// ---- arm 1: COM past the support edge -> the board tips end-over-end ----
{
const g = game ( { blocks : [ PILLAR , BOARD _OVER ] } ) ;
const board = g . blocks [ 1 ] ;
step ( g , 2.0 ) ;
const ang = board . ang || 0 ;
check ( 'overhanging board tips (COM past support edge)' , ang > 0.5 ,
'ang=' + ang + 'rad (' + ( ang * 180 / Math . PI ) . toFixed ( 1 ) + 'deg)' ) ;
}
// ---- arm 2: rotation is CONTINUOUS (Q1) -- smooth swing, intermediate
// angles, no discrete settle-then-snap ----
{
const g = game ( { blocks : [ PILLAR , BOARD _OVER ] } ) ;
const board = g . blocks [ 1 ] ;
const samples = [ ] ;
for ( let i = 0 ; i < 96 ; i ++ ) { ISLE . step ( g , DT ) ; samples . push ( board . ang || 0 ) ; }
let maxDelta = 0 ;
for ( let i = 1 ; i < samples . length ; i ++ ) maxDelta = Math . max ( maxDelta , Math . abs ( samples [ i ] - samples [ i - 1 ] ) ) ;
const finalAng = samples [ samples . length - 1 ] ;
const sawIntermediate = samples . some ( a => a > 0.1 && a < finalAng - 0.1 ) ;
check ( 'rotation is continuous (smooth swing, intermediate angles)' , sawIntermediate && maxDelta < 0.05 ,
'samples(0.8s, every 4th)=' + samples . filter ( ( _ , i ) => i % 4 === 0 ) . map ( a => a . toFixed ( 3 ) ) . join ( ',' ) + ' maxStep=' + maxDelta . toFixed ( 4 ) + ' final=' + finalAng . toFixed ( 3 ) ) ;
}
// ---- arm 3: a fully-supported board does NOT tip (negative control) ----
{
const g = game ( { blocks : [ PILLAR , BOARD _CENTERED ] } ) ;
const board = g . blocks [ 1 ] ;
step ( g , 2.0 ) ;
check ( 'fully-supported board stays put (no tip)' , ( board . ang || 0 ) === 0 && ( board . vy || 0 ) === 0 ,
'ang=' + board . ang + ' vy=' + board . vy ) ;
}
// ---- arm 4: the tip lands with IMPACT that damages a block below ----
{
const g = game ( { blocks : [ PILLAR , BOARD _OVER , VICTIM ] } ) ;
const victim = g . blocks [ 2 ] ;
step ( g , 3.0 ) ;
check ( 'tipping board impact damages a block in its fall path' , victim . hp < ISLE . MATERIALS . ice . hp ,
'victim hp=' + victim . hp + ' (full=' + ISLE . MATERIALS . ice . hp + ') boardAng=' + ( ( g . blocks [ 1 ] . ang || 0 ) * 180 / Math . PI ) . toFixed ( 1 ) + 'deg' ) ;
}
// ---- arm 5: a tipping board KILLS an enemy in its fall path (objective (b)) ----
{
const g = game ( {
blocks : [ PILLAR , BOARD _OVER ] ,
enemies : [ { x : 1250 , y : 876 , kind : 'mogg' } ] , // r=24, stands under the fall path
} ) ;
const e = g . enemies [ 0 ] ;
step ( g , 3.0 ) ;
check ( 'falling board kills an enemy in its fall path' , e . dead ,
'enemy hp=' + e . hp + ' dead=' + e . dead + ' boardAng=' + ( ( g . blocks [ 1 ] . ang || 0 ) * 180 / Math . PI ) . toFixed ( 1 ) + 'deg' ) ;
}
console . log ( '== ' + passed + ' passed, ' + failed + ' failed ==' ) ;
process . exit ( failed ? 1 : 0 ) ;