Files

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