Files
shacraft-core/client/tests/player-physics.test.js
Emil c7e86663d8
MVP checks / mvp (push) Canceled after 0s
Expand voxel gameplay, lighting, full-height streaming and world imports
Add shared Rust/WASM physics, worker meshing and diagnostics, 64-chunk full-height streaming, atlas texture support, and baseline world import. Document the current implementation and include the supplied in-game lobby screenshot.
2026-09-17 02:10:53 +03:00

209 lines
7.0 KiB
JavaScript

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);
});