Complete local voxel MVP with server, client, MCP, packages and world interchange
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { harness, Client, delay, options } from "./server-harness.mjs";
|
||||
import { raycast, direction, key } from "../client/math.js";
|
||||
const opts = options(),
|
||||
clients = [],
|
||||
checks = [];
|
||||
let h = await harness(opts);
|
||||
const createClient = () => {
|
||||
const c = new Client(h.url);
|
||||
clients.push(c);
|
||||
return c;
|
||||
};
|
||||
try {
|
||||
const manifest = await h.get("/api/manifest");
|
||||
assert.equal(manifest.protocol, 1);
|
||||
for (const pack of manifest.packages)
|
||||
for (const file of pack.files) {
|
||||
assert.notEqual(file.scope, "server");
|
||||
const r = await fetch(h.url + file.url);
|
||||
assert.equal(r.status, 200);
|
||||
const bytes = Buffer.from(await r.arrayBuffer());
|
||||
assert.equal(bytes.length, file.size);
|
||||
assert.equal(
|
||||
createHash("sha256").update(bytes).digest("hex"),
|
||||
file.sha256,
|
||||
);
|
||||
}
|
||||
const denied = await fetch(h.url + "/api/control", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ method: "world.list" }),
|
||||
});
|
||||
assert.equal(denied.status, 401);
|
||||
const secret = await fetch(
|
||||
h.url + "/packages/shacraft.trampoline/1.0.0/server/trampoline.wasm",
|
||||
);
|
||||
assert.equal(secret.status, 404);
|
||||
checks.push(
|
||||
"Manifest resource SHA-256/size, admin token required, server WASM not public",
|
||||
);
|
||||
const bad = createClient();
|
||||
await bad.open;
|
||||
bad.send({
|
||||
type: "join",
|
||||
protocol: 1,
|
||||
manifest_hash: "incorrect",
|
||||
world: "lobby",
|
||||
name: "BadManifest",
|
||||
});
|
||||
assert.match((await bad.next("error")).message, /manifest|package/i);
|
||||
await bad.close();
|
||||
checks.push("Incompatible manifest rejected");
|
||||
const catalog = await h.control("catalog.search", {
|
||||
query: "minecraft:stone",
|
||||
limit: 64,
|
||||
}),
|
||||
stone = catalog.items.find((m) => m.state === "minecraft:stone");
|
||||
assert.ok(stone);
|
||||
await h.control("world.create", { world: "test_e2e_base" });
|
||||
const plan = await h.control("build.plan", {
|
||||
world: "test_e2e_base",
|
||||
expected_revision: 0,
|
||||
operations: [
|
||||
{ type: "box", min: [-8, -1, -8], max: [8, 0, 12], block: stone.id },
|
||||
],
|
||||
});
|
||||
await h.control("build.commit", {
|
||||
plan_id: plan.plan_id,
|
||||
operation_id: "test-floor",
|
||||
});
|
||||
for (const world of ["test_e2e_a", "test_e2e_b"])
|
||||
await h.control("world.create", { world, template: "test_e2e_base" });
|
||||
let a = createClient();
|
||||
const b = createClient();
|
||||
const wa = await a.join(manifest, "test_e2e_a", "TestAlice");
|
||||
await b.join(manifest, "test_e2e_a", "TestBob");
|
||||
assert.equal(wa.revision, 0);
|
||||
assert.ok(wa.blocks.length > 500);
|
||||
await a.state((p, m) => m.players.length === 2);
|
||||
checks.push("Two clients receive shared snapshot and presence");
|
||||
const initial = (await b.state((p) => p.position[1] < 1.15)).players.find(
|
||||
(p) => p.id === b.id,
|
||||
).position;
|
||||
b.input({ forward: 1, position: [1000, 1000, 1000] });
|
||||
const moved = (
|
||||
await b.state((p) => p.position[2] < initial[2] - 0.5)
|
||||
).players.find((p) => p.id === b.id).position;
|
||||
b.input();
|
||||
assert.ok(moved.every((v) => Math.abs(v) < 20));
|
||||
assert.ok(moved[1] < 2);
|
||||
checks.push(
|
||||
"Authoritative movement and collision; client position spoof ignored",
|
||||
);
|
||||
a.send({ type: "break", pos: [1000, 0, 1000] });
|
||||
assert.match((await a.next("error")).message, /reach|sight|ray|target/i);
|
||||
checks.push("Forged out-of-reach block break rejected");
|
||||
const p = (await a.state((p) => p.position[1] < 1.15)).players.find(
|
||||
(p) => p.id === a.id,
|
||||
).position;
|
||||
const materials = new Map(wa.materials.map((m) => [m.id, m]));
|
||||
const blocks = new Map(wa.blocks.map((b) => [key(b.pos), b.block]));
|
||||
const hit = raycast(
|
||||
[p[0], p[1] + 1.62, p[2]],
|
||||
direction(0.2, -1.3),
|
||||
blocks,
|
||||
materials,
|
||||
);
|
||||
assert.ok(hit);
|
||||
const seq = a.input({ yaw: 0.2, pitch: -1.3 });
|
||||
await a.state((_p, m) => m.ack >= seq);
|
||||
await delay(150);
|
||||
a.send({ type: "break", pos: hit.pos });
|
||||
const removed = await a.next("blocks", (m) =>
|
||||
m.changes.some((c) => key(c.pos) === key(hit.pos) && c.block === 0),
|
||||
);
|
||||
await b.next("blocks", (m) => m.revision === removed.revision);
|
||||
assert.equal(removed.revision, 1);
|
||||
await delay(160);
|
||||
a.send({ type: "place", pos: hit.pos, block: stone.id });
|
||||
const restored = await a.next("blocks", (m) => m.revision === 2);
|
||||
await b.next("blocks", (m) => m.revision === 2);
|
||||
assert.equal(restored.changes[0].block, stone.id);
|
||||
checks.push("Reachable break/place, server revision ordering and broadcast");
|
||||
const late = createClient();
|
||||
const lateSnapshot = await late.join(manifest, "test_e2e_a", "TestLate");
|
||||
assert.equal(lateSnapshot.revision, 2);
|
||||
assert.equal(
|
||||
lateSnapshot.blocks.find((c) => key(c.pos) === key(hit.pos)).block,
|
||||
stone.id,
|
||||
);
|
||||
await late.close();
|
||||
checks.push("Late join receives current geometry and revision");
|
||||
await h.control("world.edit", {
|
||||
world: "test_e2e_a",
|
||||
expected_revision: 2,
|
||||
operation_id: "test-isolation",
|
||||
changes: [{ pos: [3, 1, 3], block: stone.id }],
|
||||
});
|
||||
await a.next("blocks", (m) => m.revision === 3);
|
||||
for (const world of ["test_e2e_base", "test_e2e_b"]) {
|
||||
const r = await h.control("world.read", {
|
||||
world,
|
||||
min: [3, 1, 3],
|
||||
max: [3, 1, 3],
|
||||
});
|
||||
assert.equal(r.blocks.length, 0);
|
||||
}
|
||||
checks.push("Pinned template and independent clone isolation");
|
||||
a.send({ type: "chat", text: "local integration check" });
|
||||
const chat = await b.next(
|
||||
"chat",
|
||||
(m) => m.text === "local integration check",
|
||||
);
|
||||
assert.equal(chat.name, "TestAlice");
|
||||
checks.push("Scoped game chat delivered to peer");
|
||||
const entity = await h.control("entity.spawn", {
|
||||
world: "test_e2e_a",
|
||||
kind: "minecraft:pig",
|
||||
position: [3, 2, 3],
|
||||
properties: { name: "TestPig" },
|
||||
});
|
||||
await a.next("entities", (m) =>
|
||||
m.entities.some((e) => e.id === entity.entity.id),
|
||||
);
|
||||
await b.next("entities", (m) =>
|
||||
m.entities.some((e) => e.id === entity.entity.id),
|
||||
);
|
||||
await h.control("entity.delete", { id: entity.entity.id });
|
||||
checks.push("Persisted entity events reach both clients");
|
||||
await a.close();
|
||||
a = createClient();
|
||||
const reconnect = await a.join(manifest, "test_e2e_a", "TestAlice");
|
||||
assert.equal(reconnect.revision, 3);
|
||||
assert.ok(reconnect.blocks.some((c) => key(c.pos) === "3,1,3"));
|
||||
checks.push("Reconnect restores authoritative world snapshot");
|
||||
await h.control("world.create", {
|
||||
world: "test_e2e_arena",
|
||||
template: "spleef_template",
|
||||
});
|
||||
await delay(1150);
|
||||
for (const c of [a, b]) {
|
||||
c.send({ type: "switch_world", world: "test_e2e_arena" });
|
||||
await c.next("snapshot", (m) => m.world === "test_e2e_arena");
|
||||
}
|
||||
a.send({ type: "start_match" });
|
||||
await a.state((_p, m) => m.match?.phase === "countdown");
|
||||
const active = await a.state((_p, m) => m.match?.phase === "active", 7000);
|
||||
const victim = active.players.find((p) => p.id === a.id),
|
||||
at = victim.position.map(Math.floor);
|
||||
const floor = [];
|
||||
for (let x = at[0] - 1; x <= at[0] + 1; x++)
|
||||
for (let z = at[2] - 1; z <= at[2] + 1; z++)
|
||||
floor.push({ pos: [x, 0, z], block: 0 });
|
||||
const arena = (await h.control("world.list")).find(
|
||||
(w) => w.name === "test_e2e_arena",
|
||||
);
|
||||
await h.control("world.edit", {
|
||||
world: "test_e2e_arena",
|
||||
expected_revision: arena.revision,
|
||||
operation_id: "test-eliminate",
|
||||
changes: floor,
|
||||
});
|
||||
const finished = await b.state(
|
||||
(_p, m) => m.match?.phase === "finished",
|
||||
7000,
|
||||
);
|
||||
assert.equal(finished.match.winner, "TestBob");
|
||||
await b.state(
|
||||
(_p, m) => m.tick > finished.tick && m.match?.phase === "waiting",
|
||||
8000,
|
||||
);
|
||||
const reset = await h.control("world.read", {
|
||||
world: "test_e2e_arena",
|
||||
min: [at[0], 0, at[2]],
|
||||
max: [at[0], 0, at[2]],
|
||||
});
|
||||
assert.equal(reset.blocks.length, 1);
|
||||
checks.push(
|
||||
"Full two-player Spleef countdown, elimination, winner and automatic floor reset",
|
||||
);
|
||||
for (const c of clients) await c.close();
|
||||
let metrics;
|
||||
for (let n = 0; n < 30; n++) {
|
||||
metrics = await h.get("/api/metrics");
|
||||
if (metrics.players === 0) break;
|
||||
await delay(100);
|
||||
}
|
||||
assert.equal(metrics.players, 0);
|
||||
assert.equal(metrics.active_worlds, 0);
|
||||
checks.push("Disconnect removes players and active worlds");
|
||||
const durableEdit = await h.control("world.edit", {
|
||||
world: "test_e2e_a",
|
||||
expected_revision: 3,
|
||||
operation_id: "test-durable-edit",
|
||||
changes: [{ pos: [4, 2, 4], block: stone.id }],
|
||||
});
|
||||
const durableEntity = await h.control("entity.spawn", {
|
||||
world: "test_e2e_a",
|
||||
kind: "minecraft:pig",
|
||||
position: [5, 2, 5],
|
||||
properties: { name: "DurablePig", marker: "before-sigkill" },
|
||||
});
|
||||
await h.control("arena.configure", {
|
||||
world: "test_e2e_a",
|
||||
mode: "creative",
|
||||
spawn: [1, 3, 1],
|
||||
countdown_seconds: 2,
|
||||
round_seconds: 120,
|
||||
elimination_y: -5,
|
||||
});
|
||||
const durableData = h.data;
|
||||
await h.kill("SIGKILL");
|
||||
h = await harness({ ...opts, data: durableData });
|
||||
const afterCrash = await h.control("world.read", {
|
||||
world: "test_e2e_a",
|
||||
min: [4, 2, 4],
|
||||
max: [4, 2, 4],
|
||||
});
|
||||
assert.equal(afterCrash.revision, durableEdit.revision);
|
||||
assert.equal(afterCrash.blocks[0]?.block, stone.id);
|
||||
const persisted = await h.control("entity.list", { world: "test_e2e_a" });
|
||||
const pig = persisted.items.find(
|
||||
(entity) => entity.id === durableEntity.entity.id,
|
||||
);
|
||||
assert.equal(pig?.properties.marker, "before-sigkill");
|
||||
// No fields supplied: the server returns and resaves the loaded configuration.
|
||||
const config = await h.control("arena.configure", { world: "test_e2e_a" });
|
||||
assert.equal(config.countdown_seconds, 2);
|
||||
assert.equal(config.round_seconds, 120);
|
||||
assert.equal(config.elimination_y, -5);
|
||||
assert.deepEqual(config.spawn, [1, 3, 1]);
|
||||
const afterRestart = createClient();
|
||||
const restartedWelcome = await afterRestart.join(
|
||||
await h.get("/api/manifest"),
|
||||
"test_e2e_a",
|
||||
"RestartProbe",
|
||||
);
|
||||
assert.deepEqual(restartedWelcome.spawn, [1, 3, 1]);
|
||||
assert.ok(
|
||||
restartedWelcome.blocks.some(
|
||||
(cell) => key(cell.pos) === "4,2,4" && cell.block === stone.id,
|
||||
),
|
||||
);
|
||||
await afterRestart.close();
|
||||
checks.push(
|
||||
"Real SIGKILL/restart preserves committed blocks, entity properties and arena configuration",
|
||||
);
|
||||
metrics = await h.get("/api/metrics");
|
||||
await h.report({ passed: true, checks, metrics, binary: h.binary });
|
||||
} catch (error) {
|
||||
await h.report({
|
||||
passed: false,
|
||||
checks,
|
||||
error: error.stack,
|
||||
server_logs: h.logs(),
|
||||
client_errors: clients.map((c) =>
|
||||
c.queue.filter((m) => m.type === "error"),
|
||||
),
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
await Promise.allSettled(clients.map((c) => c.close()));
|
||||
await h.stop();
|
||||
}
|
||||
Reference in New Issue
Block a user