import * as THREE from 'three'; import { createPhysicsWorld, initPhysics } from '../src/physics/world.js'; import { createMatch } from '../src/game/match.js'; import { closestLimbs, describeHit } from '../src/game/hits.js'; import { REGION } from '../src/character/skeleton.js'; import { segSegDistance } from '../src/core/math.js'; import { insideRink } from '../shared/rink.js'; import { done, near, ok, section } from './harness.mjs'; /** * Body checks, end to end and headless. * * The thing under test is the handoff: while upright the proxy capsule owns * position and the ragdoll is a kinematic passenger; a knockdown inverts that, * and getting up inverts it back. That round trip is the part of the design * with nowhere to hide, so most of this file is about proving it does not leak * a disabled proxy, a stranded sim position, or a skater who never gets up. */ const DT = 1 / 60; await initPhysics(); /** A match with everyone parked, so only the skaters under test move. */ function arena(perTeam = 1) { const physics = createPhysicsWorld(); const match = createMatch({ scene: new THREE.Group(), physics, perTeam, teams: 2 }); return { physics, match }; } /** * Drive skater 0 into skater 1 head on and run until something happens. * Returns the hits that landed. */ function collide({ closing = 'full', seconds = 6, gap = 16 } = {}) { const { physics, match } = arena(1); const [a, b] = match.states; const landed = []; const seen = new Set(); a.x = -gap / 2; a.z = 0; a.yaw = Math.PI / 2; a.vx = 0; a.vz = 0; b.x = gap / 2; b.z = 0; b.yaw = -Math.PI / 2; b.vx = 0; b.vz = 0; match.skaters[0].proxy.teleport(a.x, a.z); match.skaters[1].proxy.teleport(b.x, b.z); // Drive both directly, so the AI's avoidance steering cannot politely // sidestep the collision this test exists to cause. With cameraYaw 0 the // stick's x maps straight to world +X. match.setControl(0, { x: 1, y: 0, sprint: true, brake: false, cameraYaw: 0 }); match.setControl(1, closing === 'full' ? { x: -1, y: 0, sprint: true, brake: false, cameraYaw: 0 } : { x: 0, y: 0, sprint: false, brake: false, cameraYaw: 0 }); const steps = Math.round(seconds / DT); for (let n = 0; n < steps; n++) { match.update(DT); for (const h of match.recentHits) { const id = `${h.at}|${h.attacker}|${h.victim}`; if (!seen.has(id)) { seen.add(id); landed.push(h); } } } return { physics, match, landed }; } section('segment distance is correct'); { const A = new THREE.Vector3(); const B = new THREE.Vector3(); // Two parallel segments one metre apart. let d = segSegDistance( new THREE.Vector3(0, 0, 0), new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 1, 0), new THREE.Vector3(1, 1, 0), A, B, ); near(d, 1, 1e-9, 'parallel segments'); // Crossing segments touch. d = segSegDistance( new THREE.Vector3(-1, 0, 0), new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, -1, 0), new THREE.Vector3(0, 1, 0), A, B, ); near(d, 0, 1e-9, 'crossing segments'); // Endpoint to endpoint, no overlap in parameter space. d = segSegDistance( new THREE.Vector3(0, 0, 0), new THREE.Vector3(1, 0, 0), new THREE.Vector3(3, 0, 0), new THREE.Vector3(4, 0, 0), A, B, ); near(d, 2, 1e-9, 'collinear, disjoint'); near(A.x, 1, 1e-9, 'closest point on the first segment is its end'); near(B.x, 3, 1e-9, 'closest point on the second is its start'); // Degenerate: both segments are points. d = segSegDistance( new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, 0), new THREE.Vector3(3, 4, 0), new THREE.Vector3(3, 4, 0), A, B, ); near(d, 5, 1e-9, 'two points'); } section('the closest limb pair is found between two posed rigs'); { const { physics, match } = arena(1); const [a, b] = match.skaters; // Stand them shoulder to shoulder. match.states[0].x = 0; match.states[0].z = 0; match.states[1].x = 0.75; match.states[1].z = 0; a.applyState(match.states[0], 0); b.applyState(match.states[1], 0); a.update(DT); b.update(DT); const pair = closestLimbs(a.ragdoll, b.ragdoll); ok(pair, 'a pair was found'); ok(pair.attackerPart && pair.victimPart, 'both sides identified'); ok(pair.distance < 0.6, `and they are genuinely close (${pair.distance.toFixed(3)}m)`); // Side by side, the nearest parts must be on the facing sides, i.e. arms or // torso — never a foot to a head. ok( pair.attackerPart.name !== 'footL' && pair.attackerPart.name !== 'footR', `a shoulder-to-shoulder stance does not resolve to a foot (${pair.attackerPart.name})`, ); physics.destroy(); } section('a full-speed head-on check lands and knocks someone down'); { const { physics, match, landed } = collide({ closing: 'full' }); ok(landed.length > 0, `a hit was registered (${landed.length})`); const hit = landed[0]; ok(hit.speed > 4, `with real closing speed (${hit.speed.toFixed(1)} m/s)`); ok(hit.outcome !== 'bump', `and it was more than a bump (${hit.outcome})`); ok(hit.attacker !== hit.victim, 'attacker and victim are different skaters'); ok(typeof hit.by === 'string' && hit.by.length > 0, `it was delivered by something (${hit.by})`); ok(typeof describeHit(hit) === 'string', `and describes itself: "${describeHit(hit)}"`); physics.destroy(); } section('a skater who is run over goes down, then gets back up'); { const { physics, match } = collide({ closing: 'stationary', seconds: 5 }); const downed = match.skaters.filter((s) => s.limp); // Either someone is still down, or they already got up — both mean the path // ran. Keep simulating until nobody is down, and check it terminates. let steps = 0; while (match.skaters.some((s) => s.limp) && steps < 60 / DT) { match.update(DT); steps++; } ok(steps < 60 / DT, `everyone got back up (took ${(steps * DT).toFixed(1)}s)`); for (let i = 0; i < match.skaters.length; i++) { const sk = match.skaters[i]; ok(!sk.limp, `skater ${i} is upright`); ok(sk.proxy.enabled, `skater ${i}'s proxy is switched back on`); ok(sk.ragdoll.mode === 'driven', `skater ${i}'s rig is back under animation`); ok(insideRink(match.states[i].x, match.states[i].z, 0.3), `skater ${i} is on the ice`); ok(Number.isFinite(match.states[i].x), `skater ${i}'s position is finite`); } physics.destroy(); } section('a knockdown puts a skater on the ice, not in the air'); { // The failure this catches is specific and very visible: applying the whole // impulse at the contact point, which sits well above the centre of mass, // cartwheels the victim up over the hitter's head instead of driving them // down and back. const { physics, match } = collide({ closing: 'full', gap: 24, seconds: 3 }); const victim = match.skaters.find((s) => s.limp); ok(victim, 'somebody went down'); const pelvis = new THREE.Vector3(); const head = new THREE.Vector3(); let peakPelvis = 0; let peakHead = 0; for (let n = 0; n < 2.5 / DT; n++) { match.update(DT); victim.ragdoll.parts.pelvis.bone.getWorldPosition(pelvis); victim.ragdoll.parts.head.bone.getWorldPosition(head); peakPelvis = Math.max(peakPelvis, pelvis.y); peakHead = Math.max(peakHead, head.y); } // Standing hip height is ~1.0m and standing head height ~1.6m. Going above // those while being knocked over means they were launched. ok(peakPelvis < 1.35, `the hips never went above standing height (peak ${peakPelvis.toFixed(2)}m)`); ok(peakHead < 2.0, `and neither did the head (peak ${peakHead.toFixed(2)}m)`); physics.destroy(); } section('a knockdown drives the victim away from the hit, not back into it'); { // Run until contact rather than for a fixed time: how long the run-up takes // depends on the acceleration curve, and a test that silently ends before // the collision proves nothing. const { physics, match } = collide({ closing: 'stationary', gap: 22, seconds: 3 }); let waited = 0; while (!match.skaters.some((s) => s.limp) && waited < 6) { match.update(DT); waited += DT; } const victimIndex = match.skaters.findIndex((s) => s.limp); ok(victimIndex >= 0, `somebody went down (after ${waited.toFixed(1)}s of extra run-up)`); const pelvis = new THREE.Vector3(); match.skaters[victimIndex].ragdoll.parts.pelvis.bone.getWorldPosition(pelvis); const startX = pelvis.x; for (let n = 0; n < 1.2 / DT; n++) match.update(DT); match.skaters[victimIndex].ragdoll.parts.pelvis.bone.getWorldPosition(pelvis); // The attacker was travelling +X, so the victim has to end up further +X. ok(pelvis.x > startX, `the body carried on down the ice (${startX.toFixed(2)} → ${pelvis.x.toFixed(2)})`); physics.destroy(); } section('getting up does not teleport the body'); { // The bug this pins down: while limp the ragdoll writes the body's // displacement into the *root bone*, because the mover stays parked where // they fell. Moving the mover onto the pelvis without re-expressing that // offset applies the displacement twice — the skater visibly flies out by // however far they slid and the crossfade then drags them back. // // Measured on the rendered bones, not on the sim state, because the sim // state was always right; it was the drawn pose that jumped. const { physics, match } = arena(1); const sk = match.skaters[0]; const st = match.states[0]; st.x = -4; st.z = 3; sk.proxy.teleport(st.x, st.z); for (let n = 0; n < 20; n++) match.update(DT); sk.goDown({ severity: 9, direction: new THREE.Vector3(1, 0, 0), victimPart: 'spine2' }); // Send them sliding so the fall position and the resting position differ by // a long way — with them equal the bug cannot show. sk.ragdoll.applyImpulse('spine2', new THREE.Vector3(300, 30, 90), null); const sample = new THREE.Vector3(); const bones = ['pelvis', 'head', 'footL', 'handR']; const before = new Map(); let slid = 0; while (sk.limp) { // Remember the last frame before the handoff. for (const b of bones) { sk.ragdoll.parts[b].bone.getWorldPosition(sample); before.set(b, sample.clone()); } sk.ragdoll.parts.pelvis.bone.getWorldPosition(sample); slid = Math.hypot(sample.x - st.x, sample.z - st.z); match.update(DT); } ok(slid > 0.5, `the body really did slide away from where it fell (${slid.toFixed(2)}m)`); // First frame back under animation: every bone must be where it just was. let worst = 0; let worstBone = ''; for (const b of bones) { sk.ragdoll.parts[b].bone.getWorldPosition(sample); const moved = sample.distanceTo(before.get(b)); if (moved > worst) { worst = moved; worstBone = b; } } ok(worst < 0.12, `no bone jumped across the handoff (worst ${worstBone} ${worst.toFixed(3)}m)`); // And the whole get-up should be a pose change, not a journey. sk.ragdoll.parts.pelvis.bone.getWorldPosition(sample); const riseStart = sample.clone(); let drift = 0; while (sk.rising > 0) { match.update(DT); sk.ragdoll.parts.pelvis.bone.getWorldPosition(sample); drift = Math.max(drift, Math.hypot(sample.x - riseStart.x, sample.z - riseStart.z)); } ok(drift < 0.6, `they stood up roughly where they lay (drifted ${drift.toFixed(2)}m)`); physics.destroy(); } section('a skater who gets up faces the way they were lying'); { const { physics, match } = arena(1); const sk = match.skaters[0]; const st = match.states[0]; st.x = 0; st.z = 0; st.yaw = 0; sk.proxy.teleport(0, 0); for (let n = 0; n < 20; n++) match.update(DT); sk.goDown(null); sk.ragdoll.applyImpulse('spine2', new THREE.Vector3(0, 20, 260), null); while (sk.limp) match.update(DT); const pelvis = new THREE.Vector3(); const chest = new THREE.Vector3(); sk.ragdoll.parts.pelvis.bone.getWorldPosition(pelvis); sk.ragdoll.parts.spine3.bone.getWorldPosition(chest); const bodyYaw = Math.atan2(chest.x - pelvis.x, chest.z - pelvis.z); const off = Math.abs(Math.atan2(Math.sin(st.yaw - bodyYaw), Math.cos(st.yaw - bodyYaw))); ok(off < 0.9, `facing follows the sprawled body rather than a stale yaw (${off.toFixed(2)} rad off)`); ok(Number.isFinite(st.yaw), 'and is a real number'); physics.destroy(); } section('the sim follows the body across a knockdown'); { const { physics, match } = arena(1); const sk = match.skaters[0]; const st = match.states[0]; st.x = -5; st.z = 2; st.vx = 0; st.vz = 0; sk.proxy.teleport(st.x, st.z); for (let n = 0; n < 20; n++) match.update(DT); sk.goDown({ severity: 9, direction: new THREE.Vector3(1, 0, 0), victimPart: 'spine2' }); ok(sk.limp, 'they are down'); ok(!sk.proxy.enabled, 'the proxy switched off — no invisible bollard left behind'); // Shove the rig so it ends up somewhere other than where it fell. sk.ragdoll.applyImpulse('spine2', new THREE.Vector3(260, 40, 0), null); for (let n = 0; n < 90; n++) match.update(DT); const pelvis = new THREE.Vector3(); sk.ragdoll.parts.pelvis.bone.getWorldPosition(pelvis); while (sk.limp) match.update(DT); ok(sk.proxy.enabled, 'the proxy came back'); const gap = Math.hypot(st.x - pelvis.x, st.z - pelvis.z); ok(gap < 1.2, `the sim was moved to where the body actually ended up (${gap.toFixed(2)}m off)`); ok(Math.hypot(st.vx, st.vz) < 2, 'and starts from rest rather than inheriting the slide'); physics.destroy(); } section('a downed skater is not driven around by the sim'); { const { physics, match } = arena(1); const sk = match.skaters[0]; const st = match.states[0]; st.x = 0; st.z = 0; sk.proxy.teleport(0, 0); for (let n = 0; n < 10; n++) match.update(DT); sk.goDown(null); const at = { x: st.x, z: st.z }; // Hold full sprint intent for a second while down. for (let n = 0; n < 60; n++) { st.ix = 1; st.iz = 0; st.sprint = true; match.update(DT); } const moved = Math.hypot(st.x - at.x, st.z - at.z); near(moved, 0, 1e-6, 'the frozen sim position did not skate off without the body'); physics.destroy(); } section('ragdoll limbs join the collision world only while dynamic'); { const { physics, match } = arena(1); const sk = match.skaters[0]; const api = physics.api; const shape = sk.ragdoll.parts.spine2.shape; const drivenMask = api.b3Shape_GetFilter(shape).maskBits; sk.goDown(null); const limpMask = api.b3Shape_GetFilter(shape).maskBits; ok(limpMask !== drivenMask, 'the filter changed when the rig went dynamic'); ok(limpMask > drivenMask, 'and it got wider, not narrower'); while (sk.limp) match.update(DT); const backMask = api.b3Shape_GetFilter(shape).maskBits; near(Number(backMask), Number(drivenMask), 0, 'and went back on standing up'); physics.destroy(); } section('a 3-on-3 with hits enabled stays sane'); { const { physics, match } = arena(3); for (let n = 0; n < 90 / DT; n++) match.update(DT); for (let i = 0; i < match.states.length; i++) { const s = match.states[i]; ok(Number.isFinite(s.x) && Number.isFinite(s.z), `skater ${i} finite after 90s`); ok(insideRink(s.x, s.z, 0.3), `skater ${i} still on the ice`); } ok(match.recentHits.length >= 0, 'the hit list did not blow up'); physics.destroy(); } section('hit severity is graded, not binary'); { // Different run-ups must produce different outcomes, or "varied hits" is a // lie. A short approach is a shove; a long one puts someone on the ice. const outcomes = new Set(); const byShortRun = []; const byLongRun = []; for (const [gap, into] of [[2.5, byShortRun], [22, byLongRun]]) { const { physics, landed } = collide({ closing: 'stationary', gap, seconds: 6 }); for (const h of landed) { outcomes.add(h.outcome); into.push(h); } physics.destroy(); } ok(outcomes.size >= 2, `run-up length changes the outcome (${[...outcomes].join(', ')})`); ok(byShortRun.length > 0 && byLongRun.length > 0, 'both approaches landed something'); ok( byLongRun[0].severity > byShortRun[0].severity, `a longer run-up hits harder (${byLongRun[0].severity.toFixed(1)} vs ${byShortRun[0].severity.toFixed(1)})`, ); } section('the kind of hit follows the pose, not a coin flip'); { // Two geometries that should produce genuinely different checks: running // down a stationary skater leads with the shoulder, while a head-on between // two skaters both crouched low at speed is a hip check. const kinds = new Set(); const seen = []; for (const closing of ['stationary', 'full']) { const { physics, landed } = collide({ closing, gap: 22, seconds: 6 }); for (const h of landed) { kinds.add(h.by); seen.push(`${closing}: ${describeHit(h)}`); } physics.destroy(); } ok(kinds.size >= 2, `more than one kind of hit is reachable (${[...kinds].join(', ')})`); ok(kinds.has('shoulder') || kinds.has('hip'), `and they are real checks (${seen.join(' | ')})`); } section('nobody delivers a check with their head'); { // A skater at speed is pitched forward, which makes the head the leading // part of the body geometrically. Without the delivering-part restriction // almost every hit resolves to a headbutt. const delivered = new Set(); for (const closing of ['stationary', 'full']) { for (const gap of [5, 14, 22]) { const { physics, landed } = collide({ closing, gap, seconds: 6 }); for (const h of landed) delivered.add(h.attackerPart); physics.destroy(); } } ok(!delivered.has('head'), `no hit was credited to a head (${[...delivered].join(', ')})`); ok(!delivered.has('neck'), 'nor to a neck'); ok(delivered.size > 0, 'and hits did land'); } done('hits');