Complete local voxel MVP with server, client, MCP, packages and world interchange
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
export const root = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
);
|
||||
export const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
export function options() {
|
||||
const opts = {};
|
||||
for (let i = 2; i < process.argv.length; i += 2)
|
||||
opts[process.argv[i].replace(/^--/, "")] = process.argv[i + 1];
|
||||
return opts;
|
||||
}
|
||||
export async function harness(opts = {}) {
|
||||
const port = Number(opts.port || 4001),
|
||||
url = `http://127.0.0.1:${port}`;
|
||||
try {
|
||||
await fetch(`${url}/api/health`, { signal: AbortSignal.timeout(500) });
|
||||
throw Error(
|
||||
`Port ${port} is already serving HTTP; choose an unused --port`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error.message?.includes("already serving")) throw error;
|
||||
}
|
||||
const data = opts.data
|
||||
? path.resolve(opts.data)
|
||||
: await mkdtemp(path.join(os.tmpdir(), "shacraft-server-test-"));
|
||||
await mkdir(data, { recursive: true });
|
||||
let stderr = "";
|
||||
const binary = path.resolve(
|
||||
opts.binary || path.join(root, "target/debug/shacraft-server"),
|
||||
);
|
||||
const child = spawn(
|
||||
binary,
|
||||
[
|
||||
"--listen",
|
||||
`127.0.0.1:${port}`,
|
||||
"--data",
|
||||
data,
|
||||
"--client",
|
||||
path.join(root, "client"),
|
||||
"--packages",
|
||||
path.join(root, "packages"),
|
||||
],
|
||||
{ cwd: root, stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
child.stdout.on("data", (chunk) => (stderr += chunk));
|
||||
child.stderr.on("data", (chunk) => (stderr += chunk));
|
||||
let failure;
|
||||
child.on("error", (e) => (failure = e));
|
||||
const running = () =>
|
||||
child.exitCode === null && child.signalCode === null && !failure;
|
||||
async function kill(signal = "SIGKILL") {
|
||||
if (!running()) return;
|
||||
const exited = new Promise((resolve) => child.once("exit", resolve));
|
||||
async function waitForExit() {
|
||||
let timer;
|
||||
await Promise.race([
|
||||
exited,
|
||||
new Promise((resolve) => {
|
||||
timer = setTimeout(resolve, 5000);
|
||||
}),
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
}
|
||||
child.kill(signal);
|
||||
await waitForExit();
|
||||
if (running()) {
|
||||
child.kill("SIGKILL");
|
||||
await waitForExit();
|
||||
}
|
||||
if (running()) throw Error("Test server did not exit after SIGKILL");
|
||||
}
|
||||
async function stop() {
|
||||
await kill("SIGINT");
|
||||
}
|
||||
try {
|
||||
const deadline = Date.now() + 45000;
|
||||
while (true) {
|
||||
if (failure) throw failure;
|
||||
if (!running()) throw Error(`Server exited: ${stderr}`);
|
||||
try {
|
||||
if ((await fetch(`${url}/api/health`)).ok) break;
|
||||
} catch {}
|
||||
if (Date.now() > deadline)
|
||||
throw Error(`Server startup timeout: ${stderr}`);
|
||||
await delay(100);
|
||||
}
|
||||
} catch (e) {
|
||||
await stop();
|
||||
throw e;
|
||||
}
|
||||
const token = (
|
||||
await readFile(path.join(data, "control.token"), "utf8")
|
||||
).trim();
|
||||
async function get(route) {
|
||||
const r = await fetch(url + route);
|
||||
assert.equal(r.status, 200, `${route}: HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
async function control(method, params = {}) {
|
||||
const response = await fetch(url + "/api/control", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ method, params }),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok || body.error)
|
||||
throw Error(`${method}: ${body.error || response.status}`);
|
||||
return body.result;
|
||||
}
|
||||
return {
|
||||
url,
|
||||
data,
|
||||
pid: child.pid,
|
||||
binary,
|
||||
get,
|
||||
control,
|
||||
stop,
|
||||
kill,
|
||||
logs: () => stderr,
|
||||
async report(value) {
|
||||
const output = opts.output
|
||||
? path.resolve(opts.output)
|
||||
: path.join(data, "report.json");
|
||||
await mkdir(path.dirname(output), { recursive: true });
|
||||
await writeFile(output, JSON.stringify(value, null, 2) + "\n");
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
...value,
|
||||
samples: undefined,
|
||||
sample_count: value.samples?.length,
|
||||
report_file: output,
|
||||
data,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
export class Client {
|
||||
constructor(url) {
|
||||
this.ws = new WebSocket(url.replace(/^http/, "ws") + "/ws");
|
||||
this.queue = [];
|
||||
this.waiters = [];
|
||||
this.id = null;
|
||||
this.seq = 0;
|
||||
this.latest = null;
|
||||
this.revision = null;
|
||||
this.error = null;
|
||||
this.open = new Promise((resolve, reject) => {
|
||||
this.ws.addEventListener("open", resolve, { once: true });
|
||||
this.ws.addEventListener(
|
||||
"error",
|
||||
() => reject(Error("WebSocket connection failed")),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
this.ws.addEventListener("message", (event) => {
|
||||
let m;
|
||||
try {
|
||||
m = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (m.type === "state") this.latest = m;
|
||||
if (m.type === "welcome") {
|
||||
this.id = m.id;
|
||||
this.welcome = m;
|
||||
}
|
||||
if (["welcome", "snapshot", "blocks"].includes(m.type))
|
||||
this.revision = m.revision;
|
||||
for (let i = 0; i < this.waiters.length; i++) {
|
||||
const w = this.waiters[i];
|
||||
if (w.predicate(m)) {
|
||||
this.waiters.splice(i, 1);
|
||||
clearTimeout(w.timer);
|
||||
w.resolve(m);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.queue.push(m);
|
||||
if (this.queue.length > 500) this.queue.shift();
|
||||
});
|
||||
}
|
||||
send(message) {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
}
|
||||
async join(manifest, world, name = "TestPlayer") {
|
||||
await this.open;
|
||||
this.send({
|
||||
type: "join",
|
||||
protocol: 1,
|
||||
manifest_hash: manifest.hash,
|
||||
world,
|
||||
name,
|
||||
});
|
||||
return this.next("welcome");
|
||||
}
|
||||
next(type, predicate = () => true, timeout = 10000) {
|
||||
return this.wait((m) => m.type === type && predicate(m), timeout);
|
||||
}
|
||||
wait(predicate, timeout = 10000) {
|
||||
const i = this.queue.findIndex(predicate);
|
||||
if (i >= 0) return Promise.resolve(this.queue.splice(i, 1)[0]);
|
||||
return new Promise((resolve, reject) => {
|
||||
const w = {
|
||||
predicate,
|
||||
resolve,
|
||||
timer: setTimeout(() => {
|
||||
this.waiters = this.waiters.filter((x) => x !== w);
|
||||
reject(Error(`WebSocket message timeout (${timeout} ms)`));
|
||||
}, timeout),
|
||||
};
|
||||
this.waiters.push(w);
|
||||
});
|
||||
}
|
||||
input(fields = {}) {
|
||||
const input = {
|
||||
type: "input",
|
||||
seq: ++this.seq,
|
||||
yaw: 0,
|
||||
pitch: 0,
|
||||
forward: 0,
|
||||
strafe: 0,
|
||||
jump: false,
|
||||
...fields,
|
||||
};
|
||||
this.send(input);
|
||||
return input.seq;
|
||||
}
|
||||
async state(predicate = () => true, timeout = 10000) {
|
||||
return this.next(
|
||||
"state",
|
||||
(m) =>
|
||||
predicate(
|
||||
m.players.find((p) => p.id === this.id),
|
||||
m,
|
||||
),
|
||||
timeout,
|
||||
);
|
||||
}
|
||||
async close() {
|
||||
if (this.ws.readyState === WebSocket.CLOSED) return;
|
||||
const closed = new Promise((resolve) =>
|
||||
this.ws.addEventListener("close", resolve, { once: true }),
|
||||
);
|
||||
this.ws.close();
|
||||
await Promise.race([closed, delay(2000)]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user