Files
shacraft-core/client/tests/terrain-controller.test.js
Emil c7e86663d8
MVP checks / mvp (push) Waiting to run
Expand voxel gameplay, lighting, full-height streaming and world imports
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.
2026-09-17 02:10:53 +03:00

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"]);
});