MVP checks / mvp (push) Canceled after 0s
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.
209 lines
7.0 KiB
JavaScript
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);
|
|
});
|