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.
336 lines
12 KiB
JavaScript
336 lines
12 KiB
JavaScript
import test, { afterEach } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { readFile } from "node:fs/promises";
|
|
import { TerrainController } from "../terrain-controller.js";
|
|
import { TerrainState } from "../terrain-state.js";
|
|
import { SectionBlockMap } from "../section-block-map.js";
|
|
|
|
const properties = JSON.parse(
|
|
await readFile(new URL("../light-properties.json", import.meta.url)),
|
|
);
|
|
const controllers = [];
|
|
const bounds = { min: [-1, -1, -1], max: [18, 3, 3] };
|
|
const materials = new Map([
|
|
[1, { state: "minecraft:stone", color: [128, 128, 128] }],
|
|
[2, { state: "minecraft:torch", color: [255, 200, 100], light: 14 }],
|
|
]);
|
|
|
|
class FakeWorker {
|
|
packets = [];
|
|
transfers = [];
|
|
postMessage(packet, transfers = []) {
|
|
this.transfers.push(transfers.length);
|
|
this.packets.push(structuredClone(packet, { transfer: transfers }));
|
|
}
|
|
terminate() {
|
|
this.terminated = true;
|
|
}
|
|
reply(message) {
|
|
this.onmessage({ data: message });
|
|
}
|
|
light(packet, dirty = [], value = 0) {
|
|
this.reply({
|
|
type: "light",
|
|
epoch: packet.epoch,
|
|
version: packet.version,
|
|
dirty,
|
|
milliseconds: 1,
|
|
field: {
|
|
min: [0, 0, 0],
|
|
size: [1, 1, 1],
|
|
sourceCount: value ? 1 : 0,
|
|
data: new Uint8Array(4),
|
|
blockLevels: new Uint8Array([value]),
|
|
skyLevels: new Uint8Array([15]),
|
|
},
|
|
});
|
|
}
|
|
mesh(packet) {
|
|
this.reply({
|
|
...packet,
|
|
vertices: new Float32Array(20),
|
|
transparent: new Float32Array(),
|
|
milliseconds: 1,
|
|
});
|
|
}
|
|
}
|
|
function fixture() {
|
|
const worker = new FakeWorker(),
|
|
published = [],
|
|
dirty = new Set();
|
|
const controller = new TerrainController({
|
|
workerFactory: () => worker,
|
|
onLight: (field) => published.push(field),
|
|
onDirty: (ids) => {
|
|
for (const id of ids) dirty.add(id);
|
|
},
|
|
});
|
|
controllers.push(controller);
|
|
return { controller, worker, published, dirty };
|
|
}
|
|
afterEach(() => {
|
|
for (const controller of controllers.splice(0)) controller.dispose();
|
|
});
|
|
|
|
function queuedClock(t) {
|
|
const callbacks = new Map();
|
|
let id = 0,
|
|
now = 0;
|
|
t.mock.method(globalThis, "setTimeout", (callback) => {
|
|
callbacks.set(++id, callback);
|
|
return id;
|
|
});
|
|
t.mock.method(globalThis, "clearTimeout", (id) => callbacks.delete(id));
|
|
t.mock.method(performance, "now", () => (now += 3));
|
|
return () => {
|
|
for (let count = 0; callbacks.size; count++) {
|
|
assert.ok(count < 100, "Preparation failed to finish");
|
|
const [id, callback] = callbacks.entries().next().value;
|
|
callbacks.delete(id);
|
|
callback();
|
|
}
|
|
};
|
|
}
|
|
|
|
test("edits during incremental reset packing replay over the captured entries coherently", (t) => {
|
|
const drain = queuedClock(t),
|
|
{ controller, worker } = fixture();
|
|
const blocks = new SectionBlockMap(
|
|
Array.from({ length: 140 }, (_, x) => [`${x},0,0`, 1]),
|
|
);
|
|
controller.reset(blocks, materials, new Map(), bounds);
|
|
controller.flush();
|
|
assert.equal(controller.preparing, true);
|
|
blocks.delete("0,0,0");
|
|
blocks.set("1,0,0", 2);
|
|
blocks.set("200,0,0", 2);
|
|
controller.update({
|
|
changes: [
|
|
{ pos: [0, 0, 0], block: 0 },
|
|
{ pos: [1, 0, 0], block: 2 },
|
|
{ pos: [200, 0, 0], block: 2 },
|
|
],
|
|
});
|
|
drain();
|
|
assert.equal(worker.packets.length, 1);
|
|
const state = new TerrainState(properties),
|
|
packet = worker.packets[0];
|
|
assert.equal(packet.reset, true);
|
|
assert.equal(packet.version, controller.version);
|
|
assert.ok(packet.initial instanceof Int32Array);
|
|
assert.ok(packet.changes instanceof Int32Array);
|
|
assert.equal(state.sync(packet), true);
|
|
assert.deepEqual([...state.blocks], [...blocks]);
|
|
});
|
|
|
|
test("a replacement world cancels old reset preparation and rejects old light and mesh replies", (t) => {
|
|
const drain = queuedClock(t),
|
|
{ controller, worker, published, dirty } = fixture();
|
|
controller.reset(
|
|
new SectionBlockMap(Array.from({ length: 140 }, (_, x) => [`${x},0,0`, 1])),
|
|
materials,
|
|
new Map(),
|
|
bounds,
|
|
);
|
|
controller.flush();
|
|
assert.equal(controller.preparing, true);
|
|
const oldEpoch = controller.epoch;
|
|
controller.reset(
|
|
new SectionBlockMap([["16,0,0", 2]]),
|
|
materials,
|
|
new Map(),
|
|
bounds,
|
|
);
|
|
controller.flush();
|
|
drain();
|
|
assert.equal(worker.packets.length, 1);
|
|
const packet = worker.packets[0];
|
|
assert.deepEqual([...packet.initial], [16, 0, 0, 2]);
|
|
worker.light(packet, ["1,0,0"], 14);
|
|
assert.equal(controller.requestMesh("1,0,0"), true);
|
|
const job = controller.busy;
|
|
worker.light({ epoch: oldEpoch, version: packet.version }, ["old-world"], 15);
|
|
worker.mesh({ ...job, epoch: oldEpoch });
|
|
assert.equal(controller.busy, job);
|
|
assert.deepEqual([...dirty], ["1,0,0"]);
|
|
assert.equal(published.length, 1);
|
|
assert.equal(published[0].sample([0, 0, 0]).blockLevel, 14);
|
|
worker.mesh(job);
|
|
assert.equal(controller.ready.get("1,0,0").epoch, controller.epoch);
|
|
});
|
|
|
|
test("subsequent syncs send deduplicated numeric deltas and only changed materials or textures", () => {
|
|
const { controller, worker } = fixture();
|
|
const blocks = new SectionBlockMap([["0,0,0", 1]]),
|
|
textures = new Map([["stone", 0]]);
|
|
controller.reset(blocks, materials, textures, bounds);
|
|
controller.flush();
|
|
blocks.entries = () => {
|
|
throw Error("Incremental sync scanned the world");
|
|
};
|
|
blocks[Symbol.iterator] = blocks.entries;
|
|
controller.update({ changes: [{ pos: [0, 0, 0], block: 2 }] });
|
|
controller.update({
|
|
changes: [
|
|
{ pos: [0, 0, 0], block: 0 },
|
|
{ pos: [16, 0, 0], block: 1 },
|
|
],
|
|
});
|
|
controller.flush();
|
|
const delta = worker.packets[1];
|
|
assert.equal(delta.reset, false);
|
|
assert.equal(delta.initial, null);
|
|
assert.equal(delta.blocks, undefined);
|
|
assert.deepEqual([...delta.changes], [0, 0, 0, 0, 16, 0, 0, 1]);
|
|
assert.deepEqual(delta.materials, []);
|
|
assert.equal(delta.textures, undefined);
|
|
assert.equal(worker.transfers[1], 1);
|
|
const updated = new Map(materials);
|
|
updated.set(2, {
|
|
...materials.get(2),
|
|
light: 15,
|
|
ignoredMetadata: "not copied",
|
|
});
|
|
controller.update({ materials: updated, textures: new Map([["stone", 7]]) });
|
|
controller.flush();
|
|
assert.deepEqual(
|
|
worker.packets[2].materials.map(([id]) => id),
|
|
[2],
|
|
);
|
|
assert.equal(worker.packets[2].materials[0][1].light, 15);
|
|
assert.equal(worker.packets[2].materials[0][1].ignoredMetadata, undefined);
|
|
assert.deepEqual(worker.packets[2].textures, [["stone", 7]]);
|
|
});
|
|
|
|
test("skipped intermediate light fields contribute their dirty union without being published", () => {
|
|
const { controller, worker, dirty, published } = fixture();
|
|
controller.reset(
|
|
new SectionBlockMap([["0,0,0", 1]]),
|
|
materials,
|
|
new Map(),
|
|
bounds,
|
|
);
|
|
controller.flush();
|
|
const old = worker.packets[0];
|
|
controller.update({ changes: [{ pos: [0, 0, 0], block: 2 }] });
|
|
controller.flush();
|
|
const latest = worker.packets[1];
|
|
worker.light(old, ["0,0,0", "1,0,0"], 0);
|
|
assert.equal(published.length, 0);
|
|
assert.equal(controller.requestMesh("0,0,0"), false);
|
|
worker.light(latest, ["1,0,0", "2,0,0"], 14);
|
|
assert.equal(published.length, 1);
|
|
assert.deepEqual([...dirty], ["0,0,0", "1,0,0", "2,0,0"]);
|
|
assert.equal(controller.requestMesh("0,0,0"), true);
|
|
});
|
|
|
|
test("mesh requests allow one in flight and two ready, and stale replies cannot release a newer job", () => {
|
|
const { controller, worker } = fixture();
|
|
controller.reset(
|
|
new SectionBlockMap([["0,0,0", 1]]),
|
|
materials,
|
|
new Map(),
|
|
bounds,
|
|
);
|
|
controller.flush();
|
|
worker.light(worker.packets[0]);
|
|
assert.equal(controller.requestMesh("0,0,0"), true);
|
|
assert.equal(controller.requestMesh("1,0,0"), false);
|
|
worker.mesh(controller.busy);
|
|
assert.equal(controller.requestMesh("0,0,0"), false);
|
|
assert.equal(controller.requestMesh("1,0,0"), true);
|
|
worker.mesh(controller.busy);
|
|
assert.equal(controller.ready.size, 2);
|
|
assert.equal(controller.requestMesh("2,0,0"), false);
|
|
controller.ready.delete("0,0,0");
|
|
assert.equal(controller.requestMesh("2,0,0"), true);
|
|
const stale = controller.busy;
|
|
controller.update({ unload: [[2, 0, 0]] });
|
|
assert.equal(controller.ready.size, 0);
|
|
controller.flush();
|
|
worker.light(worker.packets.at(-1));
|
|
assert.equal(controller.requestMesh("0,0,0"), false);
|
|
worker.mesh(stale);
|
|
assert.equal(controller.ready.has("2,0,0"), false);
|
|
assert.equal(controller.requestMesh("0,0,0"), true);
|
|
const current = controller.busy;
|
|
worker.mesh(stale);
|
|
assert.equal(controller.busy, current);
|
|
worker.mesh(current);
|
|
assert.deepEqual([...controller.ready.keys()], ["0,0,0"]);
|
|
});
|
|
|
|
test("a section unloaded then re-entered within one flush keeps only its newer blocks", () => {
|
|
const { controller, worker } = fixture(),
|
|
state = new TerrainState(properties);
|
|
controller.reset(
|
|
new SectionBlockMap([
|
|
["16,0,0", 1],
|
|
["17,0,0", 1],
|
|
]),
|
|
materials,
|
|
new Map(),
|
|
bounds,
|
|
);
|
|
controller.flush();
|
|
state.sync(worker.packets[0]);
|
|
controller.update({ changes: [{ pos: [17, 0, 0], block: 2 }] });
|
|
controller.update({ unload: [[1, 0, 0]] });
|
|
controller.update({ changes: [{ pos: [16, 0, 0], block: 2 }] });
|
|
controller.flush();
|
|
state.sync(worker.packets[1]);
|
|
assert.deepEqual([...state.blocks], [["16,0,0", 2]]);
|
|
});
|
|
|
|
test("complete sections replace queued edits and replay newer edits after their numeric records", () => {
|
|
const { controller, worker } = fixture(), state = new TerrainState(properties);
|
|
controller.reset(new SectionBlockMap([["16,0,0", 1], ["17,0,0", 1]]), materials, new Map(), bounds);
|
|
controller.flush(); state.sync(worker.packets[0]);
|
|
controller.update({ changes: [{ pos: [17,0,0], block: 2 }] });
|
|
controller.update({ sections: [{ section: [1,0,0], blocks: [{ pos: [18,0,0], block: 1 }] }] });
|
|
controller.update({ changes: [{ pos: [18,0,0], block: 2 }] });
|
|
controller.flush(); state.sync(worker.packets[1]);
|
|
assert.deepEqual([...state.blocks], [["18,0,0", 2]]);
|
|
assert.deepEqual([...worker.packets[1].changes], [18,0,0,1,18,0,0,2]);
|
|
|
|
controller.update({ sections: [{ section: [1,0,0], blocks: [{ pos: [19,0,0], block: 1 }] }] });
|
|
controller.update({ unload: [[1,0,0]] });
|
|
controller.update({ sections: [{ section: [1,0,0], blocks: [{ pos: [20,0,0], block: 2 }] }] });
|
|
controller.flush(); state.sync(worker.packets[2]);
|
|
assert.deepEqual([...state.blocks], [["20,0,0", 2]]);
|
|
|
|
controller.update({ sections: [{ section: [1,0,0], blocks: [] }] });
|
|
controller.flush(); state.sync(worker.packets[3]);
|
|
assert.equal(state.blocks.size, 0, "An empty complete section clears old contents");
|
|
});
|
|
|
|
test("streamed section records are packed at flush without indexing each voxel on arrival", () => {
|
|
const { controller, worker } = fixture();
|
|
controller.reset(new SectionBlockMap(), materials, new Map(), bounds);
|
|
controller.flush();
|
|
const records = [{ pos: [16,0,0], block: 1 }];
|
|
controller.changes.set = () => { throw Error("Section records must not pass through the per-edit index"); };
|
|
controller.update({ sections: [{ section: [1,0,0], blocks: records }] });
|
|
assert.equal(controller.sectionChanges.get("1,0,0"), records);
|
|
controller.flush();
|
|
assert.deepEqual([...worker.packets[1].changes], [16,0,0,1]);
|
|
assert.equal(controller.sectionChanges.size, 0);
|
|
});
|
|
|
|
test("runtime worker errors notify the renderer asynchronously and stop after disposal", async t => {
|
|
t.mock.method(console, "error", () => {});
|
|
const worker = new FakeWorker(), errors = [];
|
|
const controller = new TerrainController({ workerFactory: () => worker, onError: message => errors.push(message) });
|
|
controllers.push(controller);
|
|
let prevented = false;
|
|
worker.onerror({ message: "Worker stopped", preventDefault() { prevented = true; } });
|
|
assert.equal(prevented, true);
|
|
assert.deepEqual(errors, []);
|
|
await Promise.resolve();
|
|
assert.deepEqual(errors, ["Worker stopped"]);
|
|
worker.onerror({ message: "Obsolete error", preventDefault() {} });
|
|
controller.dispose();
|
|
await Promise.resolve();
|
|
assert.deepEqual(errors, ["Worker stopped"]);
|
|
});
|