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.
203 lines
11 KiB
JavaScript
203 lines
11 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import { attachBlockLightSamplers, blockEmission, buildBlockLight } from "../block-light.js";
|
|
import { unitBox } from "../math.js";
|
|
|
|
const stone = { state: "minecraft:stone", render: [unitBox], light: 0, light_dampening: 15, light_occlusion: [] };
|
|
const torch = { state: "minecraft:torch", render: [], light: 14, light_dampening: 0, light_occlusion: [] };
|
|
const lamp = { ...stone, state: "minecraft:redstone_lamp[lit=true]", light: 15 };
|
|
const materials = () => new Map([
|
|
[1, stone], [2, torch], [3, lamp],
|
|
[4, { ...lamp, state: "minecraft:redstone_lamp[lit=false]", light: 0 }],
|
|
[5, { ...torch, state: "minecraft:soul_torch", light: 10 }],
|
|
[6, { ...torch, state: "minecraft:sea_lantern", light: 15 }],
|
|
[7, { ...stone, state: "minecraft:glass", transparent: true, opacity: 0.35, light_dampening: 0 }],
|
|
]);
|
|
const map = (...cells) => new Map(cells.map(([pos, id]) => [pos.join(","), id]));
|
|
const bounds = { min: [-16, -3, -3], max: [16, 5, 3] };
|
|
const sample = (field, pos) => field.sample(pos.map((value) => value + 0.5));
|
|
|
|
test("block light uses canonical source levels, one-cell falloff and finite reach", () => {
|
|
const field = buildBlockLight(map([[0, 0, 0], 2]), materials(), bounds);
|
|
assert.equal(field.sourceCount, 1);
|
|
for (let x = 0; x <= 15; x++) assert.equal(sample(field, [x, 0, 0]).blockLevel, Math.max(0, 14 - x));
|
|
assert.equal(sample(field, [3, 2, 1]).blockLevel, 8);
|
|
assert.equal(sample(field, [0, 0, 0]).skyLevel, 15);
|
|
});
|
|
|
|
test("closed solid walls occlude light without wraparound in flattened arrays", () => {
|
|
const blocks = map([[-2, 0, 0], 3]);
|
|
for (let y = -3; y <= 5; y++) for (let z = -3; z <= 3; z++) blocks.set(`0,${y},${z}`, 1);
|
|
const field = buildBlockLight(blocks, materials(), bounds);
|
|
assert.equal(sample(field, [-1, 0, 0]).blockLevel, 14);
|
|
assert.equal(sample(field, [0, 0, 0]).blockLevel, 0);
|
|
assert.equal(sample(field, [1, 0, 0]).blockLevel, 0);
|
|
assert.deepEqual(sample(field, [1, 0, 0]).block, [0, 0, 0]);
|
|
});
|
|
|
|
test("light travels around an open doorway with Manhattan path attenuation", () => {
|
|
const blocks = map([[-2, 0, 0], 3]);
|
|
for (let y = -3; y <= 5; y++) for (let z = -3; z <= 3; z++) if (y !== 0 || z !== 2) blocks.set(`0,${y},${z}`, 1);
|
|
const field = buildBlockLight(blocks, materials(), bounds);
|
|
assert.equal(sample(field, [1, 0, 0]).blockLevel, 8);
|
|
});
|
|
|
|
test("transparent glass transmits while metadata dampening controls attenuation", () => {
|
|
const blocks = map([[-2, 0, 0], 3]);
|
|
for (let y = -3; y <= 5; y++) for (let z = -3; z <= 3; z++) blocks.set(`0,${y},${z}`, 7);
|
|
const field = buildBlockLight(blocks, materials(), bounds);
|
|
assert.equal(sample(field, [1, 0, 0]).blockLevel, 12);
|
|
const mats = materials();
|
|
mats.set(7, { ...mats.get(7), light_dampening: 3 });
|
|
assert.equal(sample(buildBlockLight(blocks, mats, bounds), [1, 0, 0]).blockLevel, 10);
|
|
});
|
|
|
|
test("authoritative unlit state emits zero and recomputation removes stale light", () => {
|
|
const blocks = map([[0, 0, 0], 3]);
|
|
assert.equal(sample(buildBlockLight(blocks, materials(), bounds), [1, 0, 0]).blockLevel, 14);
|
|
blocks.set("0,0,0", 4);
|
|
const off = buildBlockLight(blocks, materials(), bounds);
|
|
assert.equal(off.sourceCount, 0);
|
|
assert.ok(off.blockLevels.every((level) => level === 0));
|
|
assert.ok(off.data.every((value) => value === 0));
|
|
blocks.delete("0,0,0");
|
|
assert.equal(sample(buildBlockLight(blocks, materials(), bounds), [1, 0, 0]).blockLevel, 0);
|
|
blocks.set("0,0,0", 3);
|
|
assert.equal(sample(buildBlockLight(blocks, materials(), bounds), [1, 0, 0]).blockLevel, 14);
|
|
});
|
|
|
|
test("torch and soul colors retain their tint at distance without colored radius shifts", () => {
|
|
const warm = buildBlockLight(map([[0, 0, 0], 2]), materials(), bounds);
|
|
const cyan = buildBlockLight(map([[0, 0, 0], 5]), materials(), bounds);
|
|
const close = sample(warm, [1, 0, 0]).block, far = sample(warm, [8, 0, 0]).block;
|
|
assert.ok(close[0] > close[1] && close[1] > close[2]);
|
|
assert.ok(far[0] > far[1] && far[1] > far[2] && far[2] > 0);
|
|
assert.ok(Math.abs(close[1] / close[0] - far[1] / far[0]) < 0.02);
|
|
const soul = sample(cyan, [1, 0, 0]).block;
|
|
assert.ok(soul[2] > soul[1] && soul[1] > soul[0]);
|
|
assert.deepEqual(blockEmission({ state: "minecraft:torch" }).level, 0);
|
|
const lampTint = blockEmission(lamp).color;
|
|
assert.ok(lampTint[1] > 0.7, "a redstone lamp casts warm white light, not red torch light");
|
|
});
|
|
|
|
test("multiple lights combine by maximum level, never by additive strength", () => {
|
|
const mats = materials();
|
|
mats.set(5, { ...mats.get(5), light: 14 });
|
|
const field = buildBlockLight(map([[-2, 0, 0], 2], [[2, 0, 0], 5]), mats, bounds);
|
|
assert.equal(field.sourceCount, 2);
|
|
assert.equal(sample(field, [0, 0, 0]).blockLevel, 12);
|
|
const middle = sample(field, [0, 0, 0]).block;
|
|
assert.ok(middle[0] > 0.4 && middle[2] > 0.4);
|
|
assert.equal(Math.max(...field.blockLevels), 14);
|
|
});
|
|
|
|
test("direct sky stays 15 down clear columns and a full roof leaves darkness below", () => {
|
|
const open = buildBlockLight(new Map(), materials(), bounds);
|
|
assert.ok(open.skyLevels.every((level) => level === 15));
|
|
const blocks = new Map();
|
|
for (let x = -16; x <= 16; x++) for (let z = -3; z <= 3; z++) blocks.set(`${x},2,${z}`, 1);
|
|
const covered = buildBlockLight(blocks, materials(), bounds);
|
|
assert.equal(sample(covered, [0, 3, 0]).skyLevel, 15);
|
|
assert.equal(sample(covered, [0, 2, 0]).skyLevel, 0);
|
|
assert.equal(sample(covered, [0, 1, 0]).skyLevel, 0);
|
|
assert.equal(sample(covered, [0, -3, 0]).skyLevel, 0);
|
|
});
|
|
|
|
test("sky spreads laterally under an overhang with one level of decay per cell", () => {
|
|
const blocks = new Map();
|
|
for (let x = 0; x <= 16; x++) for (let z = -3; z <= 3; z++) blocks.set(`${x},2,${z}`, 1);
|
|
const field = buildBlockLight(blocks, materials(), bounds);
|
|
assert.equal(sample(field, [-1, 1, 0]).skyLevel, 15);
|
|
assert.equal(sample(field, [0, 1, 0]).skyLevel, 14);
|
|
assert.equal(sample(field, [1, 1, 0]).skyLevel, 13);
|
|
// The open exterior column remains level 15 at this height as well.
|
|
assert.equal(sample(field, [2, 0, 0]).skyLevel, 12);
|
|
});
|
|
|
|
test("slab face geometry blocks downwards sky despite its zero dampening", () => {
|
|
const mats = materials(), slab = { min: [0, 0, 0], max: [1, 0.5, 1] };
|
|
mats.set(8, { state: "minecraft:stone_slab[type=bottom]", render: [slab], light: 0, light_dampening: 0, light_occlusion: [slab] });
|
|
const blocks = new Map();
|
|
for (let x = -16; x <= 16; x++) for (let z = -3; z <= 3; z++) blocks.set(`${x},2,${z}`, 8);
|
|
const field = buildBlockLight(blocks, mats, bounds);
|
|
assert.equal(sample(field, [0, 2, 0]).skyLevel, 15);
|
|
assert.equal(sample(field, [0, 1, 0]).skyLevel, 0);
|
|
});
|
|
|
|
test("complementary slab faces close their shared boundary exactly", () => {
|
|
const bottom = { min: [0, 0, 0], max: [1, 0.5, 1] }, top = { min: [0, 0.5, 0], max: [1, 1, 1] };
|
|
const mats = materials();
|
|
mats.set(8, { state: "minecraft:stone_slab[type=bottom]", render: [bottom], light: 0, light_dampening: 0, light_occlusion: [bottom] });
|
|
mats.set(9, { state: "minecraft:stone_slab[type=top]", render: [top], light: 0, light_dampening: 0, light_occlusion: [top] });
|
|
const narrow = { min: [-2, 0, 0], max: [2, 0, 0] };
|
|
const closed = buildBlockLight(map([[-2, 0, 0], 2], [[-1, 0, 0], 8], [[0, 0, 0], 9]), mats, narrow);
|
|
assert.equal(sample(closed, [-1, 0, 0]).blockLevel, 13);
|
|
assert.equal(sample(closed, [0, 0, 0]).blockLevel, 0);
|
|
assert.equal(sample(closed, [1, 0, 0]).blockLevel, 0);
|
|
const open = buildBlockLight(map([[-2, 0, 0], 2], [[-1, 0, 0], 8], [[0, 0, 0], 8]), mats, narrow);
|
|
assert.equal(sample(open, [1, 0, 0]).blockLevel, 11);
|
|
});
|
|
|
|
test("negative coordinates and chunk boundaries preserve level and indexing", () => {
|
|
const region = { min: [-20, -2, -2], max: [20, 2, 2] };
|
|
const field = buildBlockLight(map([[-16, 0, 0], 3], [[16, 0, 0], 3]), materials(), region);
|
|
assert.equal(sample(field, [-17, 0, 0]).blockLevel, 14);
|
|
assert.equal(sample(field, [-15, 0, 0]).blockLevel, 14);
|
|
assert.equal(sample(field, [15, 0, 0]).blockLevel, 14);
|
|
assert.equal(sample(field, [17, 0, 0]).blockLevel, 14);
|
|
assert.deepEqual(field.sample([-21, 0, 0]), { block: [0, 0, 0], sky: 0, blockLevel: 0, skyLevel: 0 });
|
|
});
|
|
|
|
test("face samples use the outward hemisphere and cannot pull light through a solid roof", () => {
|
|
const blocks = map([[0, 3, 0], 3]);
|
|
for (let x = -16; x <= 16; x++) for (let z = -3; z <= 3; z++) blocks.set(`${x},2,${z}`, 1);
|
|
const field = buildBlockLight(blocks, materials(), bounds);
|
|
const bottom = [[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1]];
|
|
const underside = field.sampleFace([0, 2, 0], unitBox, [0, -1, 0], bottom);
|
|
assert.deepEqual(underside.block, [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]]);
|
|
assert.deepEqual(underside.sky, [0, 0, 0, 0]);
|
|
const top = field.sampleFace([1, 2, 0], unitBox, [0, 1, 0], [[0, 1, 1], [1, 1, 1], [1, 1, 0], [0, 1, 0]]);
|
|
assert.ok(top.block.some((rgb) => rgb[0] > 0));
|
|
assert.ok(top.sky.every((value) => value === 1));
|
|
});
|
|
|
|
test("worker transfer can restore sampling without propagating the field again", () => {
|
|
const blocks = map([[0, 0, 0], 2]), mats = materials();
|
|
const field = buildBlockLight(blocks, mats, bounds);
|
|
const raw = { min: field.min, size: field.size, data: field.data, blockLevels: field.blockLevels, skyLevels: field.skyLevels, sourceCount: field.sourceCount };
|
|
const transferred = structuredClone(raw, { transfer: [raw.data.buffer, raw.blockLevels.buffer, raw.skyLevels.buffer] });
|
|
const hydrated = attachBlockLightSamplers(transferred, blocks, mats);
|
|
assert.equal(sample(hydrated, [1, 0, 0]).blockLevel, 13);
|
|
assert.ok(sample(hydrated, [1, 0, 0]).block[0] > 0);
|
|
});
|
|
|
|
test("invalid or excessive bounds fail before allocating a field", () => {
|
|
for (const invalid of [null, { min: [0, 0, 0], max: [1, 1, 0.5] },
|
|
{ min: [1, 0, 0], max: [0, 1, 1] }, { min: [0, 0, 0], max: [1024, 1024, 1024] }])
|
|
assert.throws(() => buildBlockLight(new Map(), materials(), invalid), RangeError);
|
|
const field = buildBlockLight(new Map(), materials(), { min: [0, 0, 0], max: [0, 0, 0] });
|
|
assert.equal(field.data.length, 4);
|
|
assert.equal(field.skyLevels[0], 15);
|
|
});
|
|
|
|
test("78 directional crossings match measured original Java 26.2 attenuation", () => {
|
|
const fixture = JSON.parse(readFileSync(new URL("./fixtures/lighting-java26.2.json", import.meta.url)));
|
|
const examples = new Map(fixture.examples.map((entry) => [entry.state, entry]));
|
|
const directions = { east: [1, 0, 0], west: [-1, 0, 0], up: [0, 1, 0], down: [0, -1, 0], south: [0, 0, 1], north: [0, 0, -1] };
|
|
const measured = (state, light) => {
|
|
const entry = examples.get(state);
|
|
return { state, light, render: [], light_dampening: entry.dampening,
|
|
light_occlusion: entry.occlusion.map((box) => ({ min: box.slice(0, 3), max: box.slice(3, 6) })) };
|
|
};
|
|
assert.equal(fixture.crossings.length, 78);
|
|
for (const crossing of fixture.crossings) {
|
|
const target = directions[crossing.direction];
|
|
const mats = new Map([[1, measured(crossing.from, 15)], [2, measured(crossing.to, 0)]]);
|
|
const field = buildBlockLight(map([[0, 0, 0], 1], [target, 2]), mats,
|
|
{ min: target.map((value) => Math.min(0, value)), max: target.map((value) => Math.max(0, value)) });
|
|
assert.equal(sample(field, target).blockLevel, Math.max(0, 15 - crossing.block_dampening),
|
|
`${crossing.from} -> ${crossing.to}, ${crossing.direction}`);
|
|
}
|
|
});
|