import test from "node:test"; import assert from "node:assert/strict"; import { PhysicsClock, LocalPrediction, samplePhysicsWorld, } from "../player-physics.js"; const body = (x = 0) => ({ position: [x, 1, 0], velocity: [0, 0, 0], pose: "standing", }); const motion = (x = 0, ack = 0, tick = 0, epoch = 1) => ({ body: body(x), ack, tick, epoch, settings: { movement_speed: 0.1 }, }); const step = ({ body: current, input }) => ({ ...current, position: [ current.position[0] + (input.forward || 0), ...current.position.slice(1), ], flying: input.fly_toggle ? !current.flying : current.flying, }); const makePrediction = (getWorld = () => ({ blocks: [] }), limit) => new LocalPrediction(step, getWorld, limit); const near = (a, b) => assert.ok(Math.abs(a - b) < 1e-9, `${a} != ${b}`); test("20 Hz input timing is the same at 30, 60, and 144 render frames per second", () => { for (const fps of [30, 60, 144]) { const clock = new PhysicsClock(); let ticks = 0; for (let frame = 0; frame < fps * 3; frame++) clock.advance(1000 / fps, () => ticks++); assert.equal(ticks, 60); near(clock.alpha, 0); } }); test("a long suspended frame cannot create unlimited catch-up inputs", () => { const clock = new PhysicsClock(); let ticks = 0; assert.equal( clock.advance(60_000, () => ticks++), 5, ); assert.equal( clock.advance(0, () => ticks++), 0, ); assert.equal(ticks, 5); clock.advance(25, () => ticks++); near(clock.alpha, 0.5); clock.reset(); near(clock.alpha, 0); }); test("authority acknowledges one input and replays only the still pending commands", () => { const prediction = makePrediction(); prediction.receive(motion()); prediction.push(1, { forward: 1 }); prediction.push(2, { forward: 1 }); prediction.push(3, { forward: -1 }); assert.equal(prediction.body.position[0], 1); prediction.receive(motion(0.8, 1, 1)); near(prediction.body.position[0], 0.8); assert.deepEqual( prediction.pending.map(({ seq }) => seq), [2, 3], ); near(prediction.offset[0], 0.2); near(prediction.sample(0, 0, {}).position[0], 1); near(prediction.sample(0, 1, {}).position[0], 0.8 + 0.2 * Math.exp(-15)); }); test("late ticks, decreasing acknowledgements, and duplicate inputs cannot rewind movement", () => { const prediction = makePrediction(); prediction.receive(motion(4, 4, 10)); prediction.push(5, { forward: 1 }); assert.equal(prediction.receive(motion(-10, 5, 9)), false); assert.equal(prediction.receive(motion(-10, 3, 11)), false); assert.equal(prediction.push(5, { forward: 20 }), false); assert.equal(prediction.push(4, { forward: 20 }), false); assert.equal(prediction.body.position[0], 5); }); test("world/respawn/input reset epochs drop old commands and visual correction", () => { const prediction = makePrediction(); prediction.receive(motion()); prediction.push(1, { forward: 1 }); prediction.receive(motion(0.7, 1, 1)); prediction.push(2, { forward: 1 }); prediction.receive(motion(6, 2, 2, 2)); assert.deepEqual(prediction.pending, []); assert.deepEqual(prediction.offset, [0, 0, 0]); assert.equal(prediction.body.position[0], 6); assert.equal(prediction.receive(motion(-6, 3, 3, 1)), false); assert.equal(prediction.body.position[0], 6); prediction.reset(); assert.equal(prediction.body, null); assert.equal(prediction.tick, -1); prediction.receive(motion(30, 0, 0), true); assert.equal(prediction.body.position[0], 30); }); test("a teleport without an epoch change does not replay pre-teleport movement", () => { const prediction = makePrediction(); prediction.receive(motion()); prediction.push(1, { forward: 1 }); prediction.receive(motion(100, 0, 1)); assert.equal(prediction.body.position[0], 100); assert.equal(prediction.pending.length, 0); }); test("unavailable chunks suspend prediction until authority and a complete context arrive", () => { let available = false; const prediction = makePrediction(() => (available ? { blocks: [] } : null)); prediction.receive(motion()); assert.equal(prediction.push(1, { forward: 1 }), false); assert.equal(prediction.suspended, true); assert.equal(prediction.body.position[0], 0); available = true; prediction.receive(motion(0.5, 1, 1)); prediction.push(2, { forward: 1 }); assert.equal(prediction.suspended, false); assert.equal(prediction.body.position[0], 1.5); }); test("bounded history suspends until the discarded sequence has been acknowledged", () => { const prediction = makePrediction(undefined, 3); prediction.receive(motion()); for (let seq = 1; seq <= 5; seq++) prediction.push(seq, { forward: 1 }); assert.equal(prediction.pending.length, 3); assert.equal(prediction.suspended, true); prediction.receive(motion(1, 1, 1)); assert.equal(prediction.suspended, true); prediction.receive(motion(2, 2, 2)); assert.equal(prediction.suspended, false); assert.equal(prediction.body.position[0], 5); }); test("render previews do not mutate acknowledged bodies, queue commands, or consume a flight toggle", () => { const prediction = makePrediction(); prediction.receive(motion()); for (let i = 0; i < 3; i++) { near( prediction.sample(0.5, 0, { forward: 1, fly_toggle: true }).position[0], 0.5, ); assert.equal(prediction.body.flying, undefined); assert.equal(prediction.pending.length, 0); } prediction.push(1, { forward: 1, fly_toggle: true }); assert.equal(prediction.body.flying, true); prediction.receive({ ...motion(0, 0, 1), body: body() }); assert.equal(prediction.body.flying, true); }); test("swept neighbourhood includes fast-fall floors, transparent fluids and local collision boxes", () => { const player = { ...body(), position: [-0.2, 7, 0], velocity: [0, -3.92, 0] }; const collision = [{ min: [0, 0, 0], max: [1, 1.5, 1] }]; const materials = new Map([ [1, { state: "minecraft:water[level=0]", collision: [] }], [2, { state: "minecraft:oak_fence", collision }], ]); const blocks = new Map([ ["-1,6,0", 1], ["-1,2,0", 2], ["40,0,0", 2], ]); const bounds = { min: [-32, -16, -32], max: [31, 31, 31] }; const world = samplePhysicsWorld(player, blocks, materials, bounds); assert.equal(world.blocks.length, 2); assert.deepEqual( world.blocks.find((b) => b.state.includes("fence")), { pos: [-1, 2, 0], state: "minecraft:oak_fence", collision }, ); assert.deepEqual( world.blocks.find((b) => b.state.includes("water")).collision, [], ); assert.equal(samplePhysicsWorld(player, blocks, new Map(), bounds), null); assert.equal( samplePhysicsWorld(player, blocks, materials, { min: [-1, 0, -1], max: [1, 10, 1], }), null, ); }); test("invalid motion packets leave the valid body and history untouched", () => { const prediction = makePrediction(); prediction.receive(motion()); assert.equal( prediction.receive({ ...motion(), body: { position: [NaN, 0, 0], velocity: [0, 0, 0] }, }), false, ); assert.equal(prediction.receive({ ...motion(), ack: 0.5 }), false); assert.equal(prediction.body.position[0], 0); });