MVP checks / mvp (push) Waiting to run
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.
222 lines
12 KiB
JavaScript
222 lines
12 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { createHash } from "node:crypto";
|
|
import { readFile } from "node:fs/promises";
|
|
import { harness, Client, options } from "./server-harness.mjs";
|
|
import { loadPhysics, samplePhysicsWorld } from "../client/player-physics.js";
|
|
import { getViewBounds, mergeChunkUpdate, mergeSnapshot } from "../client/world-view.js";
|
|
|
|
// This check starts a separate server and temporary database. It never connects
|
|
// to the user's running game or modifies their worlds.
|
|
const opts = {
|
|
port: "4013",
|
|
binary: "target/release/shacraft-server",
|
|
output: "artifacts/physics/network-report.json",
|
|
...options(),
|
|
};
|
|
const serverHash = createHash("sha256").update(await readFile(opts.binary)).digest("hex");
|
|
const h = await harness(opts), clients = [], checks = [], samples = [];
|
|
const world = "test_player_physics", groundedWorld = "test_player_grounded";
|
|
const inputs = new Map([[0, {}]]);
|
|
const view = { world, revision: 0, viewCenter: null, viewBounds: null, blocks: new Map() };
|
|
const materials = new Map();
|
|
let client, step, previous, heldInput = {}, receiveError, permitSnapshot = false;
|
|
let snapshots = 0, chunks = 0, comparedTicks = 0, maximumError = 0, resetCount = 0;
|
|
const zero = { yaw: 0, pitch: 0, forward: 0, strafe: 0, jump: false, sprint: false, sneak: false, fly_toggle: false };
|
|
const controls = (input) => ({
|
|
...zero,
|
|
...input,
|
|
// The wire protocol stores these fields as f32 before passing them to Rust.
|
|
...Object.fromEntries(["yaw", "pitch", "forward", "strafe"].map((key) => [key, Math.fround(input[key] || 0)])),
|
|
});
|
|
function received() { if (receiveError) throw receiveError; }
|
|
function observe(message) {
|
|
if (receiveError) return;
|
|
try {
|
|
if (message.type === "error") throw Error(message.message);
|
|
for (const material of message.materials || []) materials.set(material.id, material);
|
|
if (message.type === "welcome" || message.type === "snapshot") {
|
|
if (message.type === "snapshot") {
|
|
assert.ok(permitSnapshot, "Movement must not send a full snapshot");
|
|
snapshots++;
|
|
}
|
|
assert.ok(message.features.includes("movement_prediction_v1"));
|
|
assert.ok(message.features.includes("chunk_stream_v1"));
|
|
view.world = message.world;
|
|
view.revision = message.revision;
|
|
view.viewCenter = message.view_center;
|
|
view.viewBounds = getViewBounds(message);
|
|
mergeSnapshot(view.blocks, message.blocks);
|
|
previous = message.motion;
|
|
heldInput = { ...zero };
|
|
} else if (message.type === "chunks") {
|
|
assert.ok(!("spawn" in message) && !("blocks" in message));
|
|
assert.equal(mergeChunkUpdate(view, message).status, "applied");
|
|
chunks++;
|
|
} else if (message.type === "state") {
|
|
const motion = message.motion;
|
|
assert.ok(motion, "Negotiated state carries authoritative motion");
|
|
assert.equal(message.ack, motion.ack);
|
|
assert.equal(message.tick, motion.tick);
|
|
if (previous && motion.epoch !== previous.epoch) {
|
|
resetCount++;
|
|
heldInput = { ...zero };
|
|
} else if (previous && motion.tick === previous.tick + 1) {
|
|
assert.ok(motion.ack >= previous.ack, "Acknowledgements cannot regress");
|
|
assert.ok(motion.ack - previous.ack <= 1, "At most one queued command is processed per tick");
|
|
if (motion.ack !== previous.ack) {
|
|
assert.ok(inputs.has(motion.ack), `Known consumed command ${motion.ack}`);
|
|
heldInput = controls(inputs.get(motion.ack));
|
|
} else heldInput = { ...heldInput, fly_toggle: false };
|
|
const neighborhood = samplePhysicsWorld(previous.body, view.blocks, materials, view.viewBounds);
|
|
assert.ok(neighborhood, "Collision neighborhood is loaded during the probe");
|
|
const predicted = step({ body: previous.body, input: heldInput, world: neighborhood, settings: motion.settings });
|
|
let error = 0;
|
|
for (const field of ["position", "velocity"])
|
|
for (let axis = 0; axis < 3; axis++)
|
|
error = Math.max(error, Math.abs(predicted[field][axis] - motion.body[field][axis]));
|
|
maximumError = Math.max(maximumError, error);
|
|
assert.ok(error < 1e-10, `Native/WASM drift at tick ${motion.tick}, ack ${motion.ack}: ${error}`);
|
|
for (const field of ["pose", "on_ground", "sprinting", "flying", "jump_cooldown", "in_water", "in_lava"])
|
|
assert.equal(predicted[field], motion.body[field], `${field} matches at tick ${motion.tick}`);
|
|
comparedTicks++;
|
|
}
|
|
samples.push({ tick: motion.tick, ack: motion.ack, epoch: motion.epoch,
|
|
position: motion.body.position, pose: motion.body.pose, flying: motion.body.flying });
|
|
previous = motion;
|
|
}
|
|
} catch (error) { receiveError = error; }
|
|
}
|
|
function input(fields = {}) {
|
|
const message = { type: "input", seq: ++client.seq, ...zero, ...fields };
|
|
inputs.set(message.seq, message);
|
|
client.send(message);
|
|
return message.seq;
|
|
}
|
|
async function state(predicate = () => true, timeout = 10000) {
|
|
const message = await client.next("state", (m) => predicate(m.motion, m), timeout);
|
|
received();
|
|
return message.motion;
|
|
}
|
|
async function acknowledged(fields) {
|
|
const seq = input(fields);
|
|
return state((motion) => motion.ack >= seq);
|
|
}
|
|
async function ticks(count) {
|
|
const start = previous.tick;
|
|
return state((motion) => motion.tick >= start + count);
|
|
}
|
|
try {
|
|
const manifest = await h.get("/api/manifest");
|
|
step = await loadPhysics(`${h.url}/physics.wasm`);
|
|
const binary = Buffer.from(await (await fetch(`${h.url}/physics.wasm`)).arrayBuffer());
|
|
const wasmHash = createHash("sha256").update(binary).digest("hex");
|
|
const catalog = await h.control("catalog.search", { query: "minecraft:stone", limit: 64 });
|
|
const stone = catalog.items.find((item) => item.state === "minecraft:stone");
|
|
assert.ok(stone);
|
|
await h.control("world.create", { world });
|
|
const plan = await h.control("build.plan", { world, expected_revision: 0,
|
|
operations: [{ type: "box", min: [-96, 0, -16], max: [160, 0, 16], block: stone.id }] });
|
|
await h.control("build.commit", { plan_id: plan.plan_id, operation_id: "physics-floor" });
|
|
await h.control("arena.configure", { world, mode: "creative", spawn: [15, 1, 8] });
|
|
await h.control("world.create", { world: groundedWorld, template: world });
|
|
await h.control("arena.configure", { world: groundedWorld, mode: "spleef", spawn: [0, 1, 8] });
|
|
client = new Client(h.url); clients.push(client);
|
|
client.ws.addEventListener("message", (event) => observe(JSON.parse(event.data)));
|
|
await client.open;
|
|
client.send({ type: "join", protocol: 1, manifest_hash: manifest.hash, world,
|
|
name: "PhysicsProbe", features: ["chunk_stream_v1", "movement_prediction_v1"] });
|
|
const welcome = await client.next("welcome"); received();
|
|
assert.ok(welcome.motion.settings.allow_flight);
|
|
await state((motion) => motion.body.on_ground);
|
|
checks.push("HTTP-served WebAssembly loads and both movement/chunk features negotiate");
|
|
|
|
const beforeBurst = previous;
|
|
const burstStart = samples.length;
|
|
for (let index = 0; index < 12; index++) input({ forward: 1, strafe: .25, yaw: .3,
|
|
sprint: true, jump: index === 2 });
|
|
const burstEnd = client.seq;
|
|
const burstMotion = await state((motion) => motion.ack === burstEnd);
|
|
const acks = samples.slice(burstStart).filter((m) => m.ack > beforeBurst.ack).map((m) => m.ack);
|
|
assert.deepEqual([...new Set(acks)], Array.from({ length: 12 }, (_, i) => beforeBurst.ack + i + 1));
|
|
assert.ok(burstMotion.tick - beforeBurst.tick >= 12);
|
|
client.send({ type: "input", seq: burstEnd, ...zero, forward: -1 });
|
|
client.send({ type: "input", seq: burstEnd - 1, ...zero, forward: -1 });
|
|
const duplicate = await ticks(2); assert.equal(duplicate.ack, burstEnd);
|
|
checks.push("A 12-command burst advances at most one acknowledgement per tick; duplicate/stale inputs are ignored");
|
|
|
|
await acknowledged({}); await ticks(14);
|
|
const crouch = await acknowledged({ sneak: true });
|
|
assert.equal(crouch.body.pose, "crouching");
|
|
const jump = await acknowledged({ jump: true });
|
|
assert.equal(jump.body.pose, "standing");
|
|
assert.ok(jump.body.velocity[1] > 0 && !jump.body.on_ground);
|
|
const flying = await acknowledged({ fly_toggle: true, jump: true });
|
|
assert.equal(flying.body.flying, true);
|
|
const ascended = await ticks(4);
|
|
assert.equal(ascended.body.flying, true, "Held input cannot re-toggle flight");
|
|
assert.ok(ascended.body.position[1] > flying.body.position[1]);
|
|
const stoppedFlying = await acknowledged({ fly_toggle: true });
|
|
assert.equal(stoppedFlying.body.flying, false);
|
|
await state((motion) => motion.body.on_ground, 10000);
|
|
checks.push("Crouch dimensions, normal jump, authorized flight ascent and one-shot flight toggles are authoritative");
|
|
|
|
const epochBeforeReset = previous.epoch;
|
|
for (let i = 0; i < 12; i++) input({ forward: 1 });
|
|
client.send({ type: "input_reset" });
|
|
const reset = await state((motion) => motion.epoch > epochBeforeReset);
|
|
assert.equal(reset.ack, client.seq);
|
|
await acknowledged({}); await ticks(3);
|
|
const epochBeforeRespawn = previous.epoch;
|
|
client.send({ type: "respawn" });
|
|
const respawn = await state((motion) => motion.epoch > epochBeforeRespawn);
|
|
assert.ok(Math.abs(respawn.body.position[0] - 15) < 1e-9);
|
|
assert.ok(Math.abs(respawn.body.position[2] - 8) < 1e-9);
|
|
checks.push("Input reset discards queued commands with a new epoch; respawn clears motion and resets position");
|
|
|
|
const chunksBeforeWalk = chunks, snapshotsBeforeWalk = snapshots;
|
|
let walk = await acknowledged({ forward: 1, sprint: true, yaw: Math.PI / 2 });
|
|
const startX = walk.body.position[0];
|
|
for (let i = 0; i < 145; i++) walk = await acknowledged({ forward: 1, sprint: true, yaw: Math.PI / 2 });
|
|
await acknowledged({}); await ticks(8);
|
|
assert.ok(walk.body.position[0] - startX > 35);
|
|
assert.ok(chunks - chunksBeforeWalk >= 2);
|
|
assert.equal(snapshots, snapshotsBeforeWalk);
|
|
checks.push("Sustained predicted sprint crosses multiple chunk boundaries using deltas without full snapshots");
|
|
|
|
const oldEpoch = previous.epoch;
|
|
permitSnapshot = true;
|
|
client.send({ type: "switch_world", world: groundedWorld });
|
|
const switched = await client.next("snapshot", (m) => m.world === groundedWorld); received();
|
|
permitSnapshot = false;
|
|
assert.ok(switched.motion.epoch > oldEpoch);
|
|
assert.equal(switched.motion.settings.allow_flight, false);
|
|
const deniedFlight = await acknowledged({ fly_toggle: true, jump: true });
|
|
assert.equal(deniedFlight.body.flying, false);
|
|
await acknowledged({});
|
|
checks.push("World switch resets the epoch and denies flight outside creative mode");
|
|
|
|
const legacy = new Client(h.url); clients.push(legacy);
|
|
const legacyWelcome = await legacy.join(manifest, groundedWorld, "LegacyPhysicsProbe");
|
|
assert.equal(legacyWelcome.motion, undefined);
|
|
const legacyStart = (await legacy.state()).players.find((p) => p.id === legacy.id).position;
|
|
const legacySeq = legacy.input({ strafe: 1 });
|
|
const legacyMoved = await legacy.state((player, message) => message.ack >= legacySeq && player.position[0] > legacyStart[0] + .5);
|
|
assert.equal(legacyMoved.motion, undefined);
|
|
legacy.input();
|
|
checks.push("A client without prediction support still receives movement and acknowledgements without motion payloads");
|
|
received();
|
|
assert.ok(comparedTicks > 170);
|
|
await h.report({ ok: true, checks, server: h.url, server_sha256: serverHash, wasm_sha256: wasmHash,
|
|
native_wasm_tolerance: 1e-10, native_wasm_max_error: maximumError,
|
|
native_wasm_compared_ticks: comparedTicks, chunk_updates: chunks,
|
|
expected_full_snapshots: snapshots, observed_epoch_resets: resetCount, samples });
|
|
} catch (error) {
|
|
await h.report({ ok: false, checks, error: String(error.stack || error),
|
|
receive_error: receiveError?.stack, native_wasm_compared_ticks: comparedTicks,
|
|
native_wasm_max_error: maximumError, samples, server_log: h.logs() });
|
|
throw error;
|
|
} finally {
|
|
await Promise.all(clients.map((client) => client.close()));
|
|
await h.stop();
|
|
}
|