import * as THREE from 'three'; import { createPhysicsWorld, initPhysics } from '../src/physics/world.js'; import { PUCK, createPuck } from '../src/physics/puck.js'; import { createMatch } from '../src/game/match.js'; import { CARRY } from '../src/game/possession.js'; import { RINK, insideRink } from '../shared/rink.js'; import { done, near, ok, section } from './harness.mjs'; /** * Puck, stick and possession. * * The two things worth testing hard are the ones that are hard to see: that a * 45 m/s shot does not tunnel through the boards (it moves ten times its own * radius per step, so it will unless it is a bullet), and that possession * behaves sanely at both ends of the magnetism dial — because that dial is a * feel decision that has not been made yet, and the code has to survive * wherever it lands. */ const DT = 1 / 60; await initPhysics(); function arena(perTeam = 1) { const physics = createPhysicsWorld(); const match = createMatch({ scene: new THREE.Group(), physics, perTeam, teams: 2 }); return { physics, match }; } /** Park everyone far from the play so they cannot interfere. */ function clearIce(match, keep = []) { for (let i = 0; i < match.states.length; i++) { if (keep.includes(i)) continue; const x = -RINK.halfX * 0.8 + i * 3; match.states[i].x = x; match.states[i].z = -RINK.halfZ * 0.75; match.states[i].vx = 0; match.states[i].vz = 0; match.skaters[i].proxy.teleport(x, -RINK.halfZ * 0.75); match.setControl(i, { x: 0, y: 0, sprint: false, brake: false, cameraYaw: 0 }); } } section('the puck is a regulation puck'); { const physics = createPhysicsWorld(); const puck = createPuck(physics); near(PUCK.radius * 2, 0.0762, 1e-4, 'three inches across'); near(PUCK.thickness, 0.0254, 1e-4, 'one inch thick'); near(puck.mass, 0.170, 0.005, `and 170 grams (got ${puck.mass.toFixed(3)} kg)`); puck.destroy(); physics.destroy(); } section('the puck settles flat on the ice and stays there'); { const physics = createPhysicsWorld(); const puck = createPuck(physics, { position: { x: 0, y: 1.5, z: 0 } }); for (let n = 0; n < 3 / DT; n++) physics.step(DT); const p = puck.position(); ok(p.y > 0 && p.y < 0.05, `it lands on the surface (y=${p.y.toFixed(4)})`); // Angular X and Z are locked, so it can never be standing on its edge. const q = puck.rotation(); const up = new THREE.Vector3(0, 1, 0).applyQuaternion(q); ok(up.y > 0.999, `and lies flat rather than rolling on its edge (up.y=${up.y.toFixed(4)})`); puck.destroy(); physics.destroy(); } section('a hard shot does not tunnel through the boards'); { // The headline risk. At 45 m/s the puck covers 0.37 m per 1/120 s step, // roughly ten times its own radius, so without continuous collision it goes // straight through the wall and is never seen again. for (const speed of [20, 45, 55]) { const physics = createPhysicsWorld(); const puck = createPuck(physics, { position: { x: 0, y: 0.02, z: 0 } }); puck.setVelocity(speed, 0, 0); for (let n = 0; n < 4 / DT; n++) physics.step(DT); const p = puck.position(); ok( insideRink(p.x, p.z, 0), `a ${speed} m/s shot stayed in the rink (ended at x=${p.x.toFixed(2)}, z=${p.z.toFixed(2)})`, ); ok(Math.abs(p.y) < 1.5, `and did not go over the glass (y=${p.y.toFixed(2)})`); puck.destroy(); physics.destroy(); } } section('a shot into the corner stays in the corner'); { // Corners are a chain of short board segments; the seams between them are // where a fast small body escapes if anything is going to. for (const angle of [0.6, 1.1, -0.7, 2.4]) { const physics = createPhysicsWorld(); const puck = createPuck(physics, { position: { x: 0, y: 0.02, z: 0 } }); puck.setVelocity(Math.sin(angle) * 48, 0, Math.cos(angle) * 48); for (let n = 0; n < 5 / DT; n++) physics.step(DT); const p = puck.position(); ok(insideRink(p.x, p.z, 0), `a shot at ${angle.toFixed(1)} rad stayed inside`); puck.destroy(); physics.destroy(); } } section('a dumped puck slides a long way but does stop'); { const physics = createPhysicsWorld(); const puck = createPuck(physics, { position: { x: -RINK.halfX * 0.9, y: 0.02, z: 0 } }); puck.setVelocity(14, 0, 0); let travelled = 0; const start = puck.position().x; for (let n = 0; n < 6 / DT; n++) physics.step(DT); travelled = Math.abs(puck.position().x - start); ok(travelled > 15, `it carries down the ice (${travelled.toFixed(1)}m in 6s)`); ok(puck.speed() < 14, `and does lose speed (${puck.speed().toFixed(1)} m/s left)`); puck.destroy(); physics.destroy(); } section('proximity grab rewards lining up a moving puck'); { const { interceptQuality, speedGrabFactor, crowdGrabFactor, CARRY: C, } = await import('../src/game/possession.js'); const s = { x: 0, z: 0, yaw: Math.PI / 2, vx: 4, vz: 0 }; // Puck ahead on the line (within grab bubble), coming toward the skater. const onLine = interceptQuality( s, { x: 1.5, y: 0.02, z: 0 }, { x: -12, y: 0, z: 0 }, C.grabRadius, ); // Same distance, puck flying wide past the skater. const wide = interceptQuality( s, { x: 1.5, y: 0.02, z: 1.4 }, { x: -12, y: 0, z: 0 }, C.grabRadius, ); ok(onLine > 0.35, `lined-up hard puck is grabable (${onLine.toFixed(2)})`); ok(onLine > wide + 0.08, `and beats a wide miss (${onLine.toFixed(2)} vs ${wide.toFixed(2)})`); // Still puck at the feet — easy. const soft = interceptQuality( { x: 0, z: 0, yaw: 0, vx: 0, vz: 0 }, { x: 0.4, y: 0.02, z: 0.3 }, { x: 0, y: 0, z: 0 }, C.grabRadius, ); ok(soft > 0.4, `soft puck nearby is easy (${soft.toFixed(2)})`); } section('fast pucks and crowds lower proximity grab odds'); { const { speedGrabFactor, crowdGrabFactor, CARRY: C } = await import('../src/game/possession.js'); const still = speedGrabFactor(0, C.grabSpeedHalf); const pass = speedGrabFactor(12, C.grabSpeedHalf); const slap = speedGrabFactor(35, C.grabSpeedHalf); ok(still > 0.9, `still puck is free to grab (${still.toFixed(2)})`); ok(pass < still * 0.55, `a firm pass is harder (${pass.toFixed(2)} vs ${still.toFixed(2)})`); ok(slap < pass, `a rocket is harder still (${slap.toFixed(2)})`); ok(slap < 0.2, `slapshot proximity is weak (${slap.toFixed(2)})`); near(crowdGrabFactor(1, C.grabCrowdK), 1, 1e-9, 'one body: full claim'); ok(crowdGrabFactor(2, C.grabCrowdK) < 0.75, `two bodies cut odds (${crowdGrabFactor(2, C.grabCrowdK).toFixed(2)})`); ok( crowdGrabFactor(4, C.grabCrowdK) < crowdGrabFactor(2, C.grabCrowdK), 'more bodies cut further', ); } section('a skater picks up a loose puck'); { const { physics, match } = arena(1); clearIce(match, [0]); match.states[0].x = 0; match.states[0].z = 0; match.states[0].yaw = Math.PI / 2; match.skaters[0].proxy.teleport(0, 0); // Drop the puck right where skater 0's blade is. match.puck.place(0.8, 0.02, 0.2); for (let n = 0; n < 1 / DT; n++) match.update(DT); ok(match.possession.carrier === 0, `skater 0 picked it up (carrier=${match.possession.carrier})`); physics.destroy(); } section('a carried puck stays with the skater'); { const { physics, match } = arena(1); clearIce(match, [0]); match.states[0].x = -20; match.states[0].z = 0; match.states[0].yaw = Math.PI / 2; match.skaters[0].proxy.teleport(-20, 0); match.puck.place(-20 + 0.8, 0.02, 0); match.setControl(0, { x: 1, y: 0, sprint: true, brake: false, cameraYaw: 0 }); for (let n = 0; n < 0.5 / DT; n++) match.update(DT); ok(match.possession.carrier === 0, 'possession established'); let maxGap = 0; for (let n = 0; n < 2.5 / DT; n++) { match.update(DT); if (match.possession.carrier !== 0) break; const p = match.puck.position(); maxGap = Math.max(maxGap, Math.hypot(p.x - match.states[0].x, p.z - match.states[0].z)); } ok(match.possession.carrier === 0, 'and survived a full-speed rush'); ok(maxGap < CARRY.breakRadius + 1, `the puck stayed with the stick (worst ${maxGap.toFixed(2)}m)`); ok(match.states[0].x > -12, `while actually covering ground (x=${match.states[0].x.toFixed(1)})`); physics.destroy(); } section('the dial does what it says at both ends'); { function carryWander(magnetism) { const { physics, match } = arena(1); clearIce(match, [0]); match.states[0].x = -20; match.states[0].z = 0; match.states[0].yaw = Math.PI / 2; match.skaters[0].proxy.teleport(-20, 0); match.puck.place(-20 + 0.8, 0.02, 0); match.possession.tuning.magnetism = magnetism; match.setControl(0, { x: 1, y: 0, sprint: true, brake: false, cameraYaw: 0 }); for (let n = 0; n < 0.5 / DT; n++) match.update(DT); const had = match.possession.carrier === 0; let worst = 0; let held = 0; for (let n = 0; n < 2 / DT; n++) { match.update(DT); if (match.possession.carrier === 0) { held++; const p = match.puck.position(); const c = match.possession.carryPoint(new THREE.Vector3()); if (c) worst = Math.max(worst, p.distanceTo(c)); } } physics.destroy(); return { had, worst, held: held / (2 / DT) }; } const glued = carryWander(1); const loose = carryWander(0.15); ok(glued.had && loose.had, 'both settings pick the puck up'); ok( glued.worst < loose.worst, `high magnetism keeps the puck tighter to the blade (${glued.worst.toFixed(3)}m vs ${loose.worst.toFixed(3)}m)`, ); ok(glued.held > 0.9, `and holds possession through the rush (${(glued.held * 100).toFixed(0)}% of frames)`); } section('shooting sends the puck away and gives up possession'); { const { physics, match } = arena(1); clearIce(match, [0]); match.states[0].x = -10; match.states[0].z = 0; match.states[0].yaw = Math.PI / 2; match.skaters[0].proxy.teleport(-10, 0); match.puck.place(-10 + 0.8, 0.02, 0); for (let n = 0; n < 0.5 / DT; n++) match.update(DT); ok(match.possession.carrier === 0, 'carrying first'); const fired = match.possession.shoot(1, Math.PI / 2); ok(fired, 'the shot fired'); ok(match.possession.carrier === null, 'and possession was given up'); ok(match.puck.speed() > 25, `the puck is moving like a shot (${match.puck.speed().toFixed(1)} m/s)`); // And it must not be instantly re-captured by the shooter. for (let n = 0; n < 0.1 / DT; n++) match.update(DT); ok(match.possession.carrier !== 0, 'the shooter cannot immediately vacuum it back up'); physics.destroy(); } section('a harder shot travels faster than a soft one'); { function fire(power) { const { physics, match } = arena(1); clearIce(match, [0]); match.states[0].x = -10; match.states[0].z = 0; match.states[0].yaw = Math.PI / 2; match.skaters[0].proxy.teleport(-10, 0); match.puck.place(-10 + 0.8, 0.02, 0); for (let n = 0; n < 0.5 / DT; n++) match.update(DT); match.possession.shoot(power, Math.PI / 2); const speed = match.puck.speed(); physics.destroy(); return speed; } const soft = fire(0.2); const hard = fire(1); ok(hard > soft * 2, `full power is far harder than a soft one (${hard.toFixed(1)} vs ${soft.toFixed(1)} m/s)`); ok(hard < PUCK.maxSpeed, 'and stays under the ceiling'); } section('a knockdown loses the puck'); { const { physics, match } = arena(1); clearIce(match, [0]); match.states[0].x = 0; match.states[0].z = 0; match.skaters[0].proxy.teleport(0, 0); match.puck.place(0.8, 0.02, 0.2); for (let n = 0; n < 1 / DT; n++) match.update(DT); ok(match.possession.carrier === 0, 'carrying first'); match.skaters[0].goDown(null); match.update(DT); ok(match.possession.carrier === null, 'going down gives the puck up'); physics.destroy(); } section('the Skill Stick moves the puck around the carrier'); { const { physics, match } = arena(1); clearIce(match, [0]); match.states[0].x = 0; match.states[0].z = 0; match.states[0].yaw = Math.PI / 2; match.skaters[0].proxy.teleport(0, 0); match.puck.place(0.8, 0.02, 0); const control = { x: 0, y: 0, sprint: false, brake: false, cameraYaw: 0, skill: { x: 0, y: 0 }, pressed: {} }; match.setControl(0, control); for (let n = 0; n < 1 / DT; n++) match.update(DT); ok(match.possession.carrier === 0, 'carrying first'); control.skill.x = 1; for (let n = 0; n < 0.6 / DT; n++) match.update(DT); const right = match.puck.position().clone(); control.skill.x = -1; for (let n = 0; n < 0.6 / DT; n++) match.update(DT); const left = match.puck.position().clone(); // Skater faces +X (yaw = π/2). Mesh-right is local −X (handR side), which // is world +Z at that yaw — not the sim's up×forward "right", which is // mirrored from the skeleton. Stick-right must follow the mesh. ok( right.z > left.z + 0.25, `stick-right moves the puck to the skater's right (${right.z.toFixed(2)} vs ${left.z.toFixed(2)})`, ); physics.destroy(); } section('a 3-on-3 with a puck stays sane and produces contact'); { // This is the payoff: give six skaters one thing to want and they converge, // which is what finally exercises the hit system under normal play instead // of only in staged collisions. const { physics, match } = arena(3); let hits = 0; const seen = new Set(); for (let n = 0; n < 60 / DT; 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); hits++; } } } 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 60s`); ok(insideRink(s.x, s.z, 0.3), `skater ${i} still on the ice`); } const p = match.puck.position(); ok(Number.isFinite(p.x) && Number.isFinite(p.z), 'the puck is finite'); ok(insideRink(p.x, p.z, 0), `the puck is still on the ice (${p.x.toFixed(1)}, ${p.z.toFixed(1)})`); ok(hits > 0, `chasing a puck produced contact without staging it (${hits} hits in 60s)`); physics.destroy(); } done('puck');