306 lines
10 KiB
JavaScript
306 lines
10 KiB
JavaScript
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();
|
|
}
|