Lots of content

This commit is contained in:
2026-11-03 12:41:09 -05:00
parent 6ebe95b208
commit d34388bf2c
126 changed files with 28076 additions and 1027 deletions
+55
View File
@@ -0,0 +1,55 @@
-- Pure claim-role authorization matrix; no engine globals or storage.
local factory = dofile(assert(arg[1], "access.lua path required"))
local claims = {owner = {x = 0, y = 0, z = 0}, other = {x = 40, y = 0, z = 0}}
local memberships = {owner = {resident = "resident", keeper = "storekeeper", stale = "invented"}}
local policy = factory(claims, memberships)
local p = {x = 0, y = 0, z = 0}
local passed = 0
local function test(name, fn)
local ok, err = pcall(fn)
assert(ok, name .. ": " .. tostring(err))
passed = passed + 1
print("ok " .. passed .. " - " .. name)
end
test("owner retains full authority and outside land remains usable", function()
assert(policy.owner_at(p) == "owner" and policy.role_at(p, "owner") == "owner")
for _, action in ipairs({"build", "dig", "use", "storage", "arrival", "combat"}) do
assert(policy.allowed(p, "owner", action))
assert(policy.allowed({x = 20, y = 0, z = 0}, "outsider", action))
end
end)
test("resident can build and use but cannot attack or arrive in another home", function()
assert(policy.allowed(p, "resident", "build"))
assert(policy.allowed(p, "resident", "dig"))
assert(policy.allowed(p, "resident", "use"))
assert(policy.allowed(p, "resident", "storage"))
assert(not policy.allowed(p, "resident", "arrival"))
assert(not policy.allowed(p, "resident", "combat"))
end)
test("storekeeper may use only protected storage", function()
assert(policy.allowed(p, "keeper", "storage"))
for _, action in ipairs({"build", "dig", "use", "arrival", "combat"}) do
assert(not policy.allowed(p, "keeper", action), action)
end
end)
test("unknown roles, invalid actions, and ambiguous overlaps fail closed", function()
for _, action in ipairs({"build", "dig", "use", "storage", "arrival", "combat"}) do
assert(not policy.allowed(p, "stale", action))
end
assert(not policy.allowed(p, "resident", "manage"))
claims.overlap = {x = 10, y = 0, z = 0}
assert(policy.owner_at(p) == nil and not policy.allowed(p, "owner", "build"))
assert(not policy.allowed({}, "owner", "build"))
claims.overlap = nil
end)
test("claim release and reassignment do not transfer the former owner's membership", function()
claims.owner = nil
assert(policy.allowed(p, "resident", "build"), "released land is no longer reserved by stale membership")
assert(policy.allowed(p, "outsider", "build"))
memberships.owner = nil
claims.owner = {x = -30, y = 0, z = 0}
local reassigned = {x = -30, y = 0, z = 0}
assert(policy.owner_at(reassigned) == "owner" and not policy.allowed(reassigned, "resident", "build"))
end)
print("PASS " .. passed .. " claim-role authorization tests")
+478
View File
@@ -0,0 +1,478 @@
// Asset-only checks: no engine launch and no persistent worlds.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as fs from 'node:fs/promises';
import { realpath } from 'node:fs';
import { promisify } from 'node:util';
import os from 'node:os';
import path from 'node:path';
import { inflateSync } from 'node:zlib';
import { generateAssets } from '../scripts/assets.mjs';
import { CHARACTER_ART_MANIFEST, CHARACTER_OPTIONS, characterLayer, characterMesh, compositeCharacter } from '../scripts/character-art.mjs';
import { ANIMATIONS, ATLAS, PLAYER_HEIGHT, playerB3D, SAMPLE_RATE, SOUND_HASHES, SOUND_PROFILES, soundWAV, soundOGG, loadVorbisCodec, TOOL_NAMES, toolOBJ } from '../scripts/media.mjs';
import { inventory } from '../scripts/files.mjs';
import { packageGame, SOURCE, verifyPackage } from '../scripts/package.mjs';
async function temporary(t) {
const root = await fs.mkdtemp(path.join(await promisify(realpath)(os.tmpdir()), 'hearth-assets-'));
t.after(() => fs.rm(root, {recursive: true, force: true}));
return root;
}
async function sourceTree(root) {
const source = path.join(root, 'source');
await fs.mkdir(path.join(source, 'mods/hearth/nested'), {recursive: true});
await fs.mkdir(path.join(source, 'mods/another'), {recursive: true});
await fs.mkdir(path.join(source, 'scripts'));
for (const [file, text] of Object.entries({
'mods/hearth/players.lua': '-- skin #A1B2C3',
'mods/hearth/nested/feedback.lua': '-- new feedback #01abef',
'mods/another/init.lua': '-- another mod #39D1A2',
'scripts/ignored.lua': '-- not mod source #fedcba',
'mods/hearth/nested/ignored.txt': '#123abc',
})) await fs.writeFile(path.join(source, file), text);
return source;
}
// Independent bounded reader: never trust the serializer's own chunk helpers.
function parseB3D(bytes) {
let vertexData, triangleData, animation;
const bones = new Map(), chunks = [];
function cstring(offset, end) {
const zero = bytes.indexOf(0, offset);
assert.ok(zero >= offset && zero < end, 'unterminated chunk string');
return [bytes.toString('ascii', offset, zero), zero+1];
}
function floats(offset, count, end) {
assert.ok(offset + count*4 <= end, 'float record escapes chunk');
const result = Array.from({length: count}, (_, i) => bytes.readFloatLE(offset+i*4));
assert.ok(result.every(Number.isFinite));
return result;
}
function walk(start, end, parent = null) {
let offset = start;
while (offset < end) {
assert.ok(offset+8 <= end, 'truncated chunk header');
const tag = bytes.toString('ascii', offset, offset+4), length = bytes.readInt32LE(offset+4);
const data = offset+8, limit = data+length;
assert.ok(length >= 0 && limit <= end, `${tag} escapes parent bounds`);
chunks.push(tag);
if (tag === 'BB3D') {
assert.equal(bytes.readInt32LE(data), 1); walk(data+4, limit);
} else if (tag === 'NODE') {
const [name, next] = cstring(data, limit);
assert.ok(!bones.has(name));
const transform = floats(next, 10, limit);
bones.set(name, {parent, position: transform.slice(0,3), transform, weights: [], keys: []});
walk(next+40, limit, name);
} else if (tag === 'MESH') {
assert.equal(bytes.readInt32LE(data), 0); walk(data+4, limit, parent);
} else if (tag === 'VRTS') {
assert.deepEqual([0,4,8].map(n => bytes.readInt32LE(data+n)), [1,1,2]);
assert.equal((length-12)%32, 0);
vertexData = Array.from({length: (length-12)/32}, (_, i) => floats(data+12+i*32, 8, limit));
} else if (tag === 'TRIS') {
assert.equal(bytes.readInt32LE(data), 0); assert.equal((length-4)%12, 0);
triangleData = Array.from({length: (length-4)/12}, (_, i) =>
[0,4,8].map(n => bytes.readInt32LE(data+4+i*12+n)));
} else if (tag === 'BONE') {
assert.equal(length%8, 0);
for (let p = data; p < limit; p += 8) bones.get(parent).weights.push([bytes.readInt32LE(p), ...floats(p+4,1,limit)]);
} else if (tag === 'KEYS') {
assert.equal(bytes.readInt32LE(data), 5); assert.equal((length-4)%32, 0);
for (let p = data+4; p < limit; p += 32) bones.get(parent).keys.push({
frame: bytes.readInt32LE(p)-1, position: floats(p+4,3,limit), rotation: floats(p+16,4,limit),
});
} else if (tag === 'ANIM') {
assert.equal(length, 12);
animation = [bytes.readInt32LE(data), bytes.readInt32LE(data+4), ...floats(data+8,1,limit)];
} else if (tag === 'TEXS') {
const [texture, next] = cstring(data, limit);
assert.equal(texture, 'hearth_person.png'); assert.equal(next+28, limit);
assert.deepEqual([bytes.readInt32LE(next),bytes.readInt32LE(next+4)], [1,2]);
assert.deepEqual(floats(next+8,5,limit), [0,0,1,1,0]);
} else if (tag === 'BRUS') {
assert.equal(bytes.readInt32LE(data), 1);
const [, next] = cstring(data+4, limit);
assert.equal(next+32, limit); assert.deepEqual(floats(next,5,limit), [1,1,1,1,0]);
assert.deepEqual([20,24,28].map(n => bytes.readInt32LE(next+n)), [1,0,0]);
} else assert.fail(`unexpected chunk ${tag}`);
offset = limit;
}
assert.equal(offset, end);
}
assert.equal(bytes.toString('ascii',0,4), 'BB3D');
assert.equal(bytes.readInt32LE(4), bytes.length-8);
walk(0, bytes.length);
return {vertices: vertexData, triangles: triangleData, bones, animation, chunks};
}
function crc32(bytes) {
let value = 0xffffffff;
for (const b of bytes) {
value ^= b;
for (let bit = 0; bit < 8; bit++) value = (value >>> 1) ^ ((value & 1) ? 0xedb88320 : 0);
}
return (value ^ 0xffffffff) >>> 0;
}
function parsePNG(bytes) {
assert.deepEqual([...bytes.subarray(0,8)], [137,80,78,71,13,10,26,10]);
let width, height, offset = 8, ended = false;
const compressed = [];
while (offset < bytes.length) {
assert.ok(offset+12 <= bytes.length);
const size = bytes.readUInt32BE(offset), end = offset+8+size;
assert.ok(end+4 <= bytes.length);
const tag = bytes.toString('ascii',offset+4,offset+8), data = bytes.subarray(offset+8,end);
assert.equal(crc32(bytes.subarray(offset+4,end)), bytes.readUInt32BE(end));
if (tag === 'IHDR') {
assert.equal(size,13); width = data.readUInt32BE(0); height = data.readUInt32BE(4);
assert.deepEqual([...data.subarray(8)], [8,6,0,0,0]);
} else if (tag === 'IDAT') compressed.push(data);
else { assert.equal(tag,'IEND'); assert.equal(size,0); ended = true; }
offset = end+4;
}
assert.ok(ended); assert.equal(offset,bytes.length);
const raw = inflateSync(Buffer.concat(compressed), {maxOutputLength: 1024*1024});
assert.equal(raw.length,height*(width*4+1));
for (let y = 0; y < height; y++) assert.equal(raw[y*(width*4+1)],0);
return {width,height,pixel: (x,y) => [...raw.subarray(y*(width*4+1)+1+x*4,y*(width*4+1)+5+x*4)]};
}
for (const profile of CHARACTER_OPTIONS.bodies) test(`B3D ${profile} has bounded chunks, one textured mesh, valid triangles/normals/UVs and normalized bone weights`, () => {
const model = parseB3D(playerB3D(profile));
assert.deepEqual(model.animation,[0,100,24]);
assert.equal(model.chunks.filter(c => c==='MESH').length,1);
assert.equal(model.vertices.length,240); assert.equal(model.triangles.length,120);
assert.equal(Math.max(...model.vertices.map(v => v[1])),PLAYER_HEIGHT);
assert.equal(Math.min(...model.vertices.map(v => v[1])),0);
assert.equal(PLAYER_HEIGHT/10,1.75);
const totals = Array(model.vertices.length).fill(0), used = new Set();
for (const triangle of model.triangles) {
assert.equal(new Set(triangle).size,3);
triangle.forEach(id => { assert.ok(id>=0 && id<model.vertices.length); used.add(id); });
const [a,b,c] = triangle.map(id => model.vertices[id]);
const u = b.slice(0,3).map((v,i)=>v-a[i]), v = c.slice(0,3).map((v,i)=>v-a[i]);
const normal = [u[1]*v[2]-u[2]*v[1],u[2]*v[0]-u[0]*v[2],u[0]*v[1]-u[1]*v[0]];
assert.ok(normal.reduce((sum,n,i)=>sum+n*a[3+i],0)>0,'outward winding');
}
assert.equal(used.size,model.vertices.length);
for (const vertex of model.vertices) {
assert.ok(Math.abs(Math.hypot(...vertex.slice(3,6))-1)<1e-6);
assert.ok(vertex.slice(6).every(n => n>0 && n<1));
}
for (const bone of model.bones.values()) for (const [id, weight] of bone.weights) {
assert.ok(id>=0 && id<totals.length); assert.equal(weight,1,'rigid weighting'); totals[id]+=weight;
}
assert.ok(totals.every(n => n===1));
assert.equal(model.bones.get('Hand_Right').parent,'Arm_Right');
assert.equal(model.bones.get('Hand_Left').parent,'Arm_Left');
assert.ok(model.bones.get('Hand_Right').weights.length>0);
});
test('all body profiles share exact rig, rigid assignments, UVs and 100 keys; only torso and limb widths change', () => {
const balanced = parseB3D(playerB3D()), widths=[];
assert.deepEqual(playerB3D(),playerB3D('balanced'));
assert.deepEqual([...balanced.bones].map(([name,b])=>[name,b.parent,b.position]),[
['Root',null,[0,0,0]],['Body','Root',[0,8,0]],['Head','Body',[0,5.5,0]],
['Arm_Right','Body',[-Math.fround(3.1),5,0]],['Hand_Right','Arm_Right',[0,-Math.fround(4.8),0]],
['Arm_Left','Body',[Math.fround(3.1),5,0]],['Hand_Left','Arm_Left',[0,-Math.fround(4.8),0]],
['Leg_Right','Root',[-Math.fround(1.15),8,0]],['Leg_Left','Root',[Math.fround(1.15),8,0]],
]);
const headTiles=['hair','face','skin','hair','side','side'];
for (let face=0;face<6;face++) for (let corner=0;corner<4;corner++) {
const vertex=balanced.vertices[24+face*4+corner];
assert.deepEqual(vertex.slice(6).map(n=>Math.floor(n*8)),ATLAS[headTiles[face]],'unchanged semantic head UVs');
}
for (const profile of CHARACTER_OPTIONS.bodies) {
const model=parseB3D(playerB3D(profile));
assert.deepEqual(model.bones,balanced.bones,'bind transforms, hands, weights and all animation keys');
assert.deepEqual(model.triangles,balanced.triangles);
assert.deepEqual(model.vertices.map(v=>v.slice(6)),balanced.vertices.map(v=>v.slice(6)));
assert.deepEqual(model.vertices.map(v=>v[1]),balanced.vertices.map(v=>v[1]));
for (const name of ['Head','Hand_Right','Hand_Left']) for (const [id] of model.bones.get(name).weights)
assert.deepEqual(model.vertices[id],balanced.vertices[id],name+' remains exactly aligned');
const torso=model.bones.get('Body').weights.map(([id])=>model.vertices[id]);
widths.push(Math.max(...torso.map(v=>v[0])));
assert.ok(Math.max(...torso.filter(v=>v[1]===8).map(v=>v[0]))<widths.at(-1),'waist taper');
}
assert.ok(widths[0]<widths[1] && widths[1]<widths[2]);
assert.ok(Math.abs(Math.max(...balanced.bones.get('Head').weights.map(([id])=>balanced.vertices[id][0]))-1.6)<1e-6);
for (const bad of ['toString','../broad','BALANCED',null,{},0]) assert.throws(()=>playerB3D(bad));
});
test('B3D keys use engine frames 0..99, all six clips move weighted bones, loops close, and root motion is absent', () => {
const {bones} = parseB3D(playerB3D());
assert.deepEqual(ANIMATIONS, {idle:[0,19,12],walk:[20,39,24],run:[40,59,32],strike:[60,69,28],guard:[70,79,12],swim:[80,99,20]});
for (const bone of bones.values()) {
assert.equal(bone.keys.length,100);
bone.keys.forEach((key,i) => {
assert.equal(key.frame,i); assert.ok(Math.abs(Math.hypot(...key.rotation)-1)<1e-6);
});
}
for (const [clip,[start,end]] of Object.entries(ANIMATIONS)) {
assert.ok([...bones.values()].some(b => b.weights.length &&
b.keys.slice(start+1,end).some(k => JSON.stringify(k.rotation)!==JSON.stringify(b.keys[start].rotation)
|| k.position.some((v,i)=>Math.abs(v-b.keys[start].position[i])>1e-5))),`${clip} must animate`);
if (clip!=='strike') for (const bone of bones.values()) {
[...bone.keys[start].position,...bone.keys[start].rotation].forEach((n,i) =>
assert.ok(Math.abs(n-[...bone.keys[end].position,...bone.keys[end].rotation][i])<1e-5,`${clip} seam`));
}
}
assert.ok(bones.get('Root').keys.every(k => k.position.every(n=>n===0)));
const arm = bones.get('Arm_Right');
assert.ok(Math.abs(arm.keys[64].rotation[1])>.7,'strike is substantial, not a static placeholder');
assert.ok(Math.abs(arm.keys[70].rotation[1])>.4,'guard raises the arm');
assert.deepEqual(playerB3D(),playerB3D());
});
// Independent Ogg page/packet reader: verify framing, lacing, CRC, sequence,
// Vorbis headers, granule length and EOS; magic bytes alone cannot pass.
function parseOgg(bytes) {
assert.ok(bytes.length > 100 && bytes.length <= 65536);
let offset = 0, sequence = 0, serial, ended = false, granule = 0n;
const packets = [], pieces = [];
while (offset < bytes.length) {
assert.ok(!ended && offset + 27 <= bytes.length, 'page after EOS or truncated header');
assert.equal(bytes.toString('ascii',offset,offset+4),'OggS');
assert.equal(bytes[offset+4],0);
const flags = bytes[offset+5], segments = bytes[offset+26];
assert.equal(flags & ~7,0);
assert.equal(Boolean(flags & 1),pieces.length>0,'packet continuation');
assert.equal(Boolean(flags & 2),sequence===0,'BOS only on first page');
assert.ok(offset+27+segments<=bytes.length);
const lacing = bytes.subarray(offset+27,offset+27+segments);
const end = offset+27+segments+lacing.reduce((a,b)=>a+b,0);
assert.ok(end<=bytes.length,'page body within bounds');
const page = Buffer.from(bytes.subarray(offset,end)), storedCRC = page.readUInt32LE(22);
page.writeUInt32LE(0,22);
let crc = 0;
for (const byte of page) {
crc ^= byte << 24;
for (let bit=0;bit<8;bit++) crc = ((crc<<1) ^ ((crc & 0x80000000) ? 0x04c11db7 : 0)) >>> 0;
}
assert.equal(crc,storedCRC,'Ogg page CRC');
serial ??= bytes.readUInt32LE(offset+14);
assert.equal(bytes.readUInt32LE(offset+14),serial,'single logical stream');
assert.equal(bytes.readUInt32LE(offset+18),sequence++,'contiguous page sequence');
const position = bytes.readBigUInt64LE(offset+6);
if (position !== 0xffffffffffffffffn) { assert.ok(position>=granule); granule=position; }
let start = offset+27+segments;
for (const size of lacing) {
pieces.push(bytes.subarray(start,start+size)); start+=size;
if (size<255) { packets.push(Buffer.concat(pieces)); pieces.length=0; }
}
ended = Boolean(flags & 4); offset=end;
}
assert.ok(ended && pieces.length===0 && packets.length>3,'complete stream with audio packets');
for (const [i,type] of [1,3,5].entries()) {
assert.equal(packets[i][0],type);
assert.equal(packets[i].toString('ascii',1,7),'vorbis');
}
const identification = packets[0];
assert.equal(identification.length,30); assert.equal(identification.readUInt32LE(7),0);
assert.equal(identification[11],1); assert.equal(identification.readUInt32LE(12),SAMPLE_RATE);
assert.equal(identification[29],1);
const small = identification[28] & 15, large = identification[28] >>> 4;
assert.ok(small>=6 && large>=small && large<=13);
const comments = packets[1], vendorLength = comments.readUInt32LE(7);
let cursor = 11+vendorLength;
assert.ok(cursor+4<comments.length);
const count = comments.readUInt32LE(cursor); cursor+=4;
assert.ok(count<=16);
for (let i=0;i<count;i++) {
assert.ok(cursor+4<comments.length);
const length = comments.readUInt32LE(cursor); cursor+=4+length;
assert.ok(cursor<comments.length);
}
assert.equal(cursor+1,comments.length); assert.equal(comments[cursor],1);
assert.ok(packets.slice(3).every(p=>p.length>0 && (p[0] & 1)===0),'Vorbis audio packets');
return {granule:Number(granule),blockSize:2**large};
}
test('original Ogg Vorbis sources are hash-pinned, complete mono 22050Hz streams; synthesis and optional full decode stay bounded', async t => {
assert.equal(Object.keys(SOUND_PROFILES).length,15);
assert.deepEqual(Object.keys(SOUND_HASHES).sort(),Object.keys(SOUND_PROFILES).sort());
const codec = process.env.HEARTH_FFMPEG_CORE ? await loadVorbisCodec(process.env.HEARTH_FFMPEG_CORE) : null;
if (!codec) t.diagnostic('Codec decode/regeneration NOT run: set HEARTH_FFMPEG_CORE to the pinned optional tool. Ogg framing/CRC/hash checks still run.');
const unique = new Set();
for (const [name,[duration]] of Object.entries(SOUND_PROFILES)) {
assert.match(name,/^[a-z0-9_]+$/,'engine-supported unnumbered sound name');
const ogg = await soundOGG(name), {granule,blockSize} = parseOgg(ogg);
assert.deepEqual(ogg,await soundOGG(name)); unique.add(ogg.toString('base64'));
const bytes = soundWAV(name), count = Math.round(duration*SAMPLE_RATE);
assert.equal(granule,count,'EOS granule equals original duration');
if (codec) {
assert.deepEqual(codec.encode(bytes),ogg,'pinned regeneration is byte-identical');
const pcm = codec.decode(ogg);
assert.equal(pcm.length%2,0);
// Vorbis overlap/padding differs from source PCM; EOS is checked exactly
// above. Decode the entire stream, not just its identification header.
assert.ok(Math.abs(pcm.length/2-count)<=blockSize,'bounded decoded overlap/padding');
const decoded = Array.from({length:pcm.length/2},(_,i)=>pcm.readInt16LE(i*2));
assert.ok(Math.max(...decoded.map(Math.abs))>300,'decoded audio is non-silent');
assert.ok(decoded.every(n=>Math.abs(n)<32767),'no decoded clipping');
assert.ok(Math.abs(decoded.reduce((a,b)=>a+b,0)/decoded.length)<150,'decoded DC offset');
}
assert.deepEqual(bytes,soundWAV(name));
assert.equal(bytes.toString('ascii',0,4),'RIFF'); assert.equal(bytes.readUInt32LE(4),bytes.length-8);
assert.equal(bytes.toString('ascii',8,16),'WAVEfmt '); assert.equal(bytes.readUInt32LE(16),16);
assert.equal(bytes.readUInt16LE(20),1); assert.equal(bytes.readUInt16LE(22),1);
assert.equal(bytes.readUInt32LE(24),22050); assert.equal(bytes.readUInt32LE(28),44100);
assert.equal(bytes.readUInt16LE(32),2); assert.equal(bytes.readUInt16LE(34),16);
assert.equal(bytes.toString('ascii',36,40),'data'); assert.equal(bytes.readUInt32LE(40),count*2);
assert.equal(bytes.length,44+count*2); assert.ok(duration>=.08 && duration<=.5);
const samples = Array.from({length:count},(_,i)=>bytes.readInt16LE(44+i*2));
assert.equal(samples[0],0); assert.equal(samples.at(-1),0);
assert.ok(Math.max(...samples.map(Math.abs))>300);
assert.ok(samples.every(n=>Math.abs(n)<32767));
assert.ok(Math.abs(samples.reduce((a,b)=>a+b,0)/count)<150,'bounded DC offset');
}
assert.equal(unique.size,15); assert.throws(()=>soundWAV('../anything'));
assert.throws(()=>soundWAV('toString'));
await assert.rejects(soundOGG('../anything')); await assert.rejects(soundOGG('toString'));
const valid = await soundOGG('hearth_block');
assert.throws(()=>parseOgg(valid.subarray(0,valid.length-1)),'truncation rejected');
const corrupt = Buffer.from(valid); corrupt[corrupt.length-1]^=1;
assert.throws(()=>parseOgg(corrupt),/CRC/);
assert.throws(()=>parseOgg(soundWAV('hearth_block')),'renamed WAV rejected');
if (codec) {
assert.throws(()=>codec.decode(valid.subarray(0,80)),/conversion failed/,'decoder rejects incomplete setup');
t.diagnostic('All 15 sounds fully decoded and regenerated byte-identically with hash-verified @ffmpeg/core@0.12.10.');
}
});
test('held tool meshes have deterministic indexed geometry, grip origins and atlas UVs', () => {
const unique = new Set();
for (const name of TOOL_NAMES) {
const obj = toolOBJ(name); assert.equal(obj,toolOBJ(name)); unique.add(obj);
const vertices = [], coords = [], faces = [];
for (const line of obj.split('\n')) {
if (line.startsWith('v ')) vertices.push(line.slice(2).split(' ').map(Number));
if (line.startsWith('vt ')) coords.push(line.slice(3).split(' ').map(Number));
if (line.startsWith('f ')) faces.push(line.slice(2).split(' '));
}
assert.ok(vertices.length>=48); assert.equal(coords.length,vertices.length);
assert.ok(vertices.flat().every(Number.isFinite));
assert.ok(coords.flat().every(n=>Number.isFinite(n) && n>0 && n<1));
assert.equal(Math.min(...vertices.map(v=>v[1])),-1);
assert.ok(Math.max(...vertices.map(v=>v[1]))>3);
for (const face of faces) {
assert.equal(face.length,3);
for (const item of face) for (const id of item.split('/').map(Number)) assert.ok(id>=1 && id<=vertices.length);
}
}
assert.equal(unique.size,4); assert.throws(()=>toolOBJ('../knife'));
});
test('all mod Lua colors are scanned recursively; scripts and non-Lua are not inputs; PNG icons retain crisp alpha and metal/handle colors', async t => {
const root = await temporary(t), source = await sourceTree(root), destination = path.join(root,'output');
await generateAssets(destination,source);
const textures = path.join(destination,'mods/hearth/textures'), names = await fs.readdir(textures);
for (const color of ['a1b2c3','01abef','39d1a2']) assert.ok(names.includes(`hearth_${color}.png`));
for (const color of ['fedcba','123abc']) assert.ok(!names.includes(`hearth_${color}.png`));
for (const name of TOOL_NAMES) {
const image = parsePNG(await fs.readFile(path.join(textures,`hearth_icon_${name}.png`)));
assert.equal(image.width,32); assert.equal(image.height,32);
let opaque = 0, metal = 0, wood = 0;
for (let y=0;y<32;y++) for (let x=0;x<32;x++) {
const [r,g,b,a] = image.pixel(x,y); assert.ok(a===0 || a===255);
if (a) { opaque++; if (g>r && b>r) metal++; if (r>g && g>b) wood++; }
else assert.deepEqual([r,g,b],[0,0,0]);
}
assert.ok(opaque>35 && opaque<400); assert.ok(metal>5); assert.ok(wood>5);
assert.equal(image.pixel(0,0)[3],0); assert.equal(image.pixel(31,31)[3],0);
}
const skin = parsePNG(await fs.readFile(path.join(textures,'hearth_person.png')));
assert.equal(skin.width,64); assert.equal(skin.height,64);
assert.ok(skin.pixel(2,3)[0]<60,'visible eyes'); assert.ok(skin.pixel(3,4)[0]>150,'skin');
assert.notDeepEqual(skin.pixel(2,3),skin.pixel(3,4));
const tunic = ATLAS.tunic.map(n=>n*8+3);
assert.ok(skin.pixel(...tunic)[1]>skin.pixel(...tunic)[0],'distinct clothes');
for (let y=0;y<64;y++) for (let x=0;x<64;x++) assert.equal(skin.pixel(x,y)[3],255);
const expected=compositeCharacter();
for (let y=0;y<64;y++) for (let x=0;x<64;x++)
assert.deepEqual(skin.pixel(x,y),[...expected.data.subarray((y*64+x)*4,(y*64+x+1)*4)]);
const manifest=JSON.parse(await fs.readFile(path.join(destination,'character-art.json'),'utf8'));
assert.deepEqual(manifest.images,CHARACTER_ART_MANIFEST);
assert.deepEqual(manifest.options,CHARACTER_OPTIONS);
assert.deepEqual(manifest.meshes,CHARACTER_OPTIONS.bodies.map(body=>({body,filename:characterMesh(body)})));
assert.equal(names.filter(n=>n.startsWith('hearth_char_')).length,170);
for (const {filename} of CHARACTER_ART_MANIFEST) {
const decoded=parsePNG(await fs.readFile(path.join(textures,filename))), layer=characterLayer(filename);
assert.equal(decoded.width,64); assert.equal(decoded.height,64);
for (let y=0;y<64;y++) for (let x=0;x<64;x++)
assert.deepEqual(decoded.pixel(x,y),[...layer.data.subarray((y*64+x)*4,(y*64+x+1)*4)],filename);
}
const portraits=parsePNG(await fs.readFile(path.join(textures,'hearth_character_contact_sheet.png')));
assert.equal(portraits.width,512); assert.equal(portraits.height,64);
for (const body of CHARACTER_OPTIONS.bodies)
assert.deepEqual(await fs.readFile(path.join(destination,'mods/hearth/models',characterMesh(body))),playerB3D(body));
});
test('real-source assets regenerate byte-identically including B3D, tools, textures and sounds', async t => {
const root = await temporary(t), a = path.join(root,'a'), b = path.join(root,'b');
await generateAssets(a,SOURCE); await generateAssets(b,SOURCE);
const first = await inventory(a); assert.deepEqual(first,await inventory(b));
await fs.writeFile(path.join(a,'mods/hearth/sounds/hearth_block.wav'),soundWAV('hearth_block'));
await generateAssets(a,SOURCE); assert.deepEqual(first,await inventory(a));
for (const name of ['models/hearth_person.b3d','textures/hearth_person.png',
...TOOL_NAMES.map(n=>`models/hearth_${n}.obj`),...Object.keys(SOUND_PROFILES).map(n=>`sounds/${n}.ogg`)]) {
assert.ok(first.some(entry=>entry.path==='mods/hearth/'+name && entry.size>0),name);
}
});
test('original Ogg media is part of the verified package manifest without a runtime encoder or WAV leftovers', async t => {
const root = await temporary(t), source = await sourceTree(root);
await fs.mkdir(path.join(source,'tests/fixtures'),{recursive:true});
for (const name of ['game.conf','minetest.conf','settingtypes.txt','LICENSE.txt','release.json','tests/fixtures/scenarios.json'])
await fs.copyFile(path.join(SOURCE,name),path.join(source,name));
for (const name of ['init','model','runtime','content','encounters']) await fs.writeFile(path.join(source,'mods/hearth',name+'.lua'),'-- fixture\n');
const destination = path.join(root,'package'); await packageGame(destination,{source});
const {entries} = await verifyPackage(destination);
for (const name of Object.keys(SOUND_PROFILES)) {
const entry = entries.find(e=>e.path===`mods/hearth/sounds/${name}.ogg`);
assert.ok(entry,name); assert.equal(entry.sha256,SOUND_HASHES[name]);
parseOgg(await fs.readFile(path.join(destination,entry.path)));
}
assert.ok(!entries.some(e=>e.path.endsWith('.wav')));
assert.ok(entries.some(e=>e.path==='mods/hearth/models/hearth_person.b3d'));
assert.ok(entries.some(e=>e.path==='mods/hearth/textures/hearth_person.png'));
assert.ok(entries.some(e=>e.path==='character-art.json'));
for (const {filename} of CHARACTER_ART_MANIFEST)
assert.ok(entries.some(e=>e.path===`mods/hearth/textures/${filename}`),filename);
for (const body of CHARACTER_OPTIONS.bodies)
assert.ok(entries.some(e=>e.path===`mods/hearth/models/${characterMesh(body)}`));
});
test('asset walker refuses source directory junctions/symlinks without following or writing outside', async t => {
const root = await temporary(t), source = await sourceTree(root), outside = path.join(root,'outside');
await fs.mkdir(outside); await fs.writeFile(path.join(outside,'secret.lua'),'#c0ffee');
await fs.symlink(outside,path.join(source,'mods/linked'),process.platform==='win32'?'junction':'dir');
await assert.rejects(generateAssets(path.join(root,'output'),source),/symbolic link|junction/);
assert.deepEqual(await fs.readdir(outside),['secret.lua']);
await assert.rejects(fs.stat(path.join(root,'output')), {code:'ENOENT'});
});
test('asset writer refuses linked output directories and files', async t => {
const root = await temporary(t), source = await sourceTree(root), outside = path.join(root,'outside');
const destination = path.join(root,'output'), textures = path.join(destination,'mods/hearth/textures');
await fs.mkdir(outside); await fs.writeFile(path.join(outside,'keep'),'unchanged');
await fs.mkdir(path.dirname(textures),{recursive:true});
await fs.symlink(outside,textures,process.platform==='win32'?'junction':'dir');
await assert.rejects(generateAssets(destination,source),/symbolic link|junction/);
assert.deepEqual(await fs.readdir(outside),['keep']);
await fs.unlink(textures); await fs.mkdir(textures);
try { await fs.symlink(path.join(outside,'keep'),path.join(textures,'hearth_01abef.png'),'file'); }
catch (error) {
if (['EPERM','EACCES'].includes(error.code)) { t.diagnostic('File symlink privilege unavailable; directory junction protection verified.'); return; }
throw error;
}
await assert.rejects(generateAssets(destination,source),/symbolic link|junction/);
assert.equal(await fs.readFile(path.join(outside,'keep'),'utf8'),'unchanged');
});
test('asset source Lua has bounded read size', async t => {
const root = await temporary(t), source = await sourceTree(root);
await fs.writeFile(path.join(source,'mods/hearth/large.lua'),Buffer.alloc(2*1024*1024+1,32));
await assert.rejects(generateAssets(path.join(root,'output'),source),/size limit/);
});
+363
View File
@@ -0,0 +1,363 @@
-- Standalone Lua 5.1+; registration contract, no world or engine is launched.
local target = assert(arg[1], "biomes.lua path required")
local factory = dofile(target)
local passed = 0
local function contains(list, value)
for _, entry in ipairs(list or {}) do if entry == value then return true end end
return false
end
local function count(t) local n=0; for _ in pairs(t) do n=n+1 end; return n end
local function fixture(existing_alias)
local nodes, items, biomes, ores, decos, crafts = {}, {}, {}, {}, {}, {}
local aliases = {external_alias="external:stone"}
if existing_alias then aliases.mapgen_stone = "external:stone" end
for _, name in ipairs({"stone", "soil", "grass", "sand", "water_source", "water_flowing",
"log", "leaves", "planks", "sticks", "loose_stone", "berry_bush", "reeds", "grain_patch",
"clay_bank", "iron_ore_vein", "weir_stone", "meadow_grass", "wildflowers", "fern", "litter",
"fungi", "deadwood", "spring_greens_patch", "autumn_mushrooms_patch"}) do
nodes["hearth:"..name] = {sentinel=true}; items["hearth:"..name] = nodes["hearth:"..name]
end
for _, name in ipairs({"iron_ore", "charcoal", "charcoal_charge", "clay", "foundation"}) do
items["hearth:"..name] = {sentinel=true}
end
local external = {sentinel=true}
biomes.external = external
local placed, owned = function() end, function() end
local construction = {place_building=placed, owner=owned}
local api = {registered_nodes=nodes, registered_items=items, registered_aliases=aliases}
function api.register_alias(name, value)
assert(aliases[name] == nil, "alias overwritten"); aliases[name] = value
end
function api.register_craftitem(name, def)
assert(not items[name]); items[name] = def
end
function api.register_biome(def) assert(not biomes[def.name]); biomes[def.name] = def end
function api.register_ore(def) ores[#ores+1] = def end
function api.register_decoration(def)
assert(not decos[def.name]); decos[def.name] = def; decos[#decos+1] = def
end
function api.register_craft(def) crafts[#crafts+1] = def end
-- Any attempt to clear registries, force aliases, change settings, install
-- globalsteps or write world nodes is a test failure, not a permissive noop.
setmetatable(api, {__index=function(_, key) error("unapproved engine API: " .. key) end})
local registrations = 0
local function private_register(name, def)
assert(not name:find(":"), "private helper takes short names")
local full="hearth:"..name
assert(not nodes[full], "base node overwritten")
registrations=registrations+1; nodes[full]=def; items[full]=def
end
local hearth = {sentinel=external}
local m = factory(hearth, api, private_register, construction)
return {m=m, nodes=nodes, items=items, biomes=biomes, ores=ores, decos=decos, crafts=crafts,
aliases=aliases, api=api, hearth=hearth, construction=construction, external=external,
registrations=function() return registrations end}
end
local function test(label, fn)
local ok, err=pcall(fn)
assert(ok, label .. ": " .. tostring(err)); passed=passed+1
print("ok " .. passed .. " - " .. label)
end
local function loaded(existing) local f=fixture(existing); assert(f.m.register()==f.m); return f end
local function recipe(f, input, output)
local inputs = type(input) == "table" and input or {input}
local matches = 0
for _, craft in ipairs(f.crafts) do
if craft.output == output and craft.type == "shapeless" and #craft.recipe == #inputs then
local same = true
for i, value in ipairs(inputs) do if craft.recipe[i] ~= value then same = false end end
if same then matches = matches + 1 end
end
end
return matches == 1
end
local function cell(s, x,y,z) return s.data[1+x+s.size.x*(y+s.size.y*z)] end
test("factory is lazy and does not publish globals or mutate hearth", function()
local f=fixture()
assert(f.registrations()==0 and #f.ores==0 and #f.decos==0 and #f.crafts==0)
assert(count(f.hearth)==1 and f.hearth.sentinel==f.external)
assert(type(f.m.register)=="function" and type(f.m.surface)=="function")
end)
test("registration is idempotent and preserves external registries", function()
local f=loaded(); local n, ores, decos, crafts=f.registrations(), #f.ores, #f.decos, #f.crafts
assert(f.m.register()==f.m)
assert(n==f.registrations() and ores==#f.ores and decos==#f.decos and crafts==#f.crafts)
assert(f.biomes.external==f.external and f.nodes["hearth:grass"].sentinel)
assert(f.aliases.external_alias=="external:stone")
end)
test("mapgen aliases are non-forcing and keep canonical stone identity", function()
local f=loaded()
assert(f.aliases.mapgen_stone=="hearth:stone")
assert(f.aliases.mapgen_water_source=="hearth:water_source")
assert(f.aliases.mapgen_river_water_source=="hearth:water_source")
assert(loaded(true).aliases.mapgen_stone=="external:stone")
end)
test("alias with a registered item is not overwritten", function()
local f=fixture(); f.items.mapgen_stone={sentinel=true}; f.m.register()
assert(not f.aliases.mapgen_stone and f.items.mapgen_stone.sentinel)
end)
test("terrain node collision fails rather than overwriting", function()
local f=fixture(); local old={sentinel=true}; f.nodes["hearth:granite"]=old
assert(not pcall(f.m.register)); assert(f.nodes["hearth:granite"]==old)
end)
test("ten biomes preserve meadow and river names and resolve all materials", function()
local f=loaded(); assert(count(f.biomes)==11)
assert(f.biomes.hearth_temperate.node_top=="hearth:grass")
assert(f.biomes.hearth_riverbank.node_top=="hearth:sand")
for name, b in pairs(f.biomes) do if name~="external" then
assert(b.y_min<=b.y_max and b.heat_point>=0 and b.heat_point<=100)
assert(b.humidity_point>=0 and b.humidity_point<=100)
for key, value in pairs(b) do if key:match("^node_") then assert(f.nodes[value], value) end end
end end
end)
test("altitude coverage has no missing climate-independent fallback band", function()
local f=loaded()
for y=-512,512 do
local coverage=false
for _, b in ipairs(f.m.biome_profiles) do if y>=b.min and y<=b.max then coverage=true end end
assert(coverage, "altitude "..y)
end
local peak=f.biomes.hearth_rocky_peaks
assert(peak.y_min==211 and peak.node_top=="hearth:snow" and peak.vertical_blend==0)
assert(f.biomes.hearth_floodplain.y_max==8)
end)
test("habitat helper resolves qualified nodes and rejects unknown surfaces", function()
local m=loaded().m
assert(m.surface("hearth:peat").moisture > m.surface("podzol").moisture)
assert(m.surface({name="hearth:alluvium"}).fertility==1)
assert(not m.surface("other:grass") and not m.surface(false) and not m.surface({}))
assert(m.supports_species("willow", "hearth:alluvium"))
assert(not m.supports_species("willow", "hearth:podzol"))
assert(not m.supports_species("unknown", "grass"))
assert(not m.supports_species("oak", false))
end)
test("ten exact catalogue species have real full-block trunks, leaves, and timber", function()
local f=loaded(); assert(table.concat(f.m.species_order,",")=="oak,birch,pine,willow,alder,beech,aspen,rowan,hazel,spruce")
for _, species in ipairs(f.m.species_order) do
local log=f.nodes["hearth:"..species.."_log"]
local leaf=f.nodes["hearth:"..species.."_leaves"]
assert(log.groups.tree==1 and log.groups.choppy==2 and log.drawtype==nil)
assert(log.tiles[1]=="hearth_"..species.."_log_top.png")
assert(log.tiles[3]=="hearth_"..species.."_log_side.png")
assert(leaf.drawtype=="allfaces_optional" and leaf.groups.leaves==1)
assert(f.nodes["hearth:"..species.."_planks"].groups.wood==1)
assert(not f.nodes["hearth:"..species.."_seedling"])
end
end)
test("species timber converts losslessly into canonical progression ingredients", function()
local f=loaded()
for _, species in ipairs(f.m.species_order) do
local log="hearth:"..species.."_log"
assert(recipe(f,{log,log},"hearth:log 2"))
assert(not recipe(f,log,"hearth:log") and not recipe(f,log,"hearth:log 2"))
assert(recipe(f,"hearth:"..species.."_log","hearth:"..species.."_planks 4"))
assert(recipe(f,"hearth:"..species.."_planks","hearth:planks"))
end
-- Existing recipes accept these exact names for charcoal, walls and sticks.
assert(f.items["hearth:log"] and f.items["hearth:planks"] and f.items["hearth:clay"])
end)
test("geological stone remains useful masonry and canonical foundations", function()
local f=loaded()
for _, rock in ipairs(f.m.rocks) do
local raw, brick="hearth:"..rock, "hearth:"..rock.."_bricks"
assert(f.nodes[raw].groups.cracky==3 and f.nodes[raw].drop==nil)
assert(recipe(f,raw,brick.." 4") and recipe(f,{raw,raw},"hearth:stone 2"))
assert(not recipe(f,raw,"hearth:stone") and not recipe(f,raw,"hearth:stone 2"))
assert(f.nodes[brick].groups.hearth_building==1)
assert(f.nodes[brick].on_place==f.construction.place_building)
assert(f.nodes[brick].after_place_node==f.construction.owner)
end
end)
test("all 40 terrain recipes have unique shapeless inputs including multiplicity", function()
local f=loaded(); assert(#f.crafts==40)
local seen={}
for _, craft in ipairs(f.crafts) do
assert(craft.type=="shapeless")
local inputs={}
for i, input in ipairs(craft.recipe) do inputs[i]=input end
table.sort(inputs)
local key=table.concat(inputs,",")
assert(not seen[key], "colliding terrain recipe: "..craft.output.." and "..tostring(seen[key]))
seen[key]=craft.output
end
end)
test("all registered material drops and crafting outputs resolve", function()
local f=loaded()
local function resolved(stack)
local name=stack:match("^([^ ]+)"); assert(f.items[name], "unresolved "..stack)
end
for _, def in pairs(f.nodes) do if type(def.drop)=="string" and def.drop~="" then resolved(def.drop) end end
for _, craft in ipairs(f.crafts) do
resolved(craft.output); for _, input in ipairs(craft.recipe) do resolved(input) end
end
end)
test("resource items and ore nodes use precise original art names", function()
local f=loaded()
for item, vein in pairs({quartz="quartz_vein", copper_ore="copper_ore_vein", coal="coal_ore_vein"}) do
assert(f.items["hearth:"..item].inventory_image=="hearth_item_"..item..".png")
assert(f.nodes["hearth:"..vein].tiles[1]=="hearth_"..vein..".png")
assert(f.nodes["hearth:"..vein].drop=="hearth:"..item.." 2")
end
assert(f.nodes["hearth:iron_ore_vein"].sentinel)
end)
test("geology runs before nested restricted scatter ore registration", function()
local f=loaded(); assert(#f.ores==9)
assert(f.ores[4].ore_type=="blob" and f.ores[4].ore=="hearth:basalt")
assert(f.ores[4].noise_params and f.ores[4].y_max<0)
local generated={}
for _, ore in ipairs(f.ores) do
assert(f.nodes[ore.ore] and ore.clust_scarcity>0 and ore.y_min<=ore.y_max)
for _, host in ipairs(ore.wherein) do
assert(f.nodes[host] and f.nodes[host].groups and f.nodes[host].groups.stone==1 or host=="hearth:stone")
assert(not contains({"hearth:soil", "hearth:grass", "air"},host))
end
if ore.ore_type=="scatter" then
assert(ore.clust_num_ores<=ore.clust_size^3)
generated[ore.ore]=true
end
end
for _, name in ipairs({"quartz_vein", "copper_ore_vein", "coal_ore_vein", "iron_ore_vein"}) do assert(generated["hearth:"..name]) end
assert(contains(f.ores[5].wherein,"hearth:granite") and not contains(f.ores[5].wherein,"hearth:sandstone"))
assert(contains(f.ores[6].wherein,"hearth:basalt"))
assert(contains(f.ores[7].wherein,"hearth:sandstone") and not contains(f.ores[7].wherein,"hearth:granite"))
end)
test("v7 underground riverbank stone receives ordered resource-bearing beds", function()
local f=loaded()
for i=1,3 do assert(f.ores[i].clust_size==0 and f.ores[i].clust_scarcity==1) end
local function host_at(y)
local host=f.biomes.hearth_riverbank.node_stone
for _, bed in ipairs(f.ores) do
if bed.ore_type=="stratum" and y>=bed.y_min and y<=bed.y_max and contains(bed.wherein,host) then host=bed.ore end
end
return host
end
assert(host_at(-150)=="hearth:granite" and host_at(-80)=="hearth:granite")
assert(host_at(-79)=="hearth:slate" and host_at(-24)=="hearth:slate")
assert(host_at(-23)=="hearth:limestone" and host_at(-8)=="hearth:limestone")
assert(host_at(-7)=="hearth:sandstone")
for _, ore in ipairs(f.ores) do if ore.ore_type=="scatter" and ore.y_min==-31000 then
local found=false
for y=-150,-8 do if contains(ore.wherein,host_at(y)) and y<=ore.y_max then found=true end end
assert(found,"unreachable ore host "..ore.ore)
end end
end)
test("schematic dimensions, probabilities and trunk anchoring are valid", function()
local f=loaded()
for _, species in ipairs(f.m.species_order) do
local s=f.m.tree_schematic(species); local c=math.floor(s.size.x/2)
assert(s.size.y>=5 and s.size.y<=10 and s.size.x<=9 and #s.data==s.size.x*s.size.y*s.size.z)
for y=0,s.size.y-2 do assert(cell(s,c,y,c).name=="hearth:"..species.."_log") end
local leaves, air=0,0
for _, n in ipairs(s.data) do
assert(n.param2==0 and not n.force_place)
if n.name=="air" then assert(n.prob==0); air=air+1
else assert(f.nodes[n.name] and n.prob==255); if n.name:match("_leaves$") then leaves=leaves+1 end end
end
assert(leaves>10 and air>10)
end
assert(not pcall(f.m.tree_schematic,"unknown"))
end)
test("all ten silhouettes are geometrically distinct and young canopies stay bounded", function()
local m=loaded().m; local shapes={}
for _,species in ipairs(m.species_order) do
local s=m.tree_schematic(species); local shape={s.size.x..":"..s.size.y..":"..s.size.z}
for _,n in ipairs(s.data) do shape[#shape+1]=n.name=="air" and "." or (n.name:match("_log$") and "T" or "L") end
local signature=table.concat(shape,",")
assert(not shapes[signature],species.." duplicates "..tostring(shapes[signature])); shapes[signature]=species
local young=m.tree_schematic(species,false,"young"); local leaves=0
assert(young.size.x==3 and young.size.y<=5 and #young.data<=45)
for _,n in ipairs(young.data) do
assert(not n.force_place)
if n.name:match("_leaves$") then leaves=leaves+1 end
end
assert(leaves>=5)
end
assert(not pcall(m.tree_schematic,"oak",false,"invalid"))
end)
test("fallen logs are three full cubes with horizontal facedir and non-forcing mapgen only", function()
local f=loaded(); local d=f.decos["hearth:woodland_deadwood"]
assert(d.deco_type=="schematic" and d.rotation=="random" and d.place_offset_y==1)
assert(not d.flags:find("force_placement",1,true) and not d.force_placement)
assert(d.schematic.size.x==3 and d.schematic.size.y==1 and d.schematic.size.z==1)
assert(#d.schematic.data==3 and d.y_min==2 and d.y_max==160)
for _,n in ipairs(d.schematic.data) do
assert(n.name=="hearth:deadwood" and n.prob==255 and n.param2==12 and not n.force_place)
-- Luanti facedir / 4 = axis direction; axis 3 is +X, axis 1 is +Z.
assert(math.floor(n.param2/4)==3)
end
for _,ground in ipairs(d.place_on) do assert(ground~="hearth:water_source") end
end)
test("species silhouettes are distinct broad slender conical drooping riparian", function()
local m=loaded().m
local oak, birch, pine, willow, alder=m.tree_schematic("oak"),m.tree_schematic("birch"),m.tree_schematic("pine"),m.tree_schematic("willow"),m.tree_schematic("alder")
assert(oak.size.x>birch.size.x and birch.size.y>oak.size.y)
assert(cell(oak,6,4,3).name=="hearth:oak_leaves")
assert(cell(pine,0,2,3).name=="hearth:pine_leaves")
assert(cell(pine,0,7,3).name=="air" and cell(pine,3,8,3).name=="hearth:pine_leaves")
assert(cell(willow,7,1,7).name=="hearth:willow_leaves")
assert(cell(willow,4,1,4).name=="hearth:willow_log")
assert(alder.size.y==6 and m.profiles.alder.nitrogen_fixer)
end)
test("tree decorations are habitat-restricted and cannot erase terrain", function()
local f=loaded()
for _, species in ipairs(f.m.species_order) do
local d=f.decos["hearth:"..species.."_trees"]
assert(d.deco_type=="schematic" and #d.biomes>=1 and #d.place_on>=1)
assert(d.place_offset_y==1 and d.rotation=="random")
assert(not d.flags:find("force_placement",1,true))
if species=="willow" or species=="alder" then
assert(d.num_spawn_by==1 and contains(d.spawn_by,"hearth:water_source"))
end
end
end)
test("all canopy registrations precede ground flora", function()
local f=loaded(); local first_plant=math.huge
for i,d in ipairs(f.decos) do if d.deco_type=="simple" then first_plant=math.min(first_plant,i) end end
for i,d in ipairs(f.decos) do if d.name:match("_trees$") then assert(i<first_plant) end end
end)
test("canonical meadow timber and all-surface gathering remain available", function()
local f=loaded(); local d=f.decos["hearth:temperate_trees"]
assert(contains(d.biomes,"hearth_temperate")); local log=false
for _, n in ipairs(d.schematic.data) do if n.name=="hearth:log" then log=true end end
assert(log)
for name in pairs(f.m.surfaces) do
assert(contains(f.decos["hearth:loose_stone"].place_on,"hearth:"..name))
assert(contains(f.decos["hearth:sticks"].place_on,"hearth:"..name))
end
end)
test("riparian food and clay require water and upland springs are limited", function()
local f=loaded()
for _, id in ipairs({"reeds", "clay_bank", "old_river_weir"}) do
local d=f.decos["hearth:"..id]; assert(d.num_spawn_by==1 and d.check_offset==-1)
assert(contains(d.spawn_by,"hearth:water_source"))
end
assert(f.decos["hearth:clay_bank"].y_max==8)
assert(f.decos["hearth:seep_springs"].y_max==150)
assert(not contains(f.decos["hearth:seep_springs"].biomes,"hearth_dry_scrub"))
end)
test("flora communities and seasonal patches resolve without new growth nodes", function()
local f=loaded()
for _, d in ipairs(f.decos) do
for _, ground in ipairs(d.place_on) do assert(f.nodes[ground],ground) end
for _, biome in ipairs(d.biomes or {}) do assert(f.biomes[biome],biome) end
if d.decoration then assert(f.nodes[d.decoration],d.decoration) end
end
assert(contains(f.decos["hearth:woodland_ferns"].biomes,"hearth_wet_forest"))
assert(not contains(f.decos["hearth:woodland_ferns"].biomes,"hearth_dry_scrub"))
assert(f.decos["hearth:spring_foraging"].decoration=="hearth:spring_greens_patch")
assert(f.decos["hearth:autumn_foraging"].decoration=="hearth:autumn_mushrooms_patch")
end)
test("world texture contract is explicit for every new node", function()
local f=loaded(); local textures={}
for name, def in pairs(f.nodes) do if not def.sentinel then
assert(def.tiles and #def.tiles>=1,name)
for _, image in ipairs(def.tiles) do
assert(image:match("^hearth_[a-z_]+%.png$"),image); textures[image]=true
end
end end
-- Five extra woods add log top/side, leaves and planks (20 textures).
assert(count(textures)==62, "unexpected world tile count "..count(textures))
assert(f.registrations()==51, "unexpected private node count "..f.registrations())
end)
print("PASS " .. passed .. " biome/geology checks")
+380
View File
@@ -0,0 +1,380 @@
-- Standalone Lua 5.1+; mocked APIs only, never starts Luanti.
local target=assert(arg[1],"botany.lua path required")
local factory=dofile(target)
local passed=0
local B,H,A,world,fields,defs,time,season,allowed,room,leftover,added,auth,writes
local policies,seasonal_ids,clock_reads,climate_calls
local p={x=2,y=1,z=3}
local function key(q) return q.x..":"..q.y..":"..q.z end
local function reset()
world,fields,defs={},{},{}
policies,seasonal_ids,clock_reads,climate_calls={},{},0,0
time,season,allowed,room,leftover,added,auth,writes=100000,"Summer",true,true,0,0,0,0
A={registered_nodes=defs,now=function() clock_reads=clock_reads+1; return time end}
function A.get_node_or_nil(q) return world[key(q)] end
function A.get_meta(q)
fields[key(q)]=fields[key(q)] or {}; local f=fields[key(q)]
return {get_string=function(_,k) return f[k] or "" end,
set_string=function(_,k,v) writes=writes+1; f[k]=v end}
end
-- Missing construction at factory time is deliberate: content wires it later.
H={climate_at=function(epoch)
assert(type(epoch)=="number" and epoch==epoch and epoch==math.floor(time))
climate_calls=climate_calls+1; return {season=season}
end, seasons={
add_variants=function(id,definitions,active)
assert(not policies[id],id); policies[id]={definitions=definitions,active=active}
for s in pairs(definitions) do seasonal_ids["hearth:"..id.."_"..s:lower()]=id end
end,
kind=function(name) return seasonal_ids[name] or name:match("^hearth:(.+)$") end,
}}
B=factory(H,A)
H.construction={allowed=function(q,player,action)
auth=auth+1; assert(q==p and player:is_player() and action=="use"); return allowed
end}
end
local inv={room_for_item=function(_,list,item) assert(list=="main" and item=="hearth:berries 2"); return room end,
add_item=function(_,list,item)
assert(list=="main" and item=="hearth:berries 2"); added=added+2-leftover
return {get_count=function() return leftover end}
end}
local player={is_player=function() return true end,get_inventory=function() return inv end}
local function test(label,fn)
reset(); local ok,err=pcall(fn); assert(ok,label..": "..tostring(err))
passed=passed+1; print("PASS botany "..passed.." - "..label)
end
local function habitat(id)
local r=assert(B.get(id)); local n=r.niche
local ground=next(n.substrates)
if n.aquatic=="surface" or n.aquatic=="submerged" then ground="alluvium" end
return {loaded=true,ground=ground,substrate=id=="hornwort" and "water" or nil,
light=(n.light[1]+n.light[2])/2,moisture=(n.moisture[1]+n.moisture[2])/2,
ph=(n.ph[1]+n.ph[2])/2,water=n.water[1],aquatic=n.aquatic,
freshwater=true,nutrients=2,host=n.hosts and next(n.hosts) or nil}
end
local function register()
B.register_nodes(function(id,def) assert(not defs["hearth:"..id],id); defs["hearth:"..id]=def end)
end
local function bush(id) world[key(p)]={name="hearth:"..(id or "bilberry")} end
test("exactly eighty disjoint real taxa in eight ordered groups of ten",function()
assert(#B.groups==8 and #B.catalogue==80 and #B.list()==80)
assert(table.concat(B.groups,",")=="trees,shrubs,grasses,herbs,ferns_mosses,fungi,aquatic,lichens")
local ids,latin={},{},{}
for _,g in ipairs(B.groups) do
assert(#B.list(g)==10,g)
for _,r in ipairs(B.list(g)) do
assert(not ids[r.id] and not latin[r.latin]); ids[r.id]=true; latin[r.latin]=true
assert(r.common~="" and r.latin:match("^[A-Z][a-z]+ [a-z%-]+$"),r.latin)
assert(r.group==g and B.group_of(r.node)==g and B.kind(r.node)==r.id)
end
end
end)
test("legacy IDs map exactly without counting generic bushes or patches",function()
local expected={oak="Quercus robur",birch="Betula pendula",pine="Pinus sylvestris",willow="Salix alba",
alder="Alnus glutinosa",clover="Trifolium repens",nettle="Urtica dioica",heather="Calluna vulgaris",
bluebell="Hyacinthoides non-scripta",sedge="Carex paniculata",bulrush="Typha latifolia",
moss="Hypnum cupressiforme",lichen="Rhizocarpon geographicum"}
for id,latin in pairs(expected) do local r=B.get(id); assert(r.existing and r.latin==latin) end
for _,id in ipairs({"berry_bush","fern","meadow_grass","wildflowers","tree_seedling"}) do assert(not B.get(id)) end
assert(not B.get("foreign:oak") and not B.kind(false))
end)
test("tree stages and resources resolve through seasons without registering them",function()
H.seasons={kind=function(value) return value:gsub("_winter$",""):gsub("^hearth:","") end}
for _,r in ipairs(B.list("trees")) do
for _,part in ipairs({"seedling","young","mature","leaves","log","planks"}) do
assert(B.kind("hearth:"..r.id.."_"..part.."_winter")==r.id)
end
end
register()
for _,r in ipairs(B.list("trees")) do assert(not defs[r.node]) end
end)
test("catalogue snapshots and getters cannot alter internal policy",function()
B.catalogue[1].latin="broken"; B.get("oak").niche.light[1]=100
local list=B.list(); list[1].tags.pollinator=true
assert(B.get("oak").latin=="Quercus robur" and not B.has_tag("oak","pollinator"))
assert(B.get("oak").niche.light[1]==4)
end)
test("all eighty species have a passing season niche vector",function()
for _,r in ipairs(B.list()) do assert(B.suitable(r.id,habitat(r.id),next(r.seasons)),r.id) end
end)
test("each niche dimension rejects out-of-window values for all species",function()
for _,r in ipairs(B.list()) do
local s=next(r.seasons)
for _,field in ipairs({"light","moisture","ph"}) do
local h=habitat(r.id); h[field]=r.niche[field][1]-.01
assert(not B.suitable(r.id,h,s),r.id..field)
h[field]=r.niche[field][2]+.01; assert(not B.suitable(r.id,h,s))
end
local h=habitat(r.id); h.ground="lava"; h.substrate="lava"; assert(not B.suitable(r.id,h,s))
end
end)
test("unloaded, malformed and nonfinite habitats fail closed",function()
for _,field in ipairs({"light","moisture","ph","water"}) do
for _,bad in ipairs({0/0,math.huge,-math.huge,"5"}) do
local h=habitat("clover"); h[field]=bad; assert(not B.suitable("clover",h,"Summer"))
end
end
local h=habitat("clover"); h.loaded=false; assert(not B.suitable("clover",h,"Summer"))
h.loaded=true; h.ph=nil; assert(not B.suitable("clover",h,"Summer"))
assert(not B.suitable("clover",nil,"Summer") and not B.suitable("unknown",{},"Summer"))
end)
test("spring ephemerals and fungal fruit seasons are not year-round",function()
for _,id in ipairs({"bluebell","wild_garlic","cowslip","wood_anemone"}) do
assert(B.suitable(id,habitat(id),"Spring")); assert(not B.suitable(id,habitat(id),"Summer"))
end
assert(B.active("wood_blewit","Winter") and not B.active("fly_agaric","Winter"))
assert(B.active("moss","Winter") and not B.active("oak","Winter"))
end)
test("host-specific root fungi reject unrelated or absent tree roots",function()
local h=habitat("birch_bolete"); assert(B.suitable("birch_bolete",h,"Autumn"))
h.host="oak"; assert(not B.suitable("birch_bolete",h,"Autumn"))
h.host=nil; assert(not B.suitable("birch_bolete",h,"Autumn"))
assert(B.root_fungi("birch_bolete","birch_seedling",2)==1)
assert(B.root_fungi("birch_bolete","oak",2)==0)
end)
test("deadwood decomposers require deadwood rather than live bark",function()
local h=habitat("birch_polypore"); assert(B.suitable("birch_polypore",h,"Autumn"))
h.ground="bark"; assert(not B.suitable("birch_polypore",h,"Autumn"))
h.ground="deadwood"; h.host="pine"; assert(not B.suitable("birch_polypore",h,"Autumn"))
assert(B.has_tag("honey_fungus","parasite") and not B.interactions("honey_fungus").parasite.enabled)
end)
test("lichens distinguish acid stone, bark and humid old-woodland niches",function()
local h=habitat("lichen"); assert(B.suitable("lichen",h,"Winter"))
h.ground="limestone"; assert(not B.suitable("lichen",h,"Winter"))
h=habitat("oakmoss"); h.ground="soil"; assert(not B.suitable("oakmoss",h,"Winter"))
h=habitat("lungwort_lichen"); h.moisture=.2; assert(not B.suitable("lungwort_lichen",h,"Winter"))
end)
test("surface plants need freshwater, depth and appropriate bottom",function()
for _,id in ipairs({"duckweed","yellow_waterlily","white_waterlily","water_crowfoot","pondweed"}) do
local h=habitat(id); assert(B.suitable(id,h,"Summer"))
h.freshwater=false; assert(not B.suitable(id,h,"Summer")); h.freshwater=true
h.water=0; assert(not B.suitable(id,h,"Summer")); h.water=1
h.aquatic="submerged"; assert(not B.suitable(id,h,"Summer"))
end
end)
test("submerged rootless hornwort and rooted starwort have separate supports",function()
local h=habitat("hornwort"); assert(B.suitable("hornwort",h,"Summer"))
h.substrate=nil; assert(not B.suitable("hornwort",h,"Summer"))
h=habitat("water_starwort"); assert(B.suitable("water_starwort",h,"Summer"))
h.ground="stone"; assert(not B.suitable("water_starwort",h,"Summer"))
end)
test("wet banks are dry destination cells, not overwritten water sources",function()
for _,id in ipairs({"sedge","bulrush","water_mint","brooklime","water_forgetmenot"}) do
local h=habitat(id); assert(h.water==0 and B.suitable(id,h,"Summer"))
h.aquatic="land"; assert(not B.suitable(id,h,"Summer")); h.aquatic="bank"
h.water=1; assert(not B.suitable(id,h,"Summer"))
end
end)
test("deterministic choose returns only a suitable candidate and never mutates",function()
local h=habitat("clover"); local a,r=B.choose(p,h,"Summer")
assert(a and a==B.choose(p,h,"Summer") and B.suitable(a,h,"Summer"))
assert(r.node=="hearth:"..a and B.group_of(a)~="trees" and writes==0 and next(world)==nil)
h.loaded=false; assert(not B.choose(p,h,"Summer"))
assert(not B.choose({x=0/0,y=1,z=2},h,"Summer") and not B.choose(nil,h,"Summer"))
end)
test("choose respects surface and submerged niches without map operations",function()
for _,id in ipairs({"duckweed","hornwort"}) do
local h=habitat(id); local k=B.choose(p,h,"Summer")
assert(k and B.group_of(k)=="aquatic" and B.suitable(k,h,"Summer"))
end
assert(writes==0)
end)
test("pollinator support follows flowers, not wind-pollinated grasses or fungi",function()
assert(B.pollinator("bluebell","Spring") and not B.pollinator("bluebell","Summer"))
assert(B.pollinator("heather","Autumn") and not B.pollinator("heather","Spring"))
for _,id in ipairs({"sedge","oak","birch","nettle","fly_agaric","bracken","moss"}) do
assert(not B.pollinator(id,"Summer"))
end
end)
test("only three fictional game berry outputs are food, never toxic taxa or fungi",function()
local count=0
for _,r in ipairs(B.list()) do
if r.food then count=count+1; assert(({bilberry=true,blackberry=true,raspberry=true})[r.id] and not r.toxic) end
if r.toxic or r.group=="fungi" then assert(not r.food and not B.edible(r.id,"Summer")) end
end
assert(count==3 and B.edible("blackberry","Autumn") and not B.edible("raspberry","Winter"))
assert(not B.edible("clover","Summer") and B.browse("clover","Summer"))
end)
test("interaction descriptors cover every actual tag without mutation",function()
local seen={}
for _,r in ipairs(B.list()) do
local d=B.interactions(r.id)
for tag in pairs(r.tags) do assert(d[tag] and #d[tag].description>20); seen[tag]=true end
end
for _,tag in ipairs({"nitrogenfixer","browse","pollinator","ectomycorrhiza","saprotroph","parasite","engineer","nursery","allelopathy","resin"}) do assert(seen[tag],tag) end
local d=B.interactions("bracken"); d.allelopathy.enabled=true
assert(B.interactions("bracken").allelopathy.enabled==false and writes==0)
end)
test("nitrogen is capped and fungi transfer only an existing positive budget",function()
assert(B.nitrogen("alder",0)==1 and B.nitrogen("clover",1.5)==.5 and B.nitrogen("clover",2)==0)
assert(B.nitrogen("oak",0)==0 and B.nitrogen("alder",0/0)==0)
assert(B.root_fungi("fly_agaric","pine",.25)==.25 and B.root_fungi("fly_agaric","pine",0)==0)
assert(B.root_fungi("turkey_tail","oak",2)==0 and B.root_fungi("fly_agaric","pine",math.huge)==0)
assert(B.resin("spruce") and B.resin("pine_log") and not B.resin("oak"))
assert(writes==0)
end)
test("register exactly sixty-two missing understory nodes, never legacy or trees",function()
register(); local count=0
for name,def in pairs(defs) do
count=count+1; local r=B.get(name)
assert(r and not r.existing and r.group~="trees")
assert(def.tiles[1]=="hearth_botany_"..r.id..".png" and not def.mesh)
assert(def.description==r.common.." ("..r.latin..")")
assert(not def.walkable and not def.buildable_to and def.groups.hearth_ecosystem==1)
end
assert(count==62); register(); local again=0; for _ in pairs(defs) do again=again+1 end; assert(again==count)
end)
test("existing registered definitions are preserved and construction stays late-bound",function()
local marker={description="external owner"}; defs["hearth:bilberry"]=marker
register(); assert(defs["hearth:bilberry"]==marker)
H.construction=nil; bush(); assert(not B.harvest(p,player) and added==0)
end)
test("normal plants dig as nodes; shrubs drop sticks preventing cooldown-reset replant",function()
register()
assert(defs["hearth:yarrow"].drop=="hearth:yarrow")
for _,r in ipairs(B.list("shrubs")) do assert(defs[r.node].drop=="hearth:sticks") end
defs["hearth:bilberry"].after_place_node(p)
assert(fields[key(p)].player_placed=="1")
end)
test("aquatic sprites and flat pads remain inside one block and are never liquids",function()
register()
for _,r in ipairs(B.list("aquatic")) do
local d=defs[r.node]; assert(not d.groups.water and not d.liquidtype)
if r.niche.aquatic=="surface" then
assert(d.drawtype=="nodebox")
for _,v in ipairs(d.node_box.fixed) do assert(v>=-.5 and v<=.5) end
else assert(d.drawtype=="plantlike") end
if r.niche.aquatic=="surface" or r.niche.aquatic=="submerged" then
local stack={}; assert(d.on_place(stack,player,{})==stack and writes==0)
end
end
end)
test("harvest checks permission before metadata or inventory and returns at most two",function()
bush(); allowed=false; local ok,reason=B.harvest(p,player)
assert(not ok and reason=="denied" and auth==1 and writes==0 and added==0)
allowed=true; ok,reason=B.harvest(p,player)
assert(ok and reason==2 and added==2 and tonumber(fields[key(p)].botany_ready_at)==time+86400)
end)
test("cooldown has no catchup and survives a new module instance",function()
bush(); assert(B.harvest(p,player)); assert(not B.harvest(p,player) and added==2)
policies={} -- a fresh factory may reinstall the same seasonal policy
B=factory(H,A); assert(not B.harvest(p,player)); time=time+86400*30
assert(B.harvest(p,player) and added==4); assert(not B.harvest(p,player) and added==4)
end)
test("full inventory does not consume stock or start cooldown",function()
bush(); room=false; local ok,reason=B.harvest(p,player)
assert(not ok and reason=="inventory" and writes==0 and added==0)
room=true; leftover=2; assert(not B.harvest(p,player) and fields[key(p)].botany_ready_at=="")
leftover=1; ok,reason=B.harvest(p,player); assert(ok and reason==1 and added==1)
assert(not B.harvest(p,player))
end)
test("harvest rejects wrong node, season, unloaded and nonplayer callers",function()
for _,id in ipairs({"elder","holly","foxglove","fly_agaric","berry_bush","bilberry_fake"}) do
bush(id); assert(not B.harvest(p,player) and added==0)
end
bush(); season="Winter"; assert(not B.harvest(p,player))
season="Summer"; world[key(p)]=nil; assert(not B.harvest(p,player))
assert(not B.harvest(p,nil) and not B.harvest(p,{is_player=function() return false end}))
assert(writes==0)
end)
test("malformed clocks and cooldown metadata fail closed",function()
bush()
for _,t in ipairs({0/0,math.huge,-math.huge,-1,"clock",9007199254740991}) do
time=t; local ok,reason=B.harvest(p,player); assert(not ok and reason=="clock")
end
time=nil; local ok,reason=B.harvest(p,player); assert(not ok and reason=="clock")
assert(climate_calls==0 and writes==0 and next(fields)==nil)
time=100000; A.get_meta(p):set_string("botany_ready_at","broken"); assert(not B.harvest(p,player))
A.get_meta(p):set_string("botany_ready_at","-2"); assert(not B.harvest(p,player))
assert(added==0)
end)
test("rightclick returns item stack and reentrant inventory cannot duplicate",function()
register(); bush(); local original=inv.add_item
inv.add_item=function(self,list,item)
assert(not B.harvest(p,player)); return original(self,list,item)
end
local stack={}; assert(defs["hearth:bilberry"].on_rightclick(p,{},player,stack)==stack)
inv.add_item=original; assert(added==2)
end)
test("the actual position selector can return each of all eighty exact node IDs",function()
for index,r in ipairs(B.list()) do
local h=habitat(r.id); h.include_trees=true
local x=0
while (x*17+7)%80~=index-1 do x=x+1; assert(x<80) end
local k,selected=B.choose({x=x,y=1,z=0},h,next(r.seasons))
assert(k==r.node:sub(8) and selected.id==r.id,r.id)
assert(B.suitable(k,h,next(r.seasons)))
end
assert(writes==0)
end)
test("submerged candidates work as separate air-cell stands, never as liquid node definitions",function()
register()
for _,id in ipairs({"water_starwort","hornwort"}) do
local r=B.get(id); local h=habitat(id)
assert(B.suitable(id,h,"Summer") and h.aquatic=="submerged" and h.water>=1)
local def=defs[r.node]
assert(def.groups.hearth_submerged==1 and not def.groups.water and not def.liquidtype)
assert(not def.buildable_to and not def.walkable and def.drawtype=="plantlike")
end
end)
test("all sixty-two new understory policies match actual activity and dormant art",function()
local count=0
for _,r in ipairs(B.list()) do
local policy=policies[r.id]
if r.existing or r.group=="trees" then assert(not policy,r.id)
else
count=count+1; assert(policy,r.id)
for _,s in ipairs({"Spring","Summer","Autumn","Winter"}) do
assert((policy.active[s]==true)==B.active(r.id,s))
local v=policy.definitions[s]
assert((v~=nil)==not B.active(r.id,s),r.id..s)
if v then
assert(v.texture=="hearth_botany_"..r.id.."_dormant.png")
assert(B.kind(r.node.."_"..s:lower())==r.id)
end
end
end
end
assert(count==62)
policies.holly.active.Winter=false; assert(B.active("holly","Winter"))
assert(next(policies.juniper.definitions)==nil and next(policies.holly.definitions)==nil)
end)
test("optional seasons mocks without add_variants still support base harvesting",function()
for _,mock in ipairs({{}, {kind=function(name) return name:match("^hearth:(.+)$") end}}) do
H.seasons=mock; B=factory(H,A); bush(); fields={}
assert(B.harvest(p,player))
end
H.seasons=nil; B=factory(H,A); fields={}; assert(B.harvest(p,player))
end)
test("harvest samples one validated epoch before climate and uses the same cooldown clock",function()
time=1782043200.75; bush()
H.climate_at=function(epoch)
climate_calls=climate_calls+1
assert(clock_reads==1 and epoch==1782043200)
return {season="Summer"}
end
assert(B.harvest(p,player) and clock_reads==1 and climate_calls==1)
assert(tonumber(fields[key(p)].botany_ready_at)==1782043200+86400)
end)
test("only exact owned seasonal aliases harvest in an active fruit season",function()
bush("bilberry_winter"); season="Winter"
local ok,reason=B.harvest(p,player); assert(not ok and reason=="season" and writes==0)
-- A loaded appearance can lag the climate; harvesting never resets its stock.
season="Summer"; assert(B.harvest(p,player))
bush(); assert(not B.harvest(p,player) and added==2)
H.seasons.kind=function() return "bilberry" end
for _,id in ipairs({"bilberry_fake","bilberry_summer","bilberry_winter_extra"}) do
bush(id); ok,reason=B.harvest(p,player); assert(not ok and reason=="species")
end
world[key(p)]={name="foreign:bilberry"}; ok,reason=B.harvest(p,player)
assert(not ok and reason=="species" and added==2)
end)
test("southern berry seasons use the solar model at the validated epoch",function()
local model=dofile(target:gsub("[^/\\]+$","model.lua"))
H.climate_at=function(epoch) assert(epoch==time); return model.solar(epoch,-45,0) end
bush(); time=1782043200
local ok,reason=B.harvest(p,player); assert(not ok and reason=="season" and writes==0)
time=1797854400; assert(B.harvest(p,player) and added==2)
assert(tonumber(fields[key(p)].botany_ready_at)==time+86400)
end)
print("BOTANY_SPEC_PASS "..passed)
+194
View File
@@ -0,0 +1,194 @@
// Pure asset checks only. No engine, external art, network or live-world writes.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { ATLAS } from '../scripts/media.mjs';
import { CHARACTER_OPTIONS as O, CHARACTER_ART_MANIFEST as M, DEFAULT_CHARACTER,
CHARACTER_PORTRAITS, characterArt, characterLayer, characterLayers, characterTexture,
characterMesh, compositeCharacter, portraitSheet } from '../scripts/character-art.mjs';
const hash = image=>createHash('sha256').update(image.data).digest('hex');
const pixel = (im,tile,x,y)=> {
const [tx,ty]=ATLAS[tile], i=((ty*8+y)*im.width+tx*8+x)*4;
return [...im.data.subarray(i,i+4)];
};
const alpha = im=>im.data.reduce((n,value,i)=>n+(i%4===3 && value===255 ? 1 : 0),0);
test('exact public option IDs and 170 deterministic filenames include every Cartesian combination',()=> {
assert.deepEqual(O,{
bodies:['compact','balanced','broad'],skin:['porcelain','sand','honey','olive','umber','ebony'],
faces:['calm','angular','soft','weathered'],eyes:['hazel','brown','green','blue','grey'],
hairStyles:['cropped','sidepart','wavy','braids','long','shaved','none'],
hairColors:['ash','flax','chestnut','auburn','black','silver'],
facialHair:['none','stubble','moustache','short_beard','full_beard'],
garments:['tunic','workshirt','jerkin','coat'],clothColors:['sage','teal','slate','mauve','ochre','rust','linen','charcoal'],
trousers:['earth','slate','moss','charcoal'],boots:['walnut','black','tan'],
accents:['none','scarf','apron','sash','vest'],details:['none','freckles','scar','freckles_scar'],
});
const specs=[['skin',O.skin],['face',O.faces],['eyes',O.eyes],['hair',O.hairStyles,O.hairColors],
['beard',O.facialHair,O.hairColors],['garment',O.garments,O.clothColors],['trousers',O.trousers],
['boots',O.boots],['accent',O.accents,O.clothColors],['detail',O.details]];
const expected=specs.flatMap(([kind,ids,colors])=>ids.flatMap(id=>(colors||[null]).map(color=>
`hearth_char_${kind}_${id}${color ? '_'+color : ''}.png`)));
assert.equal(M.length,170); assert.equal(new Set(expected).size,170);
assert.deepEqual(M.map(r=>r.filename),expected);
assert.ok(Object.isFrozen(O) && Object.values(O).every(Object.isFrozen) && M.every(Object.isFrozen));
assert.deepEqual([...characterArt().keys()],expected);
for (const r of M) {
assert.match(r.filename,/^hearth_char_[a-z_]+\.png$/); assert.equal(r.width,64); assert.equal(r.height,64);
assert.equal(r.license,'MIT'); assert.match(r.author,/original/);
}
});
test('all layers are native 64 RGBA, crisp alpha, no hidden RGB; only named none layers are empty',()=> {
for (const r of M) {
const im=characterLayer(r.filename);
assert.equal(im.width,64); assert.equal(im.height,64); assert.equal(im.data.length,16384);
assert.equal(hash(im),hash(characterLayer(r.filename)));
for (let i=0;i<im.data.length;i+=4) {
assert.ok(im.data[i+3]===0 || im.data[i+3]===255,r.filename);
if (!im.data[i+3]) assert.deepEqual([...im.data.subarray(i,i+3)],[0,0,0]);
}
assert.equal(alpha(im)===0,r.empty,r.filename);
if (r.kind==='skin') assert.equal(alpha(im),4096);
else assert.ok(alpha(im)<4096);
}
assert.equal(M.filter(r=>r.empty).length,21);
});
test('overlays paint only their semantic UV tiles, never held metal, wood, wrap or unused atlas space',()=> {
const allowed={face:['face'],eyes:['face'],hair:['face','hair','side','back','sleeve'],
beard:['face','side'],garment:['tunic','back','sleeve'],trousers:['trousers'],boots:['boot'],
accent:['tunic','back','sleeve'],detail:['face']};
for (const r of M.filter(r=>r.kind!=='skin')) {
const im=characterLayer(r.filename), tiles=new Set(allowed[r.kind].map(t=>ATLAS[t].join(',')));
for (let y=0;y<64;y++) for (let x=0;x<64;x++) if (im.data[(y*64+x)*4+3])
assert.ok(tiles.has([Math.floor(x/8),Math.floor(y/8)].join(',')),r.filename);
}
});
test('all six skins differ meaningfully on every exposed tile, not clothes or tools',()=> {
const layers=O.skin.map(id=>characterLayer(`hearth_char_skin_${id}.png`));
for (let i=1;i<layers.length;i++) {
let changed=0, delta=0;
for (let y=0;y<64;y++) for (let x=0;x<64;x++) {
const offset=(y*64+x)*4, a=[...layers[i-1].data.subarray(offset,offset+3)], b=[...layers[i].data.subarray(offset,offset+3)];
if (a.some((n,c)=>n!==b[c])) { changed++; delta+=a.reduce((n,v,c)=>n+Math.abs(v-b[c]),0); }
}
assert.equal(changed,256,'four exposed 8px head/hand tiles'); assert.ok(delta/changed>50);
for (const tile of ['face','hair','side','skin']) assert.notDeepEqual(pixel(layers[i],tile,3,4),pixel(layers[i-1],tile,3,4));
for (const tile of ['tunic','boot','trousers','metal','wood','wrap'])
assert.deepEqual(pixel(layers[i],tile,3,4),pixel(layers[0],tile,3,4));
}
});
test('all nonempty style/color layers are distinct; every appearance axis changes the composed character',()=> {
const seen=new Set();
for (const r of M.filter(r=>!r.empty)) {
const key=hash(characterLayer(r.filename)); assert.ok(!seen.has(key),r.filename); seen.add(key);
}
const axes={skin:O.skin,face:O.faces,eyes:O.eyes,hair:O.hairStyles,hairColor:O.hairColors,
beard:O.facialHair,garment:O.garments,cloth:O.clothColors,trousers:O.trousers,boots:O.boots,
accent:O.accents,accentColor:O.clothColors,detail:O.details};
for (const [key,values] of Object.entries(axes))
assert.equal(new Set(values.map(id=>hash(compositeCharacter({[key]:id})))).size,values.length,key);
});
test('face linework is skin-neutral, eyes keep visible 8px landmarks, and no-hair scalp really uses chosen skin',()=> {
for (const id of O.faces) {
const im=characterLayer(`hearth_char_face_${id}.png`);
assert.deepEqual(pixel(im,'face',3,4),[0,0,0,0]);
assert.ok(alpha(im)>=5 && alpha(im)<=12,'linework, not a painted skin mask');
assert.equal(pixel(im,'face',2,3)[3],0,'iris reserved');
}
for (const id of O.eyes) {
const im=characterLayer(`hearth_char_eyes_${id}.png`); assert.equal(alpha(im),2);
assert.equal(pixel(im,'face',2,3)[3],255); assert.equal(pixel(im,'face',5,3)[3],255);
}
for (const skin of O.skin) {
const im=compositeCharacter({skin,hair:'none'}), base=characterLayer(`hearth_char_skin_${skin}.png`);
for (const tile of ['hair','side','skin']) assert.deepEqual(pixel(im,tile,4,4),pixel(base,tile,4,4));
}
});
test('layer names and order match Luanti composition; garments cannot erase long shoulder hair',()=> {
const names=characterLayers({hair:'long',garment:'coat',cloth:'rust'});
assert.deepEqual(names,[
'hearth_char_skin_sand.png','hearth_char_face_calm.png','hearth_char_eyes_hazel.png',
'hearth_char_garment_coat_rust.png','hearth_char_trousers_earth.png','hearth_char_boots_walnut.png',
'hearth_char_accent_scarf_sage.png','hearth_char_hair_long_chestnut.png',
'hearth_char_beard_none_chestnut.png','hearth_char_detail_none.png']);
assert.equal(characterTexture({hair:'long',garment:'coat',cloth:'rust'}),names.join('^'));
const im=compositeCharacter({hair:'long',garment:'coat',cloth:'rust'}), hair=characterLayer(names[7]);
assert.deepEqual(pixel(im,'back',3,2),pixel(hair,'back',3,2));
assert.deepEqual(pixel(im,'sleeve',0,1),pixel(hair,'sleeve',0,1));
assert.equal(alpha(im),4096);
const independent=new Uint8Array(16384);
for (const name of names) {
const data=characterLayer(name).data;
for (let i=0;i<data.length;i+=4) if (data[i+3]) independent.set(data.subarray(i,i+4),i);
}
assert.deepEqual(im.data,independent);
});
test('old held-tool swatches are byte-exact in all bases and final characters, without global tint',()=> {
for (const skin of O.skin) {
const base=characterLayer(`hearth_char_skin_${skin}.png`), combined=compositeCharacter({skin});
for (const [tile,rgb] of [['metal',[177,195,191]],['wood',[146,100,59]],['wrap',[201,169,116]]])
for (let y=0;y<8;y++) for (let x=0;x<8;x++) {
const [tx,ty]=ATLAS[tile], grain=(((tx*8+x)*13+(ty*8+y)*7)%5)-2;
const expected=[...rgb.map(n=>n+grain),255];
assert.deepEqual(pixel(base,tile,x,y),expected); assert.deepEqual(pixel(combined,tile,x,y),expected);
}
}
});
test('clothing has tailored collars, rolled cuffs, leather seams, plackets, boot soles and accent masks',()=> {
const garment=id=>characterLayer(`hearth_char_garment_${id}_teal.png`);
assert.notDeepEqual(pixel(garment('tunic'),'tunic',2,0),pixel(garment('tunic'),'tunic',2,2),'collar');
assert.notDeepEqual(pixel(garment('workshirt'),'sleeve',3,5),pixel(garment('workshirt'),'sleeve',3,4),'rolled cuff');
assert.notDeepEqual(pixel(garment('jerkin'),'tunic',1,3),pixel(garment('jerkin'),'tunic',2,3),'leather panel seam');
assert.notDeepEqual(pixel(garment('coat'),'tunic',3,3),pixel(garment('coat'),'tunic',2,3),'coat opening');
const masks=O.accents.map(style=>characterLayer(`hearth_char_accent_${style}_sage.png`).data.filter((_,i)=>i%4===3));
assert.equal(new Set(masks.map(bytes=>createHash('sha256').update(bytes).digest('hex'))).size,5);
for (const id of O.boots) assert.deepEqual(pixel(characterLayer(`hearth_char_boots_${id}.png`),'boot',0,7),[30,28,26,255]);
});
test('eight contact-sheet cells are assembled clothed portraits sampled from actual composited atlases',()=> {
const sheet=portraitSheet(); assert.equal(sheet.width,512); assert.equal(sheet.height,64);
assert.equal(CHARACTER_PORTRAITS.length,8); assert.equal(alpha(sheet),512*64);
assert.equal(hash(sheet),hash(portraitSheet()));
const cells=new Set();
CHARACTER_PORTRAITS.forEach((p,index)=> {
const im=compositeCharacter(p), cell=[];
for (let y=0;y<64;y++) for (let x=0;x<64;x++) {
const offset=(y*512+index*64+x)*4; cell.push(...sheet.data.subarray(offset,offset+4));
}
cells.add(createHash('sha256').update(new Uint8Array(cell)).digest('hex'));
const sample=(x,y)=>[...sheet.data.subarray((y*512+index*64+x)*4,(y*512+index*64+x+1)*4)];
assert.deepEqual(sample(28,12),pixel(im,'face',2,4));
assert.deepEqual(sample(32,32),pixel(im,'tunic',4,4));
assert.deepEqual(sample(0,0),[30,34,32,255]);
});
assert.equal(cells.size,8);
});
test('pure renderers return fresh buffers and allowlisted choices reject traversal and prototype keys',()=> {
const first=compositeCharacter(), digest=hash(first); first.data.fill(0);
assert.equal(hash(compositeCharacter()),digest);
const layer=characterLayer(M[0].filename), original=hash(layer); layer.data.fill(0);
assert.equal(hash(characterLayer(M[0].filename)),original);
assert.equal(characterMesh(),'hearth_person.b3d');
assert.equal(characterMesh('compact'),'hearth_person_compact.b3d');
assert.equal(characterMesh('broad'),'hearth_person_broad.b3d');
for (const bad of ['../sand','toString','constructor','__proto__','',null,{},0]) {
assert.throws(()=>characterLayer(bad)); assert.throws(()=>characterMesh(bad));
assert.throws(()=>compositeCharacter({skin:bad}));
}
for (const bad of [null,[],1,'sand',{unknown:'sand'},JSON.parse('{"__proto__":{}}'),
Object.create({skin:'sand'}),{skin:undefined},{[Symbol('skin')]:'sand'}])
assert.throws(()=>characterLayers(bad));
assert.deepEqual(DEFAULT_CHARACTER,{body:'balanced',skin:'sand',face:'calm',eyes:'hazel',hair:'sidepart',
hairColor:'chestnut',beard:'none',garment:'tunic',cloth:'teal',trousers:'earth',boots:'walnut',
accent:'scarf',accentColor:'sage',detail:'none'});
});
+216
View File
@@ -0,0 +1,216 @@
-- Offline Lua 5.1+ factory tests. Engine JSON is represented by exact JSON
-- fixture strings, not executable deserialization or a fake browser service.
local factory = dofile(assert(arg[1], "character.lua path required"))
local total = 0
local function test(name, fn)
local ok, err = pcall(fn); assert(ok, name .. ": " .. tostring(err))
total = total + 1; print("PASS " .. name)
end
local function copy(t) local o = {}; for k,v in pairs(t) do o[k] = v end; return o end
local function harness()
local h = {sessions = {}, messages = {}, screens = {}, props = {}, closed = {}, commands = {}, connected = {}, json = {}, parses = 0}
local api = {}
function api.write_json(value)
if h.encode_fail then error("PRIVATE encoder detail") end
local fields = {}
for k,v in pairs(value) do fields[#fields+1] = '"' .. k .. '":' .. (type(v)=="number" and v or '"' .. tostring(v) .. '"') end
table.sort(fields)
local raw = "{" .. table.concat(fields,",") .. "}"
h.json[raw] = copy(value); return raw
end
function api.parse_json(raw, null, return_error)
h.parses = h.parses + 1; assert(null == nil and return_error == true)
if h.parse_throw then error("PRIVATE parse detail") end
return h.json[raw] and copy(h.json[raw]) or nil
end
function api.formspec_escape(s)
return (s:gsub("\\","\\\\"):gsub("%[","\\["):gsub("%]","\\]"):gsub(";","\\;"):gsub(",","\\,"))
end
function api.get_player_by_name(name) return h.connected[name] end
function api.register_on_player_receive_fields(fn) h.fields = fn end
function api.register_chatcommand(name, def) h.commands[name] = def end
function api.show_formspec(name, form, spec) h.screens[#h.screens+1] = {name=name,form=form,spec=spec} end
function api.close_formspec(name, form) h.closed[#h.closed+1] = {name,form} end
function h.notify(_, message) h.messages[#h.messages+1] = message end
function h.player(name)
local meta = {raw = "", writes = 0}
function meta:get_string(k) assert(k == "hearth:appearance"); if self.read_fail then error("PRIVATE read") end; return self.raw end
function meta:set_string(k, v)
assert(k == "hearth:appearance"); self.writes = self.writes + 1
if self.fail then error("PRIVATE write") end
if self.reject then return false end
self.raw = v
end
local p = {name=name or "alice", meta=meta, properties={}}
function p:get_player_name() return self.name end
function p:get_meta() return self.meta end
function p:set_properties(props)
for k in pairs(props) do assert(k == "mesh" or k == "textures", "cosmetics cannot alter " .. k) end
h.props[#h.props+1] = props; self.properties = props
end
h.sessions[p.name], h.connected[p.name] = {}, p
return p, meta
end
h.api, h.C = api, factory(h,api)
return h
end
test("factory exports fresh defaults and exact independent option lists", function()
local h = harness(); local C = h.C
local counts = {body=3,skin=6,face=4,eyes=5,hair=7,hairColor=6,beard=5,garment=4,cloth=8,
trousers=4,boots=3,accent=5,accentColor=8,detail=4}
for k,n in pairs(counts) do assert(#C.options()[k]==n,k) end
local d, o = C.defaults(), C.options(); d.skin="BAD"; o.skin[1]="BAD"
assert(C.defaults().skin=="sand" and C.options().skin[1]=="porcelain" and C.VERSION==1)
end)
test("strict schema rejects missing extra typed version and untrusted selections", function()
local C = harness().C
for _,bad in ipairs({false, 1, "{}", {}}) do assert(not C.validate(bad)) end
for _,k in ipairs({"version","body","skin","face","eyes","hair","hairColor","beard","garment","cloth","trousers","boots","accent","accentColor","detail"}) do
local d = C.defaults(); d[k]=nil; assert(not C.validate(d),k)
end
for _,v in ipairs({2,"1",false,{}}) do local d=C.defaults(); d.version=v; assert(not C.validate(d)) end
for _,v in ipairs({"../skin.png","#ffffff","sand^other.png", "];button[0,0;", "https://invalid.example/skin", "", false, {}}) do
local d=C.defaults(); d.skin=v; assert(not C.validate(d))
end
for _,k in ipairs({"name","player","texture","__index",1}) do local d=C.defaults(); d[k]="x"; assert(not C.validate(d)) end
assert(not C.validate(setmetatable(C.defaults(),{})))
end)
test("all option values validate and map only to fixed mesh and layer filenames", function()
local C=harness().C
for k,list in pairs(C.options()) do for _,id in ipairs(list) do
local d=C.defaults(); d[k]=id; assert(C.validate(d))
local mesh, texture=C.preview(d)
assert(mesh:match("^hearth_person[_%a]*%.b3d$"))
local n=0; for layer in texture:gmatch("[^%^]+") do assert(layer:match("^hearth_char_[%a_]+%.png$")); n=n+1 end
assert(n==10)
end end
end)
test("preview exactly follows opaque skin face eyes clothing accent hair beard detail order", function()
local C=harness().C; local mesh,texture=C.preview(C.defaults())
assert(mesh=="hearth_person.b3d")
assert(texture=="hearth_char_skin_sand.png^hearth_char_face_calm.png^hearth_char_eyes_hazel.png^hearth_char_garment_tunic_teal.png^hearth_char_trousers_earth.png^hearth_char_boots_walnut.png^hearth_char_accent_scarf_sage.png^hearth_char_hair_sidepart_chestnut.png^hearth_char_beard_none_chestnut.png^hearth_char_detail_none.png")
assert(not C.preview({}))
end)
test("bounded silent JSON roundtrip refuses malformed oversize nested and invalid data", function()
local h=harness(); local C=h.C; local d=C.defaults(); local raw=assert(C.encode(d))
assert(#raw<2048 and C.decode(raw).hair==d.hair)
for _,raw_bad in ipairs({"", "{oops", "null", "[]", '{"skin":{}}', string.rep("{",1000), string.rep("x",2049)}) do
assert(not C.decode(raw_bad))
end
local before=h.parses; assert(not C.decode(string.rep("x",2049))); assert(h.parses==before)
d.extra=true; assert(not C.decode(h.api.write_json(d)))
h.parse_throw=true; assert(not C.decode(raw))
h.parse_throw=false; h.encode_fail=true; assert(not C.encode(C.defaults()))
end)
test("join applies defaults without saving or opening a form", function()
local h=harness(); local p,m=h.player(); assert(h.C.on_join(p))
assert(m.writes==0 and #h.screens==0 and #h.messages==0 and #h.props==1)
assert(not h.C.state(p).warning)
end)
test("saved JSON restores selected profile and appearance without a rewrite", function()
local h=harness(); local p,m=h.player(); local d=h.C.defaults(); d.body="broad"; d.skin="ebony"
m.raw=assert(h.C.encode(d)); assert(h.C.apply(p))
assert(p.properties.mesh=="hearth_person_broad.b3d" and p.properties.textures[1]:find("skin_ebony",1,true))
assert(m.writes==0 and not h.C.state(p).warning and #h.messages==0)
end)
test("corrupt saved bytes and read errors use defaults warn safely and never kick or overwrite", function()
for _,raw in ipairs({"PRIVATE malformed",string.rep("x",2049)}) do
local h=harness(); local p,m=h.player(); m.raw=raw; assert(h.C.on_join(p))
assert(m.raw==raw and m.writes==0 and #h.messages==1 and h.C.state(p).warning)
assert(not h.messages[1]:find("PRIVATE",1,true)); h.C.show(p)
assert(h.screens[1].spec:find("original data is retained",1,true))
end
local h=harness(); local p,m=h.player(); m.read_fail=true; assert(h.C.on_join(p) and m.writes==0)
end)
test("command requires connected own session and other forms are ignored", function()
local h=harness(); assert(not h.commands.character.func("offline"))
local p=h.player(); assert(h.commands.character.func("alice"))
assert(h.screens[1].name=="alice" and h.screens[1].form==h.C.FORM)
assert(h.fields(p,"hearth:location",{apply=true})==nil)
h.sessions.alice=nil; assert(not h.commands.character.func("alice"))
end)
test("four tab layout contains real escaped one-texture model and bounded controls", function()
local h=harness(); local p=h.player(); h.C.show(p)
for i=1,4 do
h.C.handle(p,{["tab_"..i]=true}); local spec=h.screens[#h.screens].spec
assert(spec:find("size[14,11]",1,true) and spec:find("model[0.65,1.65;5.1,6.4;traveler;hearth_person.b3d;",1,true))
assert(spec:find(";0,180;true;true;0,19;12]",1,true))
assert(spec:find("Face]",1,true) and spec:find("Hair]",1,true) and spec:find("Clothing]",1,true) and spec:find("Finishing]",1,true))
assert(not spec:find("http",1,true) and not spec:find("hearth_person.png",1,true))
for x,y,w,ht in spec:gmatch("button%[([%d.]+),([%d.]+);([%d.]+),([%d.]+);") do
assert(tonumber(x)+tonumber(w)<=14 and tonumber(y)+tonumber(ht)<=11)
end
end
assert(h.screens[#h.screens].spec:find("Freckles and scar",1,true))
-- Escape is applied to texture names too, rather than assuming safe output.
local calls=0; local original=h.api.formspec_escape
h.api.formspec_escape=function(s) calls=calls+1; return original(s) end
h.C.show(p); assert(calls>10)
end)
test("draft tabs and rotations never publish player properties or metadata", function()
local h=harness(); local p,m=h.player(); h.C.show(p); local before=#h.props
assert(h.C.handle(p,{pick_body="1",pick_skin="6",tab_2=true}))
assert(h.C.handle(p,{pick_hair="5",pick_hairColor="6",pick_beard="5"}))
assert(h.C.handle(p,{back=true}) and h.C.state(p).yaw==0)
assert(h.C.handle(p,{profile=true}) and h.C.state(p).yaw==90)
assert(h.C.handle(p,{front=true}) and h.C.state(p).yaw==180)
assert(h.C.state(p).draft.skin=="ebony" and h.C.state(p).draft.hair=="long")
assert(#h.props==before and m.writes==0)
end)
test("cancel and Escape discard draft preserving saved appearance and persistence", function()
for _,field in ipairs({"cancel","quit"}) do
local h=harness(); local p,m=h.player(); h.C.show(p); local old=p.properties.textures[1]
h.C.handle(p,{pick_skin="6"}); h.C.handle(p,{[field]=true})
assert(not h.C.state(p).draft and m.writes==0 and p.properties.textures[1]==old)
h.C.show(p); assert(h.C.state(p).draft.skin=="sand")
end
end)
test("apply writes first then publishes and saved data survives a new session", function()
local h=harness(); local p,m=h.player(); h.C.show(p)
local original=p.set_properties
p.set_properties=function(self,props) assert(m.writes==1 and h.C.decode(m.raw).skin=="ebony"); original(self,props) end
assert(h.C.handle(p,{pick_skin="6",pick_body="3",apply=true}))
assert(m.writes==1 and not h.C.state(p).draft and p.properties.mesh=="hearth_person_broad.b3d")
p.set_properties=original; h.sessions.alice={}; h.C.on_join(p)
assert(h.C.state(p).saved.skin=="ebony" and m.writes==1 and not h.C.state(p).warning)
end)
test("failed metadata or serialization leaves old published appearance and saved bytes", function()
for _,mode in ipairs({"throw","false","encode"}) do
local h=harness(); local p,m=h.player(); h.C.show(p); local old=p.properties.textures[1]
m.fail=mode=="throw"; m.reject=mode=="false"; h.encode_fail=mode=="encode"
h.C.handle(p,{pick_skin="6",apply=true})
assert(m.raw=="" and p.properties.textures[1]==old and h.C.state(p).saved.skin=="sand")
assert(h.C.state(p).draft.skin=="ebony" and h.C.state(p).warning:find("could not be saved",1,true))
assert(not h.screens[#h.screens].spec:find("PRIVATE",1,true))
end
end)
test("corrupt record is replaced only by explicit successful Apply", function()
local h=harness(); local p,m=h.player(); m.raw="PRIVATE bad"
h.C.show(p); h.C.handle(p,{reset=true}); h.C.handle(p,{cancel=true}); assert(m.raw=="PRIVATE bad")
h.C.show(p); h.C.handle(p,{apply=true}); assert(h.C.decode(m.raw) and m.writes==1 and not h.C.state(p).warning)
end)
test("reset and randomization use every allowlist without mutating defaults or publishing", function()
local h=harness(); local p,m=h.player(); h.C.show(p); local before=#h.props
for i=1,40 do h.C.handle(p,{randomize=true}); assert(h.C.validate(h.C.state(p).draft)) end
h.C.handle(p,{reset=true}); local d=h.C.state(p).draft
for k,v in pairs(h.C.defaults()) do assert(d[k]==v) end
assert(m.writes==0 and #h.props==before)
d.skin="BAD"; assert(h.C.state(p).draft.skin=="sand")
end)
test("forged fields indices hidden selections and target identities reject whole event", function()
local h=harness(); local p,m=h.player(); local q,n=h.player("bob"); h.C.show(p); h.C.show(q)
local cases={{pick_skin="0"},{pick_skin="7"},{pick_skin="01"},{pick_skin="1.0"},{pick_skin="NaN"},
{pick_skin={}}, {pick_skin=string.rep("1",2049)}, {pick_skin="];button[0,0;"},
{player="bob",apply=true,pick_skin="6"}, {pick_hair="1",apply=true}, {texture="x",apply=true}}
for _,f in ipairs(cases) do assert(not h.C.handle(p,f)); assert(h.C.state(p).draft.skin=="sand") end
assert(m.writes==0 and n.writes==0 and h.C.state(q).draft.skin=="sand")
assert(not h.C.handle({get_player_name=function() return "alice" end},{apply=true}))
end)
test("stale sessions reconnects closed drafts and disconnected objects cannot save", function()
local h=harness(); local p,m=h.player(); h.C.show(p); h.sessions.alice={}
assert(not h.C.handle(p,{apply=true}) and m.writes==0)
h.C.show(p); h.connected.alice=nil; assert(not h.C.handle(p,{apply=true}))
h.connected.alice=p; h.C.show(p); h.C.handle(p,{cancel=true}); assert(not h.C.handle(p,{apply=true}))
end)
assert(total==18,"Unexpected character test count")
print("PASS " .. total .. " character creator tests")
+239
View File
@@ -0,0 +1,239 @@
local target = assert(arg[1], "clock.lua path required")
local total = 0
local function test(name, fn)
local old_core, old_hearth, old_time = core, hearth, os.time
local ok, err = pcall(fn)
core, hearth, os.time = old_core, old_hearth, old_time
assert(ok, name .. ": " .. tostring(err)); total = total + 1
end
-- Contract doubles keep this suite independent of the engine, filesystem,
-- schema implementation, and real calendar/timezone. model_spec tests the math;
-- persistence_spec tests validation/migration of the stored records themselves.
local function harness(settings, singleplayer, stored)
local calls = {loads = 0, saves = 0, deferred = {}, solar = {}}
local C = {settings = {get = function(_, key) return settings[key] end}}
C.is_singleplayer = function() return singleplayer end
C.after = function(delay, fn) calls.deferred[#calls.deferred + 1] = {delay = delay, fn = fn} end
C.set_timeofday = function(value) calls.timeofday = value end
local P = {}
P.load_world = function(kind)
assert(kind == "location"); calls.loads = calls.loads + 1
if calls.load_error then error("Last Hearth: persistence failed; save retained for recovery", 0) end
return stored
end
P.save_world = function(kind, value)
assert(kind == "location"); calls.saves = calls.saves + 1
if calls.save_error then error("Last Hearth: persistence failed; save retained for recovery", 0) end
calls.saved = value
end
local M = {}
M.location = function(lat, lon)
lat, lon = tonumber(lat), tonumber(lon)
if not lat or not lon or lat ~= lat or lon ~= lon or math.abs(lat) > 90 or math.abs(lon) > 180 then return end
return {latitude = lat, longitude = lon}
end
M.solar = function(now, lat, lon)
local value = {now = now, latitude = lat, longitude = lon, timeofday = 0.5}
calls.solar[#calls.solar + 1] = value
return value
end
M.weather = function(now, lat, lon)
calls.weather = {now = now, latitude = lat, longitude = lon}
return "clear"
end
core, hearth = C, {model = M, persistence = P}
os.time = function() return calls.now or 1800000000 end
return calls
end
local function rejects(settings, singleplayer, text, stored)
local calls = harness(settings, singleplayer, stored)
local ok, message = pcall(dofile, target)
assert(not ok and type(message) == "string" and message:find(text, 1, true), "Expected configuration rejection")
assert(#calls.deferred == 0 and calls.saves == 0, "Rejected configuration must not start clock or save")
return calls
end
local function configured(deployment)
return {hearth_environment = deployment, hearth_latitude = "45", hearth_longitude = "0"}
end
test("singleplayer defaults local with disclosed neutral preview and deferred initialization", function()
local calls = harness({}, true)
dofile(target)
assert(hearth.has_location == false and hearth.clock == nil and hearth.weather == nil)
assert(calls.loads == 1 and calls.saves == 0 and #calls.deferred == 1)
assert(calls.deferred[1].delay == 0 and calls.deferred[1].fn == hearth.clock_update)
calls.deferred[1].fn()
assert(hearth.clock.latitude == 0 and hearth.clock.longitude == 0)
assert(hearth.clock.now == 1800000000 and hearth.weather == "clear" and calls.timeofday == 0.5)
assert(hearth.has_location == false)
end)
test("dedicated defaults production and requires explicit coordinates", function()
rejects({}, false, "requires explicit")
local calls = harness(configured(nil), false)
dofile(target); calls.deferred[1].fn()
assert(hearth.has_location and hearth.clock.latitude == 45)
end)
test("only the three environment enum values are accepted", function()
for _, value in ipairs({"", "dev", "LOCAL", "Production", " staging", "PRIVATE\nfixture"}) do
rejects({hearth_environment = value}, true, "must be local, staging, or production")
end
end)
test("dedicated local is forbidden even with coordinates", function()
rejects(configured("local"), false, "dedicated servers cannot")
end)
test("production rejects every nonempty date before parsing or persistence", function()
for _, value in ipairs({"1782043200", "bad", " ", "0", "nan"}) do
local settings = configured("production"); settings.hearth_preview_date = value
assert(rejects(settings, true, "production rejects").loads == 0)
end
end)
test("production rejects all weather settings except auto", function()
for _, value in ipairs({"rain", "clear", "", "bad", "AUTO"}) do
local settings = configured("production"); settings.hearth_preview_weather = value
rejects(settings, false, "production rejects")
end
end)
test("production rejects launcher mode and smoke token independently", function()
for _, key in ipairs({"hearth_launcher_mode", "hearth_smoke_token"}) do
for _, value in ipairs({"", "PRIVATE\nfixture"}) do
local settings = configured("production"); settings[key] = value
local calls = harness(settings, false)
local ok, message = pcall(dofile, target)
assert(not ok and message == "Last Hearth: production rejects fixture and launcher test settings.")
assert(calls.loads == 0 and calls.saves == 0 and #calls.deferred == 0)
end
end
end)
test("production rejects every course setting including empty and disabled strings before persistence", function()
for _, singleplayer in ipairs({true, false}) do
for _, key in ipairs({"hearth_m1_course", "hearth_m1_course_nonce",
"hearth_m1_course_snag", "hearth_course_token"}) do
for _, value in ipairs({"", "false", "0", "true", "PRIVATE\nfixture"}) do
local settings = configured("production"); settings[key] = value
local calls = harness(settings, singleplayer, {latitude = 45, longitude = 0})
local ok, message = pcall(dofile, target)
assert(not ok and message == "Last Hearth: production rejects fixture and launcher test settings.", key)
assert(calls.loads == 0 and calls.saves == 0 and #calls.deferred == 0)
assert(#calls.solar == 0 and calls.weather == nil and calls.timeofday == nil)
assert(hearth.clock_update == nil and hearth.set_location == nil)
end
end
end
end)
test("implicit dedicated production also rejects course flags before loading stored location", function()
for _, key in ipairs({"hearth_m1_course", "hearth_m1_course_nonce",
"hearth_m1_course_snag", "hearth_course_token"}) do
local settings = configured(nil); settings[key] = "false"
assert(rejects(settings, false, "production rejects", {latitude = 45, longitude = 0}).loads == 0)
end
end)
test("local and staging clocks accept course fixtures without granting enrollment or starting a run", function()
for _, singleplayer in ipairs({true, false}) do
local settings = configured(singleplayer and "local" or "staging")
settings.hearth_m1_course, settings.hearth_m1_course_nonce = "true", "public-clock-fixture"
settings.hearth_m1_course_snag, settings.hearth_course_token = "false", "public-clock-fixture"
settings.hearth_preview_date, settings.hearth_preview_weather = "1782043200", "clear"
settings.hearth_launcher_mode = "course"
local calls = harness(settings, singleplayer)
dofile(target); calls.deferred[1].fn()
assert(calls.loads == 1 and calls.saves == 0 and #calls.deferred == 1)
assert(hearth.clock.now == 1782043200 and hearth.clock.latitude == 45 and hearth.clock.longitude == 0)
assert(hearth.weather == "clear" and hearth.course == nil and hearth.sessions == nil)
end
end)
test("production allows empty date and automatic weather and advances real UTC", function()
local settings = configured("production")
settings.hearth_preview_date, settings.hearth_preview_weather = "", "auto"
local calls = harness(settings, false)
dofile(target); hearth.clock_update()
assert(hearth.clock.now == 1800000000)
calls.now = 1800000010; hearth.clock_update()
assert(hearth.clock.now == 1800000010 and calls.weather.now == 1800000010)
end)
test("production requires configured valid coordinates even in singleplayer with a stored location", function()
for _, singleplayer in ipairs({true, false}) do
for _, coords in ipairs({{}, {"45"}, {"bad", "0"}, {"91", "0"}, {"0", "181"}}) do
rejects({hearth_environment = "production", hearth_latitude = coords[1], hearth_longitude = coords[2]},
singleplayer, "requires explicit", {latitude = 45, longitude = 0})
end
end
end)
test("dedicated staging preserves the explicit coordinate requirement", function()
rejects({hearth_environment = "staging"}, false, "requires explicit", {latitude = 45, longitude = 0})
end)
test("staging accepts integer fixture dates including bounds on dedicated engines", function()
for _, date in ipairs({"946684800", "1782043200", "1797854400", "4102444800"}) do
local settings = configured("staging")
settings.hearth_preview_date, settings.hearth_preview_weather = date, "rain"
settings.hearth_launcher_mode, settings.hearth_smoke_token = "smoke", "fixture"
local calls = harness(settings, false)
dofile(target); hearth.clock_update()
assert(hearth.clock.now == tonumber(date) and calls.weather.now == tonumber(date))
assert(hearth.weather == "rain" and calls.timeofday == 0.5)
end
end)
test("local fixtures and all supported weather choices work", function()
for _, weather in ipairs({"auto", "clear", "rain"}) do
local calls = harness({hearth_preview_date = "1782043200", hearth_preview_weather = weather}, true)
dofile(target); hearth.clock_update()
assert(hearth.clock.now == 1782043200 and calls.weather.now == 1782043200)
assert(hearth.weather == (weather == "auto" and "clear" or weather))
end
end)
test("nonproduction rejects fractional out of range and nonfinite dates", function()
for _, deployment in ipairs({"local", "staging"}) do
for _, date in ipairs({"946684799", "4102444801", "1782043200.5", "bad", "nan", "inf", "1e309", " "}) do
local settings = configured(deployment); settings.hearth_preview_date = date
rejects(settings, deployment == "local", "must be an integer UTC timestamp")
end
end
end)
test("nonproduction rejects invalid weather instead of ignoring it", function()
for _, weather in ipairs({"", "snow", "Rain"}) do
rejects({hearth_preview_weather = weather}, true, "must be auto, clear, or rain")
end
end)
test("singleplayer stored location is loaded exclusively through persistence", function()
local calls = harness({}, true, {latitude = -30, longitude = 120})
dofile(target); hearth.clock_update()
assert(calls.loads == 1 and calls.saves == 0 and hearth.has_location)
assert(hearth.clock.latitude == -30 and hearth.clock.longitude == 120)
end)
test("configured location takes precedence but still validates stored location", function()
local calls = harness(configured("local"), true, {latitude = -30, longitude = 120})
dofile(target); hearth.clock_update()
assert(calls.loads == 1 and hearth.clock.latitude == 45 and hearth.clock.longitude == 0)
calls = harness(configured("production"), false); calls.load_error = true
local ok = pcall(dofile, target)
assert(not ok and calls.loads == 1 and calls.saves == 0 and #calls.deferred == 0)
end)
test("stored location errors never silently become neutral previews", function()
local calls = harness({}, true); calls.load_error = true
assert(not pcall(dofile, target))
assert(calls.saves == 0 and #calls.deferred == 0)
end)
test("set_location saves before publishing and next update uses the new coordinates", function()
local calls = harness({}, true)
dofile(target)
hearth.set_location(-45, 70)
assert(calls.saves == 1 and calls.saved.latitude == -45 and calls.saved.longitude == 70)
assert(hearth.has_location)
hearth.clock_update(); assert(hearth.clock.latitude == -45 and hearth.clock.longitude == 70)
end)
test("failed location save retains unchosen and previously selected state", function()
local calls = harness({}, true)
dofile(target); calls.save_error = true
assert(not pcall(hearth.set_location, 45, 10) and not hearth.has_location)
hearth.clock_update(); assert(hearth.clock.latitude == 0 and hearth.clock.longitude == 0)
calls.save_error = false; hearth.set_location(20, 30); calls.save_error = true
assert(not pcall(hearth.set_location, 45, 10) and hearth.has_location)
hearth.clock_update(); assert(hearth.clock.latitude == 20 and hearth.clock.longitude == 30)
end)
test("invalid location input and dedicated selection never write", function()
local calls = harness({}, true)
dofile(target)
assert(not pcall(hearth.set_location, 91, 0) and calls.saves == 0)
calls = harness(configured("staging"), false)
dofile(target)
assert(not pcall(hearth.set_location, 0, 0) and calls.saves == 0)
end)
print("PASS " .. total .. " clock tests")
+95
View File
@@ -0,0 +1,95 @@
-- Standalone Lua 5.1+; also runs in the engine's isolated unit environment.
local M = dofile(assert(arg[1], "combat.lua path required"))
local count = 0
local function test(name, fn)
local ok, err = pcall(fn); assert(ok, name .. ": " .. tostring(err)); count = count + 1
end
local function context()
return {live = true, hp = 20, state = {stamina = 100}, session = {}, now = 10,
weapon = "hearth:axe", allowed = true, clear = true,
origin = {x = 0, y = 0, z = 0}, eye = {x = 0, y = 1.55, z = 0},
target = {x = 0, y = 1.55, z = 2}, look = {x = 0, y = 0, z = 1}}
end
local function denied(c, reason)
local hit, why = M.valid_attack(c); assert(not hit and why == reason, tostring(why))
end
test("weapon reach damage cadence cost and durability are distinct", function()
local c = context()
for _, spec in ipairs({{"hearth:axe", 2.5, 4, 1.1, 12}, {"hearth:knife", 1.8, 3, 0.7, 8}, {"hearth:spear", 3.4, 5, 1.2, 14}}) do
c.weapon, c.target.z = spec[1], spec[2]
local a = assert(M.valid_attack(c)); assert(a.damage == spec[3] and a.cadence == spec[4] and a.stamina == spec[5] and a.wear > 0)
c.target.z = spec[2] + 0.001; denied(c, "reach")
a.damage = 999; c.target.z = spec[2]; assert(M.valid_attack(c).damage == spec[3])
end
end)
test("head center ray obstruction and full vertical distance", function()
local c = context(); c.clear = false; denied(c, "obstructed")
c.clear = nil; denied(c, "obstructed")
c.clear = true; c.target.y = 5; denied(c, "reach")
c.target.y, c.target.z = 3.55, 0; c.look = {x = 0, y = 1, z = 0}; assert(M.valid_attack(c))
end)
test("aim cone is normalized three dimensional and required even nearby", function()
local c = context(); c.target.z = 0.2
c.look = {x = 0, y = 0, z = -1}; denied(c, "aim")
c.look = {x = math.sqrt(1 - 0.35^2), y = 0, z = 0.35}; assert(M.valid_attack(c))
c.look.z = 0.349; denied(c, "aim")
c.look = {x = 0, y = 100, z = 0}; denied(c, "aim")
c.look = {x = 0, y = 0, z = 0}; denied(c, "aim")
end)
test("one session deadline survives weapon swap and target swap", function()
local c = context(); local a = assert(M.valid_attack(c))
c.session.attack_ready_at, c.session.combat_now = c.now + a.cadence, c.now
c.weapon, c.target.z, c.now = "hearth:knife", 1, 10.8; denied(c, "cadence")
c.weapon, c.target.z = "hearth:spear", 3; denied(c, "cadence")
c.now = 11.1; assert(M.valid_attack(c))
c.now = 9; denied(c, "clock")
end)
test("exhaustion never regenerates on rejected or accepted validation", function()
local c = context(); c.state.stamina = 11.99
for _ = 1, 100 do denied(c, "stamina") end
assert(c.state.stamina == 11.99 and c.session.attack_ready_at == nil)
c.state.stamina = 12; assert(M.valid_attack(c)); assert(c.state.stamina == 12)
c.state.stamina = 0 / 0; denied(c, "stamina")
end)
test("live state session dead waiting and explicit claims fail closed", function()
local c = context(); c.live = false; denied(c, "inactive")
c.live, c.hp = true, 0; denied(c, "inactive")
c.hp, c.session.waiting = 20, true; denied(c, "inactive")
c.session, c.state = {}, nil; denied(c, "inactive")
c.state, c.session = {stamina = 100}, nil; denied(c, "inactive")
c.session, c.allowed = {}, false; denied(c, "claim")
c.allowed = nil; denied(c, "claim")
end)
test("unsupported tools malformed positions and clocks cannot hit", function()
local c = context(); c.weapon = "hearth:pick"; denied(c, "weapon")
c.weapon, c.now = "hearth:axe", math.huge; denied(c, "clock")
c.now, c.target.x = 10, 0 / 0; denied(c, "position")
end)
test("short sprint displacement accepted but recent teleport rejected", function()
local c = context(); c.session.combat_sample = M.sample(nil, c.origin, 9.9)
c.origin.x = 0.8; assert(M.valid_attack(c))
c.origin.x = 5; denied(c, "movement")
local previous = c.session.combat_sample
local updated = M.sample(previous, c.origin, 10)
assert(updated ~= previous and previous.pos.x == 0 and updated.blocked_until == 10.5)
c.session.combat_sample = updated; denied(c, "movement")
c.now = 10.5; assert(M.valid_attack(c))
assert(M.sample(updated, c.origin, 9) == updated)
end)
test("round cooldown is independent of attack cooldown and entity reset", function()
local c = context(); c.session.spar_ready_at = 13
denied(c, "cadence"); c.now = 13; assert(M.valid_attack(c))
end)
test("brace requires fresh RMB spear facing and eight stamina not cached blocking", function()
local c = context(); c.weapon, c.controls = "hearth:spear", {RMB = true}
c.state.stamina = 8; assert(M.valid_guard(c)); assert(c.state.stamina == 8)
c.session.blocking, c.controls.RMB = true, false; assert(not M.valid_guard(c))
c.controls.RMB, c.look.z = true, -1; assert(not M.valid_guard(c))
c.look = {x = math.sqrt(1 - 0.4^2), y = 0, z = 0.4}; assert(M.valid_guard(c))
c.look.z = 0.399; assert(not M.valid_guard(c))
c.look = {x = 0, y = 0, z = 1}; c.state.stamina = 7.99; assert(not M.valid_guard(c))
c.state.stamina, c.weapon = 8, "hearth:knife"; assert(not M.valid_guard(c))
c.weapon, c.session.waiting = "hearth:spear", true; assert(not M.valid_guard(c))
c.session.waiting, c.hp = false, 0; assert(not M.valid_guard(c))
end)
print("PASS " .. count .. " combat checks")
+150
View File
@@ -0,0 +1,150 @@
-- Standalone, mocked engine only. arg[1] = construction.lua.
local factory = dofile(assert(arg[1], "construction.lua path required"))
local total = 0
local function test(name, fn)
local ok, err = pcall(fn)
assert(ok, name .. ": " .. tostring(err))
total = total + 1; print("ok " .. total .. " - " .. name)
end
local function harness()
local h = {world = {}, violations = 0, writes = 0, placements = 0, notices = 0, sounds = 0}
local function key(p) return p.x .. ":" .. p.y .. ":" .. p.z end
function h.put(x, y, z, name) h.world[key({x = x, y = y, z = z})] = name end
local api = {registered_nodes = {
air = {walkable = false, buildable_to = true}, ignore = {},
stone = {}, ["hearth:wall"] = {}, ["hearth:roof"] = {}, ["hearth:planks"] = {},
["hearth:foundation"] = {}, ["hearth:frame"] = {}, ["hearth:stairs"] = {},
["hearth:door"] = {}, ["hearth:door_open"] = {walkable = false},
["hearth:window"] = {}, ["hearth:vent"] = {},
water = {walkable = false, buildable_to = true, liquidtype = "source"},
leaves = {groups = {leaves = 1}},
}}
function api.get_node_or_nil(p)
local name = h.world[key(p)]
if name == false then return nil end
return {name = name or "air"}
end
function api.is_protected() return h.protected == true end
function api.record_protection_violation() h.violations = h.violations + 1 end
function api.get_meta() h.writes = h.writes + 1; error("Preview wrote metadata") end
function api.item_place(stack) h.placements = h.placements + 1; return stack, "placed" end
local hearth = {claim_allowed = function() return not h.denied end,
claim_owner_at = function(p) return h.owners and h.owners[key(p)] end,
notify = function() h.notices = h.notices + 1 end}
local player = {pos = {x = 0, y = 0, z = 0}}
function player:is_player() return true end
function player:get_pos() return self.pos end
function player:get_player_name() return "tester" end
function player:get_player_control() return {sneak = false} end
h.api, h.hearth, h.player = api, hearth, player
h.key = key
h.module = factory(hearth, api)
function h.preview(item) return h.module.preview({x = 0, y = 0, z = 0}, player, item or "hearth:wall") end
return h
end
test("supported previews do not mutate inventory, metadata, notifications or placement", function()
local h = harness(); h.put(0, -1, 0, "stone")
local stack = {get_name = function() return "hearth:wall" end,
take_item = function() error("Consumed preview") end}
for _ = 1, 20 do local ok, reason = h.preview(stack); assert(ok and reason == "ok") end
assert(h.violations == 0 and h.writes == 0 and h.placements == 0 and h.notices == 0)
end)
test("distance is six blocks inclusive", function()
local h = harness(); h.put(0, -1, 0, "stone")
h.player.pos.x = 6; assert(h.preview())
h.player.pos.x = 6.01; local ok, reason = h.preview(); assert(not ok and reason == "distance")
end)
test("protected and claim-denied preview never records a violation", function()
local h = harness(); h.put(0, -1, 0, "stone")
h.protected = true; local ok, reason = h.preview(); assert(not ok and reason == "protected")
h.protected, h.denied = false, true
ok, reason = h.preview(); assert(not ok and reason == "protected" and h.violations == 0)
assert(not h.module.allowed({x = 0, y = 0, z = 0}, h.player) and h.violations == 1)
end)
test("nil, ignore and unregistered target nodes fail unloaded", function()
for _, value in ipairs({false, "ignore", "missing"}) do
local h = harness(); h.put(0, 0, 0, value)
local ok, reason = h.preview(); assert(not ok and reason == "unloaded")
end
end)
test("occupied target is not buildable", function()
local h = harness(); h.put(0, 0, 0, "stone")
local ok, reason = h.preview(); assert(not ok and reason == "occupied")
end)
test("water replacement follows ordinary placement, but water is not an anchor", function()
local h = harness(); h.put(0, 0, 0, "water"); h.put(0, -1, 0, "water")
assert(h.preview("hearth:planks"))
local ok, reason = h.preview(); assert(not ok and reason == "support")
end)
test("unloaded support is distinguished from unsupported air or leaves", function()
local h = harness(); h.put(0, -1, 0, false)
local ok, reason = h.preview(); assert(not ok and reason == "unloaded")
h.put(0, -1, 0, "ignore"); ok, reason = h.preview(); assert(not ok and reason == "unloaded")
h.put(0, -1, 0, "leaves"); ok, reason = h.preview(); assert(not ok and reason == "support")
end)
test("two horizontal roof links allowed, third rejected", function()
local h = harness(); h.put(0, -1, 0, "stone"); h.put(0, 0, 0, "hearth:wall")
for x = 1, 3 do
local ok, reason = h.module.preview({x = x, y = 0, z = 0}, h.player, "hearth:roof")
assert(ok == (x <= 2)); if x == 3 then assert(reason == "support") end
h.put(x, 0, 0, "hearth:roof")
end
end)
test("sixteen-block vertical limit is identical in preview", function()
local h = harness(); h.put(0, -1, 0, "stone")
for y = 0, 16 do
h.player.pos.y = y
local ok, reason = h.module.preview({x = 0, y = y, z = 0}, h.player, "hearth:wall")
assert(ok == (y < 16)); if y == 16 then assert(reason == "support") end
h.put(0, y, 0, "hearth:wall")
end
end)
test("invalid coordinate, player and item inputs fail closed", function()
local h = harness()
for _, p in ipairs({{}, {x = 0/0, y = 0, z = 0}, {x = 0.1, y = 0, z = 0}}) do
local ok, reason = h.module.preview(p, h.player, "hearth:wall"); assert(not ok and reason == "invalid")
end
assert(not h.module.preview({x = 0, y = 0, z = 0}, nil, "hearth:wall"))
for _, item in ipairs({{}, 123, "missing"}) do
local ok, reason = h.preview(item); assert(not ok and reason == "item")
end
end)
test("legacy unsupported placement still notifies without feedback", function()
local h = harness(); local stack = {}
local p = {type = "node", under = {x = 0, y = 0, z = 0}, above = {x = 0, y = 1, z = 0}}
assert(h.module.place_building(stack, h.player, p) == stack)
assert(h.notices == 1 and h.placements == 0)
end)
test("optional feedback and denial sound replace only chat presentation", function()
local h = harness(); local messages = 0
h.hearth.feedback = function(_, _, kind) assert(kind == "warning"); messages = messages + 1 end
h.hearth.feedback_sound = function(name) assert(name == "hearth_denial"); h.sounds = h.sounds + 1 end
local p = {type = "node", under = {x = 0, y = 0, z = 0}, above = {x = 0, y = 1, z = 0}}
h.module.place_building({}, h.player, p)
assert(messages == 1 and h.sounds == 1 and h.notices == 0 and h.placements == 0)
end)
test("support removal refuses foundations that would leave dependent walls or roofs floating", function()
local h = harness(); h.put(0, -1, 0, "stone"); h.put(0, 0, 0, "hearth:wall")
assert(not h.module.can_dig({x = 0, y = -1, z = 0}, h.player))
assert(h.module.can_dig({x = 0, y = 0, z = 0}, h.player))
h.put(1, -1, 0, "stone"); h.put(1, 0, 0, "hearth:wall")
assert(h.module.can_dig({x = 0, y = -1, z = 0}, h.player), "alternate natural support should preserve the wall")
end)
test("support removal checks dependent structures and fails closed at unloaded edges", function()
local h = harness(); h.put(0, -1, 0, "stone"); h.put(0, 0, 0, "hearth:wall")
h.put(0, 1, 0, "hearth:roof")
assert(not h.module.can_dig({x = 0, y = 0, z = 0}, h.player))
h.put(2, 0, 0, false)
assert(not h.module.can_dig({x = 0, y = -1, z = 0}, h.player))
end)
test("placement and removal cannot create structural dependencies across homesteads", function()
local h = harness(); h.owners = {}
h.put(0, -1, 0, "stone"); h.owners[h.key({x=0,y=-1,z=0})] = "neighbor"
h.owners[h.key({x=0,y=0,z=0})] = "builder"
local ok, reason = h.preview("hearth:wall")
assert(not ok and reason == "support")
h.put(0, -1, 0, "stone"); h.put(0, 0, 0, "hearth:wall")
h.owners[h.key({x=0,y=0,z=0})] = "neighbor"
assert(not h.module.can_dig({x=0,y=-1,z=0}, h.player))
end)
print("PASS " .. total .. " construction checks")
+746 -44
View File
@@ -1,8 +1,13 @@
-- Standalone Lua 5.1+ regression harness; no engine or MTG required.
-- Pass the content module's path as the first command-line argument.
local nodes, items, crafts, biomes, decorations, lbms = {}, {}, {}, {}, {}, {}
local world, metas, timers, messages, state = {}, {}, {}, {}, {}
local now, protected, claim, violations = 100000, false, true, 0
assert(arg and arg[1], "Pass the path to mods/hearth/content.lua")
local modpath = assert(arg[1]:match("^(.*)[/\\][^/\\]+$"), "Content path needs a directory")
local nodes, items, crafts, biomes, decorations, lbms, entities, globalsteps = {}, {}, {}, {}, {}, {}, {}, {}
local craftitems, tools, texture_calls = {}, {}, {}
local ores, registrations = {}, {}
local world, metas, timers, messages, sounds, state, formspecs = {}, {}, {}, {}, {}, {}, {}
local now, game_now, protected, claim, violations = 100000, 500, false, true, 0
local claim_owner, member_role = "survivor", "owner"
local player
local function key(p) return p.x .. ":" .. p.y .. ":" .. p.z end
local function pos(x, y, z) return {x = x or 0, y = y or 0, z = z or 0} end
@@ -26,16 +31,36 @@ function Stack:replace(text)
local new = stack(text)
self.name, self.count = new.name, new.count
end
function Stack:add_wear(amount)
self.wear = (self.wear or 0) + amount
if self.wear >= 65535 then self.name, self.count = "", 0 end
end
_G.ItemStack = stack
core = {registered_nodes = nodes}
function core.register_node(name, def) nodes[name], items[name] = def, def end
function core.get_modpath(name) return name == "hearth" and modpath or nil end
function core.register_node(name, def)
assert(not nodes[name], "Duplicate node: " .. name)
nodes[name], items[name] = def, def
registrations[#registrations + 1] = name
end
function core.register_item(name, def) items[name] = def end
function core.register_tool(name, def) items[name] = def end
function core.register_craftitem(name, def) items[name] = def end
function core.register_tool(name, def) items[name], tools[name] = def, def end
function core.register_craftitem(name, def)
assert(not items[name], "Duplicate item: " .. name)
items[name], craftitems[name] = def, def
end
function core.register_craft(def) crafts[#crafts + 1] = def end
function core.register_biome(def) biomes[#biomes + 1] = def end
function core.register_decoration(def) decorations[#decorations + 1] = def end
function core.register_ore(def) ores[#ores + 1] = def end
function core.register_lbm(def) lbms[#lbms + 1] = def end
function core.register_entity(name, def) entities[name] = def end
function core.register_globalstep(fn) globalsteps[#globalsteps + 1] = fn end
function core.get_connected_players() return {} end
function core.get_objects_inside_radius() return {} end
function core.get_gametime() return game_now end
function core.sound_play(name, params) sounds[#sounds + 1] = {name = name, params = params} end
local aliases = {}
function core.register_alias(name, target) aliases[name] = target end
function core.get_node(p) return world[key(p)] or {name = "air"} end
@@ -44,15 +69,30 @@ function core.get_node_or_nil(p)
return core.get_node(p)
end
function core.swap_node(p, node) world[key(p)] = node end
function core.set_node(p, node)
local k = key(p)
world[k], metas[k], timers[k] = node, nil, nil
local def = nodes[node.name]
if def and def.on_construct then def.on_construct(p) end
end
function core.is_protected() return protected end
function core.record_protection_violation() violations = violations + 1 end
function core.show_formspec(name, form, spec)
formspecs[#formspecs + 1] = {name = name, form = form, spec = spec}
end
function core.get_meta(p)
local k = key(p)
if not metas[k] then
local fields = {}
local node_inventory = {items = {}, size = 0}
function node_inventory:set_size(_, size) self.size = size end
function node_inventory:is_empty() return #self.items == 0 end
metas[k] = {
set_string = function(_, name, value) fields[name] = value end,
get_string = function(_, name) return fields[name] or "" end,
set_int = function(_, name, value) fields[name] = math.floor(value) end,
get_int = function(_, name) return tonumber(fields[name]) or 0 end,
get_inventory = function() return node_inventory end,
}
end
return metas[k]
@@ -67,6 +107,7 @@ function core.get_node_timer(p)
end
return timers[k]
end
function core.get_gametime() return game_now end
function core.item_place(s, who, pointed)
local under = core.get_node(pointed.under)
local def = nodes[under.name]
@@ -85,34 +126,55 @@ end
nodes.air = {buildable_to = true, walkable = false}
hearth = {
model = {}, players = {survivor = state},
texture = function(color) return "[fill:16x16:" .. color end,
claim_allowed = function() return claim end,
texture = function(color)
texture_calls[#texture_calls + 1] = color
return "[fill:16x16:" .. color
end,
claim_owner_at = function() return claim and claim_owner or nil end,
claim_role_at = function(_, name) return name == claim_owner and "owner" or member_role end,
external_protected = function() return protected end,
claim_allowed = function(_, name, action)
if not claim then return false end
if name == claim_owner then return true end
if member_role == "resident" then return action ~= "arrival" and action ~= "combat" end
return member_role == "storekeeper" and action == "storage"
end,
notify = function(_, message) messages[#messages + 1] = message end,
}
local inventory = {room = true, contents = {}}
function inventory:room_for_item() return self.room end
function inventory:add_item(_, name)
self.contents[#self.contents + 1] = name
self.contents[#self.contents + 1] = type(name) == "table" and name:get_name() or name
return stack()
end
player = {position = pos(), sneak = false}
local player_fields = {}
local player_meta = {
get_int = function(_, name) return player_fields[name] or 0 end,
set_int = function(_, name, value) player_fields[name] = math.floor(value) end,
}
function player:is_player() return true end
function player:get_pos() return self.position end
function player:get_player_name() return "survivor" end
function player:get_inventory() return inventory end
function player:get_player_control() return {sneak = self.sneak} end
function player:get_meta() return player_meta end
local real_time = os.time
os.time = function() return now end
assert(arg and arg[1], "Pass the path to mods/hearth/content.lua")
dofile(arg[1])
-- Keep the runner's dofile: isolated engine runs map permitted sibling modules
-- into this same environment; standalone runs use Lua's normal file loader.
local core_before = {}
for name, value in pairs(core) do core_before[name] = value end
_G.dofile(arg[1])
local passed = 0
local function eq(actual, expected)
assert(actual == expected, "expected " .. tostring(expected) .. ", got " .. tostring(actual))
end
local function reset()
world, metas, timers, messages = {}, {}, {}, {}
now, protected, claim, violations = 100000, false, true, 0
world, metas, timers, messages, sounds, formspecs = {}, {}, {}, {}, {}, {}
now, game_now, protected, claim, violations = 100000, 500, false, true, 0
claim_owner, member_role, player_fields = "survivor", "owner", {}
player.position, player.sneak = pos(), false
inventory.room, inventory.contents = true, {}
state.food, state.water, state.stamina = 100, 100, 100
@@ -129,6 +191,18 @@ local function fire()
put(firepos, "hearth:fire")
nodes["hearth:fire"].on_construct(firepos)
end
local function ignite()
local starter = stack("hearth:firestarter")
items["hearth:firestarter"].on_use(starter, player, point(firepos, pos(0, 1)))
return starter
end
local function advance(seconds)
now, game_now = now + seconds, game_now + seconds
local node = core.get_node(firepos)
if nodes[node.name] and nodes[node.name].on_timer then
nodes[node.name].on_timer(firepos, seconds)
end
end
local function use(text)
local s = stack(text)
local node = core.get_node(firepos)
@@ -140,10 +214,17 @@ local function deadline() return tonumber(core.get_meta(firepos):get_string("fue
test("standalone registrations, recipes and mapgen resolve", function()
for _, name in ipairs({"stone", "soil", "grass", "sand", "water_source", "water_flowing",
"log", "leaves", "loose_stone", "sticks", "berry_bush", "reeds", "planks", "wall",
"roof", "fire", "fire_lit", "berries", "raw_food", "cooked_food", "vessel",
"water_raw", "water_clean", "knife", "axe", "pick", "spear"}) do
"roof", "foundation", "frame", "stairs", "door", "door_open", "window", "vent", "storage", "pantry",
"drying_rack", "bedroll", "torch", "fish_trap", "weir_stone", "sapling", "berry_bush_bare", "crop_1", "crop_2", "crop_3", "crop_4",
"grain_patch", "clay_bank", "iron_ore_vein", "fire", "fire_lit", "berries", "grain", "grain_seed",
"raw_food", "cooked_food", "fish_bait", "raw_fish", "cooked_fish", "fish_drying_charge", "dried_fish",
"vessel", "water_raw", "water_clean", "iron_ore", "iron_ingot", "charcoal",
"clay", "clay_jar", "clay_jar_charge", "dried_food", "dried_food_charge", "smelt_charge", "charcoal_charge", "knife", "axe", "iron_axe",
"pick", "spear", "firestarter", "cloak"}) do
assert(items["hearth:" .. name], name)
end
eq(nodes["hearth:torch"].light_source, 12)
eq(nodes["hearth:torch"].on_timer, nil)
for _, recipe in ipairs(crafts) do
assert(items[stack(recipe.output):get_name()])
for _, row in ipairs(recipe.recipe) do
@@ -152,16 +233,255 @@ test("standalone registrations, recipes and mapgen resolve", function()
end
end
end
eq(#biomes, 2)
eq(#decorations, 6)
assert(#biomes >= 2, "meadow and highland biomes remain available")
assert(#decorations > 0, "resource distribution remains available")
for _, name in ipairs({"meadow_grass", "wildflowers", "fern", "litter", "fungi", "deadwood",
"tree_seedling", "young_tree", "mature_tree", "dung"}) do
local def = assert(nodes["hearth:" .. name], name)
local wood = name=="young_tree" or name=="mature_tree" or name=="deadwood"
eq(def.tiles[1], wood and "hearth_log_top.png" or "hearth_" .. (name=="tree_seedling" and "sapling" or name) .. ".png")
eq(def.mesh, nil)
assert(def.groups.hearth_ecosystem and def.after_place_node and def.can_dig)
end
assert(hearth.succession and hearth.succession.forage and hearth.succession.deposit and hearth.succession.habitat)
eq(aliases.mapgen_river_water_source, "hearth:water_source")
for _, deco in ipairs(decorations) do
if deco.schematic then
eq(#deco.schematic.data, 150)
eq(#deco.schematic.data, deco.schematic.size.x * deco.schematic.size.y * deco.schematic.size.z)
for _, entry in ipairs(deco.schematic.data) do assert(nodes[entry.name]) end
else assert(nodes[deco.decoration]) end
end
end)
test("biome and diversity integration is unique, complete and ordered before seasonal variants", function()
assert(hearth.seasons and hearth.botany and hearth.diversity and hearth.biomes and hearth.succession and hearth.wildlife)
eq(#biomes, #hearth.biomes.biome_profiles)
local seen = {}
for _, biome in ipairs(biomes) do
assert(not seen[biome.name], "duplicate biome: " .. biome.name)
seen[biome.name] = true
for _, field in ipairs({"node_top", "node_filler", "node_stone", "node_water", "node_riverbed"}) do
assert(nodes[biome[field]], biome[field])
end
end
assert(seen.hearth_temperate and seen.hearth_riverbank and seen.hearth_rocky_peaks)
local index = {}
for i, name in ipairs(registrations) do index[name] = i end
eq(#hearth.biomes.species_order,10)
eq(table.concat(hearth.biomes.species_order,","),table.concat(hearth.diversity.species_order,","))
for _, species in ipairs(hearth.biomes.species_order) do
for _, part in ipairs({"log", "leaves", "planks", "seedling", "young", "mature"}) do
local name = species .. "_" .. part
assert(nodes["hearth:" .. name], name)
if part == "leaves" or part == "seedling" or part == "young" or part == "mature" then
for _, season in ipairs({"spring", "autumn", "winter"}) do
local variant = "hearth:" .. name .. "_" .. season
if species == "pine" or species == "spruce" then eq(nodes[variant], nil)
else
assert(index[variant] > index["hearth:" .. name], variant)
eq(hearth.seasons.kind(variant), name)
end
end
end
end
end
assert(index["hearth:oak_leaves"] < index["hearth:oak_seedling"])
assert(index["hearth:lichen"] < index["hearth:oak_leaves_winter"])
eq(#hearth.botany.catalogue,80)
local botany_nodes=0
for _,record in ipairs(hearth.botany.catalogue) do
assert(nodes[record.node],record.node)
if record.group~="trees" and not record.existing then
botany_nodes=botany_nodes+1
assert(index[record.node]>index["hearth:lichen"] and index[record.node]<index["hearth:oak_leaves_winter"])
local def=nodes[record.node]
eq(def.tiles[1],record.texture); eq(def.inventory_image,record.texture); eq(def.wield_image,record.texture)
end
end
eq(botany_nodes,62)
for _, name in ipairs(hearth.diversity.plants) do assert(nodes["hearth:" .. name]) end
eq(hearth.diversity.supports_species("pine", "podzol"), true)
eq(hearth.diversity.supports_species("oak", "podzol"), false)
eq(#ores, 9)
for _, ore in ipairs(ores) do
assert(nodes[ore.ore], ore.ore)
for _, host in ipairs(ore.wherein) do assert(nodes[host], host) end
end
local decoration_names = {}
for _, deco in ipairs(decorations) do
assert(not decoration_names[deco.name], "duplicate decoration: " .. deco.name)
decoration_names[deco.name] = true
assert(not (deco.name == "hearth:iron_seams"), "legacy fake floor ores retained")
end
local n, b, d, o, c = #registrations, #biomes, #decorations, #ores, #crafts
eq(hearth.biomes.register(), hearth.biomes)
hearth.diversity.register_nodes(function() error("duplicate diversity registration") end)
hearth.botany.register_nodes(function() error("duplicate botany registration") end)
eq(#registrations, n); eq(#biomes, b); eq(#decorations, d); eq(#ores, o); eq(#crafts, c)
end)
test("new minerals have exact utility recipes and copper upgrades only the flint mining tier", function()
local function recipe(output, ingredients, craft_type)
for _, def in ipairs(crafts) do
local flat = {}
for _, row in ipairs(def.recipe) do
for _, value in ipairs(type(row) == "table" and row or {row}) do
if value ~= "" then flat[#flat + 1] = value end
end
end
if def.output == output and def.type == craft_type and table.concat(flat, ",") == table.concat(ingredients, ",") then return true end
end
return false
end
assert(recipe("hearth:copper_charge", {"hearth:copper_ore", "hearth:charcoal"}, "shapeless"))
assert(recipe("hearth:copper_pick", {"hearth:copper_ingot", "hearth:copper_ingot", "hearth:copper_ingot", "hearth:sticks", "hearth:sticks"}))
assert(recipe("hearth:window", {"hearth:quartz", "hearth:quartz", "hearth:quartz"}, "shapeless"))
assert(recipe("hearth:window", {"hearth:planks", "hearth:reeds", "hearth:planks", "hearth:planks", "hearth:reeds", "hearth:planks"}))
for _, entry in ipairs({{"quartz_vein", "quartz"}, {"copper_ore_vein", "copper_ore"}, {"coal_ore_vein", "coal"}}) do
eq(nodes["hearth:" .. entry[1]].drop, "hearth:" .. entry[2] .. " 2")
end
local flint, copper = tools["hearth:pick"].tool_capabilities, tools["hearth:copper_pick"].tool_capabilities
assert(copper.groupcaps.cracky.times[3] < flint.groupcaps.cracky.times[3])
assert(copper.groupcaps.cracky.uses > flint.groupcaps.cracky.uses)
eq(copper.groupcaps.cracky.maxlevel, flint.groupcaps.cracky.maxlevel)
eq(copper.groupcaps.cracky.times[2], nil); eq(copper.groupcaps.choppy, nil)
eq(copper.damage_groups.fleshy, flint.damage_groups.fleshy)
eq(tools["hearth:copper_pick"].inventory_image, "hearth_item_copper_pick.png")
end)
test("all species and rocks keep single-item products and pair-only mass-preserving conversions", function()
local function exact(output, inputs)
local matches=0
for _, def in ipairs(crafts) do
if def.type=="shapeless" and def.output==output and #def.recipe==#inputs then
local same=true
for i, input in ipairs(inputs) do if def.recipe[i]~=input then same=false end end
if same then matches=matches+1 end
end
end
eq(matches,1)
end
for _, species in ipairs(hearth.biomes.species_order) do
local log, planks="hearth:"..species.."_log", "hearth:"..species.."_planks"
exact(planks.." 4", {log})
exact("hearth:log 2", {log,log})
exact("hearth:planks", {planks})
end
for _, rock in ipairs(hearth.biomes.rocks) do
local raw="hearth:"..rock
exact(raw.."_bricks 4", {raw})
exact("hearth:stone 2", {raw,raw})
end
exact("hearth:planks 4", {"hearth:log"})
exact("hearth:sticks 4", {"hearth:planks"})
end)
test("all 78 registered recipes close uniquely with exact output names and counts", function()
-- Exact-name registrations only. Compare shaped bounding boxes, and compare
-- ingredient multisets whenever either recipe is shapeless. This catches
-- single-input collisions even across craft types without pretending to be
-- the native resolver (the native smoke still calls get_craft_result).
eq(#crafts,78)
local signatures, outputs={},{}
for i, def in ipairs(crafts) do
assert(def.type==nil or def.type=="shapeless", "unsupported craft type")
local flat, rows, width={}, {}, 0
if def.type=="shapeless" then rows={def.recipe}
else rows=def.recipe end
for _, row in ipairs(rows) do width=math.max(width,#row) end
local xmin,ymin,xmax,ymax=width+1,#rows+1,0,0
for y, row in ipairs(rows) do for x, input in ipairs(row) do
if input~="" then
assert(items[input], "unknown or non-exact ingredient: "..input)
flat[#flat+1]=input
xmin,ymin=math.min(xmin,x),math.min(ymin,y)
xmax,ymax=math.max(xmax,x),math.max(ymax,y)
end
end end
assert(#flat>0 and #flat<=9)
table.sort(flat)
local grid={}
for y=ymin,ymax do for x=xmin,xmax do grid[#grid+1]=rows[y][x] or "" end end
local result=stack(def.output)
assert(items[result:get_name()] and result:get_count()>0)
outputs[result:get_name()]=true
signatures[i]={bag=table.concat(flat,","), shape=(xmax-xmin+1)..":"..table.concat(grid,","),
shapeless=def.type=="shapeless", name=result:get_name(), count=result:get_count()}
end
local output_count=0; for _ in pairs(outputs) do output_count=output_count+1 end
eq(output_count,52)
for i, expected in ipairs(signatures) do
local matches=0
for j, candidate in ipairs(signatures) do
local same=expected.bag==candidate.bag and
(expected.shapeless or candidate.shapeless or expected.shape==candidate.shape)
if same then
eq(candidate.name,expected.name); eq(candidate.count,expected.count)
assert(i==j, "duplicate recipe for "..expected.name)
matches=matches+1
end
end
eq(matches,1)
end
end)
test("legacy seasonal variants retain callbacks and cube geometry without mesh atlases", function()
local seasons = hearth.seasons
for _, kind in ipairs({"tree_seedling", "young_tree", "mature_tree", "leaves"}) do
local base = nodes["hearth:"..kind]
for _, season in ipairs({"spring", "autumn", "winter"}) do
local variant = assert(nodes["hearth:"..kind.."_"..season])
eq(seasons.kind("hearth:"..kind.."_"..season), kind)
eq(variant.can_dig, base.can_dig)
eq(variant.after_place_node, base.after_place_node)
eq(variant.drop, base.drop)
eq(variant.collision_box, base.collision_box)
eq(variant.mesh,nil)
if kind=="young_tree" or kind=="mature_tree" then
eq(variant.drawtype,"normal"); eq(variant.tiles[1],"hearth_log_top.png")
eq(variant.tiles[2],"hearth_log_top.png"); eq(variant.tiles[3],"hearth_log_side.png")
elseif kind=="tree_seedling" then eq(variant.tiles[1],"hearth_sapling.png")
else eq(variant.tiles[1],"hearth_leaves_"..season..".png")
end
end
end
for _, kind in ipairs({"spring_greens", "autumn_mushrooms"}) do
local base = assert(nodes["hearth:"..kind.."_patch"])
assert(base.on_rightclick and base.on_dig and not base.buildable_to)
eq(base.drop, "")
eq(base.tiles[1], "hearth_"..kind.."_dormant.png")
eq(items["hearth:"..kind].inventory_image, "hearth_item_"..kind..".png")
end
assert(items["hearth:spring_greens"].on_use)
eq(items["hearth:autumn_mushrooms"].on_use,nil)
end)
test("player placement marks even natural-looking terrain and ecological plants", function()
for _, name in ipairs({"grass", "soil", "meadow_grass", "mature_tree"}) do
local p = pos(2, 1)
nodes["hearth:" .. name].after_place_node(p, player)
eq(core.get_meta(p):get_string("player_placed"), "1")
end
end)
test("harvested crop soil remains marked farmland instead of native recruitment ground", function()
local p = pos(2, 1)
put(p, "hearth:crop_4")
hearth.ecology.harvest_crop(p, player)
eq(core.get_node(p).name, "hearth:soil")
eq(core.get_meta(p):get_string("player_placed"), "1")
end)
test("seasonal forage uses normal reach and protection authorization", function()
local original_climate=hearth.climate_at
hearth.climate_at=function() return {season="Spring",temperature=15} end
local p=pos(2,1)
put(p,"hearth:spring_greens_patch_spring")
local pick=nodes[core.get_node(p).name].on_rightclick
player.position=pos(20,1); pick(p,nil,player)
eq(#inventory.contents,0)
player.position=pos(); protected=true; pick(p,nil,player)
eq(#inventory.contents,0)
protected=false; pick(p,nil,player); eq(#inventory.contents,1)
eq(inventory.contents[1],"hearth:spring_greens")
pick(p,nil,player); eq(#inventory.contents,1)
state.food=50
items["hearth:spring_greens"].on_use(stack("hearth:spring_greens"),player)
eq(state.food,62)
hearth.climate_at=original_climate
end)
test("hand gathers soil and shrubs but cannot harvest trees or stone", function()
local caps = items[":"].tool_capabilities.groupcaps
assert(caps.crumbly and caps.snappy)
@@ -170,14 +490,86 @@ test("hand gathers soil and shrubs but cannot harvest trees or stone", function(
assert(items["hearth:axe"].tool_capabilities.groupcaps.choppy)
assert(items["hearth:pick"].tool_capabilities.groupcaps.cracky)
end)
test("fire consumes exactly one fuel and retains metadata through swaps", function()
test("fallen logs are axe-only ordinary timber cubes; loose sticks remain hand-gatherable", function()
local def = nodes["hearth:deadwood"]
eq(def.description, "Fallen log (axe; yields timber)")
eq(def.groups.snappy, nil); eq(def.groups.choppy, 2); eq(def.groups.tree,1)
eq(def.drop, "hearth:log")
eq(def.can_dig, hearth.construction.can_dig)
eq(def.on_dig, nil); eq(def.on_rightclick, nil)
eq(def.mesh, nil); eq(def.walkable, true); eq(def.drawtype,"normal"); eq(def.paramtype2,"facedir")
eq(def.selection_box,nil); eq(def.collision_box,nil); eq(def.node_box,nil)
eq(def.tiles[1],"hearth_log_top.png"); eq(def.tiles[2],def.tiles[1]); eq(def.tiles[3],"hearth_log_side.png")
eq(nodes["hearth:sticks"].groups.dig_immediate,3)
for _, kind in ipairs({"log", "young_tree", "mature_tree", "oak_log", "birch_log", "pine_log", "willow_log", "alder_log", "stone"}) do
eq(nodes["hearth:" .. kind].groups.snappy, nil)
end
end)
test("fallen log digging retains reach, claims, external protection and construction support checks", function()
local p = pos()
put(p, "hearth:deadwood")
local def = nodes["hearth:deadwood"]
eq(def.can_dig(p, player), true)
protected = true; eq(def.can_dig(p, player), false)
protected, claim = false, false; eq(def.can_dig(p, player), false)
claim, player.position = true, pos(7); eq(def.can_dig(p, player), false)
player.position = pos()
put(pos(0, 1), "hearth:wall")
eq(def.can_dig(p, player), false)
put(pos(0, 1), "air"); world[key(pos(2, 1))] = false
eq(def.can_dig(p, player), false)
eq(core.get_node(p).name, "hearth:deadwood")
eq(#inventory.contents, 0)
end)
test("deadwood migration rotates only legacy logs once and preserves provenance and timers", function()
local loader
for _,lbm in ipairs(lbms) do if lbm.name=="hearth:block_deadwood" then assert(not loader); loader=lbm end end
assert(loader and loader.run_at_every_load and #loader.nodenames==1)
eq(loader.nodenames[1],"hearth:deadwood")
local p=pos(2,1); put(p,"hearth:deadwood")
world[key(p)].param1=11
local meta=core.get_meta(p)
meta:set_string("owner","survivor"); meta:set_string("player_placed","1")
meta:set_string("succ_natural","1"); meta:set_string("succ_age","345")
core.get_node_timer(p):start(77)
loader.action(p)
eq(core.get_node(p).name,"hearth:deadwood"); eq(core.get_node(p).param2,12); eq(core.get_node(p).param1,11)
eq(meta:get_string("owner"),"survivor"); eq(meta:get_string("player_placed"),"1")
eq(meta:get_string("succ_natural"),"1"); eq(meta:get_string("succ_age"),"345")
eq(meta:get_string("succ_block_deadwood"),"1"); eq(core.get_node_timer(p).timeout,77)
world[key(p)].param2=0; loader.action(p); eq(core.get_node(p).param2,0) -- once per node
for _,facedir in ipairs({4,8,12,16}) do
metas[key(p)]=nil; put(p,"hearth:deadwood"); world[key(p)].param2=facedir
loader.action(p); eq(core.get_node(p).param2,facedir)
end
for _,name in ipairs({"air","hearth:log","hearth:oak_log","hearth:water_source","ignore"}) do
metas[key(p)]=nil; put(p,name); loader.action(p,{name="hearth:deadwood"})
eq(core.get_node(p).name,name); eq(core.get_node(p).param2,2); eq(metas[key(p)],nil)
end
put(p,"hearth:deadwood"); metas[key(p)]=nil; protected=true; loader.action(p)
eq(core.get_node(p).param2,2); eq(metas[key(p)],nil)
world[key(p)]=false; protected=false; loader.action(p); eq(world[key(p)],false)
end)
test("fuel stays cold until a reusable firestarter ignites it", function()
fire()
core.get_meta(firepos):set_string("owner", "survivor")
eq(use("hearth:sticks 3"):get_count(), 2)
eq(deadline(), now + 45)
eq(core.get_node(firepos).name, "hearth:fire")
local starter = ignite()
eq(starter.wear, math.ceil(65535 / 20))
eq(sounds[1].name, "hearth_fire")
eq(core.get_node(firepos).name, "hearth:fire_lit")
eq(core.get_node(firepos).param2, 2)
eq(core.get_meta(firepos):get_string("owner"), "survivor")
local second = pos(2)
put(second, "hearth:fire")
nodes["hearth:fire"].on_construct(second)
nodes["hearth:fire"].on_rightclick(second, core.get_node(second), player, stack("hearth:sticks"))
items["hearth:firestarter"].on_use(starter, player, point(second, pos(2, 1)))
eq(starter.wear, math.ceil(65535 / 20) * 2)
eq(core.get_node(second).name, "hearth:fire_lit")
eq(#sounds, 2)
eq(use("hearth:log"):get_count(), 0)
eq(deadline(), now + 225)
end)
@@ -189,39 +581,58 @@ test("full hearth rejects fuel rather than granting or wasting it", function()
eq(use("hearth:sticks"):get_count(), 0)
eq(use("hearth:sticks"):get_count(), 1)
eq(deadline(), now + 585)
eq(core.get_node(firepos).name, "hearth:fire")
end)
test("timer and reload expire offline fuel without resetting the clock", function()
fire()
use("hearth:sticks")
ignite()
now = now + 10
nodes["hearth:fire_lit"].on_timer(firepos, 999)
eq(deadline(), 100045)
eq(deadline(), now + 35)
now = now + 1000
lbms[1].action(firepos)
local fire_lbm
for _, lbm in ipairs(lbms) do if lbm.name == "hearth:expire_fire" then fire_lbm = lbm end end
assert(fire_lbm, "Missing hearth restart reconciler")
fire_lbm.action(firepos)
eq(core.get_node(firepos).name, "hearth:fire")
eq(deadline(), 0)
eq(core.get_node_timer(firepos).timeout, nil)
eq(use("hearth:raw_food"):get_name(), "hearth:raw_food")
end)
test("single food cooks in hand and debits fuel", function()
test("food cooks for six real seconds after reserving the heat cost", function()
fire()
use("hearth:sticks")
eq(use("hearth:raw_food"):get_name(), "hearth:cooked_food")
ignite()
eq(use("hearth:raw_food"):get_count(), 0)
eq(core.get_meta(firepos):get_string("job_kind"), "cook")
eq(nodes["hearth:fire_lit"].can_dig(firepos, player), false)
eq(deadline(), now + 25)
eq(use("hearth:raw_food"):get_name(), "hearth:cooked_food")
eq(deadline(), now + 5)
eq(use("hearth:raw_food"):get_name(), "hearth:raw_food")
end)
test("full inventory does not destroy cooking input or fuel", function()
fire()
use("hearth:sticks")
inventory.room = false
eq(use("hearth:raw_food 4"):get_count(), 4)
eq(deadline(), now + 45)
inventory.room = true
eq(use("hearth:raw_food 4"):get_count(), 3)
advance(5)
eq(core.get_meta(firepos):get_string("job_kind"), "cook")
eq(deadline(), now + 20)
advance(1)
eq(core.get_meta(firepos):get_string("job_result"), "hearth:cooked_food")
eq(deadline(), now + 19)
use("")
eq(inventory.contents[1], "hearth:cooked_food")
eq(deadline(), now + 25)
eq(core.get_meta(firepos):get_string("job_result"), "")
eq(nodes["hearth:fire_lit"].can_dig(firepos, player), true)
end)
test("full inventory preserves completed output for a later safe claim", function()
fire()
use("hearth:sticks")
ignite()
use("hearth:raw_food 4")
advance(6)
inventory.room = false
use("")
eq(core.get_meta(firepos):get_string("job_result"), "hearth:cooked_food")
eq(deadline(), now + 19)
inventory.room = true
use("")
eq(inventory.contents[1], "hearth:cooked_food")
eq(core.get_meta(firepos):get_string("job_result"), "")
end)
test("protected, claim-denied and distant fire interactions change nothing", function()
fire()
@@ -255,9 +666,13 @@ test("boiling requires fuel and drinking returns the carved cup only when thirst
fire()
eq(use("hearth:water_raw"):get_name(), "hearth:water_raw")
use("hearth:sticks")
local water = use("hearth:water_raw")
eq(water:get_name(), "hearth:water_clean")
eq(deadline(), now + 35)
ignite()
use("hearth:water_raw")
eq(core.get_meta(firepos):get_string("job_kind"), "boil")
advance(4)
eq(core.get_meta(firepos):get_string("job_result"), "hearth:water_clean")
eq(deadline(), now + 31)
local water = stack("hearth:water_clean")
local drink = items["hearth:water_clean"].on_use
drink(water, player)
eq(water:get_name(), "hearth:water_clean")
@@ -322,6 +737,7 @@ test("node-item rightclick feeds fire even when the space above is occupied", fu
nodes["hearth:sticks"].on_place(s, player, point(firepos, pos(0, 1)))
eq(s:get_count(), 1)
eq(deadline(), now + 45)
eq(core.get_node(firepos).name, "hearth:fire")
end)
test("construction height is limited to sixteen blocks above an anchor", function()
@@ -345,9 +761,13 @@ test("water primary-use boiling obeys claim and distance checks", function()
items["hearth:water_raw"].on_use(s, player, pointed)
eq(s:get_name(), "hearth:water_raw")
player.position = pos()
ignite()
items["hearth:water_raw"].on_use(s, player, pointed)
eq(s:get_name(), "hearth:water_clean")
eq(deadline(), now + 35)
eq(s:get_name(), "")
eq(core.get_meta(firepos):get_string("job_kind"), "boil")
advance(4)
eq(core.get_meta(firepos):get_string("job_result"), "hearth:water_clean")
eq(deadline(), now + 31)
end)
test("all content nodes enforce claim-aware digging and placement", function()
put(pos(0, -1), "hearth:stone")
@@ -361,9 +781,291 @@ test("all content nodes enforce claim-aware digging and placement", function()
end
end
end)
test("nested procedural fill texture arguments escape colons", function()
assert(nodes["hearth:grass"].tiles[3]:find("[fill\\:16x16\\:", 1, true))
assert(nodes["hearth:berry_bush"].tiles[1]:find("[fill\\:16x16\\:", 1, true))
test("every registration uses the named art manifest with no generic swatches", function()
local world_names = {
"stone", "soil", "grass_top", "grass_side", "sand", "log_top", "log_side", "leaves",
"planks", "wall", "roof", "thatch", "foundation", "frame", "stairs", "door", "window",
"vent", "storage", "pantry", "reeds", "berry_bush", "berry_bush_bare", "sapling",
"grain_patch", "crop_1", "crop_2", "crop_3", "crop_4", "clay_bank", "iron_ore_vein",
"water", "fire", "fire_lit", "torch", "sticks", "loose_stone", "bedroll", "drying_rack",
"fish_trap", "weir_stone", "meadow_grass", "wildflowers", "fern", "litter", "fungi",
"deadwood", "tree_seedling", "young_tree", "mature_tree", "dung",
"tree_atlas", "tree_atlas_spring", "tree_atlas_autumn", "tree_atlas_winter",
"leaves_spring", "leaves_autumn", "leaves_winter",
"meadow_grass_dormant", "fern_dormant", "wildflowers_dormant",
"spring_greens", "spring_greens_dormant", "autumn_mushrooms", "autumn_mushrooms_dormant",
}
local item_names = {
"berries", "grain", "grain_seed", "raw_food", "cooked_food", "fish_bait", "raw_fish",
"cooked_fish", "fish_drying_charge", "dried_fish", "iron_ore", "iron_ingot", "charcoal",
"charcoal_charge", "smelt_charge", "clay", "clay_jar", "clay_jar_charge", "dried_food",
"dried_food_charge", "cloak", "vessel", "water_raw", "water_clean", "firestarter", "iron_axe",
"spring_greens", "autumn_mushrooms",
"quartz", "copper_ore", "coal", "copper_ingot", "copper_charge", "copper_pick",
}
local manifest = {}
for _, name in ipairs(world_names) do manifest["hearth_" .. name .. ".png"] = true end
for _, name in ipairs({"granite", "basalt", "limestone", "slate", "sandstone"}) do
manifest["hearth_" .. name .. ".png"] = true
manifest["hearth_" .. name .. "_bricks.png"] = true
end
for _, name in ipairs({"forest_floor", "podzol", "alluvium", "peat", "alpine_turf", "gravel", "scree", "snow",
"quartz_vein", "copper_ore_vein", "coal_ore_vein"}) do manifest["hearth_" .. name .. ".png"] = true end
for _, species in ipairs(hearth.biomes.species_order) do
for _, part in ipairs({"log_top", "log_side", "planks", "leaves", "atlas"}) do
manifest["hearth_" .. species .. "_" .. part .. ".png"] = true
end
if species ~= "pine" and species ~= "spruce" then
for _, season in ipairs({"spring", "autumn", "winter"}) do
for _, part in ipairs({"leaves", "atlas"}) do
manifest["hearth_" .. species .. "_" .. part .. "_" .. season .. ".png"] = true
end
end
end
end
for _,record in ipairs(hearth.botany.catalogue) do
manifest[record.texture]=true
manifest["hearth_botany_" .. record.id .. "_dormant.png"]=true
end
for _, plant in ipairs({"clover", "nettle", "heather", "bluebell", "sedge", "bulrush", "moss", "lichen"}) do
manifest["hearth_" .. plant .. ".png"] = true
if plant ~= "moss" and plant ~= "lichen" then manifest["hearth_" .. plant .. "_dormant.png"] = true end
end
for _, name in ipairs(item_names) do manifest["hearth_item_" .. name .. ".png"] = true end
for _, name in ipairs({"knife", "axe", "pick", "spear"}) do
manifest["hearth_icon_" .. name .. ".png"] = true
end
local function check(image)
assert(type(image) == "string" and image ~= "", "missing registration art")
local source = image:match("^([^%^]+)")
assert(manifest[source], "unmanifested image: " .. image)
assert(not image:find("[fill", 1, true) and not image:find("^[multiply", 1, true)
and not image:find("^[lowpart", 1, true) and not image:find("^[contrast", 1, true), image)
end
for name, def in pairs(nodes) do
if name:match("^hearth:") then
assert(def.tiles and #def.tiles > 0, name)
for _, tile in ipairs(def.tiles) do check(type(tile) == "table" and tile.name or tile) end
for _, tile in ipairs(def.special_tiles or {}) do check(tile.name) end
end
end
for name, def in pairs(items) do
if name:match("^hearth:") then
if not nodes[name] then check(def.inventory_image) end
if def.inventory_image then check(def.inventory_image) end
if def.wield_image then check(def.wield_image) end
end
end
local count = 0
for name, def in pairs(craftitems) do
count = count + 1
eq(def.inventory_image, "hearth_item_" .. name:sub(8) .. ".png")
end
eq(count, #item_names - 3) -- firestarter, iron_axe and copper_pick are tools
for _, name in ipairs({"knife", "axe", "pick", "spear"}) do
eq(tools["hearth:" .. name].inventory_image, "hearth_icon_" .. name .. ".png")
end
for _, name in ipairs({"iron_axe", "firestarter", "copper_pick"}) do
eq(tools["hearth:" .. name].inventory_image, "hearth_item_" .. name .. ".png")
end
eq(#texture_calls, 1)
eq(texture_calls[1], "#c7aa83") -- compatibility swatch is exclusively the skin hand
end)
test("terrain faces, water and physical furniture use semantic materials", function()
for i, image in ipairs({"grass_top", "soil", "grass_side"}) do
eq(nodes["hearth:grass"].tiles[i], "hearth_" .. image .. ".png")
end
for i, image in ipairs({"log_top", "log_top", "log_side"}) do
eq(nodes["hearth:log"].tiles[i], "hearth_" .. image .. ".png")
end
for _, name in ipairs({"water_source", "water_flowing"}) do
local def = nodes["hearth:" .. name]
eq(def.tiles[1], "hearth_water.png^[opacity:185")
eq(#def.special_tiles, 2)
for i, tile in ipairs(def.special_tiles) do
eq(tile.name, def.tiles[1]); eq(tile.backface_culling, i == 2)
end
eq(def.use_texture_alpha, "blend")
end
local overrides = {door_open = "door", loose_stone = "stone", sticks = "log_side",
fire = "stone", fire_lit = "stone", drying_rack = "planks", fish_trap = "planks",
tree_seedling="sapling", young_tree="log_top", mature_tree="log_top", deadwood="log_top",
meadow_grass_winter="meadow_grass_dormant", fern_winter="fern_dormant",
wildflowers_autumn="wildflowers_dormant", wildflowers_winter="wildflowers_dormant",
spring_greens_patch="spring_greens_dormant", spring_greens_patch_spring="spring_greens",
autumn_mushrooms_patch="autumn_mushrooms_dormant", autumn_mushrooms_patch_autumn="autumn_mushrooms"}
for _, kind in ipairs({"tree_seedling", "young_tree", "mature_tree"}) do
for _, season in ipairs({"spring","autumn","winter"}) do overrides[kind.."_"..season]=kind=="tree_seedling" and "sapling" or "log_top" end
end
for _, species in ipairs(hearth.biomes.species_order) do
overrides[species .. "_log"] = species .. "_log_top"
for _, stage in ipairs({"seedling", "young", "mature"}) do
overrides[species .. "_" .. stage] = stage=="seedling" and "botany_"..species or species .. "_log_top"
if species ~= "pine" and species ~= "spruce" then
for _, season in ipairs({"spring", "autumn", "winter"}) do
overrides[species .. "_" .. stage .. "_" .. season] = stage=="seedling" and "botany_"..species or species.."_log_top"
end
end
end
end
for _,record in ipairs(hearth.botany.catalogue) do
if record.group~="trees" and not record.existing then
overrides[record.id]="botany_"..record.id
for _, season in ipairs({"Spring", "Summer", "Autumn", "Winter"}) do
if not hearth.botany.active(record.id,season) then
overrides[record.id .. "_" .. season:lower()]="botany_"..record.id.."_dormant"
end
end
end
end
for _, plant in ipairs({"clover", "nettle", "heather", "bluebell", "sedge", "bulrush"}) do
overrides[plant .. "_winter"] = plant .. "_dormant"
end
overrides.bluebell_summer, overrides.bluebell_autumn = "bluebell_dormant", "bluebell_dormant"
for name, def in pairs(nodes) do
local short = name:match("^hearth:(.+)$")
if short and short ~= "grass" and short ~= "log" and short ~= "bedroll"
and short ~= "water_source" and short ~= "water_flowing" then
eq(def.tiles[1], "hearth_" .. (overrides[short] or short) .. ".png")
end
if short and def.drawtype == "nodebox" then
local record=hearth.botany.get(name)
local image = record and not record.existing and record.group~="trees"
and ("hearth_" .. (overrides[short] or ("botany_"..record.id)) .. ".png")
or "hearth_" .. (short == "door_open" and "door" or short) .. ".png"
eq(def.inventory_image, image); eq(def.wield_image, image)
end
end
eq(nodes["hearth:bedroll"].tiles[1], "hearth_bedroll.png^[sheet:4x4:1,2")
eq(nodes["hearth:window"].tiles[1], "hearth_window.png")
eq(nodes["hearth:window"].use_texture_alpha, "blend")
eq(nodes["hearth:window"].paramtype, "light")
end)
test("botanical silhouettes have crisp alpha and waving without losing ecosystem art", function()
for _, name in ipairs({"leaves", "berry_bush", "berry_bush_bare", "sapling", "grain_patch",
"reeds", "crop_1", "crop_2", "crop_3", "crop_4", "meadow_grass", "wildflowers", "fern",
"litter", "fungi", "deadwood", "tree_seedling", "young_tree", "mature_tree", "dung"}) do
local def = nodes["hearth:" .. name]
local wood=name=="deadwood" or name=="young_tree" or name=="mature_tree"
eq(def.mesh,nil)
if wood then
eq(def.drawtype,"normal"); eq(def.tiles[1],"hearth_log_top.png")
eq(def.tiles[3],"hearth_log_side.png"); eq(def.use_texture_alpha,nil)
eq(def.paramtype,nil); eq(def.sunlight_propagates,false); eq(def.waving,nil)
else
eq(def.tiles[1], "hearth_" .. (name=="tree_seedling" and "sapling" or name) .. ".png")
eq(def.drawtype, name == "leaves" and "allfaces_optional" or "plantlike")
eq(def.use_texture_alpha, "clip"); eq(def.paramtype, "light")
eq(def.waving, 1)
eq(def.sunlight_propagates, true)
if name ~= "leaves" then
eq(def.inventory_image, def.tiles[1]); eq(def.wield_image, def.tiles[1])
end
end
end
eq(nodes["hearth:torch"].use_texture_alpha, "clip")
end)
test("fauna register the original species meshes at node scale", function()
for _, kind in ipairs({"hare", "fox", "deer"}) do
local def = assert(entities["hearth:" .. kind], kind)
local props = def.initial_properties
eq(props.visual, "mesh"); eq(props.mesh, "hearth_" .. kind .. ".obj")
eq(#props.textures, 1); eq(props.textures[1], "hearth_" .. kind .. ".png")
eq(props.visual_size.x, 1); eq(props.visual_size.y, 1)
eq(props.collisionbox[2], 0); eq(props.static_save, false)
end
end)
test("local art registrars do not wrap global APIs or require optional mock registries", function()
for name, value in pairs(core_before) do eq(core[name], value) end
for name, value in pairs(core) do eq(value, core_before[name]) end
eq(core.registered_items, nil); eq(core.registered_tools, nil); eq(core.registered_craftitems, nil)
end)
test("storage and pantry share Mocha styling and retain gated inventory access", function()
for _, entry in ipairs({{"storage", 3, 24, "HOMESTEAD STORAGE"}, {"pantry", 6, 48, "FIRED-CLAY PANTRY"}}) do
local p, def = pos(4, 2, 1), nodes["hearth:" .. entry[1]]
def.on_construct(p)
eq(core.get_meta(p):get_inventory().size, entry[3])
def.on_rightclick(p, {name = "hearth:" .. entry[1]}, player)
local form = formspecs[#formspecs]
eq(form.form, "hearth:storage")
for _, fragment in ipairs({"bgcolor[#1e1e2e;true]", "box[0.25,0.2;10.5,0.55;#313244]",
"style_type[button;bgcolor=#313244;textcolor=#cdd6f4;border=false]",
"style[close;bgcolor=#cba6f7;textcolor=#1e1e2e]",
"listcolors[#313244;#45475a;#cba6f7;#181825;#cdd6f4]", entry[4],
"list[nodemeta;4,2,1;main;0.5,1;8," .. entry[2] .. ";]", "button_exit[",
"listring[nodemeta;4,2,1;main]listring[current_player;main]"}) do
assert(form.spec:find(fragment, 1, true), fragment)
end
claim_owner, member_role = "homesteader", "storekeeper"
eq(def.allow_metadata_inventory_put(p, "main", 1, stack("hearth:grain 3"), player), 3)
eq(def.allow_metadata_inventory_take(p, "main", 1, stack("hearth:grain 2"), player), 2)
eq(def.allow_metadata_inventory_move(p, "main", 1, "main", 2, 3, player), 3)
eq(def.can_dig(p, player), false)
member_role = nil
local count = #formspecs
def.on_rightclick(p, {name = "hearth:" .. entry[1]}, player)
eq(#formspecs, count)
eq(def.allow_metadata_inventory_put(p, "main", 1, stack("hearth:grain"), player), 0)
eq(def.allow_metadata_inventory_take(p, "main", 1, stack("hearth:grain"), player), 0)
eq(def.allow_metadata_inventory_move(p, "main", 1, "main", 2, 1, player), 0)
claim_owner, member_role = "survivor", "owner"
end
end)
test("doors toggle as passable openings only for household-use permission", function()
local doorpos = pos()
put(doorpos, "hearth:door")
nodes["hearth:door"].on_rightclick(doorpos, core.get_node(doorpos), player)
eq(core.get_node(doorpos).name, "hearth:door_open")
nodes["hearth:door_open"].on_rightclick(doorpos, core.get_node(doorpos), player)
eq(core.get_node(doorpos).name, "hearth:door")
claim_owner, member_role = "homesteader", "storekeeper"
nodes["hearth:door"].on_rightclick(doorpos, core.get_node(doorpos), player)
eq(core.get_node(doorpos).name, "hearth:door")
end)
test("storage is claim-bound, form-gated, role-checked, and empty before removal", function()
local ground, chest = pos(0, -1, 0), pos()
put(ground, "hearth:stone")
local stack_item = stack("hearth:storage")
items["hearth:storage"].on_place(stack_item, player, point(ground, chest))
eq(stack_item:get_count(), 0)
local def = nodes["hearth:storage"]
local inv = core.get_meta(chest):get_inventory()
eq(inv.size, 24)
claim_owner, member_role = "homesteader", "storekeeper"
def.on_rightclick(chest, core.get_node(chest), player)
eq(formspecs[#formspecs].form, "hearth:storage")
eq(def.allow_metadata_inventory_put(chest, "main", 1, stack("hearth:planks 3"), player), 3)
eq(def.can_dig(chest, player), false)
member_role = nil
local form_count = #formspecs
def.on_rightclick(chest, core.get_node(chest), player)
eq(#formspecs, form_count)
claim_owner = "new-homesteader"
eq(def.allow_metadata_inventory_take(chest, "main", 1, stack("hearth:planks"), player), 0)
eq(def.can_dig(chest, player), false)
claim_owner = "homesteader"
claim_owner, member_role = "homesteader", "resident"
inv.items[1] = "hearth:planks"
eq(def.can_dig(chest, player), false)
inv.items = {}
eq(def.can_dig(chest, player), true)
end)
test("drying racks require a shared claim and cloaks toggle persistent player protection", function()
local ground, rack = pos(0, -1, 0), pos()
put(ground, "hearth:stone")
claim_owner, member_role = "homesteader", "storekeeper"
local stack_item = stack("hearth:drying_rack")
items["hearth:drying_rack"].on_place(stack_item, player, point(ground, rack))
eq(stack_item:get_count(), 1)
member_role = "resident"
items["hearth:drying_rack"].on_place(stack_item, player, point(ground, rack))
eq(stack_item:get_count(), 0)
local cloak = stack("hearth:cloak")
items["hearth:cloak"].on_use(cloak, player)
eq(player_meta:get_int("hearth:cloak"), 1)
eq(cloak:get_count(), 1)
items["hearth:cloak"].on_use(cloak, player)
eq(player_meta:get_int("hearth:cloak"), 0)
end)
os.time = real_time
+132
View File
@@ -0,0 +1,132 @@
-- Native headless M1 preflight, staged ONLY in a new disposable course world.
-- No fake players, no command privileges granted, no exported generator calls.
-- Enrollment/registration is eligibility, NOT proof of consent or generation.
local C, H = core, hearth
local token = C.settings:get("hearth_smoke_token")
local nonce = C.settings:get("hearth_m1_course_nonce")
assert(C.settings:get("hearth_launcher_mode") == "course", "Invalid course probe mode")
assert(type(token) == "string" and #token == 36
and token:match("^%x%x%x%x%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x$")
and token == nonce, "Missing/mismatched launcher course token")
local finished = false
local LOW, HIGH = {x = 0, y = 48, z = 0}, {x = 90, y = 58, z = 40}
local no_run = "No course run in this session. Use /course."
local function finish(ok, message)
if finished then return end
finished = true
if ok then C.log("action", "LAST_HEARTH_COURSE_PASS " .. token)
else C.log("error", "LAST_HEARTH_COURSE_FAIL: " .. tostring(message)) end
C.request_shutdown("Last Hearth course preflight complete", false, 0)
end
local function guarded(fn)
if finished then return end
local ok, message = pcall(fn)
if not ok then finish(false, message) end
end
local function registry()
assert(C.settings:get("hearth_environment") == "staging" and not C.is_singleplayer())
assert(C.settings:get_bool("hearth_m1_course") == true, "Course flag disabled")
assert(C.settings:get_bool("hearth_m1_course_snag") ~= true, "Unexpected optional snag")
assert(C.settings:get("max_users") == "0" and #C.get_connected_players() == 0)
assert(C.settings:get("bind_address") == "127.0.0.1" and C.settings:get_bool("server_announce") == false)
assert(C.settings:get_bool("enable_pvp") == false and C.settings:get_bool("secure.enable_security") == true)
assert(C.settings:get("hearth_preview_date") == "1782043200"
and C.settings:get("hearth_preview_weather") == "clear", "Course must use summer fixture")
assert(tonumber(C.settings:get("hearth_latitude")) == 45 and tonumber(C.settings:get("hearth_longitude")) == 0)
local world = C.get_worldpath():gsub("\\", "/"):gsub("/+$", "")
assert(world:match("([^/]+)$") == "course", "Not a disposable course world")
-- A bounded read of the fixed launcher-owned filename; never mint a marker.
local marker = assert(io.open(world .. "/.hearth-m1-course-nonce", "rb"), "Course marker missing")
local bytes = marker:read(65); marker:close()
assert(bytes == nonce or bytes == nonce .. "\n", "Course file nonce mismatch")
assert(type(H) == "table" and H.storage:get_string("m1_course_world_nonce") == nonce,
"Real course did not enroll before normal persistence initialization")
assert(next(H.players) == nil and next(H.sessions) == nil, "Headless course must not invent player sessions")
for module, methods in pairs({movement = {"step", "delta"}, combat = {"valid_attack", "valid_guard", "sample"},
construction = {"preview"}, course = {"report"}}) do
assert(type(H[module]) == "table", "Missing wired module: " .. module)
for _, method in ipairs(methods) do assert(type(H[module][method]) == "function", module .. "." .. method) end
end
for _, method in ipairs({"feedback", "feedback_step", "feedback_sound", "environment", "player_join", "clock_update"}) do
assert(type(H[method]) == "function", "Missing wired API: " .. method)
end
assert(H.course.report("") == no_run, "Enrollment incorrectly created a course run")
local summer = H.model.solar(1782043200, 45, 0)
assert(H.clock and H.clock.date == summer.date and H.clock.season == "Summer"
and H.clock.timeofday == summer.timeofday and H.clock.elevation == summer.elevation
and H.weather == "clear", "Real clock does not match the fixed summer fixture")
local slab = assert(C.registered_nodes["hearth:m1_course_half_step"], "Enrolled course half step not registered")
assert(slab.drawtype == "nodebox" and slab.walkable == true and slab.drop == "")
assert(slab.groups.not_in_creative_inventory == 1 and slab.node_box.type == "fixed")
local bounds = slab.node_box.fixed
assert(#bounds == 6 and bounds[1] == -0.5 and bounds[2] == -0.5 and bounds[3] == -0.5
and bounds[4] == 0.5 and bounds[5] == 0 and bounds[6] == 0.5, "Half step bounds mismatch")
for _, name in ipairs({"stone", "planks", "wall", "roof", "water_source", "log", "loose_stone"}) do
assert(C.registered_nodes["hearth:" .. name], "Missing course material: " .. name)
end
for _, name in ipairs({"axe", "knife", "spear", "berries", "water_clean"}) do
assert(C.registered_items["hearth:" .. name], "Missing course kit item: " .. name)
end
local sparring = assert(C.registered_entities["hearth:sparring"]).initial_properties
assert(sparring.visual == "mesh" and sparring.mesh == "hearth_person.b3d"
and sparring.textures[1] == "hearth_person.png" and sparring.static_save == false)
assert(C.registered_entities["hearth:practice"].initial_properties.visual == "cube")
for _, name in ipairs({"course", "practice", "spar", "comfort"}) do
assert(type(assert(C.registered_chatcommands[name], "Missing M1 command: " .. name).func) == "function")
end
end
local function terrain()
local result = {}
for x = LOW.x, HIGH.x do for z = LOW.z, HIGH.z do for y = LOW.y, HIGH.y do
local node = C.get_node_or_nil({x = x, y = y, z = z})
assert(node and node.name ~= "ignore", "Course volume was not loaded for observation")
assert(node.name ~= "hearth:m1_course_half_step", "Half steps generated without live consent")
result[#result + 1] = node.name .. ":" .. node.param1 .. ":" .. node.param2
end end end
assert(#result == 41041)
return result
end
local function denied()
-- Empty name is the real nonlive console context, not a fabricated account.
-- Staging may deny at privilege or arrival checks; never bypass either gate.
assert(C.get_player_by_name("") == nil)
for _, param in ipairs({"", "retry", "report"}) do
local ok, message = C.registered_chatcommands.course.func("", param)
assert(ok == false and type(message) == "string", "Course accepted a nonlive caller")
if C.check_player_privs("", {server = true}) then
assert(message == "Join, finish arrival and be alive before opting in.", "Live-arrival gate missing")
else
assert(message:find("Course denied:", 1, true) == 1, "Staging privilege gate missing")
end
end
assert(H.course.report("") == no_run and next(H.players) == nil and next(H.sessions) == nil)
assert(H.storage:get_string("m1_course_world_nonce") == nonce, "Command changed enrollment")
end
C.after(1, function()
guarded(function()
registry()
-- Emergence loads ordinary terrain for observation, NOT course generation.
C.emerge_area(LOW, HIGH, function(_, action, remaining)
guarded(function()
assert(action ~= C.EMERGE_ERRORED and action ~= C.EMERGE_CANCELLED, "Course observation emergence failed")
if remaining ~= 0 then return end
C.after(0, function()
guarded(function()
local before = terrain()
denied()
C.after(0.5, function()
guarded(function()
denied()
local after = terrain()
for i, value in ipairs(before) do assert(after[i] == value, "Nonlive command changed observed terrain") end
C.log("action", "Last Hearth: native course enrollment/registry and nonlive consent guards passed; 41041 observed nodes unchanged. No course generation, player traversal, client FPS, rendered mesh or audible playback proven; mock course_spec owns generation checks.")
finish(true)
end)
end)
end)
end)
end)
end)
end)
end)
C.after(60, function() finish(false, "Course preflight deadline exceeded") end)
+273
View File
@@ -0,0 +1,273 @@
-- Mock-only security/geometry/telemetry checks; never starts an engine.
local target = assert(arg[1], "course.lua path required")
local total = 0
local NONCE = "12345678-1234-4234-8234-123456789abc" -- Public test fixture, not a credential.
local function test(name, fn)
local old_core, old_hearth, old_io = core, hearth, io
local ok, err = pcall(fn)
core, hearth, io = old_core, old_hearth, old_io
assert(ok, name .. ": " .. tostring(err))
total = total + 1; print("ok " .. total .. " - " .. name)
end
local function key(p) return p.x .. ":" .. p.y .. ":" .. p.z end
local function harness(options)
local h = {settings = {hearth_environment = "local", hearth_m1_course = true,
hearth_m1_course_nonce = NONCE}, storage = {}, marker = NONCE .. "\n", worldpath = "C:/isolated/course",
single = true, time = 10, commands = {}, steps = {}, players = {}, messages = {},
registered = {}, nodes = {}, writes = 0, reads = 0, emerges = 0, storage_writes = 0,
priv_checks = 0, io_reads = 0}
for k, v in pairs(options or {}) do h[k] = v end
local C = {
settings = {get = function(_, k) return h.settings[k] end,
get_bool = function(_, k) return h.settings[k] == true end},
get_us_time = function() return h.time * 1000000 end,
get_worldpath = function() return h.worldpath end,
is_singleplayer = function() return h.single end,
check_player_privs = function(name, privs)
assert(name and privs.server == true); h.priv_checks = h.priv_checks + 1
return h.privileged == true
end,
register_chatcommand = function(name, def) h.commands[name] = def end,
register_globalstep = function(fn) h.steps[#h.steps + 1] = fn end,
register_on_leaveplayer = function(fn) h.leave = fn end,
register_on_dieplayer = function(fn) h.die = fn end,
register_node = function(name, def) h.registered[name] = def end,
get_player_by_name = function(name) return h.players[name] end,
chat_send_player = function(name, msg) h.messages[#h.messages + 1] = {name, msg} end,
get_node_or_nil = function(p)
h.reads = h.reads + 1
if h.unloaded and p.x == 90 and p.y == 58 and p.z == 40 then return nil end
if h.ignore and p.x == 90 and p.y == 58 and p.z == 40 then return {name = "ignore"} end
return {name = h.nodes[key(p)] or "air"}
end,
set_node = function(p, n)
assert(p.x >= 0 and p.x <= 90 and p.y >= 48 and p.y <= 58 and p.z >= 0 and p.z <= 40)
h.writes = h.writes + 1; assert(h.writes < 45000, "Unbounded generation")
h.nodes[key(p)] = n.name
end,
emerge_area = function(a, b, cb)
assert(a.x == 0 and a.y == 48 and a.z == 0 and b.x == 90 and b.y == 58 and b.z == 40)
h.emerges = h.emerges + 1; h.callback = cb
end,
}
local H = {players = {}, sessions = {}, texture = function(color) return "texture:" .. color end,
storage = {get_string = function(_, k) return h.storage[k] or "" end,
set_string = function(_, k, v) h.storage_writes = h.storage_writes + 1; h.storage[k] = v end},
feedback = function(_, msg) h.feedback = msg end}
local fakeio = {open = function(path, mode)
assert(path == h.worldpath:gsub("\\", "/"):gsub("/+$", "") .. "/.hearth-m1-course-nonce" and mode == "rb")
if h.marker == false then return nil end
return {read = function(_, size) assert(size == 65); h.io_reads = h.io_reads + 1; return h.marker:sub(1, size) end,
close = function() h.closed = true end}
end}
function h.load()
core, hearth, io = C, H, fakeio
h.module = dofile(target); return h.module
end
function h.player(name)
local p = {name = name or "alice", hp = 20, pos = {x = 0, y = 0, z = 0}, grants = 0, teleports = 0}
function p:is_player() return true end
function p:get_player_name() return self.name end
function p:get_hp() return self.hp end
function p:get_pos() return self.pos end
function p:set_hp(n) self.hp = n end
function p:set_pos(pos) self.pos = pos; self.teleports = self.teleports + 1 end
function p:set_velocity(v) assert(v.x == 0 and v.y == 0 and v.z == 0) end
function p:get_inventory()
return {set_list = function(_, list, items)
assert(list == "main"); self.grants = self.grants + 1; self.kit = items
end}
end
h.players[p.name] = p; H.players[p.name] = {food = 13, water = 12}; H.sessions[p.name] = {}
return p, H.sessions[p.name]
end
function h.command(name, param) return h.commands.course.func(name or "alice", param or "") end
function h.complete() h.callback({x = 0, y = 0, z = 0}, 4, 0) end
function h.tick(dt)
h.time = h.time + dt; for _, step in ipairs(h.steps) do step(dt) end
end
function h.node(x, y, z) return h.nodes[key({x = x, y = y, z = z})] end
h.C, h.H = C, H
return h
end
test("default boot is inert before join and generation requires opt-in", function()
local h = harness(); h.load()
assert(h.storage.m1_course_world_nonce == NONCE and h.closed and h.io_reads == 1)
assert(h.emerges == 0 and h.writes == 0)
assert(not h.command()); assert(h.emerges == 0)
h.player(); h.tick(1); assert(h.emerges == 0 and h.writes == 0)
end)
test("disabled setting cannot enroll or generate even with matching marker", function()
local h = harness(); h.settings.hearth_m1_course = false; h.load(); h.player()
assert(not h.command() and h.storage_writes == 0 and h.io_reads == 0 and h.emerges == 0)
end)
test("production hard-denies server admins and never touches marker or storage", function()
local h = harness({privileged = true}); h.settings.hearth_environment = "production"
h.load(); h.player(); assert(not h.command())
assert(h.writes == 0 and h.storage_writes == 0 and h.io_reads == 0 and h.emerges == 0)
end)
test("invalid and dedicated-local deployments fail closed", function()
for _, env in ipairs({"typo", "local"}) do
local h = harness({single = false, privileged = true}); h.settings.hearth_environment = env
h.load(); h.player(); assert(not h.command() and h.storage_writes == 0)
end
end)
test("staging explicitly checks server privilege at command and completion", function()
local h = harness({single = false}); h.settings.hearth_environment = "staging"
h.load(); local p = h.player(); assert(not h.command() and h.priv_checks > 0)
h.privileged = true; assert(h.command()); assert(h.emerges == 1 and p.grants == 0)
h.privileged = false; h.complete(); assert(h.writes == 0 and p.grants == 0)
end)
test("missing environment defaults to production for a dedicated server", function()
local h = harness({single = false, privileged = true}); h.settings.hearth_environment = nil
h.load(); h.player(); assert(not h.command() and h.storage_writes == 0)
end)
test("settings alone cannot bless normal world names", function()
for _, path in ipairs({"C:/worlds/last_hearth", "C:/worlds/course-not-really"}) do
local h = harness({worldpath = path}); h.load(); h.player()
assert(not h.command() and h.io_reads == 0 and h.storage_writes == 0)
end
end)
test("missing, mismatched and oversized world marker files deny enrollment", function()
for _, marker in ipairs({false, "wrong", NONCE .. string.rep("x", 100)}) do
local h = harness({marker = marker}); h.load(); h.player()
assert(not h.command() and h.storage_writes == 0 and h.emerges == 0)
end
end)
test("malformed nonce fails without file access", function()
local h = harness(); h.settings.hearth_m1_course_nonce = "../course"
h.load(); h.player(); assert(not h.command() and h.io_reads == 0)
end)
test("saved spawn, claims, location or migration backups prevent first enrollment", function()
for _, k in ipairs({"spawn", "claims", "location", "m0_backup:spawn", "m0_backup:claims", "m0_backup:location"}) do
local h = harness(); h.storage[k] = "{}"; h.load(); h.player()
assert(not h.command() and h.storage_writes == 0 and h.emerges == 0)
end
end)
test("matching enrolled world may restart after normal spawn save, mismatched storage denies", function()
local h = harness(); h.storage.m1_course_world_nonce = NONCE; h.storage.spawn = "saved"
h.load(); h.player(); assert(h.command() and h.storage_writes == 0)
local other = harness(); other.storage.m1_course_world_nonce = "other"
other.load(); other.player(); assert(not other.command() and other.storage_writes == 0)
end)
test("loading after a player session exists cannot enroll an old world", function()
local h = harness(); h.player(); h.load(); assert(not h.command() and h.storage_writes == 0)
end)
test("no join kit or teleport before all emergence callbacks finish", function()
local h = harness(); h.load(); local p = h.player(); assert(h.command())
h.callback({}, 4, 1); assert(h.writes == 0 and p.grants == 0 and p.teleports == 0)
h.complete(); assert(h.writes > 41041 and p.grants == 1 and p.teleports == 1)
assert(p.pos.x == 5 and p.pos.y == 50.5 and p.pos.z == 5 and h.H.players.alice.food == 100)
end)
test("generation is single-instance across opt-ins, retries and duplicate callbacks", function()
local h = harness(); h.load(); local a, b = h.player("alice"), h.player("bob")
assert(h.command("alice") and h.command("bob") and h.emerges == 1)
h.complete(); local writes = h.writes; h.complete()
assert(a.grants == 1 and b.grants == 1 and h.writes == writes)
assert(h.command("alice", "retry")); assert(h.emerges == 1 and h.writes == writes and a.grants == 2)
assert(dofile(target) == h.module and #h.steps == 1)
end)
test("loaded preflight fails before the first write", function()
for _, mode in ipairs({"unloaded", "ignore"}) do
local h = harness(); h[mode] = true; h.load(); local p = h.player()
assert(h.command()); h.complete(); assert(h.writes == 0 and p.grants == 0)
assert(not h.command())
end
end)
test("cancelled or errored emergence does not generate or grant", function()
for action = 0, 1 do
local h = harness(); h.load(); local p = h.player(); h.command()
h.callback({}, action, 1); h.complete(); assert(h.writes == 0 and p.grants == 0)
end
end)
test("revoked settings and storage identity prevent deferred writes", function()
for _, mode in ipairs({"production", "disabled", "nonce", "storage"}) do
local h = harness(); h.load(); local p = h.player(); h.command()
if mode == "production" then h.settings.hearth_environment = "production"
elseif mode == "disabled" then h.settings.hearth_m1_course = false
elseif mode == "nonce" then h.settings.hearth_m1_course_nonce = "changed"
else h.storage.m1_course_world_nonce = "changed" end
h.complete(); assert(h.writes == 0 and p.grants == 0)
end
end)
test("disconnect/rejoin cannot inherit deferred consent or a kit", function()
local h = harness(); h.load(); local old = h.player(); h.command()
local stale_callback = h.callback
h.leave(old); local new = h.player(); h.complete()
assert(h.writes == 0 and old.grants == 0 and new.grants == 0)
assert(h.command()); stale_callback({}, 4, 0)
assert(h.writes == 0 and new.grants == 0)
h.complete(); assert(h.writes > 0 and new.grants == 1)
end)
test("death, stale sessions and pending arrival invalidate requests", function()
local h = harness(); h.load(); local p, session = h.player()
session.waiting = true; assert(not h.command()); session.waiting = false
p.hp = 0; assert(not h.command()); p.hp = 20
h.command(); h.H.sessions.alice = {}; h.complete(); assert(h.writes == 0 and p.grants == 0)
h.command(); h.die(p); h.complete(); assert(h.writes == 0)
end)
test("terrain geometry has bounded floor, half steps, doorway, pool, falls and resources", function()
local h = harness(); h.load(); h.player(); h.command(); h.complete()
assert(h.reads == 41041 and h.writes < 45000)
local half = h.registered["hearth:m1_course_half_step"]
assert(half.node_box.fixed[2] == -0.5 and half.node_box.fixed[5] == 0)
assert(h.node(11,51,5) == "hearth:m1_course_half_step" and h.node(12,51,5) == "hearth:planks")
assert(h.node(26,51,5) == "air" and h.node(26,52,5) == "air" and h.node(26,53,5) == "hearth:wall")
assert(h.node(26,51,4) == "hearth:wall" and h.node(26,51,6) == "hearth:wall")
assert(h.node(39,48,10) == "hearth:stone" and h.node(39,49,10) == "hearth:water_source")
assert(h.node(34,49,10) == "hearth:stone" and h.node(34,50,10) == "hearth:water_source")
assert(h.node(48,52,7) == "hearth:planks" and h.node(49,51,7) == "air")
assert(h.node(60,54,7) == "hearth:planks" and h.node(61,51,7) == "air")
assert(h.node(70,51,10) == "hearth:log" and h.node(70,51,12) == "hearth:loose_stone")
assert(h.node(82,50,33) == "hearth:planks" and h.node(82,51,33) == "air")
assert(h.node(29,52,9) == "air")
end)
test("optional snag fixture is explicit and bounded", function()
local h = harness(); h.settings.hearth_m1_course_snag = true
h.load(); h.player(); h.command(); h.complete(); assert(h.node(29,52,9) == "hearth:roof")
end)
test("staged kit is limited to disposable course and known content names", function()
local h = harness(); h.load(); local p = h.player(); h.command(); h.complete()
assert(#p.kit == 8 and p.kit[1] == "hearth:axe" and p.kit[2] == "hearth:knife" and p.kit[3] == "hearth:spear")
assert(p.kit[4] == "hearth:planks 64" and p.kit[5] == "hearth:wall 64" and p.kit[6] == "hearth:roof 64")
end)
test("checkpoints progress in order within five nodes, never from mere elapsed time", function()
local h = harness(); h.load(); local p = h.player(); h.command(); h.complete()
p.pos = {x = 26, y = 50.5, z = 5}; h.tick(1)
assert(h.module.report("alice"):find("checkpoints 0/10", 1, true))
p.pos = {x = 20.01, y = 51, z = 5}; h.tick(0.1)
assert(h.module.report("alice"):find("checkpoints 0/10", 1, true))
p.pos = {x = 20, y = 51, z = 5}; h.tick(0.1)
assert(h.module.report("alice"):find("checkpoints 1/10", 1, true))
local points = {{26,50.5,5},{39,49.5,10},{50,50.5,7},{62,50.5,7},{71,50.5,10},
{79,50.5,20},{82,50.5,33},{60,50.5,33},{8,50.5,33}}
for _, v in ipairs(points) do p.pos = {x=v[1],y=v[2],z=v[3]}; h.tick(0.2) end
local result = h.module.report("alice"); assert(result:find("complete",1,true) and result:find("checkpoints 10/10",1,true))
h.tick(10); assert(h.module.report("alice") == result)
end)
test("telemetry uses bounded real step samples with honest p95 labels and retry reset", function()
local h = harness(); h.load(); h.player(); h.command(); h.complete()
for _ = 1, 650 do h.tick(0.02) end
local ok, report = h.command("alice", "report")
assert(ok and report:find("p95 20.00ms (last 600 samples)",1,true))
assert(report:find("Not client FPS or CPU profiling",1,true) and report:find("p95 0.00us",1,true))
h.command("alice", "retry"); report = h.module.report("alice")
assert(report:find("0.0s; checkpoints 0/10",1,true) and report:find("last 0 samples",1,true))
for _, dt in ipairs({0/0, math.huge, -1, 0}) do h.steps[1](dt) end
assert(h.module.report("alice") == report)
end)
test("leave/death discard telemetry; inactive players get no sampling or grants", function()
local h = harness(); h.load(); local a, b = h.player("alice"), h.player("bob")
h.command(); h.complete(); h.tick(1)
assert(b.grants == 0 and h.module.report("bob"):find("No course run",1,true))
h.die(a); assert(h.module.report("alice"):find("No course run",1,true))
h.command(); h.leave(a); assert(h.module.report("alice"):find("No course run",1,true))
end)
test("session count and run duration are bounded", function()
local h = harness(); h.load()
for i = 1, 16 do h.player("p"..i); assert(h.command("p"..i)) end
h.player("overflow"); assert(not h.command("overflow")); h.complete()
h.tick(1801); assert(h.module.report("p1"):find("No course run",1,true))
assert(h.command("overflow"))
end)
print("PASS " .. total .. " course checks")
+279
View File
@@ -0,0 +1,279 @@
-- Focused Lua 5.1+ policy/registration tests; siblings also work flattened.
local target=assert(arg[1],"diversity.lua path required")
local passed=0
local api,hearth,D,season,world,fields,claims,protected,missing,lbms,writes
local p={x=0,y=1,z=0}
local function key(q) return q.x..":"..q.y..":"..q.z end
local function meta(q)
local k=key(q); fields[k]=fields[k] or {}; local f=fields[k]
return {get_string=function(_,n) return f[n] or "" end,
set_string=function(_,n,v) writes=writes+1; f[n]=tostring(v) end,
to_table=function() return {fields=f} end}
end
local function reset()
season,world,fields,claims,protected,missing,lbms,writes="Summer",{},{},{},{},{},{},0
api={registered_nodes={}}
function api.get_meta(q) return meta(q) end
function api.get_node_or_nil(q)
if missing[key(q)] then return nil end
return world[key(q)] or {name=q.y==0 and "hearth:grass" or "air"}
end
function api.swap_node(q,n) world[key(q)]=n end
function api.register_node(n,def) assert(not api.registered_nodes[n],n); api.registered_nodes[n]=def end
function api.register_lbm(def) lbms[def.name]=def end
hearth={climate_at=function() return {season=season} end,
claim_owner_at=function(q) return claims[key(q)] and "owner",claims.ambiguous end,
claim_allowed=function() error("nature must not call claim_allowed") end,
external_protected=function(q) return protected[key(q)] end}
hearth.seasons=dofile(target:gsub("[^/\\]+$","seasons.lua"))(hearth,api)
D=dofile(target)(hearth,api); hearth.diversity=D
end
local function test(label,fn)
reset(); local ok,err=pcall(fn); assert(ok,label..": "..tostring(err))
passed=passed+1; print("ok "..passed.." - "..label)
end
local function habitat(ground,light,moisture)
return {ground=ground,light=light or 15,moisture=moisture or 0.5,canopy=0,loaded=true,nutrients=2,heat=20}
end
local function register()
D.register_nodes(function(n,def) api.register_node("hearth:"..n,def) end)
for _,s in ipairs(D.species_order) do api.register_node("hearth:"..s.."_leaves",{tiles={"hearth_"..s.."_leaves.png"},groups={leaves=1},drop="hearth:sticks"}) end
hearth.seasons.register()
end
test("exact ten species and eight understory plants have cube trunks and sprite seedlings",function()
register()
assert(table.concat(D.species_order,",")=="oak,birch,pine,willow,alder,beech,aspen,rowan,hazel,spruce")
assert(#D.plants==8)
for _,s in ipairs(D.species_order) do for _,stage in ipairs({"seedling","young","mature"}) do
local k=s.."_"..stage; local def=assert(api.registered_nodes["hearth:"..k])
assert(not def.mesh and not def.selection_box and not def.collision_box)
if stage=="seedling" then
assert(def.drawtype=="plantlike" and def.tiles[1]=="hearth_botany_"..s..".png")
else
assert(def.drawtype=="normal" and def.paramtype2=="facedir" and def.walkable)
assert(def.tiles[1]=="hearth_"..s.."_log_top.png" and def.tiles[2]==def.tiles[1])
assert(def.tiles[3]=="hearth_"..s.."_log_side.png" and #def.tiles==3)
assert(def.groups.tree==1 and def.groups.choppy==2 and not def.groups.snappy)
assert(def.groups.not_in_creative_inventory==1 and not def.sunlight_propagates)
end
assert(def.groups.hearth_ecosystem==1 and not def.buildable_to)
local a,b=D.tree_info(k); assert(a==s and b==stage)
assert(D.family(k)==({seedling="tree_seedling",young="young_tree",mature="mature_tree"})[stage])
if stage~="seedling" then assert(def.drop=="hearth:"..s.."_log") end
end end
for _,k in ipairs(D.plants) do
local def=assert(api.registered_nodes["hearth:"..k]); assert(def.tiles[1]=="hearth_"..k..".png" and def.drop=="")
end
assert(not D.tree_info("hearth:pine_log") and D.family("hearth:pine_leaves")=="leaves")
assert(D.family("hearth:willow_log")=="log" and not D.is_worldgen_flora("hearth:oak_leaves"))
end)
test("seasonal foliage changes never replace bark with atlases or meshes; both conifers stay evergreen",function()
register()
for _,s in ipairs(D.species_order) do
for _,stage in ipairs({"seedling","young","mature","leaves"}) do
for _,time in ipairs({"Spring","Autumn","Winter"}) do
season=time; local k=s.."_"..stage
local name=hearth.seasons.name_for(k)
assert(hearth.seasons.kind(name)==k)
if s=="pine" or s=="spruce" then
assert(name=="hearth:"..k and not api.registered_nodes[name.."_winter"])
assert(hearth.seasons.available(k))
elseif stage=="young" or stage=="mature" then
assert(name=="hearth:"..k.."_"..time:lower())
assert(api.registered_nodes[name].tiles[1]=="hearth_"..s.."_log_top.png")
assert(api.registered_nodes[name].tiles[3]=="hearth_"..s.."_log_side.png" and not api.registered_nodes[name].mesh)
assert(hearth.seasons.available(k)==(time~="Winter"))
else
local def=assert(api.registered_nodes[name])
assert(def.tiles[1]==(stage=="leaves" and "hearth_"..s.."_leaves_"..time:lower()..".png" or "hearth_botany_"..s..".png"))
assert(not def.mesh)
end
end
end
end
end)
test("all ten species can recruit deterministically on their supported habitat",function()
local found={}
for x=0,30 do for z=0,10 do
for _,g in ipairs({"grass","forest_floor","podzol","alpine_turf","alluvium","peat"}) do
for _,m in ipairs({0.4,0.6,0.9}) do
local h=habitat(g,10,m); h.canopy=g=="forest_floor" and 1 or 0
local q={x=x,y=1,z=z}; local k=D.choose_tree(q,h)
if k then
local s=D.tree_info(k); found[s]=true
assert(D.suitable(k,h) and D.choose_tree(q,h)==k)
end
end
end
end end
for _,s in ipairs(D.species_order) do assert(found[s],s) end
end)
test("tree plans share mapgen geometry with a canonical rooted identity and no world writes",function()
assert(not D.tree_plan("oak","mature")) -- missing biome integration fails closed
hearth.biomes=dofile(target:gsub("[^/\\]+$","biomes.lua"))(hearth,api,function() error("unexpected registration") end,{})
local before=writes
for _,s in ipairs(D.species_order) do for _,stage in ipairs({"young","mature"}) do
local plan=assert(D.tree_plan(s,stage)); local schematic=hearth.biomes.tree_schematic(s,false,stage)
assert(plan.size.x==schematic.size.x and plan.size.y==schematic.size.y and #plan>5)
local roots,leaves,trunks=0,0,0
local cx,cz=math.floor(plan.size.x/2),math.floor(plan.size.z/2)
for _,n in ipairs(plan) do
assert(n.name~="air" and n.param2==0)
local cell=schematic.data[1+n.x+cx+plan.size.x*(n.y+plan.size.y*(n.z+cz))]
if n.x==0 and n.y==0 and n.z==0 then
roots=roots+1; assert(n.name=="hearth:"..s.."_"..stage)
else assert(n.name==cell.name and cell.prob==255) end
if n.name:match("_leaves$") then leaves=leaves+1 else trunks=trunks+1 end
end
assert(roots==1 and leaves>=5 and trunks>=3)
plan[1].name="foreign:node"; plan.size.x=999
assert(D.tree_plan(s,stage)[1].name~="foreign:node" and D.tree_plan(s,stage).size.x<10)
end end
local generic=D.tree_plan("generic","mature"); local root
for _,n in ipairs(generic) do if n.x==0 and n.y==0 and n.z==0 then root=n.name end end
assert(root=="hearth:mature_tree" and writes==before)
assert(not D.tree_plan("unknown","mature") and not D.tree_plan("oak","seedling"))
end)
test("tree recruitment uses soils, cold podzol, pioneer gaps, woodland and river niches",function()
assert(D.choose_tree(p,habitat("podzol",15,0.4))=="pine_seedling")
assert(D.choose_tree(p,habitat("alpine_turf",15,0.35))=="pine_seedling")
local warm=habitat("podzol",15,0.4); warm.heat=60
assert(D.choose_tree(p,warm)=="birch_seedling")
assert(D.choose_tree(p,habitat("grass",15,0.3))=="birch_seedling")
local forest=habitat("forest_floor",10,0.6); forest.canopy=1
assert(D.choose_tree(p,forest)=="oak_seedling")
assert(D.choose_tree(p,habitat("alluvium",12,0.9))=="willow_seedling")
assert(D.choose_tree(p,habitat("peat",12,0.9))=="alder_seedling")
assert(not D.choose_tree(p,habitat("stone",15,0.1)))
end)
test("optional biome species policy is authoritative without requiring the biome module",function()
hearth.biomes={supports_species=function(s,ground) return s=="alder" and ground=="grass" end}
assert(not D.supports_species("oak","grass"))
assert(D.supports_species("alder","grass"))
assert(not D.choose_tree(p,habitat("podzol",15,0.4)))
end)
test("all eight understory niches recruit deterministically on the first establishment cycle",function()
local cases={clover={habitat("grass",15,0.5),{x=3,y=1,z=0},"Summer"},
nettle={habitat("grass",15,0.6),{x=6,y=1,z=0},"Summer"},
heather={habitat("podzol",15,0.4),p,"Summer"},
bluebell={habitat("forest_floor",8,0.6),p,"Spring"},
sedge={habitat("alluvium",15,0.9),p,"Summer"},
bulrush={habitat("peat",15,0.9),{x=3,y=1,z=0},"Summer"},
moss={habitat("forest_floor",8,0.7),p,"Summer"},
lichen={habitat("granite",15,0.1),p,"Summer"}}
for k,c in pairs(cases) do
season=c[3]; local recruit=D.choose_recruit(c[2],c[1],season)
assert(recruit==k,k.." got "..tostring(recruit))
assert(D.choose_recruit(c[2],c[1],season)==recruit)
assert(not D.choose_recruit(c[2],c[1],"Winter"))
end
assert(not D.suitable("clover",habitat("peat",15,1)))
assert(not D.suitable("heather",habitat("forest_floor",15,0.4)))
assert(not D.suitable("nettle",{ground="grass",light=12,moisture=0.6,nutrients=0}))
end)
test("dormancy gates herbivore food and spring bluebells while pollination is exposed",function()
register(); season="Spring"
assert(D.edible("hearth:clover") and D.edible("hearth:sedge"))
assert(not D.edible("hearth:bluebell") and D.pollinator("bluebell",season))
for _,s in ipairs({"Summer","Autumn","Winter"}) do
season=s; assert(not hearth.seasons.available("bluebell"))
assert(api.registered_nodes[hearth.seasons.name_for("bluebell")].tiles[1]=="hearth_bluebell_dormant.png")
end
assert(not D.edible("hearth:clover_winter") and not D.edible("hearth:sedge_winter"))
assert(hearth.seasons.available("moss") and hearth.seasons.available("lichen"))
assert(not D.pollinator("bluebell","Summer") and D.pollinator("heather","Autumn"))
end)
test("load bootstrap and scheduler adoption mark only known unmodified species flora",function()
register(); world[key(p)]={name="hearth:alder_seedling"}
assert(D.bootstrap(p) and meta(p):get_string("succ_natural")=="1")
local before=writes; assert(not D.bootstrap(p) and writes==before)
for _,k in ipairs({"oak_log","oak_leaves","log","leaves","wall","berry_bush"}) do
fields={}; world[key(p)]={name="hearth:"..k}; assert(not D.bootstrap(p),k)
end
fields={}; world[key(p)]={name="hearth:clover"}
lbms["hearth:diversity_natural_flora"].action(p,world[key(p)])
assert(meta(p):get_string("succ_natural")=="1")
end)
test("player markers, claims, ambiguity, support protection and unload prevent adoption",function()
register()
for _,field in ipairs({"owner","planter","player_placed","placed_by","planted_at","third_party:placed"}) do
fields={}; world[key(p)]={name="hearth:oak_mature"}; meta(p):set_string(field,"1")
local before=writes; assert(not D.bootstrap(p) and writes==before,field)
end
for _,mode in ipairs({"claim","ambiguous","protected","support","unloaded"}) do
reset(); world[key(p)]={name="hearth:clover"}
if mode=="claim" then claims[key(p)]=true end
if mode=="ambiguous" then claims.ambiguous=true end
if mode=="protected" then protected[key(p)]=true end
if mode=="support" then protected["0:0:0"]=true end
if mode=="unloaded" then missing["0:0:0"]=true end
assert(not D.bootstrap(p) and writes==0,mode)
end
reset(); register(); world[key(p)]={name="hearth:clover"}
api.registered_nodes["hearth:clover"].after_place_node(p)
assert(not D.bootstrap(p) and meta(p):get_string("player_placed")=="1")
end)
test("deadwood load adoption is idempotent and does not broaden generic flora policy",function()
register(); world[key(p)]={name="hearth:deadwood",param2=2}
local loader=lbms["hearth:diversity_natural_flora"]
local count=0
for _,n in ipairs(loader.nodenames) do if n=="hearth:deadwood" then count=count+1 end end
assert(count==1 and loader.run_at_every_load)
loader.action(p,world[key(p)])
assert(meta(p):get_string("succ_natural")=="1" and writes==1)
loader.action(p,world[key(p)]); assert(writes==1)
assert(world[key(p)].name=="hearth:deadwood" and world[key(p)].param2==2)
assert(meta(p):get_string("succ_age")=="")
for _,k in ipairs({"litter","fungi","log","oak_log","leaves","oak_leaves","planks"}) do
fields={}; world[key(p)]={name="hearth:"..k}
local before=writes; assert(not D.bootstrap(p) and writes==before,k)
end
end)
test("deadwood adoption fails closed for source and support provenance, authority, identity and unload",function()
local below={x=0,y=0,z=0}
for _,q in ipairs({p,below}) do
for _,mode in ipairs({"owner","planter","placed_by","player_placed","hearth_placed","hearth_player_placed","planted_at","third_party:placed",
"claim","ambiguous","protected","unloaded","ignore"}) do
reset(); world[key(p)]={name="hearth:deadwood"}
if mode=="claim" then claims[key(q)]=true
elseif mode=="ambiguous" then claims.ambiguous=true
elseif mode=="protected" then protected[key(q)]=true
elseif mode=="unloaded" then missing[key(q)]=true
elseif mode=="ignore" then world[key(q)]={name="ignore"}
else meta(q):set_string(mode,"1") end
local before=writes
assert(not D.bootstrap(p) and writes==before,mode.." at "..key(q))
end
end
for _,support in ipairs({"air","hearth:stone","hearth:log","hearth:water_source"}) do
reset(); world[key(p)]={name="hearth:deadwood"}; world[key(below)]={name=support}
assert(not D.bootstrap(p) and writes==0,support)
end
reset(); world[key(p)]={name="air"}
assert(not D.bootstrap(p,{name="hearth:deadwood"}) and writes==0)
end)
test("ground policy is separate from crops and lichen never needs to replace its rock",function()
for _,k in ipairs({"grass","soil","forest_floor","podzol","alluvium","peat","alpine_turf"}) do assert(D.is_ground("hearth:"..k)) end
assert(not D.is_ground("hearth:granite") and D.is_rock("hearth:granite"))
assert(not D.supports_ground("clover","hearth:granite"))
assert(D.supports_ground("lichen","hearth:granite"))
assert(not D.is_ground("hearth:crop_4") and not D.is_ground("ignore"))
end)
test("optional missing bases do not panic seasonal registration or install a scheduler",function()
local steps=0; api.register_globalstep=function() steps=steps+1 end
hearth.seasons.register()
assert(steps==0)
D.register_nodes(function(n,def) api.register_node("hearth:"..n,def) end)
hearth.seasons.register()
assert(api.registered_nodes["hearth:oak_seedling_winter"] and api.registered_nodes["hearth:oak_mature"].drawtype=="normal" and steps==0)
end)
test("bootstrap rechecks loaded source identity and explicit recruitment season wins over clock",function()
world[key(p)]={name="air"}
assert(not D.bootstrap(p,{name="hearth:clover"}) and writes==0)
world[key(p)]={name="hearth:clover"}; missing[key(p)]=true
assert(not D.bootstrap(p,{name="hearth:clover"}) and writes==0)
for _,bad in ipairs({{}, {x=0/0,y=1,z=0},{x=math.huge,y=1,z=0}}) do assert(not D.bootstrap(bad)) end
season="Winter"
assert(D.choose_recruit(p,habitat("forest_floor",8,0.6),"Spring")=="bluebell")
end)
print("LAST_HEARTH_DIVERSITY_PASS "..passed)
+192
View File
@@ -0,0 +1,192 @@
-- Bounded, deterministic M4 ecology accounting tests.
local target = assert(arg[1], "ecology.lua path required")
local now, nodes, metadata, timers = 100000, {}, {}, {}
local real_time = os.time
os.time = function() return now end
local function key(p) return p.x .. ":" .. p.y .. ":" .. p.z end
local function meta(pos)
local k = key(pos); metadata[k] = metadata[k] or {}
local fields = metadata[k]
return {
get_string = function(_, name) return fields[name] or "" end,
set_string = function(_, name, value) fields[name] = tostring(value) end,
get_int = function(_, name) return tonumber(fields[name]) or 0 end,
set_int = function(_, name, value) fields[name] = math.floor(value) end,
from_table = function(_, value) fields = value.fields or {}; metadata[k] = fields end,
}
end
local api = {
registered_nodes = {air = {buildable_to = true}, ["hearth:grass"] = {}, ["hearth:soil"] = {},
["hearth:crop_1"] = {}, ["hearth:crop_2"] = {}, ["hearth:crop_3"] = {}, ["hearth:crop_4"] = {},
["hearth:berry_bush"] = {}, ["hearth:berry_bush_bare"] = {}, ["hearth:sapling"] = {},
["hearth:log"] = {}, ["hearth:leaves"] = {}},
settings = {get = function() return "512" end},
}
function api.get_node_or_nil(pos) return nodes[key(pos)] or {name="air"} end
function api.get_node(pos) return api.get_node_or_nil(pos) end
function api.set_node(pos, node) nodes[key(pos)] = node; metadata[key(pos)] = {}; timers[key(pos)] = nil end
function api.swap_node(pos, node) nodes[key(pos)] = node end
function api.get_meta(pos) return meta(pos) end
function api.get_node_timer(pos)
local k = key(pos); timers[k] = timers[k] or {}
return {start = function(_, seconds) timers[k].timeout = seconds end,
stop = function() timers[k].timeout = nil end}
end
local permitted, inventory_room, delivered, partial, climate_temperature, season = true, true, 0, false, 0, "Summer"
hearth = {climate_at = function() return {temperature = climate_temperature, season=season} end,
claim_allowed = function() return permitted end,
notify = function(_, message) hearth.last_notice = message end}
local construction = {allowed = function() return permitted end}
hearth.seasons = dofile(target:gsub("[^/\\]+$","seasons.lua"))(hearth,api)
local ecology = dofile(target)(hearth, api, construction)
local function make_stack(text)
local name, count = (text or ""):match("^(%S+)%s*(%d*)$")
return {name = name or "", count = name and (tonumber(count) or 1) or 0,
get_count = function(self) return self.count end,
get_name = function(self) return self.name end,
is_empty = function(self) return self.count == 0 end,
take_item = function(self, amount) self.count = math.max(0, self.count - (amount or 1)) end}
end
ItemStack = make_stack
local inventory = {
room_for_item = function() return inventory_room end,
add_item = function(_, _, item)
local amount = partial and math.max(0, item:get_count() - 1) or item:get_count()
delivered = delivered + amount
return make_stack(amount == item:get_count() and "" or (item:get_name() .. " " .. (item:get_count() - amount)))
end,
}
local player = {is_player = function() return true end, get_player_name = function() return "gardener" end,
get_inventory = function() return inventory end}
local passed = 0
local function test(name, fn)
local ok, err = pcall(fn)
assert(ok, name .. ": " .. tostring(err)); passed = passed + 1
print("ok " .. passed .. " - " .. name)
end
local planted_at = 100000
test("crop climate uses historical samples, freezing pauses growth, and catch-up is capped", function()
local cold_days = 4 * 86400
assert(ecology.climate_progress(planted_at, planted_at + cold_days) == 0)
climate_temperature = 15
local warm_days = 6 * 86400
assert(ecology.crop_stage(planted_at, planted_at + warm_days) == 4)
assert(ecology.climate_progress(planted_at, planted_at + 90 * 86400)
== ecology.climate_progress(planted_at, planted_at + 30 * 86400))
assert(ecology.climate_progress("nan", planted_at + 1) == 0)
assert(ecology.climate_progress(planted_at, math.huge) == 0)
end)
test("crop stages derive from a durable planting timestamp independent of update count", function()
local p = {x=0,y=1,z=0}
nodes[key({x=0,y=0,z=0})] = {name="hearth:grass"}
nodes[key(p)] = {name="hearth:crop_1"}
meta(p):set_string("planted_at", tostring(planted_at))
local stage = ecology.crop_stage(planted_at, planted_at + 2 * 86400)
assert(stage >= 2 and stage <= 4)
local same_late_stage = ecology.crop_stage(planted_at, planted_at + 2 * 86400)
assert(stage == same_late_stage)
now = planted_at + 30 * 86400
assert(ecology.update_crop(p) == false and nodes[key(p)].name == "hearth:crop_4")
end)
test("berry regrowth is a single absolute deadline and harvest does not destroy the bush", function()
local p = {x=2,y=1,z=0}
nodes[key(p)] = {name="hearth:berry_bush"}
inventory_room = false
ecology.harvest_berries(p, player)
assert(nodes[key(p)].name == "hearth:berry_bush" and delivered == 0)
inventory_room, partial = true, false
ecology.harvest_berries(p, player)
assert(nodes[key(p)].name == "hearth:berry_bush_bare" and delivered == 3)
local ready = tonumber(meta(p):get_string("ready_at"))
assert(ready == now + 86400)
ecology.harvest_berries(p, player)
assert(delivered == 3, "bare bush must not yield repeated berries")
assert(ecology.refresh_bush(p) == true and nodes[key(p)].name == "hearth:berry_bush_bare")
now = ready
assert(ecology.refresh_bush(p) == false and nodes[key(p)].name == "hearth:berry_bush")
end)
test("partial inventory harvest cannot duplicate crops or berries", function()
local crop, bush = {x=4,y=1,z=0}, {x=5,y=1,z=0}
nodes[key(crop)] = {name="hearth:crop_4"}
nodes[key(bush)] = {name="hearth:berry_bush"}
partial, delivered = true, 0
ecology.harvest_crop(crop, player)
assert(nodes[key(crop)].name == "hearth:soil" and delivered == 3)
ecology.harvest_berries(bush, player)
assert(nodes[key(bush)].name == "hearth:berry_bush_bare" and delivered == 5)
partial = false
end)
test("immature or unauthorized crops cannot be harvested", function()
local p = {x=6,y=1,z=0}
nodes[key(p)] = {name="hearth:crop_1"}
local before = delivered
ecology.harvest_crop(p, player)
assert(nodes[key(p)].name == "hearth:crop_1" and delivered == before)
nodes[key(p)] = {name="hearth:crop_4"}
permitted = false
ecology.harvest_crop(p, player)
assert(nodes[key(p)].name == "hearth:crop_4" and delivered == before)
permitted = true
end)
test("sapling growth waits, respects claims, and writes a bounded tree once", function()
now = 500000
local p = {x=8,y=1,z=8}
nodes[key({x=8,y=0,z=8})] = {name="hearth:grass"}
nodes[key(p)] = {name="hearth:sapling"}
meta(p):set_string("planted_at", tostring(now - 3 * 86400))
meta(p):set_string("planter", "gardener")
permitted = false
assert(ecology.grow_sapling(p) == true and nodes[key(p)].name == "hearth:sapling")
permitted = true
assert(ecology.grow_sapling(p) == false and nodes[key(p)].name == "hearth:log")
assert(nodes[key({x=8,y=4,z=8})].name == "hearth:log")
assert(ecology.grow_sapling(p) == false, "grown tree must not be produced twice")
end)
test("seasonal harvest authorization, empty inventory room and rejected delivery cause no cooldown or yield", function()
local p={x=20,y=1,z=0}
nodes[key(p)]={name="hearth:spring_greens_patch_spring"}
season="Spring"; delivered=0; permitted=false
assert(not ecology.harvest_seasonal(p,player) and delivered==0)
permitted=true; inventory_room=false
assert(not ecology.harvest_seasonal(p,player) and delivered==0)
inventory_room=true; partial=true
assert(not ecology.harvest_seasonal(p,player) and delivered==0)
assert(meta(p):get_string("succ_harvest_ready_at")=="")
partial=false
end)
test("seasonal picking is one food per day without erasing ecological age or provenance", function()
local p={x=21,y=1,z=0}
nodes[key(p)]={name="hearth:spring_greens_patch_spring",param2=2}
meta(p):set_string("succ_age","1200"); meta(p):set_string("succ_natural","1")
meta(p):set_string("owner","gardener")
delivered=0; season="Spring"
assert(ecology.harvest_seasonal(p,player) and delivered==1)
local ready=tonumber(meta(p):get_string("succ_harvest_ready_at"))
assert(ready==now+86400 and not ecology.harvest_seasonal(p,player))
assert(meta(p):get_string("succ_age")=="1200" and meta(p):get_string("succ_natural")=="1")
assert(meta(p):get_string("owner")=="gardener")
season="Winter"; hearth.seasons.reconcile(p)
season="Spring"; hearth.seasons.reconcile(p)
assert(not ecology.harvest_seasonal(p,player) and delivered==1)
now=ready; assert(ecology.harvest_seasonal(p,player) and delivered==2)
assert(tonumber(meta(p):get_string("succ_harvest_ready_at"))==now+86400)
end)
test("forage availability checks the calendar even before appearance has reconciled", function()
local greens,mushrooms={x=22,y=1,z=0},{x=23,y=1,z=0}
nodes[key(greens)]={name="hearth:spring_greens_patch_spring"}
nodes[key(mushrooms)]={name="hearth:autumn_mushrooms_patch"}
delivered=0
for _,s in ipairs({"Summer","Winter","Autumn"}) do
season=s; assert(not ecology.harvest_seasonal(greens,player))
end
season="Spring"; assert(not ecology.harvest_seasonal(mushrooms,player))
season="Autumn"; assert(ecology.harvest_seasonal(mushrooms,player) and delivered==1)
-- New adapter instance models restart; its deadline remains in node metadata.
local reload=dofile(target)(hearth,api,construction)
assert(not reload.harvest_seasonal(mushrooms,player) and delivered==1)
end)
os.time = real_time
print("PASS " .. passed .. " bounded ecology tests")
+274
View File
@@ -0,0 +1,274 @@
-- Engine-independent adapter tests. Real pure model; complete isolated core mocks.
local target = assert(arg[1], "encounters.lua path required")
local inherited_combat = hearth and hearth.combat
local combat = inherited_combat or dofile(target:gsub("[^/\\]+$", "combat.lua"))
local total = 0
local function test(name, fn)
local old_core, old_hearth = core, hearth
local ok, err = pcall(fn); core, hearth = old_core, old_hearth
assert(ok, name .. ": " .. tostring(err)); total = total + 1
end
local function harness()
local h = {time = 10, entities = {}, commands = {}, steps = {}, players = {}, objects = {},
sounds = {}, messages = {}, rays = {}, scans = 0, nodes = 0, single = true}
core = {
get_us_time = function() return h.time * 1000000 end,
register_entity = function(name, def) h.entities[name] = def end,
register_chatcommand = function(name, def) h.commands[name] = def end,
register_globalstep = function(fn) h.steps[#h.steps + 1] = fn end,
get_player_by_name = function(name) return h.players[name] end,
get_connected_players = function() local out = {}; for _, p in pairs(h.players) do out[#out + 1] = p end; return out end,
is_singleplayer = function() return h.single end,
settings = {get = function(_, key) assert(key == "hearth_environment"); return h.deployment end},
check_player_privs = function(_, privs) assert(privs.server); return h.privileged == true end,
dir_to_yaw = function(v) return (math.atan2 or math.atan)(-v.x, v.z) end,
is_protected = function() return h.protected == true end,
line_of_sight = function(a, b, step)
assert(step == 0.1); h.rays[#h.rays + 1] = {a = a, b = b}
return not h.obstructed and (not h.ray_test or h.ray_test(a, b))
end,
registered_nodes = {air = {walkable = false, liquidtype = "none"}, ground = {walkable = true},
water = {walkable = false, liquidtype = "source"}, stone = {walkable = true}},
get_node_or_nil = function(pos)
h.nodes = h.nodes + 1
if h.unloaded then return nil end
if h.node_test then return h.node_test(pos) end
return {name = h.liquid and "water" or (pos.y < 0 and "ground" or "air")}
end,
get_objects_inside_radius = function(_, radius) assert(radius == 30); h.scans = h.scans + 1; return h.objects end,
sound_play = function(name, spec, ephemeral) assert(ephemeral and spec.pos); h.sounds[#h.sounds + 1] = name end,
}
hearth = {combat = combat, players = {}, sessions = {},
claim_allowed = function(pos, name) return not h.denied and (not h.claim_test or h.claim_test(pos, name)) end,
texture = function(color) return "texture:" .. color end,
feedback = function(p, msg, kind) h.messages[#h.messages + 1] = {p = p, msg = msg, kind = kind} end}
function h.player(name)
local p = {name = name or "alice", hp = 20, pos = {x = 0, y = 0, z = 0},
look = {x = 0, y = 0, z = 1}, controls = {}, item = "hearth:axe", wear = 0, damage = 0}
function p:is_player() return true end
function p:get_player_name() return self.name end
function p:get_pos() return self.pos end
function p:get_hp() return self.hp end
function p:get_look_dir() return self.look end
function p:get_properties() return {eye_height = 1.55} end
function p:get_player_control() return self.controls end
function p:get_wielded_item()
return {get_name = function() return p.item end, add_wear = function(_, n) p.wear = p.wear + n end}
end
function p:set_wielded_item(stack) assert(stack.get_name); self.wield_updates = (self.wield_updates or 0) + 1 end
function p:set_hp(n, reason) assert(reason.type == "set_hp" and reason.from == "mod"); self.hp = n; self.damage = self.damage + 1 end
h.players[p.name] = p; hearth.players[p.name] = {stamina = 100}; hearth.sessions[p.name] = {}
return p, hearth.players[p.name], hearth.sessions[p.name]
end
function h.entity(name, pos)
local def = assert(h.entities[name]); local e = setmetatable({name = name}, {__index = def})
local o = {pos = pos or {x = 0, y = 0, z = 1.5}, velocity = {x = 0, y = 0, z = 0}, properties = {}, animations = {}}
function o:get_pos() return self.pos end
function o:get_velocity() return self.velocity end
function o:set_velocity(v) assert(math.sqrt(v.x^2 + v.z^2) <= 2.20001); self.velocity = v end
function o:set_yaw(yaw) self.yaw = yaw end
function o:set_acceleration(v) self.acceleration = v end
function o:set_armor_groups(v) assert(v.immortal == 1) end
function o:set_properties(v) for k, value in pairs(v) do self.properties[k] = value end end
function o:set_animation(range, speed, blend, loop) self.animations[#self.animations + 1] = {range = range, speed = speed, loop = loop} end
function o:get_luaentity() return e end
e.object = o; h.objects[#h.objects + 1] = o; e:on_activate("old target data ignored")
return e
end
core.add_entity = function(pos, name) local e = h.entity(name, pos); return e.object end
dofile(target)
function h.tick(e, dt, physics)
h.time = h.time + dt; e:on_step(dt)
if physics then
local p, v = e.object.pos, e.object.velocity
p.x, p.z = p.x + v.x * math.min(dt, 0.1), p.z + v.z * math.min(dt, 0.1)
end
end
function h.counter(e) h.tick(e, 0.75); assert(e.phase == "strike") end
return h
end
test("stationary identity persists and moving model is ephemeral animated B3D", function()
local h = harness(); local a, b = h.entities["hearth:practice"], h.entities["hearth:sparring"]
assert(a.initial_properties.static_save and not b.initial_properties.static_save)
assert(b.initial_properties.mesh == "hearth_person.b3d" and b.initial_properties.textures[1] == "hearth_person.png")
local e = h.entity("hearth:practice"); e.health, e.target = 0, "stale"; e:on_activate("bad json")
assert(e.health == 30 and e.target == nil and e.phase == "idle" and e.name == "hearth:practice")
end)
test("practice local-only spar staging requires server privilege production always denies", function()
local h = harness(); h.player()
h.single, h.deployment = false, "staging"
assert(not h.commands.practice.func("alice") and not h.commands.spar.func("alice"))
h.privileged = true; assert(h.commands.spar.func("alice")); assert(#h.objects == 1)
assert(not h.commands.spar.func("alice"))
h = harness(); h.player(); h.deployment, h.privileged = "production", true
assert(not h.commands.spar.func("alice") and not h.commands.practice.func("alice") and #h.objects == 0)
h.single, h.deployment = false, nil; assert(not h.commands.spar.func("alice"))
end)
test("local practice preserved nearby duplicate refusal and no tick scans", function()
local h = harness(); h.player(); assert(h.commands.practice.func("alice")); assert(not h.commands.practice.func("alice"))
local scans = h.scans; h.tick(h.objects[1]:get_luaentity(), 0.1); assert(h.scans == scans)
end)
test("spawn rejects protected claims unloaded liquid obstructed and waiting arrival", function()
for _, flag in ipairs({"denied", "protected", "unloaded", "liquid", "obstructed"}) do
local h = harness(); h.player(); h[flag] = true; assert(not h.commands.spar.func("alice") and #h.objects == 0)
end
local h = harness(); local _, _, s = h.player(); s.waiting = true; assert(not h.commands.spar.func("alice"))
end)
test("confirmed hit spends exact stamina wears current inventory and ignores client damage", function()
local h = harness(); local p, state, s = h.player(); local e = h.entity("hearth:practice")
assert(e:on_punch(p, 999, {damage_groups = {fleshy = 999}}, nil, 999) == true)
assert(e.health == 26 and state.stamina == 88 and p.wear == 655 and p.wield_updates == 1)
assert(s.attack_ready_at == 11.1 and s.strike_until > h.time and s.spar_entity == e)
assert(h.sounds[1] == "hearth_swing_axe" and h.sounds[2] == "hearth_impact")
local ray = h.rays[1]; assert(ray.a.y == 1.55 and ray.b.y == 0.9)
end)
test("weapon swap other targets and entity activation never reset session cadence", function()
local h = harness(); local p, state, s = h.player(); local a, b = h.entity("hearth:practice"), h.entity("hearth:practice")
a:on_punch(p); local ready = s.attack_ready_at; p.item = "hearth:knife"
a:on_activate(""); b:on_punch(p); assert(b.health == 30 and state.stamina == 88 and s.attack_ready_at == ready)
-- Simulate entity removal releasing its lock, without touching session cadence.
s.spar_entity = nil; h.time = 10.8; b:on_punch(p); assert(b.health == 30)
h.time = ready; b:on_punch(p); assert(b.health == 27 and state.stamina == 80)
end)
test("invalid hits never wear spend regenerate start a round or feedback spam", function()
for _, mode in ipairs({"stamina", "waiting", "dead", "offline", "claim", "wall", "aim", "reach", "state", "session"}) do
local h = harness(); local p, state, s = h.player(); local e = h.entity("hearth:practice")
if mode == "stamina" then state.stamina = 1 elseif mode == "waiting" then s.waiting = true
elseif mode == "dead" then p.hp = 0 elseif mode == "offline" then h.players.alice = nil
elseif mode == "claim" then h.denied = true elseif mode == "wall" then h.obstructed = true
elseif mode == "aim" then p.look.z = -1 elseif mode == "reach" then e.object.pos.y = 5
elseif mode == "state" then hearth.players.alice = nil elseif mode == "session" then hearth.sessions.alice = nil end
local before = state.stamina; for _ = 1, 20 do assert(e:on_punch(p)) end
assert(e.health == 30 and e.target == nil and p.wear == 0 and state.stamina == before and #h.messages == 0, mode)
end
end)
test("independent movement samples reject sprint-impossible recent displacement", function()
local h = harness(); local p, _, s = h.player(); h.steps[1](0.1)
p.pos.z = 8; local e = h.entity("hearth:practice", {x = 0, y = 0, z = 9.5})
h.time = h.time + 0.1; e:on_punch(p); assert(e.health == 30)
h.steps[1](0.1); assert(s.combat_sample.blocked_until > h.time); e:on_punch(p); assert(e.health == 30)
end)
test("fresh facing spear RMB guard ignores stale session blocking and weapon swap", function()
for _, mode in ipairs({"guard", "back", "released", "swapped", "tired"}) do
local h = harness(); local p, state, s = h.player(); p.item = "hearth:spear"
local e = h.entity("hearth:practice"); e:on_punch(p); s.blocking = true; p.controls.RMB = true
if mode == "back" then p.look.z = -1 elseif mode == "released" then p.controls.RMB = false
elseif mode == "swapped" then p.item = "hearth:axe" elseif mode == "tired" then state.stamina = 7 end
local before = state.stamina; h.counter(e)
assert(p.hp == (mode == "guard" and 19 or 17), mode)
assert(state.stamina == before - (mode == "guard" and 8 or 0), mode)
end
end)
test("backstep lateral vertical and obstruction evade counter", function()
for _, mode in ipairs({"back", "side", "vertical", "wall"}) do
local h = harness(); local p = h.player(); local e = h.entity("hearth:practice"); e:on_punch(p)
if mode == "back" then p.pos.z = -2 elseif mode == "side" then p.pos.x = 3
elseif mode == "vertical" then p.pos.y = 4 else h.obstructed = true end
h.counter(e); assert(p.hp == 20, mode)
end
end)
test("committed windup cannot be reset or cancelled even by finishing hit", function()
local h = harness(); local p, state = h.player(); p.item = "hearth:knife"
local e = h.entity("hearth:practice"); e.health = 6; e:on_punch(p); local deadline = e.deadline
h.time = h.time + 0.7; e:on_punch(p)
assert(e.health == 0 and e.round_complete and e.phase == "windup" and e.deadline == deadline)
local before = state.stamina; e:on_punch(p); assert(state.stamina == before)
h.tick(e, 0.05); assert(e.phase == "strike" and p.hp == 17)
h.tick(e, 0.12); assert(e.phase == "recover"); h.tick(e, 0.91)
assert(e.phase == "cooldown" and hearth.sessions.alice.spar_ready_at == h.time + 3)
end)
test("round rest persists across reset other targets spawn attempts and no regen", function()
local h = harness(); local p, state, s = h.player(); local e = h.entity("hearth:practice")
e.health = 4; e:on_punch(p); h.counter(e); h.tick(e, 0.13); h.tick(e, 0.91)
local ready, stamina = s.spar_ready_at, state.stamina; assert(math.abs(ready - h.time - 3) < 0.000001)
e:on_activate(""); local other = h.entity("hearth:practice"); other:on_punch(p)
assert(other.health == 30 and not h.commands.spar.func("alice") and state.stamina == stamina)
h.time = ready + 0.000001; other:on_punch(p); assert(other.health == 26 and s.spar_ready_at == ready)
end)
test("only consenting participant can be hurt never bystanders or PvP", function()
local h = harness(); local p = h.player(); local other = h.player("bob"); local e = h.entity("hearth:practice")
e:on_punch(p); e:on_punch(other); assert(e.health == 26 and e.target == "alice")
h.counter(e); assert(p.hp == 17 and other.hp == 20 and other.damage == 0)
h = harness(); p = h.player(); p.hp = 3; e = h.entity("hearth:practice"); e:on_punch(p); h.counter(e); assert(p.hp == 2)
end)
test("death disconnect waiting or claim denial disarms without damage", function()
for _, mode in ipairs({"death", "offline", "waiting", "claim"}) do
local h = harness(); local p, _, s = h.player(); local e = h.entity("hearth:practice"); e:on_punch(p)
if mode == "death" then p.hp = 0 elseif mode == "offline" then h.players.alice = nil
elseif mode == "waiting" then s.waiting = true else h.denied = true end
h.tick(e, 1); assert(e.target == nil and e.phase == "cooldown" and p.damage == 0)
end
end)
test("moving opt in approaches at bounded speed perception five Hz and locks owner", function()
local h = harness(); local p = h.player(); local other = h.player("bob"); assert(h.commands.spar.func("alice"))
local e = h.objects[1]:get_luaentity(); assert(e.owner == "alice" and e.phase == "approach")
local before = h.nodes; h.tick(e, 0.05, true); assert(h.nodes == before)
for _ = 1, 3 do h.tick(e, 0.05, true) end
assert(e.object.velocity.z < 0 and math.abs(e.object.velocity.z) <= 2.2)
other.pos.z = 2; other.look.z = 1; e:on_punch(other); assert(e.health == 30)
for _ = 1, 30 do h.tick(e, 0.05, true); if e.phase == "windup" then break end end
assert(e.phase == "windup" and e.object.velocity.z == 0 and h.scans == 0)
local f = e.object.animations[#e.object.animations]; assert(f.range.x == 70 and f.range.y == 79)
h.counter(e); f = e.object.animations[#e.object.animations]; assert(f.range.x == 60 and not f.loop and p.hp == 17)
end)
test("terrain side avoidance loaded dry ground requirement and bounded stuck exit", function()
local h = harness(); h.player(); assert(h.commands.spar.func("alice")); local e = h.objects[1]:get_luaentity()
h.ray_test = function(a, b) return math.abs(b.x - a.x) > 0.1 end
for _ = 1, 2 do h.tick(e, 0.1) end
assert(math.abs(e.object.velocity.x) == 2.2 and e.object.velocity.z == 0)
-- No physics movement: repeated side attempts must terminate, not spin forever.
for _ = 1, 40 do h.tick(e, 0.1) end
assert(e.phase == "cooldown" and e.target == nil)
for _, flag in ipairs({"unloaded", "liquid"}) do
h = harness(); h.player(); assert(h.commands.spar.func("alice")); e = h.objects[1]:get_luaentity(); h[flag] = true
for _ = 1, 2 do h.tick(e, 0.1) end
assert(e.object.velocity.x == 0 and e.object.velocity.z == 0)
end
end)
test("leash disconnect deactivate and huge dt never pursue or catch up damage", function()
local h = harness(); local p, _, s = h.player(); assert(h.commands.spar.func("alice")); local e = h.objects[1]:get_luaentity()
p.pos.z = -10; h.tick(e, 100, true); assert(e.phase == "cooldown" and e.target == nil and p.damage == 0)
assert(s.spar_entity == nil and s.spar_ready_at > h.time)
h = harness(); p = h.player(); assert(h.commands.spar.func("alice")); e = h.objects[1]:get_luaentity()
h.players.alice = nil; h.tick(e, 0.1); assert(e.target == nil and e.object.velocity.z == 0)
h = harness(); p, _, s = h.player(); assert(h.commands.spar.func("alice")); e = h.objects[1]:get_luaentity()
e:on_deactivate(); assert(s.spar_entity == nil and s.spar_ready_at > h.time)
end)
test("optional feedback adapter sounds explicit names no chat fallback", function()
local h = harness(); local p = h.player(); hearth.feedback = nil
local calls = {}; hearth.feedback_sound = function(name, player, gain) assert(player == p and gain == 0.25); calls[#calls + 1] = name end
local e = h.entity("hearth:practice"); e:on_punch(p); h.counter(e)
assert(#h.messages == 0 and #h.sounds == 0 and calls[1] == "hearth_swing_axe" and calls[3] == "hearth_impact")
end)
test("unowned moving developer entity is inert and distant client punches do not trace world", function()
local h = harness(); local p = h.player(); local e = h.entity("hearth:sparring")
e:on_punch(p); assert(e.health == 30 and e.target == nil)
e = h.entity("hearth:practice", {x = 0, y = 0, z = 10000})
e:on_punch(p); assert(e.health == 30 and #h.rays == 0)
end)
test("moving rest and reopt in reset stuck memory but not player attack deadlines", function()
local h = harness(); local p, _, s = h.player(); assert(h.commands.spar.func("alice"))
local e = h.objects[1]:get_luaentity()
for _ = 1, 40 do h.tick(e, 0.1) end
assert(e.phase == "cooldown" and e.stuck > 3)
h.tick(e, 3.01); assert(e.phase == "idle")
p.pos.z = 1.5; p.look.z = 1; e:on_punch(p)
assert(e.target == "alice" and e.stuck == 0 and e.last_ai_pos == nil and s.attack_ready_at > h.time)
assert(not h.commands.spar.func("alice") and #h.objects == 1)
end)
test("deactivation clears consent and preserves rest without touching removed object", function()
local h = harness(); local _, _, s = h.player(); assert(h.commands.spar.func("alice"))
local e = h.objects[1]:get_luaentity()
e.object.get_velocity = function() error("removed ObjectRef accessed") end
e.object.set_properties = function() error("removed ObjectRef accessed") end
e:on_deactivate(); assert(s.spar_entity == nil and s.spar_ready_at > h.time)
end)
test("knife and spear consume durability only for confirmed in-reach hits", function()
for _, spec in ipairs({{"hearth:knife", 3, 8, 437}, {"hearth:spear", 5, 14, 546}}) do
local h = harness(); local p, state = h.player(); p.item = spec[1]
local e = h.entity("hearth:practice"); e:on_punch(p)
assert(e.health == 30 - spec[2] and state.stamina == 100 - spec[3] and p.wear == spec[4])
e:on_punch(p); assert(p.wear == spec[4] and p.wield_updates == 1)
end
end)
print("PASS " .. total .. " encounters checks")
+551 -31
View File
@@ -2,7 +2,7 @@
-- never copy this into the persistent play world. No engine globals are mocked.
local mode = core.settings:get("hearth_launcher_mode")
local token = core.settings:get("hearth_smoke_token")
assert(mode == "check" or mode == "smoke", "Invalid launcher test mode")
assert(mode == "check" or mode == "smoke" or mode == "seed", "Invalid launcher test mode")
assert(token and token:match("^[%x%-]+$"), "Missing launcher test token")
local modpath = core.get_modpath(core.get_current_modname())
local finished = false
@@ -37,6 +37,47 @@ local function registry_check()
assert(type(hearth) == "table", "Real hearth global was not loaded")
assert(type(hearth.model) == "table", "hearth.model missing")
assert(type(hearth.players) == "table", "hearth.players missing")
assert(type(hearth.movement.step) == "function", "Movement controller not loaded")
assert(type(hearth.combat.valid_attack) == "function", "Combat rules not loaded")
assert(type(hearth.presentation_step) == "function", "Held-tool presentation not loaded")
assert(type(hearth.feedback_step) == "function", "Movement/target feedback not loaded")
assert(type(hearth.course.report) == "function", "Disposable course not loaded")
assert(type(hearth.home_for) == "function" and type(hearth.store_home) == "function", "Home service not loaded")
assert(type(hearth.start_rest) == "function" and type(hearth.recipes) == "function", "M2 first-night services not loaded")
for module, methods in pairs({movement = {"step", "delta"},
combat = {"valid_attack", "valid_guard", "sample"},
construction = {"preview"}, course = {"report"}}) do
assert(type(hearth[module]) == "table", "Missing M1 module: " .. module)
for _, method in ipairs(methods) do
assert(type(hearth[module][method]) == "function", "Missing M1 API: " .. module .. "." .. method)
end
end
for _, method in ipairs({"feedback", "feedback_step", "feedback_sound"}) do
assert(type(hearth[method]) == "function", "Missing feedback API: " .. method)
end
assert(next(hearth.players) == nil and next(hearth.sessions) == nil, "Headless smoke must not invent players")
assert(#core.get_connected_players() == 0, "Headless smoke unexpectedly has live players")
assert(hearth.course.report("") == "No course run in this session. Use /course.")
assert(core.settings:get_bool("hearth_m1_course") ~= true, "Ordinary smoke must not enable course mode")
assert(hearth.storage:get_string("m1_course_world_nonce") == "", "Ordinary smoke enrolled a course world")
assert(not core.registered_nodes["hearth:m1_course_half_step"], "Course-only node leaked into ordinary smoke")
for _, name in ipairs({"course", "practice", "spar", "comfort", "home", "sethome", "recipes"}) do
local command = assert(core.registered_chatcommands[name], "Missing gameplay command: " .. name)
assert(type(command.func) == "function")
assert(command.func("", "") == false, "Command accepted a nonlive console caller: " .. name)
end
local stationary = assert(core.registered_entities["hearth:practice"], "Stationary target missing")
local moving = assert(core.registered_entities["hearth:sparring"], "Moving sparring target missing")
assert(stationary.initial_properties.visual == "cube" and stationary.initial_properties.static_save == true)
assert(moving.initial_properties.visual == "mesh" and moving.initial_properties.static_save == false)
assert(moving.initial_properties.mesh == "hearth_person.b3d")
assert(moving.initial_properties.textures[1] == "hearth_person.png")
for _, def in ipairs({stationary, moving}) do
assert(def.initial_properties.physical == true and def.initial_properties.collide_with_objects == false)
for _, method in ipairs({"on_activate", "on_step", "on_punch", "on_deactivate"}) do
assert(type(def[method]) == "function", "Missing target lifecycle: " .. method)
end
end
assert(core.get_modpath("hearth"), "hearth mod missing")
for _, name in ipairs({"default", "doors", "farming", "fire", "stairs", "wool", "sfinv"}) do
assert(not core.get_modpath(name), "Unexpected MTG dependency: " .. name)
@@ -45,16 +86,94 @@ local function registry_check()
assert(not name:match("^default:"), "MTG item leaked into standalone game: " .. name)
end
for _, name in ipairs({"stone", "soil", "grass", "sand", "water_source", "water_flowing",
"log", "leaves", "loose_stone", "sticks", "berry_bush", "reeds", "planks",
"wall", "roof", "fire", "fire_lit"}) do
"log", "leaves", "loose_stone", "sticks", "berry_bush", "berry_bush_bare", "sapling",
"grain_patch", "crop_1", "crop_2", "crop_3", "crop_4", "clay_bank", "iron_ore_vein", "fish_trap", "weir_stone",
"reeds", "planks", "wall", "roof", "foundation", "frame", "stairs", "door", "door_open", "window",
"vent", "storage", "pantry", "drying_rack", "bedroll", "torch", "fire", "fire_lit"}) do
assert(core.registered_nodes["hearth:" .. name], "Missing node: " .. name)
end
for _, name in ipairs({"berries", "raw_food", "cooked_food", "vessel", "water_raw",
"water_clean", "knife", "axe", "pick", "spear"}) do
for _, name in ipairs({"berries", "grain", "grain_seed", "raw_food", "cooked_food", "fish_bait", "raw_fish",
"cooked_fish", "fish_drying_charge", "dried_fish", "vessel", "water_raw",
"water_clean", "iron_ore", "iron_ingot", "charcoal", "charcoal_charge", "smelt_charge", "clay", "clay_jar", "clay_jar_charge",
"dried_food", "dried_food_charge", "fish_bait", "raw_fish", "cooked_fish", "fish_drying_charge", "dried_fish",
"firestarter", "cloak", "knife", "axe", "iron_axe", "pick", "spear"}) do
assert(core.registered_items["hearth:" .. name], "Missing item: " .. name)
end
for _, name in ipairs({"knife", "axe", "pick", "spear", "vessel", "fire", "wall",
"roof", "planks", "sticks", "raw_food"}) do
for name, expected in pairs({knife = {3, 0.7}, axe = {4, 1.1}, iron_axe = {6, 0.9}, pick = {3, 1.2}, spear = {5, 1.2}}) do
local def = assert(core.registered_tools["hearth:" .. name])
local caps = ItemStack("hearth:" .. name):get_tool_capabilities()
assert(caps.damage_groups.fleshy == expected[1] and math.abs(caps.full_punch_interval - expected[2]) < 0.0001,
"Weapon capabilities mismatch: " .. name)
if name ~= "iron_axe" then
assert(def.inventory_image == "hearth_icon_" .. name .. ".png", "Weapon icon missing: " .. name)
else assert(def.inventory_image:find("hearth_", 1, true), "Iron axe image missing") end
end
for name, expected in pairs({soil = {"crumbly", "soil"}, stone = {"cracky", "stone"},
log = {"choppy", "wood"}, planks = {"choppy", "wood"}, wall = {"choppy", "wood"}, roof = {"snappy", "soil"}}) do
local def = core.registered_nodes["hearth:" .. name]
assert((def.groups[expected[1]] or 0) > 0, "Missing material group: " .. name)
for _, event in ipairs({"dug", "place"}) do
local spec = assert(def.sounds[event], "Missing node sound: " .. name .. "." .. event)
assert(spec.name == "hearth_dig_" .. expected[2] and type(spec.gain) == "number"
and spec.gain > 0 and spec.gain <= 1, "Invalid node sound: " .. name .. "." .. event)
end
end
assert(core.get_item_group("hearth:water_source", "water") > 0)
assert(core.get_item_group("hearth:water_flowing", "liquid") > 0)
assert(core.get_item_group("hearth:loose_stone", "flint") > 0)
assert(core.get_dig_params(core.registered_nodes["hearth:log"].groups,
ItemStack("hearth:axe"):get_tool_capabilities()).diggable, "Axe cannot harvest course logs")
local hand = ItemStack(""):get_tool_capabilities()
local deadwood = assert(core.registered_nodes["hearth:deadwood"])
assert(not core.get_dig_params(deadwood.groups, hand).diggable, "Bare hands unexpectedly harvest fallen logs")
assert(core.get_dig_params(deadwood.groups, ItemStack("hearth:axe"):get_tool_capabilities()).diggable,
"Axe cannot harvest fallen logs")
local drops = core.get_node_drops("hearth:deadwood", "hearth:axe")
assert(#drops == 1 and ItemStack(drops[1]):get_name() == "hearth:log"
and ItemStack(drops[1]):get_count() == 1, "Fallen logs must drop exactly one timber with an axe")
assert(deadwood.can_dig == hearth.construction.can_dig, "Fallen logs bypass construction permissions")
local timber = {"log", "stone"}
assert(#hearth.diversity.species_order == 10, "Expected ten timber species")
for _, species in ipairs(hearth.diversity.species_order) do
local name = species .. "_log"
timber[#timber + 1] = name
assert(core.get_dig_params(assert(core.registered_nodes["hearth:" .. name]).groups,
ItemStack("hearth:axe"):get_tool_capabilities()).diggable, "Axe cannot harvest " .. name)
end
for _, name in ipairs(timber) do
assert(not core.get_dig_params(core.registered_nodes["hearth:" .. name].groups, hand).diggable,
"Bare hands unexpectedly harvest " .. name)
end
local catalogue = hearth.botany.list()
assert(#catalogue == 80, "Expected all 80 botanical species")
for _, record in ipairs(catalogue) do
assert(core.registered_nodes[record.node], "Missing native botanical species: " .. record.node)
end
local trees, cubes = 0, 0
for name, def in pairs(core.registered_nodes) do
local kind = hearth.seasons.kind(name)
if name:match("^hearth:") and (hearth.botany.group_of(name) == "trees"
or ({log=true, leaves=true, sapling=true, tree_seedling=true, young_tree=true,
mature_tree=true, deadwood=true})[kind]) then
assert(def.drawtype ~= "mesh", "Tree still uses mesh drawtype: " .. name)
trees = trees + 1
if (def.groups.tree or 0) > 0 then
assert(def.drawtype == "normal" and def.walkable == true and def.paramtype2 == "facedir"
and (def.visual_scale or 1) == 1, "Timber must be a full facedir cube: " .. name)
for _, box in ipairs({"node_box", "selection_box", "collision_box"}) do
assert(not def[box] or def[box].type == "regular", "Non-full timber box: " .. name .. "." .. box)
end
cubes = cubes + 1
end
end
end
assert(deadwood.drawtype == "normal" and deadwood.paramtype2 == "facedir", "Fallen log must be a full facedir cube")
core.log("action", "Last Hearth native botany/wood: species=80, timber_species=10, nonmesh_tree_nodes="
.. trees .. ", fullcube_timber_nodes=" .. cubes .. "; fallen hand=false, axe=true, drop=hearth:log 1")
for _, name in ipairs({"knife", "axe", "iron_axe", "pick", "spear", "firestarter", "vessel", "fire",
"wall", "roof", "foundation", "frame", "stairs", "door", "window", "vent", "storage", "pantry",
"drying_rack", "bedroll", "fish_trap", "fish_bait", "fish_drying_charge", "torch", "cloak",
"charcoal_charge", "clay_jar_charge", "planks", "sticks", "raw_food"}) do
local output = "hearth:" .. name
local recipe = core.get_craft_recipe(output)
assert(recipe.items and next(recipe.items), "Missing craft: " .. output)
@@ -67,47 +186,326 @@ local function registry_check()
end
local width = recipe.width > 0 and recipe.width or #inputs
local result = core.get_craft_result({method = "normal", width = width, items = inputs})
assert(result.item:get_name() == output, "Engine craft result mismatch: " .. output)
assert(result.item:get_name() == output, "Engine craft result mismatch: " .. output
.. " (method=" .. tostring(recipe.method) .. ", width=" .. tostring(recipe.width)
.. ", got=" .. result.item:get_name() .. " x" .. result.item:get_count() .. ")")
assert(result.item:get_count() == ItemStack(recipe.output):get_count(), "Craft quantity mismatch: " .. output)
end
assert(core.registered_aliases.mapgen_stone == "hearth:stone", "Missing mapgen stone alias")
assert(core.registered_aliases.mapgen_water_source == "hearth:water_source", "Missing water alias")
assert(core.registered_biomes.hearth_temperate, "Missing temperate biome")
assert(core.registered_biomes.hearth_riverbank, "Missing riverbank biome")
assert(core.registered_entities["hearth:held_visual"], "Missing held-tool visual entity")
assert(core.registered_entities["hearth:sparring"], "Missing moving sparring entity")
assert(core.registered_entities["hearth:hare"], "Missing passive bounded wildlife")
assert(core.settings:get_bool("enable_pvp") == false, "PvP must be disabled")
assert(core.settings:get_bool("secure.enable_security") == true, "Mod security must be enabled")
assert(core.settings:get("bind_address") == "127.0.0.1", "Server must bind loopback only")
end
-- Check the actual packaged media, not source-art names or a mocked registry.
local function media_check()
local files, missing, references = {}, {}, 0
local path = assert(core.get_modpath("hearth"))
for _, directory in ipairs({"textures", "models", "sounds"}) do
for _, name in ipairs(core.get_dir_list(path .. "/" .. directory, false)) do
files[name] = directory
end
end
local function reference(name, source)
references = references + 1
if not files[name] then missing[#missing + 1] = source .. " -> " .. name end
end
local function images(value, source)
if type(value) == "string" then
-- Modifiers (combine, inventorycube, sheet, opacity, etc.) retain
-- their component filenames. Never interpret them as disk paths.
for name in value:gmatch("[%w_%-]+%.png") do reference(name, source) end
elseif type(value) == "table" then
for key, entry in pairs(value) do images(entry, source .. "." .. tostring(key)) end
end
end
local items, entities = 0, 0
local function definition(def, source)
for _, key in ipairs({"tiles", "overlay_tiles", "special_tiles", "inventory_image",
"wield_image", "inventory_overlay", "wield_overlay", "textures"}) do
images(def[key], source .. "." .. key)
end
if def.mesh and def.mesh ~= "" then reference(def.mesh, source .. ".mesh") end
for event, spec in pairs(def.sounds or {}) do
local name = type(spec) == "table" and spec.name or spec
if type(name) == "string" and name ~= "" then reference(name .. ".ogg", source .. ".sounds." .. event) end
end
end
for name, def in pairs(core.registered_items) do
if name:match("^hearth:") then items = items + 1; definition(def, name) end
end
for name, def in pairs(core.registered_entities) do
if name:match("^hearth:") then entities = entities + 1; definition(def.initial_properties or {}, name) end
end
-- Original generated B3D/OBJ files can also carry atlas references.
for name, directory in pairs(files) do
if directory == "models" then
local file = assert(io.open(path .. "/models/" .. name, "rb"))
local bytes = file:read(2 * 1024 * 1024 + 1); file:close()
assert(#bytes <= 2 * 1024 * 1024, "Unexpectedly large packaged mesh: " .. name)
images(bytes, "models/" .. name)
end
end
table.sort(missing)
assert(#missing == 0, "Packaged media closure failed (" .. #missing .. "):\n" .. table.concat(missing, "\n"))
assert(items > 0 and entities > 0 and references > 0, "Empty media closure check")
core.log("action", "Last Hearth media closure: " .. items .. " items, " .. entities .. " entities, " .. references .. " references; missing=0")
end
local function craft_closure_check()
local names, count, outputs = {}, 0, 0
for name in pairs(core.registered_items) do names[#names + 1] = name end
table.sort(names)
local function ingredient(value)
if value:sub(1, 6) ~= "group:" then
assert(value == "" or core.registered_items[ItemStack(value):get_name()], "Unknown craft ingredient: " .. value)
return value
end
for _, name in ipairs(names) do
local matches = true
for group in value:sub(7):gmatch("[^,]+") do
if core.get_item_group(name, group) == 0 then matches = false end
end
if matches then return name end
end
error("No registered item satisfies craft ingredient: " .. value)
end
for _, name in ipairs(names) do
if name:match("^hearth:") then
local recipes = core.get_all_craft_recipes(name) or {}
if #recipes > 0 then outputs = outputs + 1 end
for _, recipe in ipairs(recipes) do
local inputs = {}
for i = 1, (recipe.width == 0 and #recipe.items or recipe.width * 3) do
inputs[i] = ItemStack(ingredient(recipe.items[i] or ""))
end
local result = core.get_craft_result({method = recipe.method,
width = recipe.width > 0 and recipe.width or #inputs, items = inputs})
local expected = ItemStack(recipe.output)
assert(result.item:get_name() == expected:get_name() and result.item:get_count() == expected:get_count(),
"Craft closure mismatch: " .. recipe.output .. " got " .. result.item:to_string())
count = count + 1
end
end
end
assert(count > 0 and outputs > 0, "Empty craft closure check")
core.log("action", "Last Hearth craft closure: " .. count .. " recipes, " .. outputs .. " outputs")
end
local function worldgen_registry_check()
local profiles = assert(hearth.biomes and hearth.biomes.biome_profiles, "Biome profiles not wired")
assert(#profiles == 10, "Expected ten biome profiles")
for _, p in ipairs(profiles) do
local def = assert(core.registered_biomes[p.name], "Missing native biome: " .. p.name)
assert(def.node_top == "hearth:" .. p.top and def.node_filler == "hearth:" .. p.filler
and def.node_stone == "hearth:" .. p.rock and def.y_min == p.min and def.y_max == p.max,
"Native biome/profile mismatch: " .. p.name)
end
local ores, count = {}, 0
for _, def in pairs(core.registered_ores) do
if def.name and def.name:match("^hearth:") then
assert(core.registered_nodes[def.ore], "Unknown ore: " .. tostring(def.ore))
for _, host in ipairs(type(def.wherein) == "table" and def.wherein or {def.wherein}) do
assert(core.registered_nodes[host] and core.get_item_group(host, "stone") > 0,
"Invalid native ore host: " .. tostring(host))
end
ores[def.ore], count = true, count + 1
end
end
assert(count == 9, "Expected nine native geological/ore registrations; got " .. count)
for _, ore in ipairs({"quartz_vein", "copper_ore_vein", "coal_ore_vein", "iron_ore_vein"}) do
assert(ores["hearth:" .. ore], "Missing ore registration: " .. ore)
end
for setting, expected in pairs({mg_name="v7", mapgen_limit="4096", water_level="1", mgv7_mount_zero_level="0"}) do
assert(core.get_mapgen_setting(setting) == expected, "Saved mapgen setting mismatch: " .. setting)
end
for setting, expected in pairs({mgv7_np_terrain_base={12,45,384}, mgv7_np_terrain_alt={12,28,384},
mgv7_np_mount_height={250,100,700}, mgv7_np_mountain={-0.6,1,180}}) do
local actual = assert(core.get_mapgen_setting_noiseparams(setting), "Missing saved mapgen noise: " .. setting)
assert(math.abs(actual.offset - expected[1]) < 0.0001 and math.abs(actual.scale - expected[2]) < 0.0001
and actual.spread.x == expected[3], "Saved terrain scale mismatch: " .. setting)
end
core.log("action", "Last Hearth worldgen registry: biomes=10, ores=9; saved v7 terrain base=12/45, alt=12/28, mount=250/100, mapgen_limit=4096")
end
local function unit_check()
-- Each standalone harness receives its own globals and os table. Mocked core,
-- hearth and os.time never replace globals of the running game.
local manifest = dofile(modpath .. "/syntax_manifest.lua")
local lookup = {}
for _, entry in ipairs(manifest) do lookup[entry.label] = modpath .. "/syntax/" .. entry.file end
local count = 0
for label, filename in pairs(lookup) do
local count, labels = 0, {}
for label in pairs(lookup) do labels[#labels + 1] = label end
table.sort(labels)
for _, module in ipairs({"model", "content", "schema", "persistence", "clock", "players", "runtime",
"access", "environment", "init", "ecology", "seasons", "succession", "wildlife",
"movement", "combat", "construction", "course", "encounters", "presentation", "biomes", "diversity",
"m2_processes", "m2_rest", "recipe", "botany", "species_effects", "species_reader", "character"}) do
assert(lookup["tests/" .. module .. "_spec.lua"], "Missing regression suite: " .. module)
end
local test_modules = {m2_processes = "processes", m2_rest = "runtime", recipe = "interface"}
local real_globals = {}; for k, v in pairs(_G) do real_globals[k] = v end
for _, label in ipairs(labels) do
local filename = lookup[label]
if label:match("_spec%.lua$") then
local module = label:match("tests/(.+)_spec%.lua$")
local target = assert(lookup["mods/hearth/" .. module .. ".lua"], "Missing test module")
local env = setmetatable({arg = {target}, os = {}}, {__index = _G})
for k, v in pairs(os) do env.os[k] = v end
local target = assert(lookup["mods/hearth/" .. (test_modules[module] or module) .. ".lua"], "Missing test module")
local env = setmetatable({arg = {target}, hearth = {}, core = {}, minetest = {}}, {__index = _G})
-- Do not inherit the live hearth.combat/schema via __index. Sibling
-- modules execute in this suite's environment, never against live APIs.
for _, name in ipairs({"os", "io", "math", "table", "string", "vector"}) do
env[name] = {}; for k, v in pairs(_G[name]) do env[name][k] = v end
end
env._G = env
env.dofile = function(path)
assert(path == target, "Unit harness attempted unexpected file access")
local permitted = false
for _, mapped in pairs(lookup) do if mapped == path then permitted = true; break end end
assert(permitted, "Unit harness attempted unexpected file access")
local chunk = assert(loadfile(path)); setfenv(chunk, env); return chunk()
end
-- Content factories load by engine modpath; remap only known source files
-- into their isolated staged copies, never the live game or arbitrary paths.
local isolated_dofile = env.dofile
env.dofile = function(path)
local live = core.get_modpath("hearth") .. "/"
if path:sub(1, #live) == live then
path = assert(lookup["mods/hearth/" .. path:sub(#live + 1)], "Unmapped module")
end
-- Harness derives sibling paths from its flattened target filename.
if not path:match("/%d+%.lua$") then
local sibling = path:match("([^/\\]+%.lua)$")
path = lookup["mods/hearth/" .. (sibling or "")] or path
end
return isolated_dofile(path)
end
local chunk = assert(loadfile(filename)); setfenv(chunk, env); chunk()
for k, v in pairs(real_globals) do assert(_G[k] == v, "Unit suite replaced live global: " .. tostring(k)) end
for k in pairs(_G) do assert(real_globals[k] ~= nil, "Unit suite leaked live global: " .. tostring(k)) end
assert(next(hearth.players) == nil and next(hearth.sessions) == nil, "Unit suite leaked live player state")
count = count + 1
end
end
assert(count >= 2, "Expected model and content regression suites")
assert(count == 29, "Expected all 29 regression suites; got " .. count)
core.log("action", "Last Hearth: " .. count .. " isolated unit suites executed")
end
-- Four separated, narrow columns, not a huge continuous exploration volume.
-- Their deep slices exercise actual ore hosts; surface samples need not expose
-- every biome (and no individual biome is required at the arrival point).
local function worldgen_check(done)
if mode == "seed" then done(); return end
local positions = {{-1200,-800}, {1200,800}, {-800,1200}, {800,-1200}}
local totals, biomes, index = {}, {}, 0
local wanted = {"hearth:granite", "hearth:slate", "hearth:limestone", "hearth:sandstone",
"hearth:quartz_vein", "hearth:copper_ore_vein", "hearth:coal_ore_vein", "hearth:iron_ore_vein"}
local function next_probe()
index = index + 1
if index > #positions then
local report, biome_count = {}, 0
for _, name in ipairs(wanted) do
assert((totals[name] or 0) > 0, "Broad worldgen probes missing material: " .. name)
report[#report + 1] = name .. "=" .. totals[name]
end
for _ in pairs(biomes) do biome_count = biome_count + 1 end
assert(biome_count >= 2, "Broad worldgen probes found fewer than two surface biomes")
core.log("action", "Last Hearth broad worldgen: probes=4, surface_biomes=" .. biome_count .. "; " .. table.concat(report, ", "))
done(); return
end
local p = positions[index]
local minimum, maximum = {x=p[1]-8,y=-112,z=p[2]-8}, {x=p[1]+8,y=320,z=p[2]+8}
core.emerge_area(minimum, maximum, function(_, action, remaining)
if finished then return end
guarded(function()
assert(action ~= core.EMERGE_ERRORED and action ~= core.EMERGE_CANCELLED,
"Broad worldgen emergence failed at " .. p[1] .. "," .. p[2])
if remaining ~= 0 then return end
core.after(0, function()
guarded(function()
local _, counts = core.find_nodes_in_area(minimum, maximum, wanted)
for name, count in pairs(counts) do totals[name] = (totals[name] or 0) + count end
for x = minimum.x, maximum.x, 8 do for z = minimum.z, maximum.z, 8 do
for y = 318, 0, -1 do
local pos = {x=x,y=y,z=z}
local name = core.get_node(pos).name
if hearth.biomes.surface(name) then
local data = assert(core.get_biome_data(pos), "Missing native biome data")
biomes[assert(core.get_biome_name(data.biome))] = true
break
end
end
end end
core.log("action", "Last Hearth worldgen probe: x=" .. p[1] .. ", z=" .. p[2] .. ", y=-112..320")
next_probe()
end)
end)
end)
end)
end
next_probe()
end
local function actor_check()
-- Native objects, not PlayerRef doubles. This checks gravity/collision and
-- animation API state only: no consenting player means no chase or combat.
local block = {x = 40, y = 85, z = 40}
assert(core.forceload_block(block, true), "Cannot hold native actor test block active")
local objects = {}
local function cleanup()
for _, object in ipairs(objects) do if object:get_pos() then object:remove() end end
core.forceload_free_block(block, true)
end
core.register_on_shutdown(cleanup)
for x = 37, 43 do for z = 37, 43 do
core.set_node({x = x, y = 84, z = z}, {name = "hearth:stone"})
for y = 85, 90 do core.set_node({x = x, y = y, z = z}, {name = "air"}) end
end end
for i, name in ipairs({"hearth:practice", "hearth:sparring"}) do
local object = assert(core.add_entity({x = 34 + i * 4, y = 87, z = 40}, name), "Native actor spawn failed")
objects[#objects + 1] = object
local entity = assert(object:get_luaentity())
assert(not object:is_player() and entity.name == name and entity.health == 30 and entity.phase == "idle")
assert(entity.target == nil and entity.owner == nil and entity.mobile == (i == 2))
assert(object:get_acceleration().y < -9 and object:get_properties().physical == true)
assert(object:get_armor_groups().immortal == 1)
end
local animated = objects[2]
local idle, speed, blend, loop = animated:get_animation()
assert(idle.x == 0 and idle.y == 19 and speed == 12 and loop == true, "Native idle animation mismatch")
for _, frame in ipairs({{0,19,12,true}, {20,39,24,true}, {40,59,32,true},
{60,69,28,false}, {70,79,12,true}, {80,99,20,true}}) do
animated:set_animation({x = frame[1], y = frame[2]}, frame[3], 0.12, frame[4])
local range, rate, transition, looping = animated:get_animation()
assert(range.x == frame[1] and range.y == frame[2] and range.x >= 0 and range.y <= 99)
assert(rate == frame[3] and math.abs(transition - 0.12) < 0.0001 and looping == frame[4])
end
animated:set_animation({x = 0, y = 19}, 12, 0.12, true)
core.after(2, function()
guarded(function()
for i, object in ipairs(objects) do
local pos, velocity = assert(object:get_pos()), object:get_velocity()
assert(math.abs(pos.x - (34 + i * 4)) < 0.02 and math.abs(pos.z - 40) < 0.02,
"Unowned actor moved horizontally without player consent")
assert(pos.y >= 84.49 and pos.y <= 84.7 and math.abs(velocity.y) < 0.2,
"Native gravity/collision did not settle actor on the test floor")
local entity = object:get_luaentity()
assert(entity.phase == "idle" and entity.target == nil and entity.health == 30)
end
cleanup()
core.log("action", "Last Hearth: native stationary/sparring gravity, collision and animation API checks passed; no player chase, rendered animation or audible playback proven")
finish(true)
end)
end)
end
local function emerge_check()
local callbacks = 0
core.emerge_area({x = -64, y = -32, z = -64}, {x = 64, y = 96, z = 64},
core.emerge_area({x = -64, y = -32, z = -64}, {x = 64, y = 320, z = 64},
function(_, action, remaining)
if finished then return end
guarded(function()
@@ -133,23 +531,142 @@ local function emerge_check()
end
end
assert(material, "Mapgen produced no hearth terrain in the test volume")
local resources = {}
for _, name in ipairs({"grass", "loose_stone", "sticks", "berry_bush", "reeds", "log", "water_source"}) do
local nodes = core.find_nodes_in_area({x = -64, y = -16, z = -64}, {x = 64, y = 96, z = 64}, {"hearth:" .. name})
resources[#resources + 1] = name .. "=" .. #nodes
assert(#nodes > 0, "Starting region missing required resource: " .. name)
local resources, missing = {}, {}
local minimum, maximum = {x = -64, y = 0, z = -64}, {x = 64, y = 318, z = 64}
local function side_air(p)
for _, offset in ipairs({{1, 0}, {-1, 0}, {0, 1}, {0, -1}}) do
local node = core.get_node_or_nil({x = p.x + offset[1], y = p.y, z = p.z + offset[2]})
local def = node and core.registered_nodes[node.name]
if node and node.name == "air" and def and def.walkable == false then return true end
end
return false
end
core.log("action", "Last Hearth starting resources: " .. table.concat(resources, ", "))
for _, entry in ipairs({{"ground", "group:soil", "hearth:sand", "hearth:gravel", "hearth:scree", "hearth:snow", "group:stone"},
{"flint", "group:flint"}, {"sticks", "hearth:sticks"}, {"berries", "hearth:berry_bush"},
{"reeds", "hearth:reeds"}, {"log", "hearth:log", "hearth:oak_log", "hearth:birch_log",
"hearth:pine_log", "hearth:willow_log", "hearth:alder_log"}, {"water", "group:water"}}) do
local name, selectors = entry[1], {}
for i = 2, #entry do selectors[#selectors + 1] = entry[i] end
-- Canopies cover trunk tops, but an axe can reach their sides.
-- Require loaded air at the same height: buried logs do not count.
local nodes, raw_count
if name == "log" then
local logs = core.find_nodes_in_area(minimum, maximum, selectors)
nodes, raw_count = {}, #logs
for _, pos in ipairs(logs) do
if side_air(pos) then nodes[#nodes + 1] = pos end
end
else
-- Buried water/ground still cannot prove surface access.
nodes = core.find_nodes_in_area_under_air(minimum, maximum, selectors)
end
resources[#resources + 1] = name .. "=" .. #nodes .. (raw_count and " (total=" .. raw_count .. ")" or "")
if #nodes == 0 then missing[#missing + 1] = name end
end
core.log("action", "Last Hearth starting resources: seed=" .. tostring(core.get_mapgen_setting("seed"))
.. "; " .. table.concat(resources, ", "))
if #missing > 0 then
local biomes, report = {}, {}
for x = -64, 64, 4 do for z = -64, 64, 4 do
for y = 318, 0, -1 do
local pos = {x = x, y = y, z = z}
if hearth.biomes.surface(core.get_node(pos).name) then
local data = core.get_biome_data(pos)
local name = data and core.get_biome_name(data.biome) or "unknown"
biomes[name] = (biomes[name] or 0) + 1
break
end
end
end end
for name, count in pairs(biomes) do report[#report + 1] = name .. "=" .. count end
table.sort(report)
core.log("error", "Last Hearth starting biome surface samples (4-node grid): " .. table.concat(report, ", "))
error("Starting region missing required resource: " .. table.concat(missing, ", "))
end
if mode == "seed" then
local dry_spawn = 0
local function dry_clear(p)
local node = core.get_node_or_nil({x = p.x, y = p.y, z = p.z})
if not node or node.name == "ignore" then return false end
local def = core.registered_nodes[node.name]
local groups = def and def.groups or {}
return def and def.walkable == false
and (not def.liquidtype or def.liquidtype == "none")
and (groups.water or 0) == 0 and (groups.liquid or 0) == 0
and (groups.lava or 0) == 0 and (def.damage_per_second or 0) == 0
end
local function natural_support(p)
local node = core.get_node_or_nil(p)
local support = node and node.name ~= "ignore" and core.registered_nodes[node.name]
local groups = support and support.groups or {}
if not (support and support.walkable == true
and (not support.liquidtype or support.liquidtype == "none")
and (groups.water or 0) == 0 and (groups.liquid or 0) == 0
and (groups.lava or 0) == 0 and (support.damage_per_second or 0) == 0) then return false end
for _, group in ipairs({"tree", "leaves", "wood", "hearth_canopy", "hearth_building", "hearth_non_support", "flora", "hearth_ecosystem"}) do
if (groups[group] or 0) > 0 then return false end
end
return (groups.soil or 0) > 0 or (groups.stone or 0) > 0
or (groups.sand or 0) > 0 or (groups.crumbly or 0) > 0
end
local function landing_patch(x, y, z)
for dx = -1, 1 do for dz = -1, 1 do
local p = {x = x + dx, y = y, z = z + dz}
if not natural_support(p) and not (dry_clear(p)
and natural_support({x = p.x, y = y - 1, z = p.z})) then return false end
if not dry_clear({x = p.x, y = y + 1, z = p.z})
or not dry_clear({x = p.x, y = y + 2, z = p.z}) then return false end
end end
return true
end
-- Match the player's first natural surface and 3x3 dry patch;
-- never descend through terrain/water to count a cave floor.
for x = -64, 64, 4 do for z = -64, 64, 4 do
for ground_y = 318, 0, -1 do
local p = {x = x, y = ground_y, z = z}
local node = core.get_node_or_nil(p)
local def = node and node.name ~= "ignore" and core.registered_nodes[node.name]
if not def then break end
local groups = def.groups or {}
if natural_support(p) then
if math.abs(x) < 64 and math.abs(z) < 64 and landing_patch(x, ground_y, z) then
dry_spawn = dry_spawn + 1
end
break
elseif (def.liquidtype and def.liquidtype ~= "none")
or (groups.water or 0) > 0 or (groups.liquid or 0) > 0 or (groups.lava or 0) > 0
or (def.damage_per_second or 0) > 0 or (def.walkable ~= false and (groups.tree or 0) <= 0
and (groups.leaves or 0) <= 0 and (groups.hearth_canopy or 0) <= 0) then break end
end
end end
assert(dry_spawn > 0, "Starting seed has no dry, supported spawn surface")
core.log("action", "Last Hearth seed arrival surfaces: " .. dry_spawn)
end
worldgen_check(function()
local p = {x = 0, y = 85, z = 0}
core.set_node(vector.offset(p, 0, 3, 0), {name = "hearth:roof"})
for _, d in ipairs({{1,0},{-1,0},{0,1},{0,-1}}) do
core.set_node(vector.offset(p, d[1]*2, 1, d[2]*2), {name = "hearth:wall"})
end
-- Clear this artificial fixture: dramatic terrain can now
-- occupy its old fixed altitude. This is not spawn evidence.
for x = -2, 2 do for z = -2, 2 do for y = 86, 88 do
core.set_node({x=x,y=y,z=z}, {name="air"})
end end end
for x = -3, 3 do for z = -3, 3 do
core.set_node({x = x, y = 85, z = z}, {name = "hearth:stone"})
core.set_node({x = x, y = 89, z = z}, {name = "hearth:roof"})
end end
for y = 86, 88 do for side = -3, 3 do
core.set_node({x = -3, y = y, z = side}, {name = "hearth:wall"})
core.set_node({x = 3, y = y, z = side}, {name = "hearth:wall"})
core.set_node({x = side, y = y, z = -3}, {name = "hearth:wall"})
core.set_node({x = side, y = y, z = 3}, {name = "hearth:wall"})
end end
local env = hearth.environment(p)
assert(env.roof and env.shelter == 1, "A roof and four walls must enclose a shelter")
core.remove_node(vector.offset(p, 0, 3, 0))
assert(env.enclosed and env.roof and env.shelter == 1 and not env.ventilated,
"A closed room must be sheltered but unventilated")
core.remove_node({x = 0, y = 89, z = 0})
hearth.invalidate_environment()
assert(hearth.environment(p).shelter == 0, "Walls without a roof are exposed")
finish(true)
actor_check()
end)
end)
end)
end)
@@ -161,9 +678,12 @@ core.after(1, function()
syntax_check()
if mode == "check" then finish(true); return end
registry_check()
unit_check()
media_check()
craft_closure_check()
worldgen_registry_check()
if mode ~= "seed" then unit_check() end
emerge_check()
end)
end)
core.after(60, function() finish(false, "Engine test deadline exceeded") end)
core.after(80, function() finish(false, "Engine test deadline exceeded (80s; launcher limit 90s)") end)
+90
View File
@@ -0,0 +1,90 @@
-- Bounded room/ventilation tests with an isolated fake node map.
local target = assert(arg[1], "environment.lua path required")
local function key(p) return p.x .. ":" .. p.y .. ":" .. p.z end
local h = {world = {}, reads = 0, clock = 0, fire = nil, fuel_until = 100, roof_center = nil}
local function put(x, y, z, name) h.world[key({x = x, y = y, z = z})] = name end
local function room()
h.world = {}
for x = -3, 3 do for z = -3, 3 do
put(x, 0, z, "stone")
put(x, 4, z, x == 0 and z == 0 and (h.roof_center or "roof") or "roof")
end end
for y = 1, 3 do for c = -3, 3 do
put(-3, y, c, "wall"); put(3, y, c, "wall")
put(c, y, -3, "wall"); put(c, y, 3, "wall")
end end
end
core = {registered_nodes = {
air = {walkable = false, buildable_to = true},
stone = {walkable = true}, roof = {walkable = true}, wall = {walkable = true},
["hearth:door_open"] = {walkable = false},
["hearth:vent"] = {walkable = true, groups = {hearth_vent = 1}},
}}
function core.get_node_or_nil(pos)
h.reads = h.reads + 1
local name = h.world[key(pos)]
if name == false then return nil end
return {name = name or "air"}
end
function core.get_us_time() return h.clock * 1000000 end
function core.find_node_near() return h.fire end
function core.get_meta()
return {get_string = function(_, name) return name == "fuel_until" and tostring(h.fuel_until) or "" end}
end
function core.line_of_sight() return true end
vector = {
round = function(p) return {x = math.floor(p.x + 0.5), y = math.floor(p.y + 0.5), z = math.floor(p.z + 0.5)} end,
offset = function(p, x, y, z) return {x = p.x + x, y = p.y + y, z = p.z + z} end,
distance = function(a, b) return math.sqrt((a.x-b.x)^2 + (a.y-b.y)^2 + (a.z-b.z)^2) end,
}
hearth = {model = {fuel_remaining = function(deadline) return math.max(0, deadline - 1) end},
weather = "clear", clock = {temperature = 10}}
dofile(target)
local passed = 0
local function test(name, fn)
local ok, err = pcall(fn)
assert(ok, name .. ": " .. tostring(err))
passed = passed + 1
print("ok " .. passed .. " - " .. name)
end
local player_pos = {x = 0, y = 0, z = 0}
test("closed room is fully sheltered and flood-fill work is bounded", function()
h.roof_center, h.fire = nil, nil; room(); h.reads = 0
local env = hearth.environment(player_pos)
assert(env.enclosed and env.roof and env.shelter == 1 and not env.ventilated)
assert(h.reads < 2500, "room probe exceeded its fixed-volume budget")
end)
test("open door marks one exposed side without losing all roof shelter", function()
h.roof_center, h.fire = nil, nil; room(); put(0, 1, 3, "hearth:door_open")
hearth.invalidate_environment()
local env = hearth.environment(player_pos)
assert(not env.enclosed and env.roof and env.open_sides == 1)
assert(math.abs(env.shelter - 0.8125) < 0.00001 and env.ventilated)
end)
test("a roof vent preserves weather shelter and safely exhausts hearth smoke", function()
h.roof_center, h.fire = "hearth:vent", {x = 0, y = 1, z = 0}; room()
hearth.invalidate_environment()
local env = hearth.environment(player_pos)
assert(env.enclosed and env.shelter == 1 and env.ventilated and env.heat > 0 and not env.smoke)
h.roof_center, h.fire = nil, {x = 0, y = 1, z = 0}; room(); hearth.invalidate_environment()
env = hearth.environment(player_pos)
assert(env.enclosed and env.heat > 0 and env.smoke)
end)
test("changed structure invalidates cached room geometry", function()
h.roof_center, h.fire = nil, nil; room(); hearth.invalidate_environment()
assert(hearth.environment(player_pos).enclosed)
put(0, 1, 3, "hearth:door_open")
assert(hearth.environment(player_pos).enclosed, "cache should hold until an edit invalidates it")
hearth.invalidate_environment()
assert(not hearth.environment(player_pos).enclosed)
end)
test("unloaded room probes fail closed instead of granting shelter", function()
h.roof_center, h.fire = nil, nil; room(); h.world["3:1:0"] = false
hearth.invalidate_environment()
local env = hearth.environment(player_pos)
assert(env.shelter == 0 and not env.enclosed)
end)
print("PASS " .. passed .. " room and ventilation tests")
+21
View File
@@ -0,0 +1,21 @@
-- Synthetic pre-M0 records. raw is byte-sensitive migration evidence, not Lua
-- source to evaluate. Only this trusted fixture module is loaded by the harness.
return {
needs = {
raw = ' { "food":85, "water":85, "warmth":80, "wet":0, "stamina":100 }\n',
data = {food = 85, water = 85, warmth = 80, wet = 0, stamina = 100},
},
claims = {
raw = '{ "fixture_player" : {"x":-12,"y":4,"z":8} }',
data = {fixture_player = {x = -12, y = 4, z = 8}},
},
location = {
raw = ' {"latitude":45.25, "longitude":-73.5} ',
data = {latitude = 45.25, longitude = -73.5},
},
spawn = {
raw = '{"x":0.5,"y":5.25,"z":-0.125}\n',
data = {x = 0.5, y = 5.25, z = -0.125},
},
empty_claims = {raw = '{}', data = {}},
}
+8
View File
@@ -0,0 +1,8 @@
{
"format": 1,
"scenarios": {
"first-session": { "seed": 731104, "clock": "real-date" },
"summer": { "seed": 731104, "clock": "fixed", "utc": 1782043200, "weather": "clear", "latitude": 45, "longitude": 0 },
"winter": { "seed": 731104, "clock": "fixed", "utc": 1797854400, "weather": "rain", "latitude": 45, "longitude": 0 }
}
}
+372
View File
@@ -0,0 +1,372 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as fs from 'node:fs/promises';
import { realpath } from 'node:fs';
import { promisify } from 'node:util';
import path from 'node:path';
import os from 'node:os';
import { inflateSync } from 'node:zlib';
import { FLORA_NAMES, TREE_SEASONS, LEGACY_MESH_SPECIES, SPECIES_MESH_NAMES, SPECIES_ATLAS_NAMES, floraOBJ, floraBounds, treeAtlas } from '../scripts/flora-art.mjs';
import { PALETTE, SPECIES_NAMES, EVERGREEN_NAMES, worldArt } from '../scripts/world-art.mjs';
import { BOTANY_ART_MANIFEST, generateAssets, png } from '../scripts/assets.mjs';
import { packageGame, verifyPackage } from '../scripts/package.mjs';
import { CHARACTER_ART_MANIFEST } from '../scripts/character-art.mjs';
const cross=(a,b)=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]];
const sub=(a,b)=>a.map((v,i)=>v-b[i]);
const dot=(a,b)=>a.reduce((s,n,i)=>s+n*b[i],0);
function parse(text) {
const v=[],vt=[],vn=[],groups=[]; let group;
for(const line of text.trim().split('\n')) {
const [kind,...p]=line.split(' ');
assert.ok(['#','s','v','vt','vn','g','f'].includes(kind),'no external material references');
if(kind==='v') v.push(p.map(Number));
if(kind==='vt') vt.push(p.map(Number));
if(kind==='vn') vn.push(p.map(Number));
if(kind==='g') { group={name:p[0],faces:[]}; groups.push(group); }
if(kind==='f') group.faces.push(p.map(t=>t.split('/').map(Number)));
}
return {v,vt,vn,groups};
}
test('all original deterministic, closed volumetric meshes have finite indexed triangles, outward normals and inset material UVs',()=> {
const distinct=new Set();
for(const name of FLORA_NAMES) {
const text=floraOBJ(name); assert.equal(text,floraOBJ(name)); distinct.add(text);
const {v,vt,vn,groups}=parse(text);
const species=SPECIES_MESH_NAMES.includes(name), triangles=groups.flatMap(g=>g.faces).length;
assert.ok(v.length>50 && v.length<=(species?640:300),`${name}: bounded per-species topology`);
assert.ok(triangles<=(species?1200:600),`${name}: explicit triangle budget`);
for(const values of [v,vt,vn]) assert.ok(values.flat().every(Number.isFinite));
const referenced=new Set();
for(const group of groups) {
const ids=[...new Set(group.faces.flat().map(p=>p[0]))];
const center=[0,1,2].map(i=>ids.reduce((s,id)=>s+v[id-1][i],0)/ids.length);
const edges=new Map(); let volume=0;
for(const f of group.faces) {
assert.equal(f.length,3); assert.equal(new Set(f.map(p=>p[0])).size,3);
for(const [a,t,n] of f) {
assert.ok(a>=1 && a<=v.length && t>=1 && t<=vt.length && n>=1 && n<=vn.length);
referenced.add(a);
const [u,w]=vt[t-1]; assert.ok(w>=.5/32 && w<=31.5/32);
assert.ok(group.name.startsWith('leaf') || group.name.startsWith('foliage')
? u>=32.5/64 && u<=63.5/64 : u>=.5/64 && u<=31.5/64);
}
const [a,b,c]=f.map(p=>v[p[0]-1]), normal=cross(sub(b,a),sub(c,a));
assert.ok(Math.hypot(...normal)>1e-5);
assert.ok(dot(normal,sub(a,center))>0,'outward winding');
assert.ok(dot(normal,vn[f[0][2]-1])/Math.hypot(...normal)>.99999);
volume+=dot(a,cross(b,c))/6;
for(let i=0;i<3;i++) {
const a=f[i][0], b=f[(i+1)%3][0], key=[Math.min(a,b),Math.max(a,b)].join();
const entry=edges.get(key)||[0,0]; entry[0]++; entry[1]+=a<b?1:-1; edges.set(key,entry);
}
}
assert.ok(volume>.001,'solid 3D volume, not a billboard');
for(const edge of edges.values()) assert.deepEqual(edge,[2,0],'closed oriented manifold per lobe/branch');
}
assert.equal(referenced.size,v.length);
const bound=floraBounds(name);
for(let i=0;i<3;i++) {
assert.equal(Math.min(...v.map(p=>p[i]))/10,bound.min[i]);
assert.equal(Math.max(...v.map(p=>p[i]))/10,bound.max[i]);
}
assert.equal(bound.min[1],-.5,'ground is node bottom');
assert.ok(bound.max[0]!==-bound.min[0] || bound.max[2]!==-bound.min[2],'authored asymmetry');
}
assert.equal(distinct.size,FLORA_NAMES.length);
for(const bad of ['../young_tree','constructor','oak',null]) {
assert.throws(()=>floraOBJ(bad),/Unknown flora/); assert.throws(()=>floraBounds(bad),/Unknown flora/);
}
});
test('winter trees are forked wood-only sculptures, with fewer faces and every original trunk/bough preserved exactly',()=> {
const geometry=(mesh,group)=>group.faces.map(face=>face.map(([v,t,n])=>[mesh.v[v-1],mesh.vt[t-1],mesh.vn[n-1]]));
for(const name of ['young_tree','mature_tree']) {
const summer=parse(floraOBJ(name)), winter=parse(floraOBJ(`${name}_winter`));
assert.ok(winter.groups.every(g=>!/^(leaf|foliage)/.test(g.name)),'no foliage geometry');
assert.ok(winter.groups.flatMap(g=>g.faces).length<summer.groups.flatMap(g=>g.faces).length);
const woody=summer.groups.filter(g=>!/^(leaf|foliage)/.test(g.name));
for(const group of woody) {
const retained=winter.groups.find(g=>g.name===group.name);
assert.ok(retained); assert.deepEqual(geometry(winter,retained),geometry(summer,group),group.name);
}
const forks=winter.groups.filter(g=>g.name.startsWith('winter_fork_'));
assert.equal(forks.length,woody.length-1,'one lateral fork per bough');
for(const group of forks) {
const points=geometry(winter,group).flat().map(p=>p[0]);
assert.ok(Math.max(...points.map(p=>p[0]))-Math.min(...points.map(p=>p[0]))>1,'lateral silhouette');
}
const a=floraBounds(name), b=floraBounds(`${name}_winter`);
assert.equal(b.min[1],-.5); assert.ok(b.max[1]> (name==='mature_tree'?2: .9));
for(let i=0;i<3;i++) assert.ok(b.min[i]>=a.min[i] && b.max[i]<=a.max[i],'within existing clearance envelope');
assert.ok(winter.vt.every(([u])=>u<=31.5/64),'all faces sample bark only');
}
assert.ok(!FLORA_NAMES.includes('tree_seedling_winter'),'runtime reuses seedling mesh');
});
test('tree heights, footprints and distinct overlapping foliage lobes respect gameplay scale',()=> {
for(const [name,maxHeight,x,z] of [['tree_seedling',.7,.3,.3],['young_tree',2.2,.65,.65],
['mature_tree',3.5,1.1,1.1],['deadwood',.5,.45,.9]]) {
const {min,max}=floraBounds(name);
assert.ok(max[1]-min[1]<=maxHeight+1e-6,name);
assert.ok(Math.max(Math.abs(min[0]),Math.abs(max[0]))<=x,name);
assert.ok(Math.max(Math.abs(min[2]),Math.abs(max[2]))<=z,name);
}
assert.ok(floraBounds('young_tree').max[1]>=1.3);
assert.equal(floraBounds('mature_tree').max[1],3);
for(const [name,count] of [['tree_seedling',2],['young_tree',4],['mature_tree',5]]) {
const {groups}=parse(floraOBJ(name));
assert.equal(groups.filter(g=>/^(leaf|foliage)/.test(g.name)).length,count);
assert.ok(groups.some(g=>/^(stem|trunk)$/.test(g.name)));
}
});
test('single bark/leaf atlas is deterministic 64x32 straight RGBA with solid canopy underpaint and exact encoded pixels',()=> {
const image=treeAtlas(); assert.deepEqual(image,treeAtlas());
assert.deepEqual([image.width,image.height,image.data.length],[64,32,8192]);
assert.ok(image.data.filter((_,i)=>i%4===3).every(a=>a===255),'opaque lobes do not expose torn faces');
assert.notDeepEqual(image.data.slice(0,128),image.data.slice(128,256));
const bytes=png(64,(x,y)=>image.data.subarray((y*64+x)*4,(y*64+x+1)*4),32);
assert.equal(bytes[25],6,'PNG RGBA color type');
const compressed=[];
for(let i=8;i<bytes.length;) {
const n=bytes.readUInt32BE(i);
if(bytes.toString('ascii',i+4,i+8)==='IDAT') compressed.push(bytes.subarray(i+8,i+8+n));
i+=n+12;
}
const raw=inflateSync(Buffer.concat(compressed),{maxOutputLength:9000});
for(let y=0;y<32;y++) {
assert.equal(raw[y*257],0);
assert.deepEqual(raw.subarray(y*257+1,(y+1)*257),Buffer.from(image.data.subarray(y*256,(y+1)*256)));
}
assert.throws(()=>treeAtlas(new Map()),/requires/);
});
test('all four seasonal atlases preserve byte-exact bark and opaque 64x32 materials with season-specific leaf underpaint',()=> {
const art=worldArt(), summer=treeAtlas(art), unique=new Set();
assert.deepEqual(TREE_SEASONS,['summer','spring','autumn','winter']);
for(const season of TREE_SEASONS) {
const image=treeAtlas(art,season), leaves=art.get(`hearth_leaves${season==='summer'?'':`_${season}`}.png`);
assert.deepEqual(image,treeAtlas(art,season));
assert.deepEqual([image.width,image.height,image.data.length],[64,32,8192]);
unique.add(Buffer.from(image.data).toString('hex'));
for(let y=0;y<32;y++) {
assert.deepEqual(image.data.slice(y*256,y*256+128),summer.data.slice(y*256,y*256+128),'unchanged bark');
for(let x=0;x<32;x++) {
const from=(y*32+x)*4, to=(y*64+x+32)*4;
const color=[...image.data.slice(to,to+4)]; assert.equal(color[3],255);
if(leaves.data[from+3]===255) assert.deepEqual(color,[...leaves.data.slice(from,from+4)]);
else assert.deepEqual(color,[...PALETTE[{summer:'leafDark',spring:'springDark',autumn:'autumnDark',winter:'barkDark'}[season]],255]);
}
}
}
assert.equal(unique.size,TREE_SEASONS.length);
for(const bad of ['Summer','../spring','constructor',null]) assert.throws(()=>treeAtlas(art,bad),/Unknown tree season/);
for(const season of TREE_SEASONS) assert.throws(()=>treeAtlas(new Map(),season),/requires/);
});
test('deprecated 23-species-mesh inventory stays frozen; all ten cube species have 34 compatibility atlases',()=> {
const species=['oak','birch','pine','willow','alder'];
const meshes=species.flatMap(s=>[`${s}_seedling`,`${s}_young`,`${s}_mature`,
...(s==='pine'?[]:[`${s}_young_winter`,`${s}_mature_winter`])]);
const atlases=SPECIES_NAMES.flatMap(s=>[`${s}_atlas`,...(EVERGREEN_NAMES.includes(s)?[]:['spring','autumn','winter'].map(n=>`${s}_atlas_${n}`))]);
assert.deepEqual(LEGACY_MESH_SPECIES,species);
assert.deepEqual(SPECIES_MESH_NAMES,meshes); assert.deepEqual(SPECIES_ATLAS_NAMES,atlases);
assert.equal(meshes.length,23); assert.equal(atlases.length,34); assert.equal(FLORA_NAMES.length,29);
for(const s of SPECIES_NAMES.slice(5)) for(const stage of ['seedling','young','mature','young_winter','mature_winter'])
assert.throws(()=>floraOBJ(`${s}_${stage}`),/Unknown flora/,'new species must not grow unused irregular meshes');
for(const s of species) assert.ok(!FLORA_NAMES.includes(`${s}_deadwood`));
for(const bad of ['pine_mature_winter','oak_seedling_winter','oak_mature_spring','../oak_mature'])
assert.throws(()=>floraOBJ(bad),/Unknown flora/);
});
const points=(mesh,group)=>[...new Set(group.faces.flat().map(p=>p[0]))].sort((a,b)=>a-b).map(i=>mesh.v[i-1]);
const extents=ps=>[0,1,2].map(i=>Math.max(...ps.map(p=>p[i]))-Math.min(...ps.map(p=>p[i])));
test('species architectures convey broad oak, airy slim birch, tapered pine whorls, drooping willow and rounded alder within clearance',()=> {
const meshes=Object.fromEntries(LEGACY_MESH_SPECIES.map(s=>[s,parse(floraOBJ(`${s}_mature`))]));
for(const s of LEGACY_MESH_SPECIES) for(const stage of ['seedling','young','mature']) {
const {min,max}=floraBounds(`${s}_${stage}`), half=stage==='mature'?1.5:stage==='young'?.75:.3;
assert.equal(min[1],-.5);
assert.ok(max[1]-min[1]<=(stage==='mature'?5:stage==='young'?2.5:1));
for(const i of [0,2]) assert.ok(min[i]>=-half && max[i]<=half,`${s}_${stage}: footprint`);
const mesh=parse(floraOBJ(`${s}_${stage}`));
assert.ok(mesh.groups.some(g=>/^(stem|trunk)$/.test(g.name)));
assert.ok(mesh.groups.filter(g=>/^(leaf|foliage)/.test(g.name)).length>=2);
}
const oak=extents(meshes.oak.v), birch=extents(meshes.birch.v), alder=extents(meshes.alder.v);
assert.ok(oak[0]>birch[0]*1.5,'spreading oak versus slim birch');
assert.ok(birch[1]/birch[0]>2.5,'birch is tall and narrow');
assert.ok(oak[0]>alder[0] && alder[1]<oak[1],'alder is compact, rounded and shorter');
const trunkWidth=s=>extents(points(meshes[s],meshes[s].groups.find(g=>g.name==='trunk')))[0];
assert.ok(trunkWidth('birch')<trunkWidth('oak')*.7,'slim birch bole, not just lighter paint');
const pine=meshes.pine;
for(let i=0;i<3;i++) {
const lower=pine.groups.find(g=>g.name===`foliage_tier_${i}_0`), upper=pine.groups.find(g=>g.name===`foliage_tier_${i+1}_0`);
assert.ok(extents(points(pine,lower))[0]>extents(points(pine,upper))[0],'evergreen whorls taper upward');
}
assert.ok(pine.groups.some(g=>g.name==='foliage_leader'));
const willow=meshes.willow, curtains=willow.groups.filter(g=>g.name.startsWith('foliage_curtain_'));
assert.equal(curtains.length,6);
for(const g of curtains) {
const e=extents(points(willow,g)); assert.ok(e[1]>e[0]*3 && e[1]>e[2]*3,'long pendant leaf curtains');
}
const bough=willow.groups.find(g=>g.name==='pendant_0'), ps=points(willow,bough);
const a=ps.slice(0,8).reduce((n,p)=>n+p[1],0)/8, b=ps.slice(8).reduce((n,p)=>n+p[1],0)/8;
assert.ok(a>b+10,'willow terminal wood droops, not only its painted leaves');
});
test('deciduous winter species preserve all woody parts, omit every crown and sample bark only; pine stays evergreen',()=> {
const geometry=(m,g)=>g.faces.map(f=>f.map(([v,t,n])=>[m.v[v-1],m.vt[t-1],m.vn[n-1]]));
for(const s of LEGACY_MESH_SPECIES.filter(s=>s!=='pine')) for(const stage of ['young','mature']) {
const leafy=parse(floraOBJ(`${s}_${stage}`)), bare=parse(floraOBJ(`${s}_${stage}_winter`));
const woody=leafy.groups.filter(g=>!g.name.startsWith('foliage'));
assert.deepEqual(bare.groups.map(g=>g.name),woody.map(g=>g.name),'all forks and pendants retained');
for(const g of woody) assert.deepEqual(geometry(bare,bare.groups.find(b=>b.name===g.name)),geometry(leafy,g));
assert.ok(bare.vt.every(([u])=>u<=31.5/64));
assert.ok(bare.groups.flatMap(g=>g.faces).length<leafy.groups.flatMap(g=>g.faces).length);
const a=floraBounds(`${s}_${stage}`), b=floraBounds(`${s}_${stage}_winter`);
for(let i=0;i<3;i++) assert.ok(b.min[i]>=a.min[i] && b.max[i]<=a.max[i]);
}
assert.ok(parse(floraOBJ('pine_mature')).groups.some(g=>g.name.startsWith('foliage')));
assert.throws(()=>treeAtlas(worldArt(),'winter','pine'),/evergreen/);
});
test('all species atlases use exact own bark and own seasonal leaves, opaque underpaint and half-texel inset UVs',()=> {
const art=worldArt(), distinct=new Set();
for(const s of SPECIES_NAMES) {
const summer=treeAtlas(art,'summer',s), seasons=EVERGREEN_NAMES.includes(s)?['summer']:TREE_SEASONS;
for(const season of seasons) {
const image=treeAtlas(art,season,s), bark=art.get(`hearth_${s}_log_side.png`),
leaf=art.get(`hearth_${s}_leaves${season==='summer'?'':`_${season}`}.png`);
assert.deepEqual(image,treeAtlas(art,season,s));
assert.deepEqual([image.width,image.height,image.data.length],[64,32,8192]);
distinct.add(Buffer.from(image.data).toString('hex'));
for(let y=0;y<32;y++) for(let x=0;x<32;x++) {
const from=(y*32+x)*4, left=(y*64+x)*4, right=left+128;
assert.deepEqual(image.data.slice(left,left+4),bark.data.slice(from,from+4));
assert.deepEqual(image.data.slice(left,left+4),summer.data.slice(left,left+4));
assert.equal(image.data[right+3],255);
if(leaf.data[from+3]) assert.deepEqual(image.data.slice(right,right+4),leaf.data.slice(from,from+4));
else {
const color=season==='summer'?{oak:'leafDark',birch:'leaf',pine:'pineShade',willow:'willowShade',alder:'leafDark',
beech:'leafDark',aspen:'leafDark',rowan:'leafDark',hazel:'leafDark',spruce:'pineShade'}[s]:
{spring:'springDark',autumn:'autumnDark',winter:'barkDark'}[season];
assert.deepEqual([...image.data.slice(right,right+4)],[...PALETTE[color],255]);
}
}
}
}
assert.equal(distinct.size,34);
for(const s of EVERGREEN_NAMES) for(const season of ['spring','autumn','winter'])
assert.throws(()=>treeAtlas(art,season,s),/evergreen/);
for(const bad of ['constructor','../oak','maple','']) assert.throws(()=>treeAtlas(art,'summer',bad),/Unknown tree species/);
});
test('minimal fixture generation and integrity-verified package include every seasonal texture, atlas and model; linked outputs refused',async t=> {
const root=await fs.mkdtemp(path.join(await promisify(realpath)(os.tmpdir()),'hearth-flora-'));
t.after(()=>fs.rm(root,{recursive:true,force:true}));
const source=path.join(root,'source'), out=path.join(root,'out');
await fs.mkdir(path.join(source,'mods/hearth'),{recursive:true});
for(const name of ['init','model','runtime','content','encounters'])
await fs.writeFile(path.join(source,`mods/hearth/${name}.lua`),'-- minimal fixture\n');
await generateAssets(out,source);
async function check(destination) {
for(const name of FLORA_NAMES) assert.equal(await fs.readFile(path.join(destination,`mods/hearth/models/hearth_${name}.obj`),'utf8'),floraOBJ(name));
const art=worldArt();
for(const season of TREE_SEASONS) {
const atlas=treeAtlas(art,season), expected=png(64,(x,y)=>atlas.data.subarray((y*64+x)*4,(y*64+x+1)*4),32);
assert.deepEqual(await fs.readFile(path.join(destination,`mods/hearth/textures/hearth_tree_atlas${season==='summer'?'':`_${season}`}.png`)),expected);
}
for(const name of SPECIES_ATLAS_NAMES) {
const [s,,season='summer']=name.split('_'), atlas=treeAtlas(art,season,s);
const expected=png(64,(x,y)=>atlas.data.subarray((y*64+x)*4,(y*64+x+1)*4),32);
assert.deepEqual(await fs.readFile(path.join(destination,`mods/hearth/textures/hearth_${name}.png`)),expected);
}
for(const [name,image] of art) assert.deepEqual(await fs.readFile(path.join(destination,'mods/hearth/textures',name)),
png(32,(x,y)=>image.data.subarray((y*32+x)*4,(y*32+x+1)*4)));
const expectedTextures=[...art.keys(),...TREE_SEASONS.map(s=>`hearth_tree_atlas${s==='summer'?'':`_${s}`}.png`),
...SPECIES_ATLAS_NAMES.map(n=>`hearth_${n}.png`),
...['knife','axe','pick','spear'].map(n=>`hearth_icon_${n}.png`),
...CHARACTER_ART_MANIFEST.map(entry=>entry.filename),
'hearth_character_contact_sheet.png',
'hearth_person.png','crosshair.png','hearth_art_contact_sheet.png','hearth_c7aa83.png','hearth_947853.png'];
assert.equal(expectedTextures.length,578,'includes all 170 character layers and their review sheet');
assert.deepEqual(JSON.parse(await fs.readFile(path.join(destination,'botany-art.json'),'utf8')),
{format:1,images:BOTANY_ART_MANIFEST},'all 160 botanical image identities are packaged');
assert.deepEqual((await fs.readdir(path.join(destination,'mods/hearth/textures'))).sort(),expectedTextures.sort());
const expectedModels=[...FLORA_NAMES,...['hare','fox','deer','knife','axe','pick','spear','person']]
.map(n=>`hearth_${n}.obj`).concat('hearth_person.b3d','hearth_person_compact.b3d','hearth_person_broad.b3d');
assert.equal(expectedModels.length,40,'37 OBJ files plus three animated player builds');
assert.deepEqual((await fs.readdir(path.join(destination,'mods/hearth/models'))).sort(),expectedModels.sort());
}
await check(out);
for(const name of ['game.conf','minetest.conf','settingtypes.txt','LICENSE.txt']) await fs.writeFile(path.join(source,name),'fixture\n');
await fs.writeFile(path.join(source,'release.json'),JSON.stringify({format:1,gameId:'last_hearth',version:'0.4.0-m3',
engine:{name:'Luanti',version:'5.18.0'},node:'>=22',packageFormat:1,backupFormat:1}));
await fs.mkdir(path.join(source,'tests/fixtures'),{recursive:true});
await fs.writeFile(path.join(source,'tests/fixtures/scenarios.json'),'{}');
const packaged=path.join(root,'package'); await packageGame(packaged,{source}); await check(packaged); await verifyPackage(packaged);
const outside=path.join(root,'outside'); await fs.mkdir(outside);
await fs.rm(path.join(out,'mods/hearth/models'),{recursive:true});
await fs.symlink(outside,path.join(out,'mods/hearth/models'),process.platform==='win32'?'junction':'dir');
await assert.rejects(generateAssets(out,source),/link|junction|unsafe/i);
assert.deepEqual(await fs.readdir(outside),[]);
});
test('read-only cube diversity and biome contracts cover ten exact species; review layout is documented',async()=> {
const source=await fs.readFile(new URL('../mods/hearth/diversity.lua',import.meta.url),'utf8');
const species=source.match(/species_order=\{([^}]+)\}/)[1].match(/"[a-z]+"/g).map(s=>s.slice(1,-1));
assert.deepEqual(species,SPECIES_NAMES);
assert.match(source,/drawtype=woody and "normal" or "plantlike"/);
assert.match(source,/tiles=woody and \{"hearth_"\.\.s\.\."_log_top.png","hearth_"\.\.s\.\."_log_top.png","hearth_"\.\.s\.\."_log_side.png"\}/);
assert.ok(!source.includes('.obj') && !source.includes('_atlas'),'no runtime dependency on deprecated irregular tree meshes');
assert.match(source,/evergreen=s=="pine" or s=="spruce"/);
assert.match(source,/plant~="moss" and plant~="lichen"/,'persistent groundcover has no dormant alias');
const biomes=await fs.readFile(new URL('../mods/hearth/biomes.lua',import.meta.url),'utf8');
const biomeSpecies=biomes.match(/species_order\s*=\s*\{([^}]+)\}/)[1].match(/"[a-z]+"/g).map(s=>s.slice(1,-1));
assert.deepEqual(biomeSpecies,SPECIES_NAMES);
const art=worldArt();
for(const s of species) for(const face of ['log_top','log_side','leaves','planks'])
assert.ok(art.has(`hearth_${s}_${face}.png`));
const docs=await fs.readFile(new URL('../docs/art-direction.md',import.meta.url),'utf8');
const current=docs.slice(0,docs.indexOf('<details>'));
assert.match(current,/512×736/); assert.match(current,/1–512/); assert.match(current,/16 columns/);
assert.match(current,/ceil\(count\/16\)\*32/);
assert.match(current,/360/); assert.match(current,/160/); assert.match(current,/botanicalLatin/);
for(const s of SPECIES_NAMES) assert.ok(current.includes(s));
});
test('actual seasonal content uses exact generated stems, explicit cube tiles and separate patch/craftitem naming',async()=> {
const text=await fs.readFile(new URL('../mods/hearth/content.lua',import.meta.url),'utf8');
const loop=text.slice(text.indexOf('for _, plant in ipairs({'),text.indexOf('-- Cosmetic migration'));
assert.match(loop,/drawtype = woody and "normal" or "plantlike"/);
assert.match(loop,/visual_scale = woody and 1 or scale/);
assert.match(loop,/walkable = woody/);
assert.match(loop,/buildable_to = not woody/);
assert.match(loop,/tiles = woody and \{world_image\("log_top"\),world_image\("log_top"\),world_image\("log_side"\)\}/);
assert.ok(!loop.includes('.obj'),'retained OBJ exports are not the runtime tree contract');
assert.match(text,/def\.tiles = def\.tiles or/,'registrar preserves explicit mesh tiles without override_item');
for(const name of ['meadow_grass','wildflowers','fern']) assert.ok(loop.includes(`{"${name}"`));
const seasons=await fs.readFile(new URL('../mods/hearth/seasons.lua',import.meta.url),'utf8');
const docs=await fs.readFile(new URL('../docs/ecosystems.md',import.meta.url),'utf8');
const art=worldArt();
for(const stem of ['meadow_grass_dormant','fern_dormant','wildflowers_dormant',
'spring_greens','spring_greens_dormant','autumn_mushrooms','autumn_mushrooms_dormant',
'item_spring_greens','item_autumn_mushrooms']) {
assert.ok(art.has(`hearth_${stem}.png`)); assert.ok(docs.includes(`hearth_${stem}.png`));
}
for(const kind of ['tree_seedling','young_tree','mature_tree','leaves']) for(const season of ['spring','autumn','winter']) {
const mesh=kind==='leaves'?null:`${kind}${season==='winter' && kind!=='tree_seedling'?'_winter':''}`;
if(mesh) assert.ok(FLORA_NAMES.includes(mesh));
if(kind==='leaves') assert.ok(art.has(`hearth_leaves_${season}.png`));
else assert.ok(TREE_SEASONS.includes(season));
}
// Seasonal runtime cube-copy semantics are tested by the seasons owner; this
// suite verifies available bytes, not a stale assertion requiring OBJ usage.
for(const kind of ['spring_greens','autumn_mushrooms']) {
assert.ok(text.includes(`{"${kind}",`));
assert.ok(seasons.includes(`variant("${kind}_patch",`));
assert.ok(text.includes(`api.register_craftitem("hearth:${kind}",`));
assert.ok(!art.has(`hearth_${kind}_patch.png`),'patch node name is not a texture stem');
}
});
+122
View File
@@ -0,0 +1,122 @@
-- Isolated tests for real claim commands, persistence, and wrapper protection.
local target = assert(arg[1], "init.lua path required")
local saved = {claims = {}, homes = {}, access = {}}
local writes, commands, particles = {}, {}, {}
local players = {}
local function copy(value)
if type(value) ~= "table" then return value end
local result = {}; for k, v in pairs(value) do result[k] = copy(v) end; return result
end
for _, name in ipairs({"owner", "housemate", "keeper", "outsider", "another-owner"}) do
local player = {name = name, pos = {x = 0, y = 10, z = 0}}
function player:get_player_name() return self.name end
function player:get_pos() return self.pos end
players[name] = player
end
core = {
registered_nodes = {},
get_modpath = function() return "/hearth" end,
get_mod_storage = function() return {} end,
get_player_by_name = function(name) return players[name] end,
register_chatcommand = function(name, def) commands[name] = def end,
is_protected = function() return false end,
record_protection_violation = function() end,
chat_send_player = function() end,
add_particle = function(spec) particles[#particles + 1] = spec end,
}
vector = {round = function(p) return {x = math.floor(p.x + 0.5), y = math.floor(p.y + 0.5), z = math.floor(p.z + 0.5)} end}
local persistence = {
load_world = function(kind) return copy(saved[kind]) end,
save_world = function(kind, value) saved[kind] = copy(value); writes[#writes + 1] = kind end,
}
local original_dofile = dofile
local loaded, character_marker = {}, {}
dofile = function(path)
local name = path:match("([^/\\]+%.lua)$")
loaded[#loaded + 1] = name
if name == "character.lua" then return function(h, api)
assert(h == hearth and api == core); return character_marker
end end
if name == "interface.lua" or name == "players.lua" then
assert(hearth.character == character_marker, "Character factory must precede UI and players")
end
if name == "model.lua" then return {location = function() return true end} end
if name == "schema.lua" then return {} end
if name == "persistence.lua" then return function() return persistence end end
if name == "access.lua" then
local source = path:match("[/\\]init%.lua$") and path:gsub("init%.lua$", "access.lua") or "/hearth/access.lua"
return original_dofile(source)
end
if name == "movement.lua" or name == "combat.lua" then return {} end
return true -- Remaining bootstrap modules are intentionally inert in this suite.
end
local total = 0
local function test(name, fn)
local ok, err = pcall(fn)
assert(ok, name .. ": " .. tostring(err))
total = total + 1
print("ok " .. total .. " - " .. name)
end
local function command(param, name)
return commands.claim.func(name or "owner", param or "")
end
original_dofile(target)
test("character factory loads once before interface and player initialization", function()
local positions = {}
for i, name in ipairs(loaded) do
assert(not positions[name], "Duplicate module load: " .. name); positions[name] = i
end
assert(hearth.character == character_marker)
assert(positions["character.lua"] < positions["interface.lua"] and positions["character.lua"] < positions["players.lua"])
assert(positions["presentation.lua"] < positions["players.lua"])
end)
test("claim creation, boundaries, and world persistence are owner-scoped", function()
local ok = command("")
assert(ok and saved.claims.owner.x == 0 and saved.claims.owner.y == 10)
assert(hearth.claim_role_at({x = 0, y = 10, z = 0}, "owner") == "owner")
assert(hearth.claim_allowed({x = 12, y = 34, z = 12}, "owner", "build"))
assert(not hearth.claim_allowed({x = 12, y = 10, z = 0}, "outsider", "build"))
local shown, message = command("show")
assert(shown and message:find("X %-12%.%.12") and #particles >= 40)
local listed = command("list")
assert(listed and #writes == 1)
end)
test("resident and storekeeper roles are least-privilege and immediately persisted", function()
assert(command("share housemate resident"))
assert(hearth.claim_allowed({x = 0, y = 10, z = 0}, "housemate", "build"))
assert(hearth.claim_allowed({x = 0, y = 10, z = 0}, "housemate", "dig"))
assert(hearth.claim_allowed({x = 0, y = 10, z = 0}, "housemate", "use"))
assert(not hearth.claim_allowed({x = 0, y = 10, z = 0}, "housemate", "combat"))
assert(command("share keeper storekeeper"))
assert(hearth.claim_allowed({x = 0, y = 10, z = 0}, "keeper", "storage"))
for _, action in ipairs({"build", "dig", "use", "combat"}) do
assert(not hearth.claim_allowed({x = 0, y = 10, z = 0}, "keeper", action), action)
end
local ok, members = command("list")
assert(ok and members:find("housemate %(resident%)") and members:find("keeper %(storekeeper%)"))
end)
test("invalid grants, outsider mutations, and revoked permissions fail closed", function()
players["another-owner"].pos = {x = 100, y = 10, z = 0}
assert(command("", "another-owner"))
for _, param in ipairs({"share bad/name resident", "share outsider admin", "share owner resident",
"share another-owner resident", "unshare bad/name"}) do
assert(not command(param), param)
end
assert(not command("share outsider resident", "outsider"))
assert(command("unshare housemate"))
assert(not hearth.claim_allowed({x = 0, y = 10, z = 0}, "housemate", "build"))
assert(not command("share " .. string.rep("a", 33) .. " resident"))
for i = 1, 15 do assert(command("share crew" .. i .. " resident")) end
assert(not command("share overflow resident"))
end)
test("claim release revokes grants and removes the owner's saved home", function()
assert(hearth.in_own_claim({x = 0, y = 10, z = 0}, "owner"))
hearth.store_home("owner", {x = 0, y = 10, z = 0})
assert(hearth.home_for("owner"))
assert(command("remove"))
assert(not saved.claims.owner and not saved.access.owner and not hearth.home_for("owner"))
assert(hearth.claim_allowed({x = 0, y = 10, z = 0}, "outsider", "build"))
end)
print("PASS " .. total .. " claim service tests")
+581
View File
@@ -0,0 +1,581 @@
-- Isolated M2 hearth process tests; pass mods/hearth/processes.lua as argument.
assert(arg and arg[1], "Pass the path to mods/hearth/processes.lua")
local now, game_now = 100000, 500
local protected, claimed, distant, violations = false, true, false, 0
local world, metadata, timers, sounds = {}, {}, {}, {}
local function key(pos) return pos.x .. ":" .. pos.y .. ":" .. pos.z end
local hearth_pos = {x = 0, y = 0, z = 0}
local function stack(text)
local name, count = (text or ""):match("^(%S+)%s*(%d*)$")
return setmetatable({name = name or "", count = name and (tonumber(count) or 1) or 0,
wear = 0}, {
__index = {
get_name = function(self) return self.name end,
get_count = function(self) return self.count end,
is_empty = function(self) return self.count == 0 end,
take_item = function(self, amount)
self.count = math.max(0, self.count - (amount or 1))
if self.count == 0 then self.name = "" end
end,
add_wear = function(self, amount)
self.wear = self.wear + amount
if self.wear >= 65535 then self.name, self.count = "", 0 end
end,
},
})
end
ItemStack = stack
local function meta_for(pos)
local k = key(pos)
if not metadata[k] then
local fields = {}
metadata[k] = {
set_string = function(_, name, value) fields[name] = tostring(value) end,
get_string = function(_, name) return fields[name] or "" end,
set_int = function(_, name, value) fields[name] = math.floor(value) end,
get_int = function(_, name) return tonumber(fields[name]) or 0 end,
}
end
return metadata[k]
end
local api = {}
function api.get_meta(pos) return meta_for(pos) end
function api.get_node(pos) return world[key(pos)] or {name = "air"} end
function api.swap_node(pos, node) world[key(pos)] = node end
function api.get_gametime() return game_now end
function api.get_node_timer(pos)
local k = key(pos)
if not timers[k] then
timers[k] = {
start = function(self, timeout) self.timeout = timeout end,
stop = function(self) self.timeout = nil end,
}
end
return timers[k]
end
function api.sound_play(name, params) sounds[#sounds + 1] = {name = name, params = params} end
local player
local function allowed(pos, who)
local dx = pos.x - who.position.x
local dy = pos.y - who.position.y
local dz = pos.z - who.position.z
if protected or not claimed or distant or dx * dx + dy * dy + dz * dz > 36 then
violations = violations + 1
return false
end
return true
end
local construction = {allowed = allowed}
local hearth = {}
function hearth.notify(_, message) hearth.last_notice = message end
local processes = dofile(arg[1])(hearth, api, construction)
local inventory = {room = true, added = {}, reject_next = false}
function inventory:room_for_item(_, item) return self.room and not item:is_empty() end
function inventory:add_item(_, item)
if self.reject_next then
self.reject_next = false
return stack(item:get_name())
end
self.added[#self.added + 1] = item:get_name()
return stack("")
end
player = {position = {x = 0, y = 0, z = 0}}
function player:get_inventory() return inventory end
local real_time = os.time
os.time = function() return now end
local function eq(actual, expected)
assert(actual == expected, "expected " .. tostring(expected) .. ", got " .. tostring(actual))
end
local passed = 0
local function reset()
now, game_now = 100000, 500
protected, claimed, distant, violations = false, true, false, 0
world, metadata, timers, sounds = {}, {}, {}, {}
inventory.room, inventory.added, inventory.reject_next = true, {}, false
player.position = {x = 0, y = 0, z = 0}
hearth.last_notice = nil
world[key(hearth_pos)] = {name = "hearth:fire", param2 = 2}
local meta = api.get_meta(hearth_pos)
meta:set_int("lit", 0)
meta:set_string("fuel_until", "0")
end
local function test(name, fn)
reset()
local ok, err = pcall(fn)
if not ok then error(name .. ": " .. tostring(err), 0) end
passed = passed + 1
print("ok " .. passed .. " - " .. name)
end
local function use(text)
local item = stack(text)
processes.use_fire(hearth_pos, api.get_node(hearth_pos), player, item)
return item
end
local function starter(item, pos)
return processes.use_firestarter(item, player, {type = "node", under = pos or hearth_pos})
end
local function deadline(pos)
return tonumber(api.get_meta(pos or hearth_pos):get_string("fuel_until")) or 0
end
local function advance(seconds)
now, game_now = now + seconds, game_now + seconds
local timer = api.get_node_timer(hearth_pos)
if timer.timeout then
timer.timeout = timer.timeout - seconds
if timer.timeout <= 0 then processes.fire_tick(hearth_pos) end
end
end
test("fuel does not ignite itself and a firestarter is reusable", function()
local firestarter = stack("hearth:firestarter")
starter(firestarter)
eq(firestarter.wear, 0)
eq(api.get_node(hearth_pos).name, "hearth:fire")
eq(use("hearth:sticks"):get_count(), 0)
eq(api.get_node(hearth_pos).name, "hearth:fire")
eq(api.get_meta(hearth_pos):get_int("lit"), 0)
starter(firestarter)
eq(api.get_node(hearth_pos).name, "hearth:fire_lit")
eq(firestarter.wear, math.ceil(65535 / 20))
local second = {x = 1, y = 0, z = 0}
world[key(second)] = {name = "hearth:fire", param2 = 0}
api.get_meta(second):set_int("lit", 0)
api.get_meta(second):set_string("fuel_until", "0")
processes.use_fire(second, api.get_node(second), player, stack("hearth:sticks"))
starter(firestarter, second)
eq(firestarter.wear, math.ceil(65535 / 20) * 2)
for _ = 3, 20 do
local meta = api.get_meta(hearth_pos)
meta:set_int("lit", 0)
processes.refresh_fire(hearth_pos, processes.fuel_left(hearth_pos, now), now)
starter(firestarter)
end
eq(firestarter.wear, math.ceil(65535 / 20) * 20)
eq(firestarter:is_empty(), true)
eq(#sounds, 20)
eq(sounds[1].name, "hearth_fire")
end)
test("cooking waits six real seconds and reserves the heat cost at start", function()
use("hearth:sticks")
starter(stack("hearth:firestarter"))
local input = use("hearth:raw_food 2")
eq(input:get_count(), 1)
eq(api.get_meta(hearth_pos):get_int("job_until"), now + 6)
eq(api.get_meta(hearth_pos):get_int("job_cost"), 20)
eq(deadline(), now + 25)
eq(api.get_node_timer(hearth_pos).timeout, 6)
advance(5)
eq(api.get_meta(hearth_pos):get_string("job_kind"), "cook")
eq(deadline(), now + 20)
advance(1)
eq(api.get_meta(hearth_pos):get_string("job_kind"), "")
eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:cooked_food")
eq(deadline(), now + 19)
processes.fire_tick(hearth_pos)
eq(deadline(), now + 19)
end)
test("boiling waits four gameplay seconds and charges its own fuel cost", function()
use("hearth:sticks")
starter(stack("hearth:firestarter"))
use("hearth:water_raw")
eq(api.get_meta(hearth_pos):get_int("job_until"), now + 4)
eq(api.get_meta(hearth_pos):get_int("job_cost"), 10)
advance(3)
eq(api.get_meta(hearth_pos):get_string("job_kind"), "boil")
eq(deadline(), now + 32)
advance(1)
eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:water_clean")
eq(deadline(), now + 31)
end)
test("process start requires the complete duration-plus-cost fuel reserve", function()
local meta = api.get_meta(hearth_pos)
meta:set_int("lit", 1)
meta:set_string("fuel_until", tostring(now + 25))
local too_little = use("hearth:raw_food")
eq(too_little:get_count(), 1)
eq(meta:get_string("job_kind"), "")
meta:set_string("fuel_until", tostring(now + 27))
local enough = use("hearth:raw_food")
eq(enough:get_count(), 0)
eq(meta:get_string("job_kind"), "cook")
advance(6)
eq(deadline(), now + 1)
eq(api.get_node(hearth_pos).name, "hearth:fire_lit")
end)
test("inventory rejection preserves completed output until a successful claim", function()
use("hearth:sticks")
starter(stack("hearth:firestarter"))
use("hearth:raw_food")
advance(6)
inventory.room = false
processes.use_fire(hearth_pos, api.get_node(hearth_pos), player, stack(""))
eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:cooked_food")
inventory.room, inventory.reject_next = true, true
processes.use_fire(hearth_pos, api.get_node(hearth_pos), player, stack(""))
eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:cooked_food")
eq(#inventory.added, 0)
processes.use_fire(hearth_pos, api.get_node(hearth_pos), player, stack(""))
eq(api.get_meta(hearth_pos):get_string("job_result"), "")
eq(#inventory.added, 1)
eq(inventory.added[1], "hearth:cooked_food")
end)
test("a queued job and pending output block replacement or a second queue", function()
use("hearth:sticks")
starter(stack("hearth:firestarter"))
use("hearth:raw_food")
eq(use("hearth:water_raw"):get_count(), 1)
eq(api.get_meta(hearth_pos):get_string("job_kind"), "cook")
advance(6)
eq(use("hearth:water_raw"):get_count(), 1)
eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:cooked_food")
end)
test("load reconciliation resumes remaining time and completes overdue jobs once", function()
use("hearth:sticks")
starter(stack("hearth:firestarter"))
use("hearth:water_raw")
game_now, now = game_now + 2, now + 2 -- Simulate shutdown without timer callbacks.
processes.fire_tick(hearth_pos) -- The registered LBM callback.
eq(api.get_node_timer(hearth_pos).timeout, 2)
advance(2)
eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:water_clean")
local charged = deadline()
game_now, now = game_now + 20, now + 20
processes.fire_tick(hearth_pos)
processes.fire_tick(hearth_pos)
eq(deadline(), charged)
eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:water_clean")
end)
test("charcoal sustains M4 iron-smelting charges and produces one ingot", function()
use("hearth:charcoal")
starter(stack("hearth:firestarter"))
local charge = use("hearth:smelt_charge")
eq(charge:get_count(), 0)
eq(api.get_meta(hearth_pos):get_string("job_kind"), "smelt")
eq(deadline(), now + 60)
advance(60)
eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:iron_ingot")
use("")
eq(inventory.added[1], "hearth:iron_ingot")
eq(api.get_meta(hearth_pos):get_string("job_result"), "")
end)
for _, species in ipairs({"oak", "birch", "pine", "willow", "alder", "beech", "aspen", "rowan", "hazel", "spruce"}) do
test(species .. " logs fuel directly for 180 seconds without ignition or overflow", function()
eq(use("hearth:" .. species .. "_log 2"):get_count(), 1)
eq(deadline(), now + 180)
eq(api.get_meta(hearth_pos):get_int("lit"), 0)
use("hearth:" .. species .. "_log")
use("hearth:" .. species .. "_log")
eq(deadline(), now + 540)
eq(use("hearth:" .. species .. "_log"):get_count(), 1)
eq(deadline(), now + 540)
end)
end
test("coal adds 240 seconds and rejects overflow without consuming an item", function()
eq(use("hearth:coal 3"):get_count(), 2)
eq(deadline(), now + 240)
eq(api.get_meta(hearth_pos):get_int("lit"), 0)
use("hearth:coal")
eq(deadline(), now + 480)
eq(use("hearth:coal"):get_count(), 1)
eq(deadline(), now + 480)
advance(120)
eq(use("hearth:coal"):get_count(), 0)
eq(processes.fuel_left(hearth_pos, now), 600)
eq(use("hearth:sticks"):get_count(), 1)
end)
test("coal fuels copper smelting for 45 seconds with 30 reserved and one ingot", function()
use("hearth:coal")
starter(stack("hearth:firestarter"))
eq(use("hearth:copper_charge 2"):get_count(), 1)
local meta = api.get_meta(hearth_pos)
eq(meta:get_string("job_kind"), "smelt_copper")
eq(meta:get_int("job_until"), now + 45)
eq(meta:get_int("job_cost"), 30)
eq(deadline(), now + 210)
eq(api.get_node_timer(hearth_pos).timeout, 45)
assert(meta:get_string("infotext"):find("smelting copper", 1, true))
advance(44)
eq(meta:get_string("job_kind"), "smelt_copper")
eq(use(""):get_count(), 0)
eq(#inventory.added, 0)
advance(1)
eq(meta:get_string("job_kind"), "")
eq(meta:get_string("job_result"), "hearth:copper_ingot")
eq(processes.fuel_left(hearth_pos, now), 165)
use(""); use(""); processes.fire_tick(hearth_pos)
eq(#inventory.added, 1)
eq(inventory.added[1], "hearth:copper_ingot")
eq(meta:get_string("job_result"), "")
end)
test("copper needs ignition and exactly duration plus cost before consuming its charge", function()
use("hearth:coal")
eq(use("hearth:copper_charge"):get_count(), 1)
starter(stack("hearth:firestarter"))
local meta = api.get_meta(hearth_pos)
meta:set_string("fuel_until", tostring(now + 74))
eq(use("hearth:copper_charge"):get_count(), 1)
eq(meta:get_string("job_kind"), "")
eq(deadline(), now + 74)
meta:set_string("fuel_until", tostring(now + 75))
eq(use("hearth:copper_charge"):get_count(), 0)
eq(deadline(), now + 45)
advance(45)
eq(meta:get_string("job_result"), "hearth:copper_ingot")
eq(processes.fuel_left(hearth_pos, now), 0)
end)
test("copper reload resumes then completes long-offline output idempotently", function()
use("hearth:pine_log")
starter(stack("hearth:firestarter"))
use("hearth:copper_charge")
local charged = deadline()
now = now + 20 -- No timer callback while unloaded.
local reloaded = dofile(arg[1])(hearth, api, construction)
reloaded.fire_tick(hearth_pos)
eq(api.get_node_timer(hearth_pos).timeout, 25)
eq(deadline(), charged)
now = now + 86400 * 30
for _ = 1, 3 do reloaded.fire_tick(hearth_pos) end
local meta = api.get_meta(hearth_pos)
eq(meta:get_string("job_result"), "hearth:copper_ingot")
eq(meta:get_int("job_cost"), 0)
eq(meta:get_string("job_kind"), "")
eq(api.get_node(hearth_pos).name, "hearth:fire")
eq(use("hearth:copper_charge"):get_count(), 1) -- Pending output cannot be overwritten.
eq(use("hearth:smelt_charge"):get_count(), 1)
use("")
reloaded.fire_tick(hearth_pos); use("")
eq(#inventory.added, 1)
eq(inventory.added[1], "hearth:copper_ingot")
end)
test("full inventory and insertion rejection retain copper until exactly one collection", function()
use("hearth:coal"); starter(stack("hearth:firestarter")); use("hearth:copper_charge")
eq(use("hearth:copper_charge"):get_count(), 1)
eq(use("hearth:smelt_charge"):get_count(), 1)
advance(45)
inventory.room = false
use("")
eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:copper_ingot")
inventory.room, inventory.reject_next = true, true
use("")
eq(#inventory.added, 0)
eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:copper_ingot")
use(""); use("")
eq(#inventory.added, 1)
eq(inventory.added[1], "hearth:copper_ingot")
end)
test("authorization blocks new fuels copper queue and copper output before mutation", function()
local function deny(mode)
protected, claimed, distant = mode == "protected", mode ~= "claim", mode == "distance"
end
for _, mode in ipairs({"protected", "claim", "distance"}) do
deny(mode)
for _, fuel in ipairs({"coal", "oak_log", "birch_log", "pine_log", "willow_log", "alder_log",
"beech_log", "aspen_log", "rowan_log", "hazel_log", "spruce_log"}) do
eq(use("hearth:" .. fuel):get_count(), 1)
end
eq(deadline(), 0)
eq(starter(stack("hearth:firestarter")).wear, 0)
end
protected, claimed, distant = false, true, false
use("hearth:coal"); starter(stack("hearth:firestarter"))
local fuel_deadline = deadline()
for _, mode in ipairs({"protected", "claim", "distance"}) do
deny(mode)
eq(use("hearth:copper_charge"):get_count(), 1)
eq(api.get_meta(hearth_pos):get_string("job_kind"), "")
eq(deadline(), fuel_deadline)
end
protected, claimed, distant = false, true, false
use("hearth:copper_charge"); advance(45)
for _, mode in ipairs({"protected", "claim", "distance"}) do
deny(mode); use("")
eq(#inventory.added, 0)
eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:copper_ingot")
end
protected, claimed, distant = false, true, false
use("")
eq(#inventory.added, 1)
end)
test("raw minerals planks and unknown species cannot act as fuel or charges", function()
use("hearth:coal"); starter(stack("hearth:firestarter"))
local fuel_deadline = deadline()
for _, item in ipairs({"copper_ore", "quartz", "copper_ingot", "oak_planks", "beech_planks", "aspen_planks",
"rowan_planks", "hazel_planks", "spruce_planks", "wall", "frame", "roof", "door", "storage",
"deadwood", "spruce_mature", "beech_leaves", "granite_bricks", "unknown_log", "group:tree"}) do
eq(use("hearth:" .. item):get_count(), 1)
eq(api.get_meta(hearth_pos):get_string("job_kind"), "")
eq(deadline(), fuel_deadline)
end
end)
test("copper result cannot validate a forged iron job or invalid cost", function()
local meta = api.get_meta(hearth_pos)
for _, kind in ipairs({"smelt", "smelt_copper"}) do
meta:set_string("job_kind", kind)
meta:set_string("job_until", tostring(now - 1))
meta:set_int("job_cost", kind == "smelt" and 30 or 29)
meta:set_string("job_result", "hearth:copper_ingot")
processes.fire_tick(hearth_pos); use("")
eq(meta:get_string("job_result"), "")
eq(#inventory.added, 0)
end
end)
test("drying charges preserve food with bounded offline completion", function()
for _ = 1, 4 do use("hearth:charcoal") end
starter(stack("hearth:firestarter"))
local charge = use("hearth:dried_food_charge")
eq(charge:get_count(), 0)
eq(api.get_meta(hearth_pos):get_string("job_kind"), "dry")
game_now, now = game_now + 300, now + 300 -- Simulate absence; LBM/timer reconciliation catches up once.
processes.fire_tick(hearth_pos)
eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:dried_food")
processes.fire_tick(hearth_pos)
eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:dried_food")
use("")
eq(inventory.added[1], "hearth:dried_food")
end)
test("pottery fires once and preserves its output across repeated reconciliation", function()
use("hearth:charcoal")
use("hearth:charcoal")
starter(stack("hearth:firestarter"))
local charge = use("hearth:clay_jar_charge")
eq(charge:get_count(), 0)
eq(api.get_meta(hearth_pos):get_string("job_kind"), "fire")
eq(deadline(), now + 150)
game_now, now = game_now + 120, now + 120
processes.fire_tick(hearth_pos)
eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:clay_jar")
processes.fire_tick(hearth_pos)
eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:clay_jar")
use("")
eq(inventory.added[1], "hearth:clay_jar")
end)
test("one crafted charcoal preserves exactly one log of hearth fuel", function()
use("hearth:log")
starter(stack("hearth:firestarter"))
local charge = use("hearth:charcoal_charge")
eq(charge:get_count(), 0)
eq(api.get_meta(hearth_pos):get_string("job_kind"), "char")
advance(60)
eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:charcoal")
use("")
eq(inventory.added[1], "hearth:charcoal")
eq(deadline(), now + 90)
use("hearth:charcoal")
eq(deadline(), now + 180)
end)
test("raw river fish roasts once and its output is collected safely", function()
use("hearth:charcoal")
starter(stack("hearth:firestarter"))
local raw = use("hearth:raw_fish")
eq(raw:get_count(), 0)
eq(api.get_meta(hearth_pos):get_string("job_kind"), "roast")
advance(8)
eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:cooked_fish")
use("")
eq(inventory.added[1], "hearth:cooked_fish")
processes.fire_tick(hearth_pos)
eq(inventory.added[1], "hearth:cooked_fish")
end)
test("dried river fish completes offline and stays shelf-stable output", function()
use("hearth:log")
use("hearth:log")
starter(stack("hearth:firestarter"))
local charge = use("hearth:fish_drying_charge")
eq(charge:get_count(), 0)
eq(api.get_meta(hearth_pos):get_string("job_kind"), "dry_fish")
game_now, now = game_now + 300, now + 300
processes.fire_tick(hearth_pos)
eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:dried_fish")
processes.fire_tick(hearth_pos)
eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:dried_fish")
use("")
eq(inventory.added[1], "hearth:dried_fish")
end)
test("invalid job metadata fails closed without producing or charging output", function()
local meta = api.get_meta(hearth_pos)
meta:set_int("lit", 1)
meta:set_string("fuel_until", tostring(now + 90))
meta:set_string("job_kind", "cook")
meta:set_int("job_until", game_now + 4)
meta:set_int("job_cost", 999)
meta:set_string("job_result", "hearth:cooked_food")
processes.fire_tick(hearth_pos)
eq(meta:get_string("job_kind"), "")
eq(meta:get_string("job_result"), "")
eq(deadline(), now + 90)
processes.fire_tick(hearth_pos)
eq(meta:get_string("job_result"), "")
meta:set_string("job_result", "hearth:cooked_food")
meta:set_int("job_cost", 20)
processes.use_fire(hearth_pos, api.get_node(hearth_pos), player, stack(""))
eq(#inventory.added, 0)
processes.fire_tick(hearth_pos)
eq(meta:get_string("job_result"), "")
end)
test("protection, claim and six-block checks cover fuel, jobs, ignition and output", function()
protected = true
eq(use("hearth:sticks"):get_count(), 1)
eq(starter(stack("hearth:firestarter")).wear, 0)
eq(violations, 2)
protected, claimed = false, false
eq(use("hearth:sticks"):get_count(), 1)
eq(use("hearth:raw_food"):get_count(), 1)
claimed, distant = true, true
eq(starter(stack("hearth:firestarter")).wear, 0)
eq(violations, 5)
distant = false
use("hearth:sticks")
starter(stack("hearth:firestarter"))
use("hearth:raw_food")
advance(6)
protected = true
processes.use_fire(hearth_pos, api.get_node(hearth_pos), player, stack(""))
eq(#inventory.added, 0)
eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:cooked_food")
end)
test("destroyed hearth callbacks cannot recreate nodes or claim vanished output", function()
use("hearth:sticks")
starter(stack("hearth:firestarter"))
use("hearth:raw_food")
advance(6)
world[key(hearth_pos)] = {name = "air"}
metadata[key(hearth_pos)], timers[key(hearth_pos)] = nil, nil
local before = #inventory.added
processes.use_fire(hearth_pos, api.get_node(hearth_pos), player, stack(""))
eq(processes.fire_tick(hearth_pos), false)
eq(api.get_node(hearth_pos).name, "air")
eq(#inventory.added, before)
end)
os.time = real_time
print("1.." .. passed)
+118
View File
@@ -0,0 +1,118 @@
-- M2 bedroll interaction and scheduler tests; isolated engine interface.
local target = assert(arg[1], "runtime.lua path required")
local total = 0
local function test(name, fn)
local old_core, old_hearth, old_vector = core, hearth, vector
local ok, err = pcall(fn)
core, hearth, vector = old_core, old_hearth, old_vector
assert(ok, name .. ": " .. tostring(err)); total = total + 1
end
local function harness()
local h = {steps = {}, connected = {}, saved = 0, messages = {}, clock = 0,
nodes = {ground = {walkable = true}, air = {walkable = false},
["hearth:bedroll"] = {walkable = false, groups = {snappy = 3}}}}
core = {
settings = {get = function(_, k) if k == "mapgen_limit" then return "512" end end},
registered_nodes = h.nodes,
get_node = function(p) return {name = p.x == 0 and p.y == 3 and p.z == 0
and "hearth:bedroll" or (p.y < 4 and "ground" or "air")} end,
get_node_or_nil = function(p) return {name = p.x == 0 and p.y == 3 and p.z == 0
and "hearth:bedroll" or (p.y < 4 and "ground" or "air")} end,
get_connected_players = function() return h.connected end,
register_globalstep = function(fn) h.steps[#h.steps + 1] = fn end,
get_us_time = function() return h.clock * 1000000 end,
find_node_near = function() return nil end,
add_particlespawner = function() end,
}
vector = {offset = function(p, x, y, z) return {x=p.x+x,y=p.y+y,z=p.z+z} end}
hearth = {players = {}, sessions = {}, weather = "clear"}
hearth.movement = {delta = function(dt) return math.max(0, math.min(dt, .1)) end,
step = function(s, state, controls, dt)
s.sprinting = false; s.blocking = false
return {speed = 1, moving = false, grounded = true, swimming = false}
end}
hearth.clock_update = function() end
hearth.model = {needs = function(state)
local copy = {}; for k,v in pairs(state) do copy[k] = v end
return copy, 0
end}
hearth.environment = function()
return {temperature=15,shelter=h.shelter or .75,heat=0,smoke=false,rain=h.rain or false,roof=true}
end
hearth.construction = {allowed = function(pos, p)
return not h.denied and p:get_player_name() == "resting-player"
and math.abs(pos.x-p:get_pos().x) <= 6
end}
hearth.feedback = function(_, msg, kind) h.messages[#h.messages+1]={msg=msg,kind=kind} end
hearth.feedback_step = function() end
hearth.update_hud = function() end
hearth.player_save = function() h.saved=h.saved+1; return true end
hearth.safe_arrival = function() end
hearth.texture = function(c) return c end
function h.player()
local meta = {values={}}
function meta:set_string(k,v) self.values[k]=v end
function meta:set_int(k,v) self.values[k]=v end
function meta:get_int(k) return self.values[k] or 0 end
local p = {pos={x=0,y=4,z=0}, velocity={x=0,y=0,z=0}, hp=20, controls={}}
function p:is_player() return true end
function p:get_player_name() return "resting-player" end
function p:get_pos() return self.pos end
function p:get_velocity() return self.velocity end
function p:get_hp() return self.hp end
function p:get_player_control() return self.controls end
function p:get_wielded_item() return {get_name=function() return "" end} end
function p:get_meta() return meta end
function p:set_physics_override() end
function p:set_clouds() end
h.connected={p}; hearth.players[p:get_player_name()]={food=80,water=80,warmth=50,wet=0,stamina=20}
hearth.sessions[p:get_player_name()]={save=0,damage=0,waiting=false}
return p, meta
end
dofile(target)
function h.tick(dt)
h.clock=h.clock+dt
for _,step in ipairs(h.steps) do step(dt) end
end
return h
end
test("rest requires a living joined player, permitted placed bedroll and roof", function()
local h=harness(); local p=h.player()
assert(hearth.start_rest(p,{x=0,y=3,z=0}))
hearth.sessions["resting-player"].resting=nil; h.denied=true
assert(not hearth.start_rest(p,{x=0,y=3,z=0}))
h.denied=false; h.shelter=0
assert(not hearth.start_rest(p,{x=0,y=3,z=0}))
h.shelter=.5; h.rain=true
assert(not hearth.start_rest(p,{x=0,y=3,z=0}))
end)
test("five uninterrupted sheltered seconds grant warmth without directly changing stamina", function()
local h=harness(); local p=h.player(); local s=hearth.players[p:get_player_name()]
assert(hearth.start_rest(p,{x=0,y=3,z=0}))
for _=1,49 do h.tick(.1) end
assert(hearth.sessions[p:get_player_name()].resting and s.stamina==20)
h.tick(.1)
assert(not hearth.sessions[p:get_player_name()].resting)
for _=1,10 do h.tick(.1) end -- warmth is applied on the bounded needs tick
s = hearth.players[p:get_player_name()]
assert(s.stamina==20 and s.warmth==58)
assert(h.saved==1 and h.messages[#h.messages].kind=="success")
end)
test("movement, unsafe weather and damage interrupt without granting rest", function()
for _,mode in ipairs({"move","rain","damage"}) do
local h=harness(); local p=h.player(); local s=hearth.players[p:get_player_name()]
assert(hearth.start_rest(p,{x=0,y=3,z=0}))
for _=1,10 do h.tick(.1) end
if mode=="move" then p.pos.x=1 elseif mode=="rain" then h.shelter=.25; h.rain=true else p.hp=19 end
for _=1,3 do h.tick(.1) end -- spatial shelter is revalidated at most every 250ms
assert(not hearth.sessions[p:get_player_name()].resting and s.stamina<38 and s.warmth<58)
end
end)
test("rest cooldown and completion cannot be used as free spam", function()
local h=harness(); local p=h.player()
assert(hearth.start_rest(p,{x=0,y=3,z=0}))
for _=1,50 do h.tick(.1) end
assert(not hearth.start_rest(p,{x=0,y=3,z=0}))
end)
print("PASS "..total.." M2 bedroll rest tests")
+8
View File
@@ -40,6 +40,14 @@ test("shelter dries while exposed rain wets", function()
assert(dry.wet < initial.wet and wet.wet > initial.wet)
assert(initial.wet == 20) -- no hidden mutation
end)
test("rain cloak slows wetting, adds warmth, and a rack accelerates sheltered drying", function()
local cold = {food = 80, water = 80, warmth = 68, wet = 20, stamina = 70}
local bare = M.needs(cold, {temperature = 5, shelter = 0, rain = true}, 1)
local coated = M.needs(cold, {temperature = 5, shelter = 0, rain = true, clothing = true}, 1)
assert(coated.wet < bare.wet and coated.warmth > bare.warmth)
local rack = M.needs(initial, {temperature = 5, shelter = 1, rain = true, drying = true}, 1)
assert(rack.wet < M.needs(initial, {temperature = 5, shelter = 1, rain = true}, 1).wet)
end)
test("rest recovers sprint reserve", function()
local rest = M.needs(initial, {temperature = 20}, 1)
local run = M.needs(initial, {temperature = 20, sprinting = true}, 1)
+91
View File
@@ -0,0 +1,91 @@
local M = dofile(assert(arg[1], "movement.lua path required"))
local total = 0
local function test(name, fn)
local ok, err = pcall(fn)
assert(ok, name .. ": " .. tostring(err)); total = total + 1
end
local function fixture(stamina)
return {}, {stamina = stamina or 100, food = 80, water = 80, wet = 0},
{aux1 = true, up = true}, {grounded = true, velocity = {x = 3, y = 0, z = 0}}
end
local function near(a, b) assert(math.abs(a - b) < 0.00001, tostring(a) .. " ~= " .. tostring(b)) end
test("zero negative and invalid dt cannot change speed or stamina", function()
local s, state, c, e = fixture()
for _, dt in ipairs({0, -1, 0 / 0}) do
local r = M.step(s, state, c, dt, e); near(r.speed, 1); near(state.stamina, 100)
end
end)
test("stall clamps speed and stamina without replay", function()
local s, state, c, e = fixture()
local r = M.step(s, state, c, 100, e)
near(r.speed, 1.3); near(state.stamina, 98.7)
end)
test("speed smoothly reaches target without overshoot", function()
local s, state, c, e = fixture()
local r = M.step(s, state, c, 0.05, e); near(r.speed, 1.15)
for _ = 1, 8 do r = M.step(s, state, c, 0.05, e); assert(r.speed <= 1.45) end
near(r.speed, 1.45)
c.aux1 = false; r = M.step(s, state, c, 0.05, e); near(r.speed, 1.3)
end)
test("exhaustion cannot stutter while aux remains held", function()
local s, state, c, e = fixture(8.5)
assert(not M.step(s, state, c, 0.1, e).sprinting and s.exhausted)
for _ = 1, 90 do assert(not M.step(s, state, c, 0.1, e).sprinting) end
assert(state.stamina >= 30)
c.aux1 = false; M.step(s, state, c, 0.1, e)
c.aux1 = true; assert(M.step(s, state, c, 0.1, e).sprinting)
end)
test("release remembers intent but still requires thirty reserve", function()
local s, state, c, e = fixture(8)
M.step(s, state, c, 0, e); c.aux1 = false; M.step(s, state, c, 0.1, e)
c.aux1 = true; state.stamina = 29.99
assert(not M.step(s, state, c, 0, e).sprinting)
state.stamina = 30; assert(M.step(s, state, c, 0, e).sprinting)
-- A release at zero must survive subsequent held frames below the threshold.
s, state, c, e = fixture(0); c.aux1 = false; M.step(s, state, c, 0.1, e)
c.aux1 = true
for _ = 1, 34 do M.step(s, state, c, 0.1, e) end
assert(M.step(s, state, c, 0, e).sprinting)
end)
test("backward diagonal backwards and sneak never sprint", function()
local s, state, c, e = fixture()
c.down = true; assert(not M.step(s, state, c, 0.1, e).sprinting)
c.down, c.up = false, false; assert(not M.step(s, state, c, 0.1, e).sprinting)
c.up, c.sneak = true, true; assert(not M.step(s, state, c, 0.1, e).sprinting)
end)
test("spear brace requires wield and takes priority over sprint", function()
local s, state, c, e = fixture(); c.RMB = true; e.weapon = "hearth:spear"
local r = M.step(s, state, c, 0.1, e)
assert(r.blocking and not r.sprinting); near(r.speed, 0.7)
r = M.step(s, state, c, 0.1, e); near(r.speed, 0.6)
e.weapon = "hearth:knife"; r = M.step(s, state, c, 0.1, e)
assert(not r.blocking and r.sprinting)
end)
test("real vertical velocity and support determine grounded", function()
local s, state, c, e = fixture(); e.velocity.y = 4
assert(not M.step(s, state, c, 0.1, e).grounded)
e.velocity.y = -4; assert(not M.step(s, state, c, 0.1, e).sprinting)
e.velocity.y, e.grounded = 0, false; assert(not M.step(s, state, c, 0.1, e).sprinting)
end)
test("wet speed does not change wetness or other survival values", function()
local s, state, c, e = fixture(); state.wet = 75; c.aux1 = false
local r = M.step(s, state, c, 0.1, e); near(r.speed, 0.95)
assert(state.wet == 75 and state.food == 80 and state.water == 80)
end)
test("swimming forbids sprint brace and uses swim pace", function()
local s, state, c, e = fixture(); e.swimming, e.weapon, c.RMB = true, "hearth:spear", true
local r = M.step(s, state, c, 0.1, e)
assert(r.swimming and not r.sprinting and not r.blocking); near(r.speed, 0.75)
end)
test("distance motion is velocity derived not a held direction", function()
local s, state, c, e = fixture(); e.velocity.x = 0
assert(not M.step(s, state, c, 0.1, e).moving)
e.velocity.z = 1; c.up = false; assert(M.step(s, state, c, 0.1, e).moving)
end)
test("recovery stays bounded and respects depleted needs", function()
local s, state, c, e = fixture(50); c.aux1 = false; state.food = 0
M.step(s, state, c, 0.1, e); near(state.stamina, 50.2)
state.stamina, state.food = 99.9, 80; M.step(s, state, c, 0.1, e); near(state.stamina, 100)
end)
assert(total == 12, "Unexpected movement regression count")
print("PASS " .. total .. " movement tests")
+320
View File
@@ -0,0 +1,320 @@
local factory = dofile(assert(arg[1], "persistence.lua path required"))
local total = 0
local function test(name, fn)
local ok, err = pcall(fn)
assert(ok, name .. ": " .. tostring(err)); total = total + 1
end
local function clone(value)
if type(value) ~= "table" then return value end
local result = {}; for k, v in pairs(value) do result[k] = clone(v) end; return result
end
local defaults = {food = 85, water = 85, warmth = 80, wet = 0, stamina = 100}
-- Adapter unit tests inject this contract double, not a second schema
-- implementation. schema_spec owns numeric/field validation. An explicit second
-- argument (or a real hearth.schema in integration smoke) uses the real module.
local schema = {
defaults = function() return clone(defaults) end,
decode = function(kind, value)
assert(type(value) == "table", "PRIVATE schema error")
local migrated = value.schema == nil
local data = value
if not migrated then
assert(value.schema == 1 and value.kind == kind, "PRIVATE schema error")
data = value.data
end
assert(type(data) == "table" and not data.invalid, "PRIVATE schema error")
return {schema = 1, kind = kind, data = clone(data)}, migrated
end,
}
if arg[2] then schema = dofile(arg[2])
elseif hearth and hearth.schema then schema = hearth.schema end
local fixtures = {
needs = {raw = ' { "food":85, "water":85, "warmth":80, "wet":0, "stamina":100 }\n', data = defaults},
claims = {raw = '{ "fixture_player" : {"x":-12,"y":4,"z":8} }',
data = {fixture_player = {x = -12, y = 4, z = 8}}},
homes = {raw = '{ "fixture_player" : {"x":-12,"y":5,"z":8} }',
data = {fixture_player = {x = -12, y = 5, z = 8}}},
access = {raw = '{ "fixture_player" : {"housemate":"resident"} }',
data = {fixture_player = {housemate = "resident"}}},
location = {raw = ' {"latitude":45.25, "longitude":-73.5} ', data = {latitude = 45.25, longitude = -73.5}},
spawn = {raw = '{"x":0.5,"y":5.25,"z":-0.125}\n', data = {x = 0.5, y = 5.25, z = -0.125}},
}
if arg[3] then
local historic = dofile(arg[3])
for kind, fixture in pairs(historic) do fixtures[kind] = fixture end
end
local function fingerprint(value)
if type(value) ~= "table" then return type(value) .. ":" .. tostring(value) end
local keys, parts = {}, {}
for key in pairs(value) do keys[#keys + 1] = key end
table.sort(keys)
for _, key in ipairs(keys) do parts[#parts + 1] = key .. "=" .. fingerprint(value[key]) end
return "{" .. table.concat(parts, ";") .. "}"
end
local function harness()
local json = {}
for _, fixture in pairs(fixtures) do json[fixture.raw] = clone(fixture.data) end
json.future = {schema = 2, kind = "claims", data = {}}
json.invalid = {schema = 1, kind = "needs", data = {invalid = true}}
json.scalar = false
local C = {}
function C.parse_json(raw)
if raw == "throw" then error("PRIVATE parser input") end
return clone(json[raw])
end
function C.write_json(value)
local raw = "encoded:" .. fingerprint(value)
json[raw] = clone(value); return raw
end
local function store()
local s = {values = {}, writes = {}}
function s:get_string(key)
if self.read_hook then self.read_hook(self, key) end
return self.values[key] or ""
end
function s:set_string(key, value)
self.writes[#self.writes + 1] = {key = key, value = value}
if self.hook and self.hook(self, key, value) then return end
self.values[key] = value
end
return s
end
local world, meta = store(), store()
local player = {get_meta = function() return meta end}
return factory(schema, C, world), world, meta, player, C, json
end
local function rejects(fn)
local ok, err = pcall(fn)
assert(not ok and err == "Last Hearth: persistence failed; save retained for recovery",
"Expected sanitized fail-closed persistence error")
end
test("missing records return independent defaults without writes", function()
local p, w, m, player = harness()
assert(next(p.load_world("claims")) == nil)
assert(next(p.load_world("access")) == nil)
assert(p.load_world("location") == nil and p.load_world("spawn") == nil)
local a = p.load_player(player); assert(a.food == 85 and a.stamina == 100)
a.food = 0; assert(p.load_player(player).food == 85)
assert(#w.writes == 0 and #m.writes == 0)
end)
for _, kind in ipairs({"claims", "homes", "access", "location", "spawn", "needs"}) do
test(kind .. " backs up exact legacy bytes before migration and reloads read-only", function()
local p, w, m, player, C = harness()
local s = kind == "needs" and m or w
local key = kind == "needs" and "hearth:needs" or kind
local bk = kind == "needs" and "hearth:m0_backup:needs" or "m0_backup:" .. kind
s.values[key] = fixtures[kind].raw
local function load() return kind == "needs" and p.load_player(player) or p.load_world(kind) end
assert(fingerprint(load()) == fingerprint(fixtures[kind].data))
assert(#s.writes == 2 and s.writes[1].key == bk and s.writes[2].key == key)
assert(s.values[bk] == fixtures[kind].raw)
local envelope = C.parse_json(s.values[key])
assert(envelope.schema == 1 and envelope.kind == kind)
assert(fingerprint(load()) == fingerprint(fixtures[kind].data) and #s.writes == 2)
-- A restored legacy primary must never replace the first migration backup.
s.values[key] = fixtures[kind].raw
load(); assert(#s.writes == 3 and s.values[bk] == fixtures[kind].raw)
end)
end
test("existing backup remains immutable even if it is not JSON", function()
local p, w = harness()
w.values.claims, w.values["m0_backup:claims"] = fixtures.claims.raw, "PRIVATE recovery bytes"
p.load_world("claims")
assert(#w.writes == 1 and w.values["m0_backup:claims"] == "PRIVATE recovery bytes")
end)
test("empty legacy claims migrate without being mistaken for absent storage", function()
local p, w, _, _, _, json = harness()
json["{}"] = {}; w.values.claims = "{}"
assert(next(p.load_world("claims")) == nil and #w.writes == 2)
assert(w.values["m0_backup:claims"] == "{}")
end)
test("malformed scalar and future world records fail load and save with zero writes", function()
for _, raw in ipairs({"malformed PRIVATE", "throw", "scalar", "future"}) do
local p, w = harness(); w.values.claims = raw
rejects(function() p.load_world("claims") end)
rejects(function() p.save_world("claims", {}) end)
assert(w.values.claims == raw and #w.writes == 0)
end
end)
test("invalid player saves remain byte exact on both load and save", function()
for _, raw in ipairs({"malformed PRIVATE", "throw", "scalar", "future", "invalid"}) do
local p, _, m, player = harness(); m.values["hearth:needs"] = raw
rejects(function() p.load_player(player) end)
rejects(function() p.save_player(player, clone(defaults)) end)
assert(m.values["hearth:needs"] == raw and #m.writes == 0)
end
end)
test("invalid new data and unknown world kinds never write", function()
local p, w, m, player = harness()
rejects(function() p.save_world("claims", {invalid = true}) end)
rejects(function() p.save_player(player, {invalid = true}) end)
for _, kind in ipairs({"needs", "PRIVATE", false, {}}) do
rejects(function() p.load_world(kind) end)
rejects(function() p.save_world(kind, {}) end)
end
assert(#w.writes == 0 and #m.writes == 0)
end)
test("all world kinds and player needs save current envelopes", function()
local p, w, m, player, C = harness()
for _, kind in ipairs({"claims", "homes", "access", "location", "spawn"}) do
p.save_world(kind, fixtures[kind].data)
local value = C.parse_json(w.values[kind])
assert(value.schema == 1 and value.kind == kind)
assert(w.values["m0_backup:" .. kind] == nil)
end
p.save_player(player, clone(defaults))
assert(C.parse_json(m.values["hearth:needs"]).kind == "needs" and #m.writes == 1)
p.save_player(player, clone(defaults)); assert(#m.writes == 2)
end)
test("direct save of legacy state still backs up the old bytes", function()
local p, w, m, player = harness()
w.values.claims = fixtures.claims.raw
p.save_world("claims", {})
assert(w.values["m0_backup:claims"] == fixtures.claims.raw and #w.writes == 2)
m.values["hearth:needs"] = fixtures.needs.raw
local updated = clone(defaults); updated.food = 25
p.save_player(player, updated)
assert(m.values["hearth:m0_backup:needs"] == fixtures.needs.raw)
assert(p.load_player(player).food == 25 and #m.writes == 2)
end)
test("writer failure nonstrings and invalid roundtrips never touch originals or backups", function()
for _, mode in ipairs({"throw", "nil", "number", "empty", "malformed", "legacy", "changed", "future"}) do
local p, w, _, _, C, json = harness(); w.values.claims = fixtures.claims.raw
C.write_json = function(value)
if mode == "throw" then error("PRIVATE writer error") end
if mode == "nil" then return nil end
if mode == "number" then return 42 end
if mode == "empty" then return "" end
if mode == "legacy" then return fixtures.claims.raw end
if mode == "future" then return "future" end
if mode == "changed" then
json.changed = {schema = 1, kind = "claims", data = {}}
return "changed"
end
return "malformed PRIVATE"
end
rejects(function() p.load_world("claims") end)
rejects(function() p.save_world("claims", fixtures.claims.data) end)
assert(w.values.claims == fixtures.claims.raw and #w.writes == 0)
end
end)
test("backup failure prevents primary writes", function()
for _, mode in ipairs({"throw", "ignore", "corrupt"}) do
local p, w = harness(); w.values.claims = fixtures.claims.raw
w.hook = function(s, key)
assert(key == "m0_backup:claims")
if mode == "throw" then error("PRIVATE storage error") end
if mode == "corrupt" then s.values[key] = "PRIVATE truncated" end
return true
end
rejects(function() p.load_world("claims") end)
assert(w.values.claims == fixtures.claims.raw and #w.writes == 1)
end
end)
test("primary corruption or throw-after-write restores original and retains backup", function()
for _, mode in ipairs({"corrupt", "throw", "ignore"}) do
local p, w = harness(); w.values.claims = fixtures.claims.raw
local once = true
w.hook = function(s, key, value)
if key == "claims" and once then
once = false
if mode == "throw" then s.values[key] = value; error("PRIVATE storage error") end
if mode == "corrupt" then s.values[key] = "PRIVATE truncated" end
return true
end
end
rejects(function() p.load_world("claims") end)
assert(w.values.claims == fixtures.claims.raw)
assert(w.values["m0_backup:claims"] == fixtures.claims.raw)
end
end)
test("failed rollback quarantines key even if damaged readback looks valid", function()
local p, w, _, _, C = harness(); w.values.claims = fixtures.claims.raw
local replacement = C.write_json({schema = 1, kind = "claims", data = {}})
w.hook = function(s, key)
if key == "claims" then s.values[key] = replacement; error("PRIVATE persistent failure") end
end
rejects(function() p.load_world("claims") end)
local count = #w.writes; w.hook = nil
rejects(function() p.load_world("claims") end)
rejects(function() p.save_world("claims", {}) end)
assert(#w.writes == count and w.values["m0_backup:claims"] == fixtures.claims.raw)
end)
test("player migration write failure rolls back only player metadata", function()
local p, w, m, player = harness(); m.values["hearth:needs"] = fixtures.needs.raw
local once = true
m.hook = function(s, key)
if key == "hearth:needs" and once then once = false; s.values[key] = "PRIVATE partial"; return true end
end
rejects(function() p.load_player(player) end)
assert(m.values["hearth:needs"] == fixtures.needs.raw)
assert(m.values["hearth:m0_backup:needs"] == fixtures.needs.raw and #w.writes == 0)
end)
test("failed first save restores missing storage and creates no migration backup", function()
local p, w = harness(); local once = true
w.hook = function(s, key)
if once then once = false; s.values[key] = "PRIVATE partial"; return true end
end
rejects(function() p.save_world("claims", {}) end)
assert(w.values.claims == "" and w.values["m0_backup:claims"] == nil)
end)
test("failed current-schema save restores exact prior bytes without a migration backup", function()
local p, w, _, _, C = harness()
local original = C.write_json({schema = 1, kind = "claims", data = fixtures.claims.data})
w.values.claims = original
local once = true
w.hook = function(s, key)
if once then once = false; s.values[key] = "PRIVATE truncated"; return true end
end
rejects(function() p.save_world("claims", {}) end)
assert(w.values.claims == original and w.values["m0_backup:claims"] == nil)
end)
test("explicit false setter result fails and rolls back", function()
local p, w = harness(); local setter = w.set_string; local once = true
w.set_string = function(self, key, value)
setter(self, key, value)
if once then once = false; return false end
end
rejects(function() p.save_world("claims", {}) end)
assert(w.values.claims == "" and #w.writes == 2)
end)
test("readback exception triggers verified rollback", function()
local p, w = harness(); local once = true
w.read_hook = function(s, key)
if key == "claims" and #s.writes == 1 and once then
once = false; error("PRIVATE readback error")
end
end
rejects(function() p.save_world("claims", {}) end)
assert(w.values.claims == "" and #w.writes == 2)
end)
test("player quarantine survives fresh metadata wrappers", function()
local p, _, m, _, C = harness()
m.values["hearth:needs"] = fixtures.needs.raw
local calls = 0
local player = {get_meta = function()
calls = calls + 1
return {get_string = function(_, key) return m:get_string(key) end,
set_string = function(_, key, value) return m:set_string(key, value) end}
end}
local changed = clone(defaults); changed.food = 25
local replacement = C.write_json({schema = 1, kind = "needs", data = changed})
m.hook = function(s, key)
if key == "hearth:needs" then s.values[key] = replacement; error("PRIVATE write error") end
end
rejects(function() p.load_player(player) end)
local count = #m.writes; m.hook = nil
rejects(function() p.load_player(player) end)
rejects(function() p.save_player(player, clone(defaults)) end)
assert(calls == 1 and #m.writes == count)
assert(m.values["hearth:m0_backup:needs"] == fixtures.needs.raw)
end)
test("read errors and metadata errors are sanitized without writes", function()
local p, w, m = harness()
w.read_hook = function() error("PRIVATE read error") end
rejects(function() p.load_world("claims") end)
rejects(function() p.save_world("claims", {}) end)
rejects(function() p.load_player({get_meta = function() error("PRIVATE player error") end}) end)
assert(#w.writes == 0 and #m.writes == 0)
end)
print("PASS " .. total .. " persistence tests")
+409
View File
@@ -0,0 +1,409 @@
-- Standalone Lua 5.1+ lifecycle regressions; arg[1] is players.lua.
local target = assert(arg[1], "players.lua path required")
local root = assert(target:match("^(.*[/\\])"), "module directory required")
local schema = dofile(root .. "schema.lua")
local persistence = dofile(root .. "persistence.lua")
local total = 0
local function test(name, fn)
local old_core, old_hearth, old_vector = core, hearth, vector
local ok, message = pcall(fn)
core, hearth, vector = old_core, old_hearth, old_vector
assert(ok, name .. ": " .. tostring(message)); total = total + 1
end
local function copy(value)
if type(value) ~= "table" then return value end
local result = {}; for k, v in pairs(value) do result[k] = copy(v) end; return result
end
local spawn = {x = 4, y = 4, z = -8}
local function harness(saved_spawn)
local h = {callbacks = {}, after = {}, logs = {}, kicks = {}, connected = {}, moves = {}, gui = {}, messages = {},
loaded = true, ground = true, clock = 1000000, emerges = {}, homes = {}}
local function store()
local m = {values = {}, ints = {}, writes = 0}
function m:get_string(key)
if self.read_fail then error("PRIVATE read payload") end
return self.values[key] or ""
end
function m:set_string(key, value)
self.writes = self.writes + 1
if self.write_fail then error("PRIVATE save payload") end
self.values[key] = value
end
function m:get_int(key) return self.ints[key] or 0 end
function m:set_int(key, value)
if key == "hearth:arrived" then h.moves[#h.moves + 1] = "arrived" end
self.ints[key] = value
end
return m
end
local json, serial = {}, 0
local C = {registered_nodes = {
air = {walkable = false}, ground = {walkable = true, groups = {soil = 1}},
leaves = {walkable = true, groups = {leaves = 1, hearth_canopy = 1}},
log = {walkable = true, groups = {tree = 1}},
floor = {walkable = true, groups = {stone = 1, hearth_building = 1}},
water = {walkable = false, liquidtype = "source", groups = {water = 1}},
fire = {walkable = false, damage_per_second = 4},
}}
function C.write_json(value) serial = serial + 1; local raw = "fixture:" .. serial; json[raw] = copy(value); return raw end
function C.parse_json(raw, _, return_error)
assert(return_error == true, "Persistence must suppress parser payload logging"); return copy(json[raw])
end
function C.get_node_or_nil(pos)
if not h.loaded then return nil end
if h.node_at then return h.node_at(pos) end
if h.water_at and h.water_at(pos) then return {name = "water"} end
if h.fire_at and h.fire_at(pos) then return {name = "fire"} end
if h.ground and pos.y == 3 then return {name = "ground"} end
return {name = "air"}
end
function C.get_mod_storage() return h.world end
function C.register_on_joinplayer(fn) h.callbacks.join = fn end
function C.register_on_leaveplayer(fn) h.callbacks.leave = fn end
function C.register_on_shutdown(fn) h.callbacks.shutdown = fn end
function C.register_on_respawnplayer(fn) h.callbacks.respawn = fn end
function C.register_on_punchplayer(fn) h.callbacks.punch = fn end
C.get_connected_players = function() return h.connected end
C.get_player_by_name = function(name) for _, p in ipairs(h.connected) do if p.name == name then return p end end end
C.log = function(level, text) assert(level == "error"); h.logs[#h.logs + 1] = text end
C.kick_player = function(name, text) h.kicks[#h.kicks + 1] = text end
C.after = function(delay, fn) h.after[#h.after + 1] = {delay = delay, fn = fn} end
C.get_us_time = function() h.clock = h.clock + 1000; return h.clock end
C.settings = {get = function(_, key) if key == "mapgen_limit" then return "512" end end}
C.emerge_area = function(minimum, maximum, fn)
h.emerges[#h.emerges + 1] = {minimum = copy(minimum), maximum = copy(maximum), callback = fn}
end
h.world = store()
if saved_spawn then h.world.values.spawn = C.write_json({schema = 1, kind = "spawn", data = saved_spawn}) end
local function claim_allowed() return true end
core, vector = C, {round = function(p) return {x = math.floor(p.x + 0.5), y = math.floor(p.y + 0.5), z = math.floor(p.z + 0.5)} end}
hearth = {players = {}, sessions = {}, schema = schema, has_location = true, claim_allowed = claim_allowed}
hearth.persistence = persistence(schema, C, h.world)
hearth.home_for = function(name) local p = h.homes[name]; return p and copy(p) end
hearth.in_own_claim = function(pos, name) return h.claim_owner == name and h.in_claim ~= false end
hearth.store_home = function(name, pos) assert(hearth.in_own_claim(pos, name)); h.homes[name] = copy(pos); h.home_saves = (h.home_saves or 0) + 1 end
hearth.notify = function(_, message) h.messages[#h.messages + 1] = message end
hearth.texture = function() return "texture.png" end
hearth.inventory = function(p) p.inventory_calls = p.inventory_calls + 1 end
hearth.make_hud = function(p) p.hud_calls = p.hud_calls + 1 end
hearth.guide = function(p) h.gui[#h.gui + 1] = {kind = "guide", player = p} end
hearth.location_form = function(p) h.gui[#h.gui + 1] = {kind = "location", player = p} end
function h.player(name)
local meta = store()
local p = {name = name or "fixture-player", meta = meta, pos = {x = 0, y = 4, z = 0},
inventory_calls = 0, hud_calls = 0, sizes = {}, widths = {}, hp = 20}
function p:get_player_name() return self.name end
function p:get_meta() return self.meta end
function p:get_pos() return self.pos end
function p:get_inventory()
return {set_size = function(_, key, value) self.sizes[key] = value end,
set_width = function(_, key, value) self.widths[key] = value end}
end
function p:set_properties(value)
self.property_calls = (self.property_calls or 0) + 1
self.properties = self.properties or {}
for k, v in pairs(value) do self.properties[k] = copy(v) end
end
function p:set_armor_groups(value) self.armor = value end
function p:set_clouds(value) self.clouds = value end
function p:set_pos(value) self.pos = copy(value); h.moves[#h.moves + 1] = "pos" end
function p:set_velocity(value) self.velocity = copy(value); h.moves[#h.moves + 1] = "velocity" end
function p:get_hp() return self.hp end
function p:set_hp(value) self.hp = value end
h.connected[#h.connected + 1] = p
return p, meta
end
function h.drain_zero()
local count = 0
while count < 100 do
local item
for i, queued in ipairs(h.after) do if queued.delay == 0 then item = table.remove(h.after, i); break end end
if not item then return end
count = count + 1; item.fn()
end
error("Unbounded scheduled callback loop")
end
dofile(target)
return h
end
local function no_private(h)
for _, messages in ipairs({h.logs, h.kicks}) do
for _, message in ipairs(messages) do assert(not message:find("PRIVATE", 1, true)) end
end
end
test("lifecycle callbacks expose safe handlers and disable PvP", function()
local h = harness(spawn)
assert(h.callbacks.join == hearth.player_join and h.callbacks.leave == hearth.player_leave)
assert(h.callbacks.respawn == hearth.player_respawn and h.callbacks.punch() == true)
assert(type(h.callbacks.shutdown) == "function" and type(hearth.safe_arrival) == "function")
end)
test("initial arrival validates spawn and only then marks arrived", function()
local h = harness(spawn); local p, meta = h.player(); h.callbacks.join(p)
assert(meta:get_int("hearth:arrived") == 1 and p.pos.x == spawn.x and p.pos.y == spawn.y)
assert(table.concat(h.moves, ",") == "pos,velocity,arrived")
assert(not hearth.sessions[p.name].waiting and #h.kicks == 0)
end)
test("normal reconnect preserves saved location without home/spawn teleport", function()
local h = harness(spawn); local p, meta = h.player(); meta.ints["hearth:arrived"] = 1
p.pos = {x = 123, y = -40, z = 456}; h.callbacks.join(p)
assert(#h.moves == 0 and p.pos.x == 123 and p.pos.y == -40 and p.pos.z == 456)
assert(not hearth.sessions[p.name].waiting)
end)
test("initial arrival waits for emergence and does not set arrived early", function()
local h = harness(); h.loaded = false
local p, meta = h.player(); h.callbacks.join(p)
assert(meta:get_int("hearth:arrived") == 0 and hearth.sessions[p.name].waiting and #h.moves == 0)
assert(#h.emerges == 1 and h.emerges[1].minimum.x == -64 and h.emerges[1].minimum.z == -64
and h.emerges[1].maximum.x == 64 and h.emerges[1].maximum.z == 64
and h.emerges[1].minimum.y == 0 and h.emerges[1].maximum.y == 320,
"Bounded arrival must emerge surfaces through 318 plus feet/head clearance")
h.loaded, h.ground = true, true
h.emerges[1].callback(nil, nil, 0); h.drain_zero()
assert(meta:get_int("hearth:arrived") == 1 and not hearth.sessions[p.name].waiting)
assert(p.pos.y == 4 and #h.kicks == 0)
end)
test("shared emergence resolves every waiting player without kicking", function()
local h = harness(); h.loaded = false
local p, m = h.player("one"); local q, n = h.player("two")
h.callbacks.join(p); h.callbacks.join(q)
assert(#h.emerges == 1 and hearth.sessions.one.waiting and hearth.sessions.two.waiting)
h.loaded, h.ground = true, true
h.emerges[1].callback(nil, nil, 0); h.drain_zero()
assert(m:get_int("hearth:arrived") == 1 and n:get_int("hearth:arrived") == 1)
assert(not hearth.sessions.one.waiting and not hearth.sessions.two.waiting and #h.kicks == 0)
assert(p.pos.x ~= q.pos.x or p.pos.z ~= q.pos.z, "Arrival must not overlap another player")
end)
test("leaving during emergence removes that waiter and stale callback cannot move them", function()
local h = harness(); h.loaded = false
local p, meta = h.player(); h.callbacks.join(p); h.callbacks.leave(p)
h.loaded = true; h.emerges[1].callback(nil, nil, 0); h.drain_zero()
assert(#h.moves == 0 and meta:get_int("hearth:arrived") == 0 and not hearth.sessions[p.name])
end)
test("no safe bounded location leaves player waiting and preserves metadata", function()
local h = harness(); h.loaded = false; h.ground = false
local p, meta = h.player(); h.callbacks.join(p)
h.loaded = true; h.ground = false; h.emerges[1].callback(nil, nil, 0); h.drain_zero()
assert(meta:get_int("hearth:arrived") == 0 and hearth.sessions[p.name].waiting)
assert(#h.kicks == 0 and #h.moves == 0 and hearth.players[p.name].food == 85)
end)
test("high altitude patch is found without settling on leaves or logs", function()
local h = harness(); local p, meta = h.player()
h.node_at = function(pos)
assert(math.abs(pos.x) <= 64 and math.abs(pos.z) <= 64 and pos.y <= 320,
"Surface search must remain bounded")
if pos.y == 318 and math.abs(pos.x) <= 2 and math.abs(pos.z) <= 2 then return {name = "ground"} end
if pos.y == 290 then return {name = pos.x < 0 and "leaves" or "log"} end
return {name = "air"}
end
h.callbacks.join(p)
assert(#h.moves == 0 and #h.emerges == 1)
h.emerges[1].callback(nil, nil, 0); h.drain_zero()
assert(meta:get_int("hearth:arrived") == 1 and p.pos.y == 319 and p.pos.x == 0 and p.pos.z == 0)
end)
test("natural biome surface groups support high altitude arrival", function()
for _, groups in ipairs({{soil = 1, hearth_habitat = 1}, {stone = 1, hearth_geology = 1},
{sand = 1, falling_node = 1}, {crumbly = 3}, {crumbly = 3, falling_node = 1}, {clay = 1, crumbly = 2}}) do
local h = harness(); core.registered_nodes.variant = {walkable = true, groups = groups}
h.node_at = function(pos) return {name = pos.y == 250 and "variant" or "air"} end
local p, meta = h.player(); h.callbacks.join(p)
h.emerges[1].callback(nil, nil, 0); h.drain_zero()
assert(meta:get_int("hearth:arrived") == 1 and p.pos.y == 251)
end
end)
test("canopy or log only worlds never fabricate a safe fallback", function()
for _, node in ipairs({"leaves", "log", "floor"}) do
local h = harness()
h.node_at = function(pos) return {name = pos.y == 250 and node or "air"} end
local p, meta = h.player(); h.callbacks.join(p)
h.emerges[1].callback(nil, nil, 0); h.drain_zero()
assert(#h.moves == 0 and meta:get_int("hearth:arrived") == 0 and hearth.sessions[p.name].waiting)
end
end)
test("isolated high cliff pillar is not a landing patch", function()
local h = harness()
h.node_at = function(pos)
return {name = pos.x == 0 and pos.z == 0 and pos.y <= 250 and "ground" or "air"}
end
local p, meta = h.player(); h.callbacks.join(p)
h.emerges[1].callback(nil, nil, 0); h.drain_zero()
assert(#h.moves == 0 and meta:get_int("hearth:arrived") == 0 and hearth.sessions[p.name].waiting)
end)
test("unsafe upper surfaces cannot fall through to underground cave floors", function()
local h = harness()
h.node_at = function(pos)
if pos.y >= 260 or pos.y == 200 then return {name = "ground"} end
return {name = "air"}
end
local p, meta = h.player(); h.callbacks.join(p)
h.emerges[1].callback(nil, nil, 0); h.drain_zero()
assert(#h.moves == 0 and meta:get_int("hearth:arrived") == 0 and hearth.sessions[p.name].waiting)
end)
test("high altitude wet and damaging surfaces remain unsafe", function()
for _, hazard in ipairs({"water", "fire"}) do
local h = harness()
h.node_at = function(pos)
if pos.y == 250 then return {name = "ground"} end
return {name = pos.y == 251 and hazard or "air"}
end
local p, meta = h.player(); h.callbacks.join(p)
h.emerges[1].callback(nil, nil, 0); h.drain_zero()
assert(#h.moves == 0 and meta:get_int("hearth:arrived") == 0 and hearth.sessions[p.name].waiting)
end
end)
test("bounded arrivals authorize unclaimed land but reject foreign claims", function()
for _, claimed in ipairs({false, true}) do
local h = harness()
local claims = claimed and {owner = {x = 0, y = 251, z = 0}} or {}
local policy = dofile(root .. "access.lua")(claims, {owner = {newcomer = "resident"}})
local checks = 0
hearth.claim_allowed = function(pos, name, action)
assert(action == "arrival" and name == "newcomer"); checks = checks + 1
return policy.allowed(pos, name, action)
end
h.node_at = function(pos)
return {name = pos.y == 250 and math.abs(pos.x) <= 2 and math.abs(pos.z) <= 2 and "ground" or "air"}
end
local p, meta = h.player("newcomer"); h.callbacks.join(p)
h.emerges[1].callback(nil, nil, 0); h.drain_zero()
assert(checks > 0)
assert(meta:get_int("hearth:arrived") == (claimed and 0 or 1))
assert(hearth.sessions[p.name].waiting == claimed)
end
end)
test("saved home retains safe constructed floor support", function()
local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p)
h.claim_owner = p.name
h.node_at = function(pos) return {name = pos.y == 249 and "floor" or "air"} end
p.pos = {x = 20, y = 250, z = 20}
assert(hearth.set_home(p))
p.pos = {x = 100, y = 4, z = 100}
assert(hearth.go_home(p)); h.drain_zero()
assert(p.pos.x == 20 and p.pos.y == 250 and p.pos.z == 20)
end)
test("sethome requires own claim and dry grounded support, then stores safe integer position", function()
local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p)
assert(not hearth.set_home(p) and (h.home_saves or 0) == 0)
p.pos = {x = 0, y = 4, z = 0}
h.claim_owner = p.name
local ok, message = hearth.set_home(p)
assert(ok and message:find("Safe home", 1, true) and h.home_saves == 1)
assert(h.homes[p.name].x == 0 and h.homes[p.name].y == 4 and h.homes[p.name].z == 0)
h.water_at = function(pos) return pos.y == 4 end
assert(not hearth.set_home(p) and h.home_saves == 1)
end)
test("sethome rejects foreign claim and unsupported or obstructed positions", function()
local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p)
h.claim_owner = "another-owner"; assert(not hearth.set_home(p))
h.claim_owner = p.name; h.ground = false; assert(not hearth.set_home(p))
h.ground = true; h.node_at = function(pos)
if pos.y == 3 then return {name = "ground"} end
if pos.y == 5 then return {name = "ground"} end
return {name = "air"}
end
assert(not hearth.set_home(p))
end)
test("home command revalidates home and moves only on explicit request", function()
local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p)
h.homes[p.name] = {x = 20, y = 4, z = 20}
p.pos = {x = 100, y = 4, z = 100}
local before = #h.moves
local ok = hearth.go_home(p)
assert(ok and #h.moves == before + 2 and p.pos.x == 20 and p.pos.z == 20)
assert(h.messages[#h.messages]:find("verified safe", 1, true))
end)
test("home command without a saved home still performs safe world-spawn recovery", function()
local h = harness(spawn); local p = h.player("new-player"); h.callbacks.join(p)
p.pos = {x = 100, y = 4, z = 100}
local ok, message = hearth.go_home(p)
h.drain_zero()
assert(ok and message:find("No personal home", 1, true))
assert(p.pos.x == spawn.x and p.pos.y == spawn.y and p.pos.z == spawn.z)
end)
test("unsafe saved home falls back to validated spawn without teleporting into fluid", function()
local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p)
h.homes[p.name] = {x = 20, y = 4, z = 20}
h.water_at = function(pos) return pos.x >= 15 and pos.x <= 25 and pos.z >= 15 and pos.z <= 25
and (pos.y == 4 or pos.y == 5) end
assert(hearth.go_home(p)); h.drain_zero()
assert(p.pos.x == spawn.x and p.pos.y == spawn.y and p.pos.z == spawn.z)
end)
test("respawn preserves food, water, wetness and inventory while limiting recovery needs", function()
local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p)
local state = hearth.players[p.name]
state.food, state.water, state.wet, state.warmth, state.stamina = 19, 27, 44, 35, 2
p.pos = {x = 200, y = 4, z = 200}
assert(h.callbacks.respawn(p) == true)
h.drain_zero()
assert(#h.emerges == 1 and hearth.sessions[p.name].waiting)
h.emerges[1].callback(nil, nil, 0); h.drain_zero()
assert(state.food == 19 and state.water == 27 and state.wet == 44)
assert(state.warmth == 35 and state.stamina == 35 and p.inventory_calls == 1)
assert(p.pos.y == 4 and not hearth.sessions[p.name].waiting and #h.kicks == 0)
end)
test("death recovery avoids a recently lethal home and uses emergence-safe fallback", function()
local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p)
h.homes[p.name] = {x = 20, y = 4, z = 20}; p.pos = {x = 20, y = 4, z = 20}
hearth.sessions[p.name].recovery_avoid = {x = 20, y = 4, z = 20, until_us = h.clock + 10000000}
h.callbacks.respawn(p)
h.drain_zero()
assert((p.pos.x ~= 20 or p.pos.z ~= 20) and not hearth.sessions[p.name].waiting)
end)
test("failed player load is sanitized and cannot establish a session", function()
local h = harness(spawn); local p, meta = h.player(); meta.read_fail = true; h.callbacks.join(p)
assert(not hearth.players[p.name] and not hearth.sessions[p.name] and #h.kicks == 1)
assert(#h.moves == 0 and meta.writes == 0); no_private(h)
end)
test("saved spawn and home never accept unloaded, liquid, unsupported, or damaging nodes", function()
local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p)
h.node_at = function(pos)
if pos.y == 3 then return {name = "ground"} end
if pos.x == spawn.x and pos.z == spawn.z and (pos.y == 4 or pos.y == 5) then return {name = "water"} end
return {name = "air"}
end
h.homes[p.name] = {x = spawn.x, y = spawn.y, z = spawn.z}
p.pos = {x = 100, y = 4, z = 100}
assert(hearth.go_home(p)); h.drain_zero()
assert(p.pos.x ~= spawn.x or p.pos.z ~= spawn.z)
assert(#h.kicks == 0)
end)
test("character join hook runs after session and base properties without taking form priority", function()
local h = harness(spawn); local p, meta = h.player()
local calls = 0
hearth.character = {on_join = function(player)
assert(player == p and hearth.sessions[p.name] and p.properties.visual == "mesh")
assert(p.properties.mesh == "hearth_person.b3d" and p.properties.eye_height == 1.55)
calls = calls + 1
player:set_properties({mesh = "hearth_person_compact.b3d", textures = {"fixture_appearance.png"}})
end}
h.callbacks.join(p)
assert(calls == 1 and p.properties.mesh == "hearth_person_compact.b3d")
assert(p.properties.eye_height == 1.55 and p.properties.visual_size.x == 1
and p.properties.collisionbox[5] == 1.75 and p.properties.hp_max == 20)
assert(meta:get_int("hearth:arrived") == 1 and #h.gui == 0)
hearth.has_location = false; core.is_singleplayer = function() return true end
for _, entry in ipairs(h.after) do if entry.delay == 1 then entry.fn() end end
assert(#h.gui == 1 and h.gui[1].kind == "location", "Location must retain join form priority")
end)
test("real character factory restores saved metadata during player initialization", function()
local h = harness(spawn); local p, meta = h.player()
core.register_on_player_receive_fields = function() end
core.register_chatcommand = function() end
hearth.character = dofile(root .. "character.lua")(hearth, core)
local d = hearth.character.defaults(); d.body = "broad"; d.skin = "umber"
meta.values["hearth:appearance"] = assert(hearth.character.encode(d))
h.callbacks.join(p)
assert(p.properties.mesh == "hearth_person_broad.b3d" and p.property_calls == 2)
assert(p.properties.textures[1]:find("hearth_char_skin_umber.png",1,true))
assert(p.properties.eye_height == 1.55 and p.properties.stepheight == 0.6)
assert(meta.writes == 0, "Joining with saved appearance must not rewrite metadata")
assert(#h.kicks == 0 and not hearth.character.state(p).warning)
end)
test("malformed saved world spawn fails module startup without overwrite", function()
local h = harness(spawn); h.world.values.spawn = "PRIVATE malformed"
local ok, message = pcall(dofile, target)
assert(not ok and not tostring(message):find("PRIVATE", 1, true))
assert(h.world.values.spawn == "PRIVATE malformed" and h.world.writes == 0); no_private(h)
end)
assert(total == 27, "Unexpected lifecycle regression count")
print("PASS " .. total .. " player lifecycle safety tests")
+183
View File
@@ -0,0 +1,183 @@
-- Standalone Lua 5.1+; no real core/hearth mutation leaks into engine suites.
local target = assert(arg[1], "presentation.lua path required")
local total = 0
local function test(name, fn)
local old_core, old_hearth = core, hearth
local ok, message = pcall(fn)
core, hearth = old_core, old_hearth
assert(ok, name .. ": " .. tostring(message))
total = total + 1; print("PASS " .. name)
end
local function harness()
local h = {objects = {}, connected = {}, leaves = {}, deaths = {}, shutdowns = {}}
core = {
register_entity = function(name, def) h.entity_name, h.def = name, def end,
get_player_by_name = function(name) return h.connected[name] end,
register_on_leaveplayer = function(fn) h.leaves[#h.leaves + 1] = fn end,
register_on_dieplayer = function(fn) h.deaths[#h.deaths + 1] = fn end,
register_on_shutdown = function(fn) h.shutdowns[#h.shutdowns + 1] = fn end,
}
hearth = {players = {}, sessions = {}}
function h.player(name)
local p = {name = name or "fixture", item = "hearth:knife", hp = 20}
function p:get_player_name() return self.name end
function p:get_wielded_item() return {get_name = function() return self.item end} end
function p:get_pos() return {x = 1, y = 52, z = 3} end
function p:get_hp() return self.hp end
h.connected[p.name] = p
hearth.players[p.name] = {food = 85, stamina = 100}
hearth.sessions[p.name] = {}
return p
end
core.add_entity = function(pos, name, staticdata)
assert(name == h.entity_name and pos.y == 52)
if h.fail then return nil end
if h.throw then error("fixture failure") end
local obj = {props = {}, writes = 0}
local lua = {object = obj}; obj.lua = lua
function obj:get_pos() if not self.removed then return pos end end
function obj:remove()
assert(not self.removed, "duplicate removal")
self.removed = true; h.def.on_deactivate(lua)
end
function obj:set_properties(props)
self.writes = self.writes + 1
for k, v in pairs(props) do self.props[k] = v end
end
function obj:set_armor_groups(groups) self.armor = groups end
function obj:set_attach(parent, bone, position, rotation, forced)
if h.refuse_attach then return end
self.parent, self.bone, self.position, self.rotation, self.forced = parent, bone, position, rotation, forced
end
function obj:get_attach() return self.parent, self.bone, self.position, self.rotation, self.forced end
h.objects[#h.objects + 1] = obj
h.def.on_activate(lua, staticdata or "", 0)
return obj
end
function h.count()
local n = 0; for _, obj in ipairs(h.objects) do if not obj.removed then n = n + 1 end end; return n
end
function h.tick_objects()
for _, obj in ipairs(h.objects) do if not obj.removed then h.def.on_step(obj.lua, 0.1) end end
end
dofile(target)
return h
end
test("fixed whitelist creates all four generated UV meshes with correct bone transform", function()
local h = harness(); local p = h.player()
for _, tool in ipairs({"knife", "axe", "pick", "spear"}) do
p.item = "hearth:" .. tool; hearth.presentation_step(p)
local obj = h.objects[#h.objects]
assert(h.count() == 1 and obj.props.mesh == "hearth_" .. tool .. ".obj")
assert(obj.parent == p and obj.bone == "Hand_Right" and obj.forced == false)
assert(obj.position.x == 0 and obj.position.y == 0 and obj.position.z == 0)
assert(obj.rotation.x == 90 and obj.rotation.y == 0 and obj.rotation.z == 0)
assert(obj.props.is_visible == true and obj.armor.immortal == 1)
end
assert(h.def.initial_properties.textures[1] == "hearth_person.png")
assert(h.def.initial_properties.visual_size.x == 1 and h.def.initial_properties.visual_size.y == 1)
end)
test("steady owner has exactly one entity and no repeated property traffic", function()
local h = harness(); local p = h.player(); hearth.presentation_step(p)
local obj = h.objects[1]; local writes = obj.writes
for _ = 1, 200 do hearth.presentation_step(p); h.tick_objects() end
assert(#h.objects == 1 and h.count() == 1 and obj.writes == writes)
end)
test("weapon change removes old entity before creating replacement", function()
local h = harness(); local p = h.player(); hearth.presentation_step(p)
p.item = "hearth:axe"; hearth.presentation_step(p)
assert(h.objects[1].removed and h.count() == 1 and #h.objects == 2)
h.def.on_deactivate(h.objects[1].lua); hearth.presentation_step(p)
assert(h.count() == 1 and #h.objects == 2)
end)
test("empty unknown and path-like names remove visuals without accepting paths", function()
local h = harness(); local p = h.player()
for _, name in ipairs({"", "hearth:log", "../hearth_spear.obj", "hearth:knife.obj", "other:spear"}) do
p.item = "hearth:knife"; hearth.presentation_step(p)
p.item = name; hearth.presentation_step(p); assert(h.count() == 0)
end
end)
test("entity self-cleans on weapon change even without feedback tick", function()
local h = harness(); local p = h.player(); hearth.presentation_step(p)
p.item = ""; h.tick_objects(); assert(h.count() == 0)
end)
test("disconnect callback removes immediately and reconnect never inherits owner", function()
local h = harness(); local p = h.player(); hearth.presentation_step(p)
h.connected[p.name] = nil; h.leaves[1](p); assert(h.count() == 0)
local replacement = h.player(); hearth.presentation_step(replacement)
h.leaves[1](p); hearth.presentation_step(p)
assert(h.count() == 1 and #h.objects == 2 and h.objects[2].parent == replacement)
end)
test("session replacement invalidates old entity even on same PlayerRef", function()
local h = harness(); local p = h.player(); hearth.presentation_step(p)
hearth.sessions[p.name] = {}; h.tick_objects(); assert(h.count() == 0)
hearth.presentation_step(p); assert(h.count() == 1 and #h.objects == 2)
end)
test("lost player state session or connected identity self-cleans", function()
for _, missing in ipairs({"state", "session", "connection"}) do
local h = harness(); local p = h.player(); hearth.presentation_step(p)
if missing == "state" then hearth.players[p.name] = nil
elseif missing == "session" then hearth.sessions[p.name] = nil
else h.connected[p.name] = nil end
h.tick_objects(); assert(h.count() == 0); hearth.presentation_step(p); assert(h.count() == 0)
end
end)
test("waiting dead and death callback cleanup are idempotent", function()
local h = harness(); local p = h.player(); hearth.presentation_step(p)
hearth.sessions[p.name].waiting = true; h.tick_objects(); assert(h.count() == 0)
hearth.presentation_step(p); assert(h.count() == 0)
hearth.sessions[p.name].waiting = false; hearth.presentation_step(p)
p.hp = 0; h.tick_objects(); h.deaths[1](p); h.leaves[1](p); assert(h.count() == 0)
hearth.presentation_step(p); assert(h.count() == 0)
end)
test("foreign activation and static restoration are rejected with no saved payload", function()
local h = harness(); h.player()
for _, data in ipairs({"", "inventory payload", "{owner=fixture}"}) do
local obj = core.add_entity({y = 52}, h.entity_name, data)
assert(obj.removed and h.def.get_staticdata(obj.lua) == "")
end
assert(h.count() == 0)
end)
test("harmless entity cannot collide select attack or create inventory", function()
local h = harness(); local p = h.player(); hearth.presentation_step(p)
local def, obj = h.def, h.objects[1]; local props = def.initial_properties
assert(props.static_save == false and props.physical == false and props.pointable == false)
assert(props.collide_with_objects == false and props.is_visible == false)
assert(props.makes_footstep_sound == false and obj.lua.inventory == nil)
assert(def.on_punch(obj.lua, p) == true); def.on_rightclick(obj.lua, p)
assert(p.hp == 20 and h.count() == 1 and hearth.players[p.name].stamina == 100)
end)
test("detached redirected and removed objects are replaced without duplicates", function()
for _, mode in ipairs({"detach", "redirect", "remove"}) do
local h = harness(); local p = h.player(); hearth.presentation_step(p)
local obj = h.objects[1]
if mode == "remove" then obj:remove()
elseif mode == "detach" then obj.parent = nil
else obj.bone = "Head" end
hearth.presentation_step(p); assert(h.count() == 1 and #h.objects == 2)
end
end)
test("failed creation and silent attach failure leave no orphan", function()
for _, failure in ipairs({"fail", "throw", "refuse_attach"}) do
local h = harness(); local p = h.player(); h[failure] = true
hearth.presentation_step(p); assert(h.count() == 0)
h[failure] = false; hearth.presentation_step(p); assert(h.count() == 1)
end
end)
test("multiple owners are bounded independently and shutdown cleans all", function()
local h = harness(); local a, b = h.player("a"), h.player("b")
for _ = 1, 100 do hearth.presentation_step(a); hearth.presentation_step(b) end
assert(h.count() == 2 and #h.objects == 2)
h.shutdowns[1](); h.shutdowns[1](); assert(h.count() == 0)
end)
test("presentation never writes persisted state or session fields", function()
local h = harness(); local p = h.player()
local state, session = hearth.players[p.name], hearth.sessions[p.name]
setmetatable(state, {__newindex = function() error("state write") end})
setmetatable(session, {__newindex = function() error("session write") end})
for _, item in ipairs({"hearth:knife", "hearth:spear", ""}) do p.item = item; hearth.presentation_step(p) end
h.leaves[1](p); assert(next(session) == nil and state.food == 85 and state.stamina == 100)
assert(hearth.players[p.name] == state and hearth.sessions[p.name] == session)
end)
print("PASS " .. total .. " presentation tests")
+570
View File
@@ -0,0 +1,570 @@
-- Isolated journal / recipe / HUD tests; no engine or real player required.
local target = assert(arg[1], "interface.lua path required")
local sibling = target:match("^(.*[/\\])") or ""
local items = {
["hearth:knife"] = {description="Flint knife"}, ["hearth:loose_stone"] = {description="Loose flint"},
["hearth:firestarter"] = {description="Firestarter"}, ["hearth:torch"] = {description="Reed torch"},
["hearth:bedroll"] = {description="Bedroll"}, ["hearth:reeds"] = {description="Reeds"},
["hearth:planks"] = {description="Planks"}, ["hearth:fire"] = {description="Stone hearth"},
["hearth:quartz"] = {description="Quartz crystal"}, ["hearth:window"] = {description="Reed-lattice window"},
["hearth:copper_ore"] = {description="Copper ore"}, ["hearth:charcoal"] = {description="Prepared charcoal"},
["hearth:copper_ingot"] = {description="Copper ingot"}, ["hearth:copper_charge"] = {description="Copper charge"},
["hearth:copper_pick"] = {description="Copper pick"}, ["hearth:sticks"] = {description="Dry sticks"},
["hearth:log"] = {description="Timber log"}, ["hearth:stone"] = {description="Weathered stone"},
["hearth:alluvium"] = {description="Alluvium"},
}
local species = {"oak", "birch", "pine", "willow", "alder", "beech", "aspen", "rowan", "hazel", "spruce"}
local rocks = {"granite", "basalt", "limestone", "slate", "sandstone"}
for _, s in ipairs(species) do
items["hearth:"..s.."_log"] = {description=s.." log"}
items["hearth:"..s.."_planks"] = {description=s.." planks"}
end
for _, rock in ipairs(rocks) do
items["hearth:"..rock] = {description=rock.." bedrock"}
items["hearth:"..rock.."_bricks"] = {description=rock.." masonry"}
end
local registered, screens, commands = {}, {}, {}
core = {registered_items=items, is_singleplayer=function() return true end}
function core.get_modpath() return sibling end
function core.formspec_escape(s)
return (tostring(s):gsub("\\","\\\\"):gsub("%[","\\["):gsub("%]","\\]"):gsub(";","\\;"):gsub(",","\\,"))
end
function core.colorize(color, s) return "\27(c@" .. color .. ")" .. s .. "\27(c@#ffffff)" end
function core.register_on_player_receive_fields(fn) registered.fields=fn end
function core.register_chatcommand(name, def) commands[name]=def end
function core.get_all_craft_recipes(output)
local recipes = {
["hearth:knife"] = {{method="normal",width=3,items={"hearth:loose_stone","hearth:loose_stone",nil,nil,"hearth:loose_stone"},output="hearth:knife"}},
["hearth:firestarter"] = {{method="normal",width=0,items={"hearth:loose_stone","hearth:sticks"},output="hearth:firestarter"}},
["hearth:torch"] = {{method="normal",width=0,items={"hearth:sticks","hearth:reeds"},output="hearth:torch 2"}},
["hearth:bedroll"] = {{method="normal",width=3,items={"hearth:planks","hearth:planks","hearth:planks","hearth:reeds","hearth:reeds","hearth:reeds"},output="hearth:bedroll"}},
["hearth:fire"] = {{method="normal",width=3,items={"hearth:loose_stone"},output="hearth:fire"}},
["hearth:copper_charge"] = {{method="normal",width=0,items={"hearth:copper_ore","hearth:charcoal"},output="hearth:copper_charge"}},
["hearth:copper_pick"] = {{method="normal",width=3,items={"hearth:copper_ingot","hearth:copper_ingot","hearth:copper_ingot","","hearth:sticks","","","hearth:sticks",""},output="hearth:copper_pick"}},
["hearth:window"] = {
{method="normal",width=0,items={"hearth:quartz","hearth:quartz","hearth:quartz"},output="hearth:window"},
{method="normal",width=3,items={"hearth:planks","hearth:reeds","hearth:planks","hearth:planks","hearth:reeds","hearth:planks"},output="hearth:window"}},
["hearth:log"] = {}, ["hearth:planks"] = {}, ["hearth:stone"] = {},
}
for _, s in ipairs(species) do
recipes["hearth:"..s.."_planks"] = {{method="normal",width=0,items={"hearth:"..s.."_log"},output="hearth:"..s.."_planks 4"}}
table.insert(recipes["hearth:log"], {method="normal",width=0,items={"hearth:"..s.."_log","hearth:"..s.."_log"},output="hearth:log 2"})
table.insert(recipes["hearth:planks"], {method="normal",width=0,items={"hearth:"..s.."_planks"},output="hearth:planks"})
end
for _, rock in ipairs(rocks) do
recipes["hearth:"..rock.."_bricks"] = {{method="normal",width=0,items={"hearth:"..rock},output="hearth:"..rock.."_bricks 4"}}
table.insert(recipes["hearth:stone"], {method="normal",width=0,items={"hearth:"..rock,"hearth:"..rock},output="hearth:stone 2"})
end
return recipes[output]
end
function core.show_formspec(name, form, spec) screens[#screens+1]={name=name,form=form,spec=spec} end
hearth = {sessions={alice={}}, model={location=function() end}}
dofile(target)
local player={get_player_name=function() return "alice" end}
function player:get_pos() return {x=10,y=4,z=-2} end
function player:set_inventory_formspec(spec) self.inventory_spec=spec end
local hud = {}
function player:hud_add(def) hud[#hud+1]=def; return #hud end
function player:hud_change(id, field, value) assert(hud[id],"unknown HUD id"); hud[id][field]=value end
function player:hud_set_flags(flags) self.flags=flags end
function player:hud_set_hotbar_itemcount(count) self.hotbar_count=count end
function player:hud_set_hotbar_image(texture) self.hotbar_texture=texture end
function player:hud_set_hotbar_selected_image(texture) self.hotbar_selected=texture end
function core.get_player_by_name(name) return name=="alice" and player or nil end
local total=0
local function test(name, fn)
local ok, err=pcall(fn); assert(ok, name..": "..tostring(err)); total=total+1
print("PASS " .. name)
end
local function latest() return screens[#screens] end
test("search lists craft recipes and elapsed real-time process instructions", function()
hearth.recipes(player,"",1)
local spec=latest().spec
assert(latest().form=="hearth:recipes" and spec:find("Page 1 /",1,true))
hearth.recipes(player,"knife",1)
assert(latest().spec:find("Flint knife",1,true))
hearth.recipes(player,"Cooked forage",1)
assert(latest().spec:find("6 real seconds",1,true), latest().spec)
hearth.recipes(player,"Boiled water",1)
assert(latest().spec:find("4 real seconds",1,true), latest().spec)
hearth.recipes(player,"Pottery firing",1)
assert(latest().spec:find("120 real seconds",1,true), latest().spec)
hearth.recipes(player,"River fish trap",1)
assert(latest().spec:find("600 real seconds",1,true), latest().spec)
hearth.recipes(player,"Spring fish run",1)
assert(latest().spec:find("scheduled deadline",1,true), latest().spec)
hearth.recipes(player,"River fish trap",1)
assert(latest().spec:find("600 real seconds",1,true), latest().spec)
hearth.recipes(player,"Spring fish run",1)
assert(latest().spec:find("scheduled deadline",1,true), latest().spec)
hearth.recipes(player,"firestarter",1)
assert(latest().spec:find("Firestarter",1,true))
hearth.recipes(player,"bedroll",1)
assert(latest().spec:find("Bedroll",1,true))
end)
test("plain text searches match ingredients and result names", function()
hearth.recipes(player,"Seasonal forage",1)
assert(latest().spec:find("Spring greens",1,true) and latest().spec:find("Autumn mushrooms",1,true))
hearth.recipes(player,"loose_stone",1)
assert(latest().spec:find("Flint knife",1,true))
hearth.recipes(player,"torch",1)
assert(latest().spec:find("Reed torch",1,true))
end)
test("search is bounded and formspec escaped, unknown term reports no matches", function()
local query=string.rep("x",80).."];button[0,0;"
hearth.recipes(player,query,1)
local spec=latest().spec
assert(spec:find("x",1,true) and not spec:find(query,1,true))
hearth.recipes(player,"no-such-item",1)
assert(latest().spec:find("No recipe or process matches",1,true))
end)
test("guide navigation and chat command enter the real recipe index", function()
assert(commands.recipes and commands.recipes.func("alice","reeds"))
assert(latest().form=="hearth:recipes" and latest().spec:find("Reed torch",1,true))
registered.fields(player,"hearth:guide",{recipe_index=true})
assert(latest().form=="hearth:recipes")
registered.fields(player,"hearth:recipes",{close=true})
assert(latest().form=="hearth:guide")
end)
test("all journal surfaces share Mocha controls, lists and botanical dividers", function()
local function themed(spec)
for _,color in ipairs({"#1e1e2e","#181825","#292940","#3c3d56","#cdd6f4","#cba6f7","#94e2d5"}) do
assert(spec:find(color,1,true), "missing palette anchor "..color)
end
assert(spec:find("style_type[button:hovered,button_exit:hovered",1,true))
assert(spec:find("bgimg="..core.formspec_escape("[fill:1x1:#ffffff"),1,true),"borderless buttons need visible backing")
assert(spec:find("style_type[field,textarea;",1,true))
assert(spec:find("listcolors[#292940;#3c3d56;#3c3d56;#181825;#cdd6f4]",1,true))
assert(spec:find("0.16,0.07;#94e2d5]",1,true),"pressed-sprig divider missing")
end
hearth.guide(player,1); themed(latest().spec)
hearth.recipes(player,"",1); themed(latest().spec)
hearth.inventory(player); themed(player.inventory_spec)
hearth.location_form(player); themed(latest().spec)
end)
test("guide keeps eight pages and every sidebar button inside 14 by 10", function()
for page=1,8 do
hearth.guide(player,page)
local spec,count=latest().spec,0
assert(spec:find("size[14,10]",1,true))
assert(spec:find(string.format("%02d / 08",page),1,true))
for x,y,w,h in spec:gmatch("button%[([%d.]+),([%d.]+);([%d.]+),([%d.]+);page_%d+;") do
assert(tonumber(x)+tonumber(w)<=3.3 and tonumber(y)+tonumber(h)<8.5)
count=count+1
end
assert(count==8 and not spec:find("page_9",1,true))
end
hearth.guide(player,6)
assert(latest().spec:find("Living ecosystems",1,true))
assert(latest().spec:find("Dynamic Life",1,true))
assert(not latest().spec:find("Riverward field season",1,true))
assert(latest().spec:find("no offline catch-up",1,true))
hearth.guide(player,0/0); assert(hearth.sessions.alice.page==1)
hearth.guide(player,math.huge); assert(hearth.sessions.alice.page==8)
end)
test("resource searches show quartz windows copper charges picks and process contract", function()
for _, query in ipairs({"quartz", "hearth:quartz"}) do
hearth.recipes(player,query,1)
assert(latest().spec:find("Reed-lattice window <- Quartz crystal + Quartz crystal + Quartz crystal",1,true))
end
hearth.recipes(player,"window",1)
assert(latest().spec:find("Planks + Planks + Planks + Planks + Reeds + Reeds",1,true))
hearth.recipes(player,"copper_ore",1)
assert(latest().spec:find("Copper charge <- Copper ore + Prepared charcoal",1,true))
hearth.recipes(player,"copper_pick",1)
assert(latest().spec:find("Copper pick <- Copper ingot + Copper ingot + Copper ingot + Dry sticks + Dry sticks",1,true))
hearth.recipes(player,"Copper smelting",1)
for _, text in ipairs({"45 real seconds", "30 fuel seconds", "75 fuel seconds", "one hearth:copper_ingot", "full inventory"}) do
assert(latest().spec:find(text,1,true),text)
end
hearth.recipes(player,"Fire fuel",1)
for _, text in ipairs({"180", "90", "240", "600", "nonrenewable", "not consumed"}) do
assert(latest().spec:find(text,1,true),text)
end
end)
for _, s in ipairs(species) do
test(s.." search discovers matching planks and canonical timber conversions", function()
hearth.recipes(player,"hearth:"..s.."_log",1)
assert(latest().spec:find("4 x "..s.." planks <- "..s.." log",1,true))
assert(latest().spec:find("2 x Timber log <- "..s.." log + "..s.." log",1,true))
assert(not latest().spec:find("\nTimber log <- "..s.." log",1,true))
hearth.recipes(player,"hearth:"..s.."_planks",1)
assert(latest().spec:find("Planks <- "..s.." planks",1,true))
end)
end
for _, rock in ipairs(rocks) do
test(rock.." search discovers four bricks and canonical stone", function()
hearth.recipes(player,"hearth:"..rock,1)
assert(latest().spec:find("4 x "..rock.." masonry <- "..rock.." bedrock",1,true))
assert(latest().spec:find("2 x Weathered stone <- "..rock.." bedrock + "..rock.." bedrock",1,true))
assert(not latest().spec:find("\nWeathered stone <- "..rock.." bedrock",1,true))
end)
end
test("regional guide covers ten biomes conversions and operator-controlled old-world seams", function()
hearth.guide(player,8)
for _, text in ipairs({"Temperate meadow", "oak woodland", "birch forest", "pine taiga", "Wet forest", "floodplain",
"Dry scrub", "alpine meadow", "rocky peaks", "riverbank", "canonical log", "45 real seconds", "240 seconds",
"fresh worlds only", "unexplored old-world boundaries", "Operators must explicitly choose", "climate HUD remains shared"}) do
assert(latest().spec:find(text,1,true),text)
end
for _, text in ipairs({"two matching species logs to two canonical logs", "two matching rocks convert to two canonical stones",
"separate crafting slots", "no single-log or single-rock canonical conversion"}) do
assert(latest().spec:find(text,1,true),text)
end
registered.fields(player,"hearth:guide",{page_8=true})
assert(hearth.sessions.alice.page==8)
hearth.guide(player,6)
for _,text in ipairs({"Birch pioneers", "Evergreen pine and spruce", "alluvium/peat", "two nutrient units", "Nitrogen-fixer policy",
"not a separate fertilizer process", "not fully simulated hydrology", "no offline catch-up"}) do
assert(latest().spec:find(text,1,true),text)
end
hearth.recipes(player,"Species timber",1)
assert(latest().spec:find("convert species logs in matching pairs first",1,true))
assert(latest().spec:find("two canonical hearth:log",1,true))
hearth.recipes(player,"Regional geology",1)
assert(latest().spec:find("two matching rocks to two canonical stones",1,true))
hearth.recipes(player,"Finite nitrogen",1)
assert(latest().spec:find("at most two nutrient units",1,true))
end)
test("inventory retains crafting, preview, ten by four storage and transfer rings", function()
hearth.inventory(player)
assert(player.inventory_spec:find("style_type[list;size=1,1;spacing=0.12,0.12]",1,true))
assert(0.6+10+9*0.12<12.4 and 5.7+4+3*0.12<11.3,"inventory grid must fit the paper")
for _,element in ipairs({"list[current_player;craft;0.6,1.8;3,3;]",
"list[current_player;craftpreview;4.8,2.9;1,1;]", "list[current_player;main;0.6,5.7;10,4;]",
"listring[current_player;main]listring[current_player;craft]", "field_guide;Open field notes]"}) do
assert(player.inventory_spec:find(element,1,true),element)
end
registered.fields(player,"",{field_guide=true}); assert(latest().form=="hearth:guide")
end)
test("recipe query escaping, literal matching and pagination remain bounded", function()
hearth.recipes(player,"];button[0,0;1,1;attack;Injected]",0/0)
assert(latest().spec:find(core.formspec_escape("];button[0,0;1,1;attack;Injected]"),1,true))
assert(not latest().spec:find(";attack;Injected]",1,true))
hearth.recipes(player,"%[.*",1)
assert(latest().spec:find("No recipe or process matches",1,true))
hearth.recipes(player,"\n\tfood",1); assert(hearth.sessions.alice.recipe_query==" food")
hearth.recipes(player,"",1)
registered.fields(player,"hearth:recipes",{next=true}); assert(hearth.sessions.alice.recipe_page==2)
registered.fields(player,"hearth:recipes",{previous=true}); assert(hearth.sessions.alice.recipe_page==1)
registered.fields(player,"hearth:recipes",{key_enter=true,query="torch"})
assert(latest().spec:find("Reed torch",1,true))
end)
test("ecosystem command and guide button only read one habitat and status snapshot", function()
local reads, snapshots=0,0
local function forbidden() error("observations must never mutate or load the map") end
for _,name in ipairs({"set_node","swap_node","emerge_area","forceload_block","add_entity","get_objects_inside_radius","get_meta"}) do
core[name]=forbidden
end
hearth.succession={habitat=function(pos)
assert(pos.x==10 and pos.y==4 and pos.z==-2); reads=reads+1
return {grass=3,flowers=2,cover=4,water=1,canopy=1.5,moisture=0.65,light=10,loaded=true,
ground="alluvium",pollination=7,nutrients=2}
end,step=forbidden,forage=forbidden,deposit=forbidden}
hearth.wildlife={status=function()
snapshots=snapshots+1
return {hare=3,deer=2,fox=1,total=6,global_cap=32,local_cap=6,player_cap=6}
end}
assert(commands.ecosystem.func("alice"))
assert(reads==1 and snapshots==1 and latest().form=="hearth:ecosystem")
for _,text in ipairs({"Natural grass cover: 3", "Flower-support cover: 2", "Shrub / fern / aquatic / tree cover: 4",
"Moisture: 65%", "Hares: 3", "Deer: 2", "Foxes: 1", "Active total: 6 / 32 globally",
"not a local census", "Loaded sample", "No terrain is loaded", "Ground: Alluvium",
"Pollinator-support plants: 7", "Nutrient reserve: 2 / 8", "pine and spruce stay evergreen", "deciduous canopy thins"}) do
assert(latest().spec:find(text,1,true),text)
end
registered.fields(player,"hearth:guide",{ecosystem_survey=true})
assert(reads==2 and snapshots==2 and latest().form=="hearth:ecosystem")
registered.fields(player,"hearth:ecosystem",{refresh=true})
assert(reads==3 and snapshots==3)
registered.fields(player,"hearth:ecosystem",{field_notes=true})
assert(hearth.sessions.alice.page==6 and reads==3 and snapshots==3)
assert(not commands.ecosystem.func("offline"))
end)
test("survey discloses unloaded and unavailable data without changing sessions or world", function()
hearth.succession.habitat=function() return {loaded=false,grass=1} end
hearth.ecosystem(player)
assert(latest().spec:find(core.formspec_escape("Incomplete sample / unloaded cells are unknown, not empty"),1,true))
assert(latest().spec:find("Natural grass cover: 1",1,true))
assert(latest().spec:find("Ground: unknown",1,true))
assert(latest().spec:find("Pollinator-support plants: unavailable",1,true))
assert(latest().spec:find("Nutrient reserve: unavailable",1,true))
hearth.succession.habitat=function()
return {loaded=true,ground="];button[0,0;1,1;attack;Injected]",pollination=0/0,nutrients=math.huge}
end
hearth.ecosystem(player)
assert(not latest().spec:find(";attack;Injected]",1,true))
assert(latest().spec:find(core.formspec_escape("hearth:];button[0,0;1,1;attack;Injected]"),1,true))
assert(latest().spec:find("Pollinator-support plants: 0",1,true))
assert(latest().spec:find("Nutrient reserve: 0 / 8",1,true))
hearth.succession,hearth.wildlife=nil,nil
hearth.ecosystem(player)
assert(latest().spec:find("Succession observations are not available",1,true))
assert(latest().spec:find("Wildlife observations are not available",1,true))
local session,count=hearth.sessions.alice,#screens
hearth.sessions.alice=nil
assert(not commands.ecosystem.func("alice"))
registered.fields(player,"hearth:guide",{ecosystem_survey=true})
registered.fields(player,"hearth:ecosystem",{refresh=true})
assert(#screens==count)
hearth.sessions.alice=session
end)
test("botanical guide tolerates absent empty and partial optional catalogue APIs", function()
for _, mock in ipairs({false, {}, {list=function() return {} end}}) do
hearth.botany=mock or nil
hearth.botanical_guide(player,"",math.huge)
assert(latest().form=="hearth:botany" and latest().spec:find("catalogue unavailable",1,true))
assert(hearth.sessions.alice.botany_page==1)
hearth.guide(player,6)
assert(latest().spec:find("catalogue unavailable",1,true))
end
end)
-- Actual catalogue factory/methods, not an invented list of test species.
local botany_path = target:match("^(.*[/\\])") or ""
local actual_botany = dofile(botany_path .. "botany.lua")(hearth,{})
test("actual botanical catalogue displays eighty species in eight ten-row pages", function()
hearth.botany=actual_botany
local records, groups = actual_botany.list(), {}
assert(#records==80)
for page=1,8 do
hearth.botanical_guide(player,"",page)
local spec,rows=latest().spec,0
assert(spec:find("Page "..page.." / 8 / 80 matches",1,true))
for _ in spec:gmatch("Habitat:") do rows=rows+1 end
assert(rows==10,"each page must contain ten species rows")
for i=(page-1)*10+1,page*10 do
local r=records[i]; groups[r.group]=(groups[r.group] or 0)+1
assert(spec:find(core.formspec_escape(r.common.." ("..r.latin..")"),1,true),r.id)
assert(spec:find("Active seasons:",1,true) and spec:find("Roles / conditional policy or descriptors:",1,true))
end
end
local count=0; for _,n in pairs(groups) do assert(n==10); count=count+1 end; assert(count==8)
hearth.guide(player,6)
for _,r in ipairs(records) do assert(latest().spec:find(core.formspec_escape(r.common.." ("..r.latin..")"),1,true),r.id) end
end)
test("botanical searches use real scientific names niches seasons and honest role labels", function()
for _,query in ipairs({"Fagus sylvatica", "beech"}) do
hearth.botanical_guide(player,query,1)
assert(latest().spec:find("European beech (Fagus sylvatica)",1,true))
end
hearth.botanical_guide(player,"fly_agaric",1)
for _,text in ipairs({"Amanita muscaria", "toxic flag: YES", "Summer", "Autumn", "hosts:", "no catalogue food harvest"}) do
assert(latest().spec:find(text,1,true),text)
end
hearth.botanical_guide(player,"hornwort",1)
for _,text in ipairs({"Ceratophyllum demersum", "submerged", "water depth 1-4", "NOT food permission", "habitat engineering (descriptive tag)"}) do
assert(latest().spec:find(core.formspec_escape(text),1,true),text)
end
hearth.botanical_guide(player,"bracken",1)
assert(latest().spec:find("descriptor only",1,true) and latest().spec:find("no killing",1,true))
hearth.botanical_guide(player,"honey_fungus",1)
assert(latest().spec:find("no plant damage",1,true))
hearth.botanical_guide(player,"bilberry",1)
assert(latest().spec:find("seasonal game berry harvest",1,true))
assert(latest().spec:find("NOT a real-world forage guide",1,true))
end)
test("botanical query escaping literal search and finite pagination bounds", function()
local query="];button[0,0;1,1;attack;Injected]"
hearth.botanical_guide(player,query,0/0)
assert(latest().spec:find(core.formspec_escape(query),1,true))
assert(not latest().spec:find(";attack;Injected]",1,true))
hearth.botanical_guide(player,"%[.*",1)
assert(latest().spec:find("No species matches that literal search",1,true))
hearth.botanical_guide(player,string.rep("x",70),999)
assert(#hearth.sessions.alice.botany_query==48 and hearth.sessions.alice.botany_page==1)
hearth.botanical_guide(player,"\n\tlichen",-math.huge)
assert(hearth.sessions.alice.botany_query==" lichen" and hearth.sessions.alice.botany_page==1)
for _,p in ipairs({0/0,-8,"bad",0}) do hearth.botanical_guide(player,"",p); assert(hearth.sessions.alice.botany_page==1) end
hearth.botanical_guide(player,"",math.huge); assert(hearth.sessions.alice.botany_page==8)
local record=actual_botany.list()[1]; record.common=query
hearth.botany={list=function() return {record} end}
hearth.botanical_guide(player,"",1)
assert(latest().spec:find(core.formspec_escape(query),1,true) and not latest().spec:find(";attack;Injected]",1,true))
hearth.botany=actual_botany
end)
test("botanical commands buttons pagination and exits route without world access", function()
assert(commands.botany.func("alice","Quercus robur"))
assert(latest().form=="hearth:botany" and latest().spec:find("Pedunculate oak",1,true))
assert(not commands.botany.func("offline",""))
registered.fields(player,"hearth:guide",{botany_index=true})
assert(latest().form=="hearth:botany")
registered.fields(player,"hearth:botany",{next=true,query="ignored"}); assert(hearth.sessions.alice.botany_page==2)
registered.fields(player,"hearth:botany",{previous=true}); assert(hearth.sessions.alice.botany_page==1)
registered.fields(player,"hearth:botany",{key_enter=true,query="Picea abies"})
assert(latest().spec:find("Norway spruce",1,true))
registered.fields(player,"hearth:botany",{search=true,query="Betula pendula"})
assert(latest().spec:find("Silver birch",1,true) and hearth.sessions.alice.botany_page==1)
registered.fields(player,"hearth:botany",{field_notes=true}); assert(latest().form=="hearth:guide" and hearth.sessions.alice.page==6)
registered.fields(player,"hearth:ecosystem",{botany_index=true}); assert(latest().form=="hearth:botany")
registered.fields(player,"hearth:botany",{ecosystem_survey=true}); assert(latest().form=="hearth:ecosystem")
local session,count=hearth.sessions.alice,#screens; hearth.sessions.alice=nil
assert(not commands.botany.func("alice",""))
hearth.botanical_guide(player,"",1)
registered.fields(player,"hearth:botany",{next=true})
registered.fields(player,"hearth:ecosystem",{botany_index=true})
registered.fields(player,"hearth:guide",{botany_index=true})
assert(#screens==count); hearth.sessions.alice=session
end)
test("botanical navigation fits existing panels and does not add a ninth journal page", function()
hearth.guide(player,6)
assert(latest().spec:find("botany_index;Botanical guide",1,true) and not latest().spec:find("page_9",1,true))
local guide=latest().spec
hearth.ecosystem(player); local survey=latest().spec
hearth.botanical_guide(player,"",4); local browser=latest().spec
for _,spec in ipairs({guide,survey,browser}) do
local right=0
for x,y,w,h in spec:gmatch("button[%w_]*%[([%d.]+),([%d.]+);([%d.]+),([%d.]+);") do
x,y,w,h=tonumber(x),tonumber(y),tonumber(w),tonumber(h)
assert(x+w<=14 and y+h<=10)
if y==8.85 then assert(x>=right,"footer buttons overlap"); right=x+w end
end
end
end)
test("survey exposes actual optional botanical habitat fields without inventing counts", function()
hearth.succession={habitat=function() return {loaded=true,ground="peat",grass=2,flowers=1,cover=4,
near_water=8,water=2,aquatic="submerged",ph=4.5,nursery=3} end}
hearth.ecosystem(player)
for _,text in ipairs({"Nearby water cells: 8", "Water depth: 2", "Game substrate pH: 4.5", "Nursery-support plants: 3", "Aquatic niche: submerged"}) do
assert(latest().spec:find(text,1,true),text)
end
hearth.succession.habitat=function() return {loaded=false} end
hearth.ecosystem(player)
for _,text in ipairs({"Game substrate pH: unavailable", "Nursery-support plants: unavailable", "Water depth: unavailable", "Aquatic niche: unavailable"}) do
assert(latest().spec:find(text,1,true),text)
end
end)
test("fallen log guidance matches existing axe-only sideways cube behavior", function()
hearth.recipes(player,"Fallen logs",1)
for _,text in ipairs({"sideways", "full-cube", "axe", "one canonical hearth:log", "hands do not yield sticks"}) do
assert(latest().spec:find(text,1,true),text)
end
end)
test("location stays local-only, bounded and escaped", function()
local writes, checks=0,0
hearth.set_location=function(lat,lon) assert(lat==45 and lon==0); writes=writes+1 end
hearth.model.location=function(lat,lon) checks=checks+1; return lat=="45" and lon=="0" end
core.is_singleplayer=function() return false end
registered.fields(player,"hearth:location",{save_location=true,latitude="45",longitude="0"})
assert(writes==0 and checks==0 and not commands.location.func("alice"))
core.is_singleplayer=function() return true end
for _,value in ipairs({string.rep("1",17), "];button[0,0;", 42}) do
registered.fields(player,"hearth:location",{save_location=true,latitude=value,longitude="0"})
assert(writes==0 and latest().form=="hearth:location")
end
hearth.location_form(player,"];button[0,0;1,1;attack;Injected]")
assert(latest().spec:find(core.formspec_escape("];button[0,0;1,1;attack;Injected]"),1,true))
assert(not latest().spec:find(";attack;Injected]",1,true))
registered.fields(player,"hearth:location",{save_location=true,latitude="45",longitude="0"})
assert(writes==1 and latest().form=="hearth:guide")
end)
test("HUD preserves runtime keys, tiles ten hotbar slots and uses readable graphical reserves", function()
hearth.make_hud(player)
local ids=hearth.sessions.alice.hud
for _,key in ipairs({"title","clock","needs","context","help"}) do assert(hud[ids[key]],key) end
assert(player.hotbar_count==10 and player.flags.minimap==false)
local expected="[combine:320x32"
for i=0,9 do expected=expected..":"..(i*32)..",0=hearth_hotbar.png" end
assert(player.hotbar_texture==expected and player.hotbar_selected=="hearth_selected.png")
hearth.clock={hour=13.5,season="Autumn",date="2026-11-02"}
hearth.players={alice={warmth=10,water=35,food=75,stamina=100,wet=30}}
hearth.update_hud(player,{temperature=8,shelter=1,rain=true,heat=1,smoke=true})
assert(hud[ids.clock].text:find("Solar 13:30",1,true))
assert(hud[ids.warmth_label].text=="WARMTH 10%" and hud[ids.warmth_label].number==0xf38ba8)
assert(hud[ids.water_label].number==0xf9e2af and hud[ids.food_label].number==0x94e2d5)
assert(hud[ids.food_fill].text=="[fill:165x6:#94e2d5")
assert(hud[ids.context].text:find("SMOKE / open a wall or roof vent",1,true))
assert(hud[ids.context].number==0xf38ba8)
hearth.players.alice.food=0; hearth.players.alice.stamina=200
hearth.update_hud(player,{temperature=8,shelter=0,roof=true,heat=0})
assert(hud[ids.food_fill].text=="[fill:1x6:#18182500")
assert(hud[ids.stamina_label].text=="STAMINA 100%")
end)
test("HUD accepts legacy mocks without optional hotbar image setters", function()
local image,selected=player.hud_set_hotbar_image,player.hud_set_hotbar_selected_image
player.hud_set_hotbar_image,player.hud_set_hotbar_selected_image=nil,nil
hearth.make_hud(player)
assert(hearth.sessions.alice.hud.needs)
player.hud_set_hotbar_image,player.hud_set_hotbar_selected_image=image,selected
end)
assert(total==38,"The original 38 recipe/journal checks must remain intact")
test("inspect command uses exact species and rejects absent session unknown and URL input",function()
hearth.botany=actual_botany
hearth.wiki_articles=dofile(sibling.."wiki_articles.lua")
hearth.species_effects=dofile(sibling.."species_effects.lua")(hearth,{})
assert(commands.inspect and not commands.inspect.privs)
assert(commands.inspect.func("alice","Quercus robur"))
assert(latest().form=="hearth:species" and latest().spec:find("Wikipedia excerpt (offline)",1,true))
assert(not commands.inspect.func("alice","https://en.wikipedia.org/wiki/Oak"))
assert(not commands.inspect.func("alice","Quercus"))
assert(not commands.inspect.func("offline","oak"))
end)
test("botanical article selection uses trusted current-page numeric rows only",function()
hearth.botanical_guide(player,"",2)
assert(latest().spec:find("tooltip[species_choice;Species article]",1,true))
registered.fields(player,"hearth:botany",{species_choice="2",species_article=true,id="oak"})
assert(latest().form=="hearth:species" and hearth.sessions.alice.species_reader.id=="blackberry")
for _,v in ipairs({"oak","0","11","2.5","1e0","999",{},string.rep("1",1000)}) do
hearth.botanical_guide(player,"",2); local n=#screens
registered.fields(player,"hearth:botany",{species_choice=v,species_article=true})
assert(#screens==n,"forged choice opened article")
end
hearth.botanical_guide(player,"no-such-species",1); local n=#screens
registered.fields(player,"hearth:botany",{species_article=true,id="oak"}); assert(#screens==n)
end)
test("article navigation retains server species and returns to selected botanical page",function()
hearth.botanical_guide(player,"",3)
registered.fields(player,"hearth:botany",{species_choice="2",species_article=true})
local id=hearth.sessions.alice.species_reader.id
registered.fields(player,"hearth:species",{reader_tab="2",id="pine",url="bad",text="edited"})
assert(hearth.sessions.alice.species_reader.id==id and latest().spec:find("SIMULATED",1,true))
registered.fields(player,"hearth:species",{reader_botany=true})
assert(latest().form=="hearth:botany" and hearth.sessions.alice.botany_page==3)
registered.fields(player,"hearth:botany",{species_choice="1",species_article=true})
registered.fields(player,"hearth:species",{quit=true}); local n=#screens
registered.fields(player,"hearth:species",{reader_tab="1"}); assert(#screens==n)
end)
test("fieldnotes inspect target uses loaded eye ray without changing other actions",function()
core.get_node_or_nil=function() return {name="hearth:oak_leaves"} end
function player:get_properties() return {eye_height=1.6} end
function player:get_look_dir() return {x=0,y=0,z=1} end
core.raycast=function(start,finish,objects,liquids)
assert(start.y==5.6 and finish.z==4 and not objects and not liquids)
local used=false
return function()
if used then return nil end; used=true
return {type="node",under={x=10,y=6,z=0},intersection_point={x=10,y=5.6,z=0}}
end
end
hearth.guide(player,6); assert(latest().spec:find("inspect_target;Inspect target",1,true))
registered.fields(player,"hearth:guide",{inspect_target=true})
assert(latest().form=="hearth:species" and hearth.sessions.alice.species_reader.id=="oak")
assert(commands.inspect.func("alice",""))
core.get_node_or_nil=function() return nil end
assert(not commands.inspect.func("alice",""))
end)
test("dropdown labels escape cached IDs and absent sessions reject article fields",function()
local r=actual_botany.list()[1]; r.id="];button[0,0;1,1;attack;Injected]"
hearth.botany={list=function() return {r} end}
hearth.botanical_guide(player,"",1)
assert(latest().spec:find(core.formspec_escape(r.id),1,true) and not latest().spec:find(";attack;Injected]",1,true))
hearth.botany=actual_botany
local s,n=hearth.sessions.alice,#screens; hearth.sessions.alice=nil
registered.fields(player,"hearth:botany",{species_choice="1",species_article=true})
registered.fields(player,"hearth:species",{reader_tab="2"})
assert(not commands.inspect.func("alice","oak") and #screens==n)
hearth.sessions.alice=s
end)
test("inventory appearance entry opens only the requesting player's creator", function()
local called = 0
hearth.character = {show = function(p) assert(p == player); called = called + 1 end}
hearth.inventory(player)
assert(player.inventory_spec:find("character_creator;Character appearance]",1,true))
assert(player.inventory_spec:find("Cosmetic only",1,true))
registered.fields(player,"",{character_creator=true}); assert(called == 1)
registered.fields(player,"hearth:recipes",{character_creator=true}); assert(called == 1)
hearth.character = nil
registered.fields(player,"",{character_creator=true}); assert(called == 1)
end)
assert(total==44,"Unexpected recipe/journal/character integration count")
print("PASS "..total.." recipe discovery tests")
+303
View File
@@ -0,0 +1,303 @@
-- Private two-boot recovery integration probe. Executed ONLY in restored copies.
-- "capture" runs in the hearth mod context before its real init; "probe" runs
-- as a dependent worldmod after startup. No engine globals or live players mocked.
return function(mode)
local C = core
local native_assert = assert
local verified_assertions = 0
local failure_label = "checkpoint-probe-start"
-- Assertion labels below are source-code constants, never engine/save input.
-- Never expose pcall errors, original records or parser diagnostics.
local function assert(value, label)
label = label or "Unlabelled engine serialization"
if not value then
failure_label = "checkpoint-" .. label:lower():gsub("[^a-z]+", "-"):gsub("%-$", ""):sub(1, 96)
C.log("error", "LAST_HEARTH_RECOVERY_FAIL " .. failure_label)
return native_assert(value, failure_label)
end
verified_assertions = verified_assertions + 1
return value
end
local phase = C.settings:get("hearth_recovery_phase")
local token = C.settings:get("hearth_recovery_token")
local root = C.get_worldpath()
local kinds = {"claims", "homes", "access", "location", "spawn"}
assert(phase == "seed" or phase == "restart", "Invalid recovery phase")
assert(token and token:match("^[%x%-]+$"), "Invalid recovery nonce")
local function equal(a, b)
if type(a) ~= type(b) then return false end
if type(a) ~= "table" then return a == b end
for k, v in pairs(a) do if not equal(v, b[k]) then return false end end
for k in pairs(b) do if a[k] == nil then return false end end
return true
end
local function decode(raw)
local value = C.parse_json(raw, nil, true)
assert(type(value) == "table", "Invalid recovery JSON")
return value
end
local function read(name)
local file = assert(io.open(root .. "/" .. name, "rb"), "Missing recovery evidence")
local raw = file:read("*a")
file:close()
return decode(raw)
end
local function write(name, value)
assert(C.safe_file_write(root .. "/" .. name, assert(C.write_json(value))), "Evidence write failed")
end
if mode == "capture" then
assert(C.get_current_modname() == "hearth", "Capture requires real hearth storage context")
if phase == "seed" then
local storage = C.get_mod_storage()
local expected = {format = 1, token = token, keys = {}, backups = {}}
for _, kind in ipairs(kinds) do
expected.keys[kind] = storage:get_string(kind)
expected.backups[kind] = storage:get_string("m0_backup:" .. kind)
end
write("m0-recovery-expected.json", expected)
else
local expected = read("m0-recovery-expected.json")
assert(expected.format == 1 and expected.token == token, "Original evidence missing on restart")
end
return
end
assert(mode == "probe", "Invalid recovery operation")
local finished, ready = false, false
local report = {format = 1, phase = phase, token = token, existingKeys = 0, legacyKeys = 0}
-- The game's mapgen_limit is 512. Stay inside complete generatable chunks,
-- high above terrain; refuse occupied space rather than overwrite any copy data.
local fixture_pos = {x = 320, y = 320, z = 320}
local expired_pos = {x = 321, y = 320, z = 320}
local live_pos = {x = 322, y = 320, z = 320}
local function no_clients()
assert(#C.get_connected_players() == 0, "Recovery forbids live clients")
end
local function fail()
if finished then return end
finished = true
-- Never include assertion/parser strings: original records may be private.
C.log("error", "LAST_HEARTH_RECOVERY_FAIL " .. failure_label)
C.request_shutdown("Private recovery failed", false, 0)
end
local function guard(fn)
if finished then return end
local ok = pcall(fn)
if not ok then fail() end
end
local function envelope(kind, raw)
local parsed = decode(raw)
assert(parsed.schema == 1 and parsed.kind == kind and type(parsed.data) == "table", "Missing schema-1 envelope")
local result, migrated = hearth.schema.decode(kind, parsed)
assert(migrated == false and equal(result, parsed), "Envelope did not roundtrip")
return parsed.data
end
local function world_check()
failure_label = "checkpoint-world-check"
assert(C.get_game_info().id == C.settings:get("hearth_recovery_gameid"), "Disposable game selection mismatch")
assert(type(hearth) == "table" and type(hearth.persistence) == "table", "Persistence not wired")
assert(type(hearth.schema) == "table" and type(hearth.storage) == "userdata", "Real storage/schema missing")
assert(not C.get_modpath("hearth_smoke"), "Legacy smoke mod loaded")
assert(C.settings:get("hearth_environment") == "staging", "Not a staging boot")
assert(C.settings:get("bind_address") == "127.0.0.1" and C.settings:get("max_users") == "0", "Client isolation missing")
assert(C.settings:get_bool("secure.enable_security") == true, "Mod security disabled")
no_clients()
local expected = read("m0-recovery-expected.json")
assert(expected.format == 1 and expected.token == token, "Wrong original evidence")
local saved = {}
for _, kind in ipairs(kinds) do
local raw, before_backup = expected.keys[kind], expected.backups[kind]
assert(type(raw) == "string" and type(before_backup) == "string", "Missing pre-migration bytes")
local backup_key = "m0_backup:" .. kind
local backup = hearth.storage:get_string(backup_key)
if raw ~= "" then
report.existingKeys = report.existingKeys + 1
local normalized, legacy = hearth.schema.decode(kind, decode(raw))
if legacy then report.legacyKeys = report.legacyKeys + 1 end
-- Inspect BEFORE load_world: a lazy probe must not hide missing startup migration.
local actual = envelope(kind, hearth.storage:get_string(kind))
assert(equal(actual, normalized.data), "Stored original data changed during migration")
local expected_backup = before_backup ~= "" and before_backup or (legacy and raw or "")
assert(backup == expected_backup, "Original backup bytes changed")
if legacy then
local recovered, was_legacy = hearth.schema.decode(kind, decode(backup))
assert(was_legacy and equal(recovered, normalized), "Legacy backup cannot recover original data")
end
saved[kind] = hearth.persistence.load_world(kind)
assert(equal(saved[kind], normalized.data), "Adapter changed original world data")
hearth.persistence.save_world(kind, saved[kind])
assert(hearth.storage:get_string(backup_key) == backup, "Save replaced immutable backup")
else
assert(backup == before_backup, "Missing record created a backup")
end
end
assert(report.existingKeys > 0 and report.legacyKeys > 0, "Not an existing legacy save")
assert(saved.spawn, "Original saved spawn required")
assert(equal(hearth.persistence.load_world("spawn"), saved.spawn), "Saved spawn changed")
local claims, homes = saved.claims or {}, saved.homes or {}
assert(type(hearth.claim_allowed) == "function", "Claims protection missing")
assert(type(hearth.home_for) == "function", "Home lookup missing")
for owner, home in pairs(homes) do
local exposed = hearth.home_for(owner)
assert(equal(exposed, home), "Persisted home lookup changed")
exposed.x = exposed.x + 1
assert(equal(hearth.home_for(owner), home), "Home lookup exposed mutable storage")
end
report.homes = true
local outsider = "__hearth_recovery_outsider"
while claims[outsider] do outsider = outsider .. "_" end
report.claimCount = 0
for owner, pos in pairs(claims) do
report.claimCount = report.claimCount + 1
assert(not hearth.claim_allowed(pos, outsider) and C.is_protected(pos, outsider), "Original claim lost protection")
local allowed = true
for other, center in pairs(claims) do
if other ~= owner and math.abs(pos.x - center.x) <= 12 and math.abs(pos.z - center.z) <= 12
and math.abs(pos.y - center.y) <= 24 then allowed = false end
end
assert(hearth.claim_allowed(pos, owner) == allowed, "Claim ownership changed")
end
report.claims = true
return saved.spawn
end
local original_needs = {food = 72, water = 63, warmth = 54, wet = 9, stamina = 81}
local saved_needs = {food = 61, water = 52, warmth = 43, wet = 8, stamina = 79}
local function fixture_check()
failure_label = "checkpoint-fixture-check"
local meta = C.get_meta(fixture_pos)
-- The deterministic synthetic player only adapts get_meta; its backing store
-- is REAL engine node metadata, and the adapter uses REAL engine JSON.
local player = {get_meta = function() return meta end}
if phase == "seed" then
for _, pos in ipairs({fixture_pos, expired_pos, live_pos}) do
assert(C.get_node(pos).name == "air", "Isolated fixture space occupied")
end
C.set_node(fixture_pos, {name = "hearth:wall", param2 = 2})
meta = C.get_meta(fixture_pos)
local original_raw = assert(C.write_json(original_needs))
meta:set_string("hearth:needs", original_raw)
assert(equal(hearth.persistence.load_player(player), original_needs), "Real-JSON player migration failed")
assert(meta:get_string("hearth:m0_backup:needs") == original_raw, "Player legacy bytes not backed up")
hearth.persistence.save_player(player, saved_needs)
assert(equal(envelope("needs", meta:get_string("hearth:needs")), saved_needs), "Player fixture not saved")
assert(meta:get_string("hearth:m0_backup:needs") == original_raw, "Player save overwrote backup")
meta:set_string("recovery_token", token)
local inv = meta:get_inventory()
inv:set_size("main", 4)
local stack = ItemStack("hearth:axe")
stack:set_wear(1379)
stack:get_meta():set_string("recovery", token)
inv:set_stack("main", 1, stack)
inv:set_stack("main", 2, ItemStack("hearth:berries 3"))
local now = os.time()
local fixture = {format = 1, token = token, originalNeeds = original_raw,
liveDeadline = now + 300, expiredDeadline = now - 60,
stack = stack:to_string(), berries = inv:get_stack("main", 2):to_string()}
for _, pos in ipairs({expired_pos, live_pos}) do C.set_node(pos, {name = "hearth:fire_lit"}) end
C.get_meta(expired_pos):set_string("fuel_until", tostring(fixture.expiredDeadline))
C.get_meta(live_pos):set_string("fuel_until", tostring(fixture.liveDeadline))
hearth.storage:set_string("m0_recovery:marker", token)
write("m0-recovery-fixture.json", fixture)
else
local fixture = read("m0-recovery-fixture.json")
assert(fixture.format == 1 and fixture.token == token, "Fixture evidence not restored")
assert(hearth.storage:get_string("m0_recovery:marker") == token, "Real mod storage marker lost")
local node = C.get_node(fixture_pos)
assert(node.name == "hearth:wall" and node.param2 == 2, "Map node did not survive backup/restore")
assert(meta:get_string("recovery_token") == token, "Real node metadata lost")
assert(equal(hearth.persistence.load_player(player), saved_needs), "Player fixture did not survive restart")
assert(meta:get_string("hearth:m0_backup:needs") == fixture.originalNeeds, "Player immutable backup changed on restart")
assert(equal(decode(fixture.originalNeeds), original_needs), "Player original fixture changed")
local inv = meta:get_inventory()
assert(inv:get_size("main") == 4 and inv:get_stack("main", 1):to_string() == fixture.stack
and inv:get_stack("main", 2):to_string() == fixture.berries
and inv:get_stack("main", 3):is_empty() and inv:get_stack("main", 4):is_empty(), "Real inventory bytes changed")
assert(inv:get_stack("main", 1):get_wear() == 1379
and inv:get_stack("main", 1):get_meta():get_string("recovery") == token, "ItemStack metadata lost")
-- Forceload activation must execute the game's real run-at-every-load LBM;
-- do not invoke its callback manually or replace the engine clock.
assert(C.get_node(expired_pos).name == "hearth:fire"
and C.get_meta(expired_pos):get_string("fuel_until") == "0", "Expired fire was not reconciled by LBM")
assert(fixture.liveDeadline > os.time(), "Fixture live deadline exceeded")
assert(C.get_node(live_pos).name == "hearth:fire_lit"
and tonumber(C.get_meta(live_pos):get_string("fuel_until")) == fixture.liveDeadline, "Absolute fire deadline extended or lost")
end
report.fixture = true
end
local function emerge(minimum, maximum, done, context)
local complete = false
C.emerge_area(minimum, maximum, function(_, action, remaining)
guard(function()
if action == C.EMERGE_ERRORED or action == C.EMERGE_CANCELLED then
failure_label = "checkpoint-" .. context .. (action == C.EMERGE_ERRORED and "-emerge-errored" or "-emerge-cancelled")
fail()
return
end
if remaining == 0 and not complete then
complete = true
C.after(0, function() guard(done) end)
end
end)
end)
end
local function await_activation()
guard(function()
failure_label = "checkpoint-fixture-activation"
no_clients()
-- Forceload activation follows the engine's active-block management
-- interval, not necessarily the emerge callback or the next server step.
-- Wait for actual LBM effects; the independent 75s watchdog bounds this.
if not C.compare_block_status(fixture_pos, "active") then
C.after(0.1, await_activation)
return
end
if phase == "restart" and (C.get_node(expired_pos).name ~= "hearth:fire"
or C.get_meta(expired_pos):get_string("fuel_until") ~= "0") then
C.after(0.1, await_activation)
return
end
fixture_check()
ready = true
C.request_shutdown("Private recovery complete", false, 0)
end)
end
C.register_on_prejoinplayer(function() return "Private offline recovery does not accept clients" end)
C.register_on_joinplayer(function() fail() end)
C.register_on_shutdown(function()
if not ready or finished then return end
local ok = pcall(function()
failure_label = "checkpoint-shutdown-evidence"
no_clients()
report.noClients = true
report.verifiedAssertions = verified_assertions
write("m0-recovery-" .. phase .. ".json", report)
end)
finished = true
if ok then C.log("action", "LAST_HEARTH_RECOVERY_PASS " .. phase .. " " .. token)
else C.log("error", "LAST_HEARTH_RECOVERY_FAIL " .. failure_label) end
end)
C.after(0, function()
guard(function()
local spawn = world_check()
local p = vector.round(spawn)
emerge(vector.offset(p, -2, -2, -2), vector.offset(p, 2, 2, 2), function()
local node = C.get_node_or_nil(p)
assert(node and node.name ~= "ignore" and C.registered_nodes[node.name], "Saved spawn map unavailable")
assert(equal(hearth.persistence.load_world("spawn"), spawn), "Spawn changed during emergence")
report.spawn = true
assert(C.forceload_block(fixture_pos, true), "Cannot activate fixture mapblock")
emerge(fixture_pos, vector.offset(fixture_pos, 3, 1, 1), function()
C.after(0, await_activation)
end, "fixture")
end, "spawn")
end)
end)
C.after(75, function() if not ready then fail() end end)
end
+299
View File
@@ -0,0 +1,299 @@
-- Standalone Lua 5.1+ scheduler regressions; arg[1] is runtime.lua.
-- Lifecycle persistence and respawn callbacks are exercised by players_spec.
local target = assert(arg[1], "runtime.lua path required")
local total = 0
local function test(name, fn)
local old_core, old_hearth, old_vector = core, hearth, vector
local ok, message = pcall(fn)
core, hearth, vector = old_core, old_hearth, old_vector
assert(ok, name .. ": " .. tostring(message)); total = total + 1
end
local function copy(value)
local result = {}; for k, v in pairs(value) do result[k] = v end; return result
end
local function harness(map_limit)
local h = {steps = {}, connected = {}, needs = {}, hud = {}, saves = {}, arrivals = {}, particles = {}, clocks = 0,
mapgen_limit = map_limit}
core = {
register_globalstep = function(fn) h.steps[#h.steps + 1] = fn end,
settings = {get = function(_, key) if key == "mapgen_limit" then return h.mapgen_limit end end},
registered_nodes = {ground = {walkable = true}, air = {walkable = false},
water = {walkable = false, groups = {water = 3}}},
get_node = function(pos)
h.node_calls = (h.node_calls or 0) + 1
return {name = h.water and "water" or (pos.y < 4 and "ground" or "air")}
end,
get_connected_players = function() return h.connected end,
find_node_near = function() return nil end,
add_particlespawner = function(value) h.particles[#h.particles + 1] = value end,
}
core.get_node_or_nil = core.get_node
vector = {offset = function(p, x, y, z) return {x = p.x + x, y = p.y + y, z = p.z + z} end}
hearth = {players = {}, sessions = {}, weather = "clear", model = {}}
hearth.movement = dofile(target:gsub("[^/\\]+$", "movement.lua"))
hearth.clock_update = function() h.clocks = h.clocks + 1 end
hearth.environment = function(pos)
h.environment_calls = (h.environment_calls or 0) + 1
h.last_pos = pos
return {temperature = 20, shelter = 0, heat = h.heat or 0, smoke = h.smoke, roof = h.roof}
end
hearth.model.needs = function(state, env, dt)
h.needs[#h.needs + 1] = {state = state, env = env, dt = dt}
local updated = copy(state); updated.food = state.food - dt
updated.wet = math.max(0, state.wet + (env.drying and -1.5 or -0.35) * dt)
return updated, h.danger or 0
end
hearth.update_hud = function(p, env) h.hud[#h.hud + 1] = {player = p, env = env} end
hearth.player_save = function(p) h.saves[#h.saves + 1] = p; return true end
hearth.safe_arrival = function(p) h.arrivals[#h.arrivals + 1] = p; p.pos = {x = 4, y = 5, z = 8} end
hearth.texture = function(value) return "texture:" .. value end
function h.player(name)
local meta = {get_int = function() return 0 end}
local p = {name = name or "fixture", hp = 20, pos = {x = 0, y = 4, z = 0}, controls = {},
item = "", physics = {}, damage = {}, clouds = {}}
function p:get_player_name() return self.name end
function p:get_player_control() return self.controls end
function p:get_wielded_item() return {get_name = function() return self.item end} end
function p:get_pos() return self.pos end
function p:get_velocity() return self.velocity or {x = 0, y = 0, z = 0} end
function p:get_hp() return self.hp end
function p:get_meta() return meta end
function p:set_hp(value, reason) self.hp = value; self.damage[#self.damage + 1] = reason end
function p:set_physics_override(value) self.physics[#self.physics + 1] = value end
function p:set_clouds(value) self.clouds[#self.clouds + 1] = value end
h.connected[#h.connected + 1] = p
hearth.players[p.name] = {food = 85, water = 85, warmth = 80, wet = 10, stamina = 100}
hearth.sessions[p.name] = {damage = 0, save = 0, ready = false}
return p, hearth.players[p.name], hearth.sessions[p.name]
end
dofile(target)
return h
end
local function feedback_harness()
local h = harness(); local p, state, s = h.player()
h.time, h.sounds, h.animations, h.fovs, h.messages, h.commands, h.rays = 0, {}, {}, {}, {}, {}, 0
core.get_us_time = function() return h.time * 1000000 end
core.sound_play = function(name, spec, ephemeral)
h.sounds[#h.sounds + 1] = {name = name, spec = spec}; assert(ephemeral)
end
core.register_chatcommand = function(name, def) h.commands[name] = def end
core.get_player_by_name = function(name) if name == p.name then return p end end
core.get_dig_params = function() return {diggable = h.suitable ~= false} end
core.registered_nodes.ground.description = "Soil"
core.raycast = function(start, finish, objects, liquids)
h.rays = h.rays + 1
assert(not objects and not liquids and finish.z - start.z == 5)
local used = false
return function()
if used or h.no_target then return end
used = true; return {type = "node", under = {x = 0, y = 3, z = 0}}
end
end
function p:get_look_dir() return {x = 0, y = 0, z = 1} end
function p:get_properties() return {eye_height = 1.55} end
function p:get_wielded_item()
return {get_name = function() return self.item end, get_tool_capabilities = function() return {} end,
get_wear = function() return 0 end}
end
function p:set_animation(range, speed, blend, loop)
h.animations[#h.animations + 1] = {range = range, speed = speed, loop = loop}
end
function p:set_fov(fov, multiplier, transition)
assert(not multiplier and transition == 0.2); h.fovs[#h.fovs + 1] = fov
end
function p:hud_add(def) h.messages[#h.messages + 1] = def.text; return 42 end
function p:hud_change(id, field, value)
assert(id == 42); if field == "text" then h.messages[#h.messages + 1] = value end
end
dofile(target:gsub("[^/\\]+$", "feedback.lua"))
function h.tick(dt) h.time = h.time + dt; hearth.feedback_step(p, dt) end
return h, p, state, s
end
test("scheduler registers two callbacks and clocks advance without connected players", function()
local h = harness(); assert(#h.steps == 2)
h.steps[1](9); assert(h.clocks == 0)
h.steps[1](1); assert(h.clocks == 1)
h.steps[1](100); assert(h.clocks == 2, "Clock must not replay unbounded catch-up updates")
assert(#h.needs == 0 and #h.saves == 0)
end)
test("waiting arrival never evaluates needs damages saves HUD or fall recovery", function()
local h = harness(); local p, state, session = h.player(); session.waiting = true
state.food = 0; p.pos.y = -101; h.danger = 100; h.smoke = true
for _ = 1, 5 do h.steps[1](1) end
assert(hearth.players[p.name] == state and state.food == 0 and p.hp == 20)
assert(session.damage == 0 and session.save == 0 and h.environment_calls == nil)
assert(#h.needs == 0 and #h.saves == 0 and #h.hud == 0 and #h.arrivals == 0)
session.waiting = false; p.pos.y = 4; h.danger = 0; h.smoke = false; h.steps[1](1)
assert(#h.needs == 1 and #h.hud == 1 and session.save == 1)
end)
test("subsecond controls gate sprint brace and avoid redundant physics writes", function()
local h = harness(); local p, state = h.player()
p.controls = {aux1 = true, up = true}; h.steps[1](0.1); assert(p.physics[1].speed == 1.3)
h.steps[1](0.1); assert(p.physics[2].speed == 1.45)
h.steps[1](0.1); assert(#p.physics == 2)
p.item = "hearth:spear"; p.controls.RMB = true
for _ = 1, 3 do h.steps[1](0.1) end
assert(math.abs(p.physics[#p.physics].speed - 0.6) < 0.001)
assert(hearth.sessions[p.name].blocking and not hearth.sessions[p.name].sprinting)
p.item = ""; p.controls.sneak = true
for _ = 1, 2 do h.steps[1](0.1) end
assert(p.physics[#p.physics].speed == 1)
p.controls.sneak = false; state.stamina = 8; h.steps[1](0.1)
state.stamina = 31; h.steps[1](0.01); assert(not hearth.sessions[p.name].sprinting)
assert(p.physics[#p.physics].jump == nil and p.physics[#p.physics].gravity == nil and #h.needs == 0)
end)
test("one Hz needs caps elapsed step at two seconds and applies bounded heat drying", function()
local h = harness(); local p, state = h.player(); p.controls = {aux1 = true, up = true}
h.heat = 3; h.wet = 10
h.steps[1](0.4); assert(#h.needs == 0)
h.steps[1](0.6)
assert(#h.needs == 1 and h.needs[1].dt == 1 and h.needs[1].state == state and h.needs[1].env.sprinting)
assert(hearth.players[p.name] ~= state and hearth.players[p.name].wet == 8.5 and state.wet == 10)
h.steps[1](100)
assert(#h.needs == 2 and h.needs[2].dt == 2 and hearth.players[p.name].food == 82)
assert(hearth.players[p.name].wet == 5.5 and hearth.sessions[p.name].save == 3)
hearth.players[p.name].wet = 1
h.heat = 20; h.steps[1](1); assert(hearth.players[p.name].wet == 0)
end)
test("danger smoke accumulation damages at threshold with at most one hit per capped step", function()
local h = harness(); local p, _, session = h.player(); h.danger = 1; h.smoke = true
h.steps[1](1); assert(p.hp == 20 and session.damage == 2)
h.steps[1](1); assert(p.hp == 19 and session.damage == 0 and #p.damage == 1)
assert(p.damage[1].type == "set_hp" and p.damage[1].from == "mod")
h.danger = 100; h.steps[1](100)
assert(p.hp == 18 and #p.damage == 2 and session.damage == 0 and h.needs[3].dt == 2)
end)
test("autosave uses bounded active simulation time and resets its interval", function()
local h = harness(); local p, _, session = h.player()
for _ = 1, 9 do h.steps[1](1) end
assert(#h.saves == 0 and session.save == 9)
h.steps[1](1); assert(#h.saves == 1 and h.saves[1] == p and session.save == 0)
h.steps[1](100); assert(#h.saves == 1 and session.save == 2)
for _ = 1, 8 do h.steps[1](1) end
assert(#h.saves == 2 and session.save == 0 and #h.hud == 19)
end)
test("fall recovery uses mapgen_limit plus margin and does not teleport valid caves", function()
local h = harness(); local p = h.player(); p.pos.y = -100
h.steps[1](1); assert(#h.arrivals == 0)
p.pos.y = -544; h.steps[1](1); assert(#h.arrivals == 0)
p.pos.y = -545; h.steps[1](1)
assert(#h.arrivals == 1 and h.arrivals[1] == p and p.pos.y == 5)
h = harness(1000); p = h.player(); p.pos.y = -1032; h.steps[1](1)
assert(#h.arrivals == 0); p.pos.y = -1033; h.steps[1](1); assert(#h.arrivals == 1)
end)
test("dead and left players skip needs while a fresh respawn or rejoin remains schedulable", function()
local h = harness(); local p, _, old_session = h.player(); p.hp = 0
h.steps[1](1); assert(#h.needs == 0 and #h.hud == 0 and old_session.save == 0)
-- Engine may still enumerate a just-left player; lifecycle removes both maps.
hearth.players[p.name], hearth.sessions[p.name] = nil, nil
h.steps[1](1); assert(#h.needs == 0 and #h.saves == 0)
h.connected = {}; local q, fresh, session = h.player(p.name)
h.steps[1](1)
assert(h.needs[1].state == fresh and #h.hud == 1 and session.save == 1 and old_session.save == 0)
assert(q.hp == 20 and hearth.players[q.name].food == 84)
end)
test("autosave teardown is safe for the current tick later players and subsequent ticks", function()
local h = harness(); local p, _, session = h.player("one"); local q = h.player("two")
session.save = 9
hearth.player_save = function(player)
h.saves[#h.saves + 1] = player
hearth.players[player.name], hearth.sessions[player.name] = nil, nil
return false
end
h.steps[1](1)
assert(#h.saves == 1 and h.saves[1] == p and not hearth.sessions[p.name] and not hearth.players[p.name])
assert(hearth.players[q.name].food == 84 and #h.needs == 2)
h.steps[1](1)
assert(#h.needs == 3 and #h.saves == 1 and hearth.players[q.name].food == 83)
end)
test("rain callback is throttled player local roof aware and independent of needs", function()
local h = harness(); local p = h.player(); hearth.weather = "rain"
h.steps[2](2); assert(#p.clouds == 0 and #h.particles == 0)
h.steps[2](1)
assert(p.clouds[1].density == 0.65 and #h.particles == 1)
local rain = h.particles[1]
assert(rain.playername == p.name and rain.amount == 80 and rain.time == 3)
assert(rain.minpos.x == -7 and rain.minpos.y == 11 and rain.maxpos.z == 7)
assert(rain.collisiondetection and rain.collision_removal and #h.needs == 0)
h.roof = true; h.steps[2](3); assert(#h.particles == 1)
hearth.weather = "clear"; h.steps[2](3)
assert(#h.particles == 1 and p.clouds[3].density == 0.25)
end)
test("continuous stamina is not drained twice by needs and sprint food costs remain", function()
local h = harness(); local p, state = h.player(); p.controls = {aux1 = true, up = true}
local original = hearth.model.needs
hearth.model.needs = function(s, env, dt)
local updated, danger = original(s, env, dt)
updated.stamina = s.stamina - 13 * dt
return updated, danger
end
h.steps[1](0.5); assert(math.abs(state.stamina - 98.7) < 0.001)
h.steps[1](0.5)
assert(math.abs(hearth.players[p.name].stamina - 97.4) < 0.001)
assert(#h.needs == 1 and h.needs[1].env.sprinting)
end)
test("water velocity and lifecycle gate movement and optional per frame feedback", function()
local h = harness(); local p, _, s = h.player(); local calls = 0
hearth.feedback_step = function(player, dt) assert(player == p and dt == 0.1); calls = calls + 1 end
p.controls = {aux1 = true, up = true}; h.water = true; h.steps[1](0.1)
assert(s.swimming and not s.sprinting and calls == 1)
h.water = false; p.velocity = {x = 3, y = -5, z = 0}; h.steps[1](0.1)
assert(not s.grounded and not s.sprinting and s.moving and calls == 2)
s.waiting = true; local nodes = h.node_calls; h.steps[1](0.1)
assert(h.node_calls == nodes and calls == 2)
s.waiting = false; p.hp = 0; h.steps[1](0.1); assert(calls == 2)
p.hp = 20; hearth.players[p.name] = nil; h.steps[1](0.1); assert(calls == 2)
end)
test("feedback animates only state transitions and never imposes default FOV", function()
local h, p, _, s = feedback_harness()
h.tick(0.01); h.tick(0.01)
assert(#h.animations == 1 and h.animations[1].range.x == 0 and #h.fovs == 0)
s.moving = true; h.tick(0.01); assert(h.animations[2].range.x == 20)
s.sprinting = true; h.tick(0.01); assert(h.animations[3].range.x == 40)
s.blocking = true; h.tick(0.01); assert(h.animations[4].range.x == 70)
s.swimming = true; h.tick(0.01); assert(h.animations[5].range.x == 80)
s.swimming, s.blocking, s.moving = false, false, false; p.controls.LMB = true
h.tick(0.01); assert(h.animations[6].range.x == 60 and not h.animations[6].loop)
h.tick(0.4); assert(h.animations[7].range.x == 0)
assert(h.commands.comfort.func(p.name, "fov 75")); h.tick(0.01); h.tick(0.01)
assert(#h.fovs == 1 and h.fovs[1] == 75)
assert(not h.commands.comfort.func(p.name, "fov 999"))
assert(h.commands.comfort.func(p.name, "default")); h.tick(0.01)
assert(#h.fovs == 2 and h.fovs[2] == 0)
end)
test("feedback lazily caches targeting and expires bounded notifications monotonically", function()
local h, p, _, s = feedback_harness()
h.tick(0.01); assert(s.feedback_hud == 42 and #h.messages == 1 and h.rays == 1)
for _ = 1, 5 do h.tick(0.01) end
assert(h.rays == 1 and #h.messages == 1)
h.suitable = false; h.tick(0.1)
assert(h.messages[#h.messages]:find("Different tool needed", 1, true))
hearth.feedback(p, string.rep("x", 300) .. "\n", "warning")
assert(#s.feedback_text == 200 and s.feedback_until > h.time)
h.tick(0.1); assert(h.messages[#h.messages] == s.feedback_text)
local rays = h.rays; h.tick(0.1); assert(h.rays == rays)
h.no_target = true; h.tick(3.1); assert(h.messages[#h.messages] == "")
assert(#h.sounds == 0)
end)
test("footfalls require grounded distance and cadence with one landing not per frame audio", function()
local h, p, _, s = feedback_harness()
s.grounded, s.moving = true, true; h.tick(0.1)
for _ = 1, 5 do h.tick(0.1) end; assert(#h.sounds == 0)
for _ = 1, 5 do p.pos.x = p.pos.x + 0.4; h.tick(0.1) end
assert(#h.sounds == 1 and h.sounds[1].name == "hearth_step_soil")
assert(h.sounds[1].spec.pos == p.pos and h.sounds[1].spec.max_hear_distance == 12)
s.grounded = false; p.velocity = {x = 2, y = -5, z = 0}
for _ = 1, 5 do p.pos.x = p.pos.x + 0.4; h.tick(0.1) end
assert(#h.sounds == 1)
s.grounded = true; p.velocity.y = 0; h.tick(0.1); h.tick(0.1)
assert(#h.sounds == 2 and h.sounds[2].name == "hearth_land")
p.pos.x = p.pos.x + 100; h.tick(0.1); assert(#h.sounds == 2)
for index, material in ipairs({"wood", "stone", "water"}) do
core.registered_nodes.ground.groups = {[material] = 1}
for _ = 1, 5 do p.pos.x = p.pos.x + 0.4; h.tick(0.1) end
assert(#h.sounds == index + 2 and h.sounds[#h.sounds].name == "hearth_step_" .. material)
end
end)
assert(total == 15, "Unexpected scheduler regression count")
print("PASS " .. total .. " runtime scheduler safety tests")
+164
View File
@@ -0,0 +1,164 @@
local S = dofile(assert(arg[1], "schema.lua path required"))
local total = 0
local function test(name, fn)
local ok, err = pcall(fn)
assert(ok, name .. ": " .. tostring(err)); total = total + 1
end
local function reject(kind, value)
local ok, err = pcall(S.decode, kind, value)
assert(not ok and err == "Last Hearth: invalid save schema or data", "Expected sanitized rejection")
end
local function v1(kind, data)
return {schema = 1, kind = kind, data = data}
end
local samples = {
needs = {food = 85, water = 85, warmth = 80, wet = 0, stamina = 100},
claims = {fixture_player = {x = -12, y = 4, z = 8}},
homes = {fixture_player = {x = -12, y = 5, z = 8}},
access = {fixture_player = {housemate = "resident", keeper = "storekeeper"}},
location = {latitude = 45.25, longitude = -73.5},
spawn = {x = 0.5, y = 5.25, z = -0.125},
}
test("defaults are exact independent needs copies", function()
local a, b = S.defaults(), S.defaults()
for key, value in pairs(samples.needs) do assert(a[key] == value and b[key] == value) end
a.food = 0; assert(b.food == 85 and S.defaults().food == 85)
end)
for _, kind in ipairs({"needs", "claims", "homes", "access", "location", "spawn"}) do
test(kind .. " migrates without mutating or aliasing input", function()
local source = samples[kind]
local envelope, migrated = S.decode(kind, source)
assert(migrated == true and envelope.schema == 1 and envelope.kind == kind)
assert(envelope.data ~= source and source.schema == nil)
if kind == "claims" or kind == "homes" or kind == "access" then assert(envelope.data.fixture_player ~= source.fixture_player) end
local current, again = S.decode(kind, envelope)
assert(again == false and current ~= envelope and current.data ~= envelope.data)
end)
test(kind .. " rejects future versions, wrong kinds and extra envelope fields", function()
for _, version in ipairs({0, -1, 2, 1.5, "1", false, math.huge, 0 / 0}) do
reject(kind, {schema = version, kind = kind, data = samples[kind]})
end
reject(kind, {schema = 1, kind = "unknown", data = samples[kind]})
reject(kind, {schema = 1, kind = kind})
reject(kind, {schema = 1, data = samples[kind]})
reject(kind, {kind = kind, data = samples[kind]})
reject(kind, {schema = 1, kind = kind, data = samples[kind], private = "PRIVATE"})
end)
test(kind .. " rejects scalar values and missing data", function()
reject(kind, nil)
for _, value in ipairs({false, 0, "PRIVATE", function() end}) do
reject(kind, value); reject(kind, v1(kind, value))
end
end)
end
test("unknown kinds and metatables fail without executing input", function()
reject("PRIVATE", {})
reject({}, {})
local hostile = setmetatable({}, {__index = function() error("PRIVATE") end})
reject("needs", hostile); reject("needs", v1("needs", hostile))
reject("claims", {owner = hostile})
end)
test("needs require all five fields and reject unknown fields", function()
for key in pairs(samples.needs) do
local data = S.defaults(); data[key] = nil; reject("needs", data)
end
local data = S.defaults(); data.private = "PRIVATE"; reject("needs", v1("needs", data))
reject("needs", {}); reject("needs", {85, 85, 80, 0, 100})
end)
test("needs boundaries and fractional finite numbers are valid", function()
local data = {food = 0, water = 100, warmth = 0.125, wet = 99.5, stamina = 50}
assert(S.decode("needs", v1("needs", data)).data.warmth == 0.125)
end)
test("every needs field rejects nonnumeric nonfinite or out of range values", function()
for key in pairs(samples.needs) do
for _, value in ipairs({"85", true, {}, -0.001, 100.001, math.huge, -math.huge, 0 / 0}) do
local data = S.defaults(); data[key] = value
reject("needs", v1("needs", data)); reject("needs", data)
end
end
end)
for _, kind in ipairs({"claims", "homes", "access"}) do
test("empty " .. kind .. " and envelope-field player names remain valid", function()
assert(next(S.decode(kind, {}).data) == nil)
assert(next(S.decode(kind, v1(kind, {})).data) == nil)
local claims
if kind == "access" then claims = {schema = {}, kind = {}, data = {}}
else claims = {schema = {x = 0, y = 0, z = 0}, kind = {x = 1, y = 2, z = 3},
data = {x = -1, y = -2, z = -3}} end
local decoded, migrated = S.decode(kind, claims)
assert(migrated)
if kind == "access" then assert(decoded.data.schema and decoded.data.kind and decoded.data.data)
else assert(decoded.data.schema.x == 0 and decoded.data.data.z == -3) end
end)
if kind ~= "access" then test(kind .. " require nonempty string owners and integer complete positions", function()
reject(kind, {{x = 0, y = 0, z = 0}})
reject(kind, {[""] = {x = 0, y = 0, z = 0}})
reject(kind, {owner = {x = 0.5, y = 0, z = 0}})
reject(kind, {owner = {x = 0, y = 0}})
reject(kind, {owner = {x = 0, y = 0, z = 0, private = true}})
reject(kind, {owner = "PRIVATE"})
end) end
end
test("access map accepts only bounded usernames and explicit household roles", function()
local access = {owner = {resident = "resident", keeper = "storekeeper"}}
local decoded = S.decode("access", access)
assert(decoded.data.owner.resident == "resident" and decoded.data.owner.keeper == "storekeeper")
for _, value in ipairs({
{[""] = {}}, {owner = {bad = "admin"}}, {owner = {"resident"}},
{owner = {owner = "resident"}}, {owner = {['bad/name'] = "resident"}},
{owner = "resident"}, {owner = setmetatable({}, {})},
}) do reject("access", value); reject("access", v1("access", value)) end
local long_name = string.rep("a", 33)
reject("access", {[long_name] = {member = "resident"}})
local too_many = {owner = {}}
for i = 1, 17 do too_many.owner["member" .. i] = "resident" end
reject("access", too_many)
end)
for _, kind in ipairs({"spawn", "claims", "homes"}) do
test(kind .. " enforces all coordinate bounds and numeric types", function()
local function wrap(pos) return (kind == "claims" or kind == "homes") and {owner = pos} or pos end
assert(S.decode(kind, wrap({x = -31000, y = 31000, z = 0})))
for _, axis in ipairs({"x", "y", "z"}) do
for _, value in ipairs({"0", true, {}, -31000.001, 31000.001, math.huge, -math.huge, 0 / 0}) do
local pos = {x = 0, y = 0, z = 0}; pos[axis] = value
reject(kind, wrap(pos)); reject(kind, v1(kind, wrap(pos)))
end
local pos = {x = 0, y = 0, z = 0}; pos[axis] = nil; reject(kind, wrap(pos))
end
end)
end
test("spawn supports floats but rejects unknown fields", function()
assert(S.decode("spawn", samples.spawn).data.y == 5.25)
reject("spawn", {x = 0, y = 0, z = 0, private = true})
end)
test("location accepts boundaries and rejects unknown or missing fields", function()
assert(S.decode("location", {latitude = -90, longitude = 180}))
assert(S.decode("location", {latitude = 90, longitude = -180}))
reject("location", {latitude = 0}); reject("location", {longitude = 0})
reject("location", {latitude = 0, longitude = 0, private = "PRIVATE"})
end)
test("every location coordinate rejects strings nonfinite values and overflow", function()
for _, key in ipairs({"latitude", "longitude"}) do
local limit = key == "latitude" and 90 or 180
for _, value in ipairs({"0", false, {}, limit + 0.001, -limit - 0.001, math.huge, -math.huge, 0 / 0}) do
local data = {latitude = 0, longitude = 0}; data[key] = value
reject("location", data); reject("location", v1("location", data))
end
end
end)
-- Optional explicit fixture path for standalone/integration runners. The engine's
-- restricted one-module smoke harness exercises the same values above without
-- needing secondary file access or changing live globals.
if arg[2] then
local fixtures = dofile(arg[2])
test("historic fixture records migrate to schema1", function()
for kind in pairs(fixtures) do
if samples[kind] then
local decoded, migrated = S.decode(kind, fixtures[kind].data)
assert(migrated and decoded.kind == kind and type(fixtures[kind].raw) == "string")
end
end
assert(next(S.decode("claims", fixtures.empty_claims.data).data) == nil)
end)
end
print("PASS " .. total .. " schema tests")
+244
View File
@@ -0,0 +1,244 @@
-- Standalone Lua 5.1+: controlled names, actual-epoch climate and lossless swaps.
local target = assert(arg[1], "seasons.lua path required")
local real_time, now, passed = os.time, 1782043200, 0
os.time = function() return now end
local season, world, data, missing, protected, swaps, meta_writes, lbm
local p = {x=0,y=1,z=0}
local hearth, api, seasons
local function key(q) return q.x..":"..q.y..":"..q.z end
local function reset()
season, world, data, missing, protected, swaps, meta_writes = "Summer", {}, {}, false, false, 0, 0
api = {registered_nodes={}}
api.get_node_or_nil = function(q) return not missing and world[key(q)] or nil end
api.swap_node = function(q,n) swaps=swaps+1; world[key(q)]=n end
api.get_meta = function() error("appearance must not access metadata") end
api.register_node = function(name, def) assert(not api.registered_nodes[name]); api.registered_nodes[name]=def end
api.register_lbm = function(def) lbm=def end
hearth = {climate_at=function(epoch) assert(epoch==now); return {season=season} end,
external_protected=function() return protected end,
claim_owner_at=function() return "builder" end}
seasons = dofile(target)(hearth,api)
hearth.seasons = seasons
for _, name in ipairs(seasons.names()) do
local kind=seasons.kind(name)
local base="hearth:"..kind
api.registered_nodes[base] = api.registered_nodes[base] or {
drawtype=(kind=="young_tree" or kind=="mature_tree" or kind=="tree_seedling") and "mesh" or "plantlike",
groups={flora=1}, drop="hearth:sticks", tiles={"base.png"},
can_dig=function() return false end, on_rightclick=function() end,
after_place_node=function() end, collision_box={type="fixed"},
}
end
seasons.register()
end
local function put(name)
world[key(p)]={name="hearth:"..name,param1=7,param2=3}
end
local function name() return world[key(p)].name end
local function test(label,fn)
reset()
local ok,err=pcall(fn); assert(ok,label..": "..tostring(err))
passed=passed+1; print("ok "..passed.." - "..label)
end
test("all seasons use explicit variants and summer restores the canonical tree", function()
put("mature_tree")
for _, s in ipairs({"Spring","Autumn","Winter","Summer"}) do
season=s; assert(seasons.reconcile(p))
assert(name()=="hearth:mature_tree"..(s=="Summer" and "" or "_"..s:lower()))
assert(world[key(p)].param1==7 and world[key(p)].param2==3)
assert(not seasons.reconcile(p), "same-season appearance is idempotent")
end
assert(swaps==4)
end)
test("winter bare mesh returns to the base mesh in spring", function()
for _, kind in ipairs({"young_tree","mature_tree"}) do
local winter=api.registered_nodes["hearth:"..kind.."_winter"]
local spring=api.registered_nodes["hearth:"..kind.."_spring"]
assert(winter.mesh=="hearth_"..kind.."_winter.obj")
assert(spring.mesh=="hearth_"..kind..".obj")
assert(winter.tiles[1]=="hearth_tree_atlas_winter.png")
end
end)
test("legacy mapgen leaves recolor without changing timber or destroying canopy", function()
put("leaves"); season="Autumn"; lbm.action(p)
assert(name()=="hearth:leaves_autumn")
season="Winter"; lbm.action(p); assert(name()=="hearth:leaves_winter")
put("log"); assert(not seasons.reconcile(p) and name()=="hearth:log")
assert(not seasons.kind("hearth:foreign_tree_winter"):match("^mature_tree$"))
end)
test("perennials and flowers rest in the correct seasons without adding needless variants", function()
for _, kind in ipairs({"meadow_grass","fern","wildflowers"}) do
put(kind); season="Winter"; assert(seasons.reconcile(p))
assert(api.registered_nodes[name()].tiles[1]=="hearth_"..kind.."_dormant.png")
season="Spring"; assert(seasons.reconcile(p) and name()=="hearth:"..kind)
end
put("wildflowers"); season="Autumn"; assert(seasons.reconcile(p))
assert(name()=="hearth:wildflowers_autumn" and not seasons.available("wildflowers"))
assert(not api.registered_nodes["hearth:fern_spring"])
assert(seasons.available("litter") and seasons.available("fungi"))
end)
test("food patches only bloom in their season and stay occupied when dormant", function()
for _, s in ipairs({"Spring","Summer","Autumn","Winter"}) do
season=s
assert(seasons.available("spring_greens_patch")== (s=="Spring"))
assert(seasons.available("autumn_mushrooms_patch")== (s=="Autumn"))
assert(seasons.name_for("spring_greens_patch")=="hearth:spring_greens_patch"..(s=="Spring" and "_spring" or ""))
end
end)
test("callbacks, authorization, drops and geometry survive every variant", function()
for _, name in ipairs(seasons.names()) do
local kind=seasons.kind(name)
local base,def=api.registered_nodes["hearth:"..kind],api.registered_nodes[name]
assert(def.can_dig==base.can_dig and def.on_rightclick==base.on_rightclick)
assert(def.after_place_node==base.after_place_node and def.collision_box==base.collision_box)
assert(def.drop==base.drop and def.groups.flora==1)
end
end)
test("claimed handplaced flora can recolor with no metadata access or harvest refill", function()
data={succ_age="8412",succ_natural="1",succ_harvest_ready_at=tostring(now+86400),player_placed="1",owner="builder"}
local reference=data
put("spring_greens_patch"); season="Spring"; assert(seasons.reconcile(p))
season="Winter"; assert(seasons.reconcile(p))
assert(data==reference and data.succ_age=="8412" and data.player_placed=="1")
assert(data.succ_harvest_ready_at==tostring(now+86400) and meta_writes==0)
end)
test("unloaded, ignore, protected and uncontrolled names cause no writes", function()
season="Winter"; put("leaves"); missing=true
assert(not seasons.reconcile(p)); missing=false; protected=true
assert(not seasons.reconcile(p)); protected=false
world[key(p)]={name="ignore"}; assert(not seasons.reconcile(p))
put("foreign_tree"); assert(not seasons.reconcile(p))
assert(swaps==0)
end)
test("hemisphere seasons come from the existing solar model, not the host month", function()
local model=dofile(target:gsub("[^/\\]+$","model.lua"))
local south=model.solar(now,-45,0)
local north=model.solar(now,45,0)
assert(north.season=="Summer" and south.season=="Winter")
hearth.climate_at=function(epoch) assert(epoch==now); return model.solar(epoch,-45,0) end
assert(seasons.season()=="Winter")
put("young_tree"); assert(seasons.reconcile(p))
assert(name()=="hearth:young_tree_winter")
now=1797854400 -- December, southern summer.
assert(seasons.reconcile(p) and name()=="hearth:young_tree")
end)
test("extension is available before registration and missing optional bases are safe", function()
seasons.add_variants("optional_plant",{Winter={texture="optional_dormant.png"}})
seasons.register()
assert(not api.registered_nodes["hearth:optional_plant_winter"])
api.registered_nodes["hearth:optional_plant"]={groups={flora=1},drop="",tiles={"optional.png"}}
seasons.register()
assert(api.registered_nodes["hearth:optional_plant_winter"].tiles[1]=="optional_dormant.png")
assert(seasons.kind("hearth:optional_plant_winter")=="optional_plant")
end)
test("full block stage variants retain bark faces and never acquire sculpted meshes", function()
local stage={drawtype="normal",tiles={"end.png","end.png","bark.png"},paramtype2="facedir",groups={tree=1},drop="hearth:log"}
for _,kind in ipairs({"young_tree","mature_tree","oak_young","oak_mature"}) do
api.registered_nodes["hearth:"..kind]=stage
seasons.add_variants(kind,{Winter={texture="canopy_atlas.png",mesh="obsolete_tree.obj"}})
api.registered_nodes["hearth:"..kind.."_winter"]=nil
end
seasons.register()
for _,kind in ipairs({"young_tree","mature_tree","oak_young","oak_mature"}) do
local d=api.registered_nodes["hearth:"..kind.."_winter"]
assert(d.drawtype=="normal" and d.mesh==nil and d.tiles==stage.tiles)
assert(d.tiles[3]=="bark.png" and d.paramtype2=="facedir" and d.drop=="hearth:log")
end
end)
test("all twelve original calendar months retain hemisphere-aware seasons", function()
local model=dofile(target:gsub("[^/\\]+$","model.lua"))
local north={"Winter","Winter","Spring","Spring","Spring","Summer","Summer","Summer","Autumn","Autumn","Autumn","Winter"}
local opposite={Winter="Summer",Spring="Autumn",Summer="Winter",Autumn="Spring"}
for month=1,12 do
now=real_time({year=2026,month=month,day=15,hour=12,min=0,sec=0})
for _,latitude in ipairs({45,-45}) do
hearth.climate_at=function(epoch) assert(epoch==now); return model.solar(epoch,latitude,0) end
local expected=latitude>0 and north[month] or opposite[north[month]]
assert(seasons.season()==expected)
assert(seasons.available("wildflowers")== (expected=="Spring" or expected=="Summer"))
end
end
end)
local function install_botany()
local botany=dofile(target:gsub("[^/\\]+$","botany.lua"))(hearth,api)
botany.register_nodes(function(id,def) api.register_node("hearth:"..id,def) end)
seasons.register()
return botany
end
test("actual botany factory installs all new policies before seasonal registration", function()
local b=install_botany(); local count=0
for _,r in ipairs(b.list()) do
if not r.existing and r.group~="trees" then
count=count+1
for _,s in ipairs({"Spring","Summer","Autumn","Winter"}) do
season=s; local active=b.active(r.id,s)
local expected=r.node..(active and "" or "_"..s:lower())
assert(seasons.name_for(r.id,now)==expected,r.id..s)
assert(seasons.available(r.id,now)==active,r.id..s)
local def=assert(api.registered_nodes[expected])
assert(def.description==r.common.." ("..r.latin..")")
assert(def.tiles[1]=="hearth_botany_"..r.id..(active and "" or "_dormant")..".png")
assert(seasons.kind(expected)==r.id and b.kind(expected)==r.id)
end
end
end
assert(count==62)
end)
test("botany leaves diversity-owned taxa and block tree policies untouched", function()
local diversity=dofile(target:gsub("[^/\\]+$","diversity.lua"))(hearth,api)
-- Production shares exactly this seasonal service with both factories.
assert(diversity.plants and #diversity.plants==8)
local before={}
for _,id in ipairs(diversity.plants) do
before[id]={}
for _,s in ipairs({"Spring","Summer","Autumn","Winter"}) do
season=s; before[id][s]={seasons.name_for(id,now),seasons.available(id,now)}
end
end
install_botany()
for id,states in pairs(before) do for s,state in pairs(states) do
season=s; assert(seasons.name_for(id,now)==state[1] and seasons.available(id,now)==state[2])
end end
for _,id in ipairs({"holly","juniper","harts_tongue","polypody","turkey_tail","oakmoss"}) do
season="Winter"; assert(seasons.available(id,now) and seasons.name_for(id,now)=="hearth:"..id)
assert(not api.registered_nodes["hearth:"..id.."_winter"])
end
end)
test("dormant aquatic variants retain geometry callbacks and separate real water", function()
local b=install_botany()
for _,r in ipairs(b.list("aquatic")) do
local base=api.registered_nodes[r.node]
world[key(p)]={name=r.node,param1=7,param2=3}
world["0:0:0"]={name="hearth:water",param2=0}
local water=world["0:0:0"]
season="Winter"; assert(seasons.reconcile(p))
local def=api.registered_nodes[name()]
assert(def.tiles[1]=="hearth_botany_"..r.id.."_dormant.png")
assert(def.inventory_image==def.tiles[1] and def.wield_image==def.tiles[1])
assert(def.drawtype==base.drawtype and def.node_box==base.node_box and def.selection_box==base.selection_box)
assert(def.on_place==base.on_place and def.after_place_node==base.after_place_node)
assert(def.drop==base.drop and not def.walkable and not def.buildable_to)
assert(not def.liquidtype and not def.groups.water)
assert(def.groups.hearth_submerged==base.groups.hearth_submerged)
if def.on_place then local stack={}; assert(def.on_place(stack,nil,{})==stack) end
assert(world["0:0:0"]==water and water.name=="hearth:water")
assert(world[key(p)].param1==7 and world[key(p)].param2==3)
season="Summer"; assert(seasons.reconcile(p) and name()==r.node)
end
end)
test("berry seasonal swaps preserve cooldown provenance and harvest authorization", function()
install_botany()
local base=api.registered_nodes["hearth:bilberry"]
data={botany_ready_at=tostring(now+86400),player_placed="1",owner="builder"}
local reference=data
put("bilberry"); season="Winter"; assert(seasons.reconcile(p))
local def=api.registered_nodes[name()]
assert(def.drop=="hearth:sticks" and def.on_rightclick==base.on_rightclick)
assert(def.after_place_node==base.after_place_node and def.groups.not_in_creative_inventory==1)
assert(not seasons.available(name(),now))
season="Summer"; assert(seasons.reconcile(p) and name()=="hearth:bilberry")
assert(data==reference and data.botany_ready_at==tostring(now+86400) and data.owner=="builder")
assert(data.player_placed=="1" and meta_writes==0)
end)
os.time=real_time
print("LAST_HEARTH_SEASONS_PASS "..passed)
+89
View File
@@ -0,0 +1,89 @@
-- Standalone Lua 5.1+; policy/source checks, no engine and no network.
local target=assert(arg[1],"species_effects.lua path required")
local root=target:gsub("[^/\\]+$","")
local hearth={}
hearth.botany=dofile(root.."botany.lua")(hearth,{})
hearth.wiki_articles=dofile(root.."wiki_articles.lua")
local E=dofile(target)(hearth,setmetatable({}, {__index=function(_,k) error("unexpected API access: "..k) end}))
local passed=0
local function test(label,fn) fn(); passed=passed+1; print("ok "..passed.." - "..label) end
local function near(a,b) assert(math.abs(a-b)<0.000001,tostring(a).." != "..tostring(b)) end
local function entry(value,title)
for _,row in ipairs(E.describe(value)) do if row.title==title then return row end end
error("missing effect "..title)
end
test("all80 identity sources are mapped without claiming unreviewed trait evidence",function()
local sources=E.sources(); local total=0
for _,r in ipairs(hearth.botany.list()) do
local s=assert(sources[r.id]); total=total+1
assert(s.source_species==r.latin and s.source_url:match("^https://en%.wikipedia%.org/wiki/"))
assert(s.identity_revision==hearth.wiki_articles[r.id].revision)
assert(s.identity_revision_url==hearth.wiki_articles[r.id].revision_url)
if not s.reviewed then assert(s.note:find("no species%-effect evidence reviewed")) end
for _,row in ipairs(E.describe(r)) do
assert(row.status=="simulated" or row.status=="descriptive")
assert(row.game~="" and row.source_species==r.latin and row.source_url==s.source_url)
end
end
assert(total==80)
assert(E.source("unknown")==nil and #E.describe({id="unknown",tags={engineer=true}})==0)
end)
test("reviewed mechanisms cite species and explicitly identify broader evidence",function()
for _,id in ipairs({"alder","clover","dog_lichen","sphagnum","duckweed","birch_polypore","fly_agaric"}) do
local s=E.source(id); assert(s.reviewed and s.reviewed_on=="2026-11-03" and s.evidence_url)
end
assert(E.source("sphagnum").evidence_url:match("/Sphagnum$"))
assert(E.source("duckweed").evidence_url:match("/Lemnoideae$"))
assert(E.source("dog_lichen").evidence_url:match("/Peltigera$"))
assert(entry("fly_agaric","ectomycorrhiza").game:find("debits at most 1 existing soil nutrient"))
end)
test("descriptors do not silently become engineer, parasite, resin or poison mechanics",function()
for _,pair in ipairs({{"willow","engineer"},{"bracken","allelopathy"},{"honey_fungus","parasite"},{"pine","resin"},{"foxglove","toxicity"}}) do
assert(entry(pair[1],pair[2]).status=="descriptive")
end
for _,id in ipairs({"alder","clover","dog_lichen"}) do assert(entry(id,"nitrogenfixer").status=="simulated") end
assert(entry("pine","canopy").game:find("15%%"))
assert(entry("birch_polypore","saprotroph").status=="simulated")
assert(entry("yarrow","pollinator").status=="simulated")
end)
test("sphagnum changes only a fresh local land estimate with two-mat quantitative ceilings",function()
local h={loaded=true,water=0,aquatic="bank",moisture=0.65,ph=6.5,light=10,nutrients=3,nested={value=1}}
local out=E.habitat(h,{sphagnum=10000,duckweed=10000,willow=10000})
near(out.moisture,0.85); near(out.ph,6.1); assert(out.light==10 and out.nutrients==3)
assert(h.ph==6.5 and h.moisture==0.65 and out.source_ph==6.5 and out.source_moisture==0.65)
out.nested.value=2; assert(h.nested.value==1)
for _=1,20 do near(E.habitat(h,{sphagnum=2}).ph,6.1) end
out=E.habitat({loaded=true,water=0,moisture=0.99,ph=3.6},{sphagnum=2})
assert(out.moisture==1 and out.ph==3.5)
local cowslip={loaded=true,ground="grass",substrate="grass",water=0,aquatic="land",light=12,moisture=0.3,ph=6}
assert(hearth.botany.suitable("cowslip",cowslip,"Spring"))
assert(not hearth.botany.suitable("cowslip",E.habitat(cowslip,{sphagnum=2}),"Spring"),"acidification changes real niche eligibility")
end)
test("floating mats shade only submerged freshwater, with a six-light ceiling and no brightening",function()
for _,base in ipairs({15,7,3,1,0}) do
local h={loaded=true,water=2,aquatic="submerged",freshwater=true,light=base,moisture=1,ph=6.5}
local out=E.habitat(h,{duckweed=10000,sphagnum=10000})
assert(out.light==base-math.min(6,math.max(0,base-2)) and h.light==base)
assert(out.water==2 and out.ph==6.5 and out.moisture==1)
end
for _,mode in ipairs({"surface","land","salt"}) do
local h={loaded=true,water=mode=="land" and 0 or 2,aquatic=mode=="salt" and "submerged" or mode,freshwater=mode~="salt",light=10}
assert(E.habitat(h,{duckweed=4}).light==10)
end
end)
test("unknown, malformed and unloaded observations grant no effects",function()
local h={loaded=true,water=0,moisture=0.5,ph=6.5,light=10}
for _,n in ipairs({-1,math.huge,0/0,"2",false}) do
local out=E.habitat(h,{sphagnum=n,duckweed=n}); assert(out.ph==h.ph and out.moisture==h.moisture)
end
assert(E.habitat(h,{unknown=999,alder=999}).ph==6.5)
h.loaded=false; assert(E.habitat(h,{sphagnum=2}).ph==6.5)
assert(E.habitat(nil)==nil)
end)
test("source and description results cannot mutate the reviewed policy",function()
local row=entry("sphagnum","engineer"); row.status="descriptive"; row.source_url="wrong"
assert(entry("sphagnum","engineer").status=="simulated")
local sources=E.sources(); sources.alder.note="wrong"
assert(E.source("alder").note~="wrong")
end)
print("LAST_HEARTH_SPECIES_EFFECTS_PASS "..passed)
+223
View File
@@ -0,0 +1,223 @@
-- Focused injected reader tests; no engine, network or metadata required.
local target=assert(arg[1],"species_reader.lua path required")
local path=target:match("^(.*[/\\])") or ""
local factory=dofile(target)
local total=0
local function test(name,fn)
local ok,err=pcall(fn); assert(ok,name..": "..tostring(err))
total=total+1; print("PASS "..name)
end
local function forbidden() error("Reader attempted a side effect") end
local screens, hits, node, reads, rays={}, {}, {name="hearth:oak_log"}, 0, 0
local H={sessions={alice={}},players={alice={food=73,water=61}},clock={season="Autumn"}}
H.botany=dofile(path.."botany.lua")(H,{})
H.wiki_articles=dofile(path.."wiki_articles.lua")
H.species_effects=dofile(path.."species_effects.lua")(H,{})
local api={set_node=forbidden,swap_node=forbidden,get_meta=forbidden,
emerge_area=forbidden,forceload_block=forbidden,request_http_api=forbidden,
show_url=forbidden,register_chatcommand=forbidden,register_on_player_receive_fields=forbidden}
function api.formspec_escape(s)
return (s:gsub("\\","\\\\"):gsub("%[","\\["):gsub("%]","\\]"):gsub(";","\\;"):gsub(",","\\,"))
end
function api.show_formspec(name,form,spec) screens[#screens+1]={name=name,form=form,spec=spec} end
function api.get_node_or_nil() reads=reads+1; return node end
local eye_height=1.6
local player={get_player_name=function() return "alice" end,
get_pos=function() return {x=0,y=0,z=0} end,
get_properties=function() return {eye_height=eye_height} end,
get_look_dir=function() return {x=0,y=0,z=2} end,
get_meta=forbidden,set_pos=forbidden,get_inventory=forbidden}
local function hit(z,name)
return {type="node",under={x=0,y=2,z=z},intersection_point={x=0,y=1.6,z=z},name=name}
end
function api.raycast(start,finish,objects,liquids)
rays=rays+1
assert(start.x==0 and start.y==1.6 and start.z==0,"not eye origin")
assert(finish.y==start.y and finish.z==6,"reach not normalized to six")
assert(objects==false and liquids==false)
local i=0
return function() i=i+1; assert(i<=3,"unbounded scan"); return hits[i] end
end
local R=factory(H,api)
local function latest() return screens[#screens].spec end
test("factory installs no global callbacks or API mutations",function()
assert(type(R.show)=="function" and type(R.look)=="function" and type(R.resolve)=="function" and type(R.handle)=="function")
assert(api.set_node==forbidden and H.species_reader==nil)
end)
test("exact IDs and canonical Latin resolve without fuzzy URL fallback",function()
assert(R.resolve("oak").latin=="Quercus robur")
assert(R.resolve(" Quercus robur ").id=="oak")
for _,v in ipairs({"Quercus","quercus robur","Pedunculate oak","hearth:oak_log","https://en.wikipedia.org/wiki/Oak",string.rep("x",121),42}) do
assert(not R.resolve(v))
end
assert(not R.resolve(nil))
end)
test("actual botanical seasonal stages logs leaves and copy API resolve",function()
local S=dofile(path.."seasons.lua")(H,{})
H.seasons=S
-- The real diversity factory installs the existing seasonal tree mappings.
H.diversity=dofile(path.."diversity.lua")(H,{})
for _,v in ipairs({"hearth:oak_seedling","hearth:oak_young","hearth:oak_mature","hearth:oak_log","hearth:oak_leaves","hearth:oak_leaves_winter","hearth:clover_winter"}) do
local r=H.botany.get(v); assert(r,v)
assert(R.show(player,r.id))
end
local r=R.resolve("oak"); r.common="changed"; assert(R.resolve("oak").common~="changed")
end)
test("ray uses normalized six-block eye reach and loaded-node read only",function()
hits={hit(6)}; node={name="hearth:oak_log"}; reads=0
assert(R.look(player).id=="oak" and reads==1)
end)
test("ray rejects hits beyond reach without node reads",function()
hits={hit(6.01)}; reads=0; assert(not R.look(player) and reads==0)
end)
test("unknown and unloaded first surfaces cannot be inspected through",function()
hits={hit(2),hit(3)}
for _,v in ipairs({false,{name="ignore"},{name="hearth:stone"},{name="foreign:oak_log"}}) do
node=v or nil; reads=0; assert(not R.look(player) and reads==1)
end
end)
test("empty ray and invalid hit positions do not read map",function()
hits={}; reads=0; assert(not R.look(player) and reads==0)
hits={{type="node",under={x=0,y=0,z=0}}}; assert(not R.look(player) and reads==0)
hits={hit(3)}; hits[1].under.x=0/0; assert(not R.look(player) and reads==0)
eye_height=math.huge; local n=rays; assert(not R.look(player) and rays==n); eye_height=1.6
end)
test("all eighty entries render with honest seventy-eight excerpt and two note headings",function()
local excerpts,notes=0,0
for _,r in ipairs(H.botany.list()) do
assert(R.show(player,r.id)); local a=H.wiki_articles[r.id]
assert(latest():find(api.formspec_escape(r.latin),1,true))
if a.kind=="wikipedia_intro" then
excerpts=excerpts+1; assert(latest():find("Wikipedia excerpt (offline)",1,true))
else
notes=notes+1
assert(r.id=="old_mans_beard" or r.id=="cup_lichen")
assert(latest():find("No Wikipedia article cached (offline)",1,true))
assert(not latest():find("Wikipedia excerpt (offline)",1,true))
assert(latest():find("CC0",1,true) and not latest():find("CC BY-SA 4.0",1,true))
assert(not a.revision and latest():find(api.formspec_escape(a.text),1,true))
end
end
assert(excerpts==78 and notes==2)
end)
test("excerpt retains full shipped introduction and attribution revision history license",function()
assert(R.show(player,"oak")); local a=H.wiki_articles.oak
for _,key in ipairs({"title","text","attribution","license","license_url","url","revision_url","history_url","revision_timestamp","retrieved","changes"}) do
assert(latest():find(api.formspec_escape(a[key]),1,true),key)
end
assert(latest():find("not the full wiki",1,true) and latest():find("not",1,true))
assert(latest():find("medical advice",1,true))
end)
test("every shipped introduction fits and is displayed in full",function()
for _,r in ipairs(H.botany.list()) do
assert(R.show(player,r.id))
assert(latest():find(api.formspec_escape(H.wiki_articles[r.id].text),1,true),r.id)
end
end)
test("current effects use actual describe records with simulated descriptive separation",function()
assert(R.show(player,"honey_fungus",2))
assert(latest():find("SIMULATED",1,true) and latest():find("DESCRIPTIVE (not simulated)",1,true))
assert(latest():find("no infection or attack",1,true))
assert(R.show(player,"alder",2))
assert(latest():find("900 warm active seconds",1,true) and latest():find("Frankia",1,true))
assert(latest():find("not measured Wikipedia constants",1,true))
end)
test("article textarea and provenance are unnamed read-only copyable fields",function()
assert(R.show(player,"oak"))
local count=0
for body in latest():gmatch("textarea%[([^\n]-);;;") do count=count+1 end
assert(count==3 and not latest():find("field[",1,true))
assert(latest():find("URLs are copyable",1,true))
end)
test("forged IDs source URLs text and pages never replace session article",function()
assert(R.show(player,"oak"))
local before=#screens
assert(R.handle(player,{id="pine",url="javascript:boom",text="replacement",page="999",reader_tab="99"}))
assert(#screens==before and H.sessions.alice.species_reader.id=="oak")
R.handle(player,{reader_tab="2",id="pine"}); assert(H.sessions.alice.species_reader.id=="oak")
R.handle(player,{reader_tab="1",text="replacement"}); assert(latest():find(api.formspec_escape(H.wiki_articles.oak.text),1,true))
end)
test("malicious cached text source and URLs are plain escaped inert data",function()
local a=H.wiki_articles.oak
local payload="];button[0,0;1,1;attack;Injected]<script>error('execute')</script>"
H.wiki_articles.oak={kind="wikipedia_intro",text=payload,url=payload,attribution=payload,license=payload}
assert(R.show(player,"oak"))
assert(latest():find(api.formspec_escape(payload),1,true) and not latest():find(";attack;Injected]",1,true))
assert(not latest():find("hypertext[",1,true) and not latest():find("button_url[",1,true))
H.wiki_articles.oak=a
end)
test("Unicode body caps at eight thousand with four-thousand character pages",function()
local a=H.wiki_articles.oak
local char=string.char(195,169)
H.wiki_articles.oak={kind="wikipedia_intro",text=string.rep(char,9000)}
assert(R.show(player,"oak"))
assert(latest():find(string.rep(char,4000),1,true) and not latest():find(string.rep(char,4001),1,true))
assert(latest():find("display limited to 8000",1,true))
R.handle(player,{reader_next=true}); assert(H.sessions.alice.species_reader.page==2)
assert(latest():find(string.rep(char,4000),1,true))
R.handle(player,{reader_next=true}); assert(H.sessions.alice.species_reader.page==2)
R.handle(player,{reader_previous=true}); assert(H.sessions.alice.species_reader.page==1)
assert(#latest()<20000)
H.wiki_articles.oak=a
end)
test("absent optional catalogue sources and effects are explicit not counterfeit",function()
local B,A,E=H.botany,H.wiki_articles,H.species_effects
H.wiki_articles=nil; assert(R.show(player,"oak")); assert(latest():find("No article is available",1,true))
H.species_effects=nil; assert(R.show(player,"oak",2)); assert(latest():find("Effect descriptions unavailable",1,true))
H.botany=nil; assert(not R.resolve("oak") and not R.show(player,"oak"))
H.botany,H.wiki_articles,H.species_effects=B,A,E
end)
test("player close confirmation clears reader but changes no gameplay or persistent data",function()
local needs=H.players.alice
assert(R.show(player,"oak")); local old=H.sessions.alice.species_reader
R.handle(player,{text="modified"}); assert(H.sessions.alice.species_reader==old)
R.handle(player,{quit=true}); assert(not H.sessions.alice.species_reader)
assert(H.players.alice==needs and needs.food==73 and needs.water==61)
assert(not R.handle(player,{reader_next=true}))
end)
test("missing session rejects forms and forged follow-up packets",function()
local s=H.sessions.alice; H.sessions.alice=nil; local n=#screens
assert(not R.show(player,"oak") and not R.handle(player,{reader_tab="2"}))
assert(#screens==n); H.sessions.alice=s
end)
test("actual feedback HUD hints known species with hand or building and fails closed unloaded",function()
local saved_core,saved_hearth=core,hearth
local ok,err=pcall(function()
local t,wield_name,message=1,"",""
local s={}
hearth={sessions={alice=s},botany=H.botany,movement={delta=function(d) return d end},
construction={preview=function() return true,"ok" end}}
core={get_us_time=function() return t*1000000 end,
register_chatcommand=function() end,raycast=function(start,finish)
assert(start.y==1.6 and finish.z==5)
local i=0; return function() i=i+1; return hits[i] end
end,get_node_or_nil=api.get_node_or_nil,
get_meta=forbidden,set_node=forbidden,swap_node=forbidden,sound_play=forbidden,
get_dig_params=function() return {diggable=true} end,
registered_nodes={["hearth:oak_log"]={description="Oak log",groups={tree=1}},
["hearth:stone"]={description="Stone",groups={stone=1}},
["hearth:wall"]={description="Wall",groups={wood=1}}}}
local p={get_player_name=player.get_player_name,get_pos=player.get_pos,
get_properties=player.get_properties,get_look_dir=function() return {x=0,y=0,z=1} end,
get_hp=function() return 20 end,get_velocity=function() return {x=0,y=0,z=0} end,
get_player_control=function() return {} end,set_animation=function() end,
hud_add=function(_,def) message=def.text; return 1 end,
hud_change=function(_,_,key,value) if key=="text" then message=value end end}
function p:get_wielded_item()
return {get_name=function() return wield_name end,get_wear=function() return 0 end,
get_tool_capabilities=function() return {} end}
end
dofile(path.."feedback.lua")
hits={hit(3)}; node={name="hearth:oak_log"}
hearth.feedback_step(p,0); assert(message:find("/inspect species",1,true))
wield_name="hearth:wall"; t=2
hearth.feedback_step(p,0)
assert(message:find("Ready to place",1,true) and message:find("/inspect species",1,true))
wield_name=""; node={name="hearth:stone"}; t=3
hearth.feedback_step(p,0); assert(not message:find("/inspect",1,true))
node=nil; t=4; hearth.feedback_step(p,0); assert(message=="")
end)
core,hearth=saved_core,saved_hearth
assert(ok,err)
end)
print("PASS "..total.." species reader tests")
+938
View File
@@ -0,0 +1,938 @@
-- Standalone Lua 5.1+; deterministic loaded-world fixture, no engine required.
local target = assert(arg[1], "succession.lua path required")
local original_time, passed = os.time, 0
local now, world, fields, protected, claims, missing, light, humidity, temperature, season
local api, hearth, sim, probes, writes, reads, players, lbm, real_positions
local p = {x=0,y=1,z=0}
local function key(q) return q.x .. ":" .. q.y .. ":" .. q.z end
local function pos(x,y,z) return {x=x or 0,y=y or 1,z=z or 0} end
local function metadata(q)
local k=key(q); fields[k]=fields[k] or {}
local f=fields[k]
return {get_string=function(_,name) return f[name] or "" end,
set_string=function(_,name,value) f[name]=tostring(value) end,
to_table=function() return {fields=f} end}
end
local function put(q,k,natural)
world[key(q)]={name=k=="air" and k or "hearth:"..k}
if natural then metadata(q):set_string("succ_natural","1") end
end
local function name(q) return api.get_node_or_nil(q).name end
local function load()
probes=0
sim=dofile(target)(hearth,api)
end
local function reset()
p=pos(0,1,0)
now,world,fields,protected,claims,missing=1000000,{},{},{},{},{}
light,humidity,temperature,season=15,60,15,"Summer"
writes,reads,players,real_positions=0,0,true,false
api={}
function api.get_meta(q) return metadata(q) end
function api.get_node_or_nil(q)
reads=reads+1
if missing[key(q)] then return nil end
return world[key(q)] or {name=q.y==0 and "hearth:grass" or "air"}
end
function api.swap_node(q,n) writes=writes+1; world[key(q)]=n end
function api.get_node_light() return light end
function api.get_biome_data() return {humidity=humidity} end
function api.register_globalstep(fn) api.step=fn end
function api.register_lbm(def) lbm=def end
function api.get_connected_players()
if not players then return {} end
return {{get_pos=function()
-- Follow the deterministic scan so its first column is our test plot.
-- Subsequent probes of the same node at the same timestamp earn no time.
local index=probes%625; probes=probes+1
if real_positions then return pos(0,1,0) end
return pos(p.x+12-index%25,p.y,p.z+12-math.floor(index/25))
end}}
end
hearth={claim_owner_at=function(q) return claims[key(q)] and "owner" end,
claim_allowed=function(q, who) return who ~= "" and not claims[key(q)] end,
external_protected=function(q) return protected[key(q)] end,
climate_at=function() return {temperature=temperature,season=season} end}
hearth.seasons=dofile(target:gsub("[^/\\]+$","seasons.lua"))(hearth,api)
os.time=function() return now end
load()
end
local function advance(dt)
now=now+(dt or 60)
assert(sim.step(1)==sim.BUDGET)
end
local function baseline(k,age)
if k then put(p,k,true) end
sim.step(1)
if age then metadata(p):set_string("succ_age",age) end
end
local function field(q,f) return tonumber(metadata(q):get_string(f)) or 0 end
local function test(label,fn)
reset()
local ok,err=pcall(fn)
assert(ok,label..": "..tostring(err)); passed=passed+1
print("ok "..passed.." - "..label)
end
test("existing natural ground recruits native grass without regeneration",function()
baseline()
advance(60); advance(60)
assert(name(p)=="air")
advance(60)
assert(name(p)=="hearth:meadow_grass")
assert(metadata(p):get_string("succ_natural")=="1")
end)
test("nutrients accelerate establishment and are consumed rather than copied",function()
metadata(pos(0,0,0)):set_string("succ_nutrients","4")
baseline(); advance(100)
assert(name(p)=="hearth:meadow_grass")
assert(field(pos(0,0,0),"succ_nutrients")==3)
end)
test("sunny mixed meadow recruits wildflowers, dense grass excludes them",function()
put(pos(1),"meadow_grass",true); put(pos(-1),"meadow_grass",true)
baseline(); advance(100); advance(100)
assert(name(p)=="hearth:wildflowers")
for x=-2,2 do for z=-2,2 do
if x~=0 or z~=0 then put(pos(x,1,z),"meadow_grass",true) end
end end
for _=1,6 do advance(100) end
assert(name(p)=="hearth:litter")
end)
test("mature canopy shades out grasses and favors moist ferns",function()
put(pos(1),"mature_tree",true); put(pos(-1),"mature_tree",true); put(pos(0,1,1),"mature_tree",true)
baseline("meadow_grass")
assert(sim.habitat(p).light==6)
for _=1,6 do advance(100) end
assert(name(p)=="hearth:litter")
put(p,"air"); metadata(p):set_string("succ_age","180")
advance(100)
assert(name(p)=="hearth:fern")
end)
test("dry exposed fern dies, while river moisture changes its niche",function()
light,humidity=14,20
baseline("fern")
for _=1,6 do advance(100) end
assert(name(p)=="hearth:litter")
put(pos(1,0,0),"water_source")
assert(sim.habitat(p).water==1 and sim.habitat(p).moisture>0.2)
end)
test("tree stages require active age, then mature trees become decomposing wood",function()
baseline("tree_seedling",21500); advance(100)
assert(name(p)=="hearth:young_tree" and field(p,"succ_age")==0)
metadata(p):set_string("succ_age","86300"); advance(100)
assert(name(p)=="hearth:mature_tree")
metadata(p):set_string("succ_age","259100"); advance(100)
assert(name(p)=="hearth:deadwood")
metadata(p):set_string("succ_age","7100"); advance(100)
assert(name(p)=="hearth:litter" and field(pos(0,0,0),"succ_nutrients")==3)
end)
test("established sparse meadow eventually recruits a native tree",function()
baseline("meadow_grass",7100); advance(100)
assert(name(p)=="hearth:tree_seedling")
end)
test("dense meadow delays rather than permanently preventing woodland recruitment",function()
for x=-2,2 do for z=-2,2 do put(pos(x,1,z),"meadow_grass",true) end end
baseline("meadow_grass",7100); advance(100)
assert(name(p)=="hearth:meadow_grass")
metadata(p):set_string("succ_age","14300"); advance(100)
assert(name(p)=="hearth:tree_seedling")
end)
test("mature tree disperses one seed on a durable attempt schedule",function()
baseline("mature_tree",2000)
metadata(p):set_string("succ_serial","9")
advance(100)
local seed=pos(-1,1,0) -- serial 10 selects offset 3
assert(name(seed)=="hearth:tree_seedling")
local before=writes
sim.step(99999)
assert(writes==before and field(p,"succ_serial")==10)
end)
test("grass spreads locally but never replaces occupied flower or crop cells",function()
baseline("meadow_grass",1000)
metadata(p):set_string("succ_serial","2")
put(pos(0,1,-1),"wildflowers",true) -- serial 3 selects offset 4
advance(100)
assert(name(pos(0,1,-1))=="hearth:wildflowers")
metadata(p):set_string("succ_serial","2")
put(pos(0,1,-1),"air"); advance(100)
assert(name(pos(0,1,-1))=="hearth:meadow_grass")
end)
test("moist shaded litter seeds fungi, then returns finite nutrients",function()
light=10
baseline("litter",300); advance(100)
assert(name(pos(0,1,1))=="hearth:fungi") -- first attempt selects offset 2
metadata(p):set_string("succ_age","1700"); advance(100)
assert(name(p)=="air" and field(pos(0,0,0),"succ_nutrients")==1)
advance(1)
assert(field(pos(0,0,0),"succ_nutrients")==1)
end)
test("dung decomposes, fertilizes soil and does not instantly sprout a forest",function()
assert(sim.deposit(p))
local patch=pos(-1,1,-1)
assert(name(patch)=="hearth:dung")
-- Move the patch to the scheduler's plot without resetting its natural marker.
put(p,"dung",true); baseline(nil,500); advance(100)
assert(name(p)=="air" and field(pos(0,0,0),"succ_nutrients")==4)
end)
test("grazing consumes exactly one natural grass or fern, not flowers or planted nodes",function()
put(p,"meadow_grass",true); put(pos(1),"fern",true)
assert(sim.forage(p))
assert(name(p)=="air" and name(pos(1))=="hearth:fern")
assert(field(p,"succ_next")==now+300)
put(pos(1),"wildflowers",true)
assert(not sim.forage(p))
put(p,"meadow_grass",false); metadata(p):set_string("succ_natural","")
assert(not sim.forage(p))
end)
test("grazing cooldown prevents immediate seed recolonization",function()
baseline("meadow_grass")
assert(sim.forage(p))
advance(100); advance(100)
assert(name(p)=="air")
advance(100)
assert(name(p)=="hearth:meadow_grass")
end)
test("source claims and external protection block all simulation writes",function()
baseline("mature_tree",259100)
protected[key(p)]=true; advance(100)
assert(name(p)=="hearth:mature_tree" and writes==0)
protected={}; claims[key(p)]=true; advance(100)
assert(writes==0 and not sim.deposit(p) and not sim.forage(p))
end)
test("support protection and markers block source decay and grazing",function()
baseline("meadow_grass",21600)
protected[key(pos(0,0,0))]=true; advance(100)
assert(writes==0 and not sim.forage(p))
protected={}; metadata(pos(0,0,0)):set_string("placed_by","builder"); advance(100)
assert(writes==0 and not sim.forage(p))
end)
test("seed dispersal respects destination AND destination-support protection",function()
baseline("mature_tree",2000)
local seed=pos(-1,1,0)
protected[key(seed)]=true
metadata(p):set_string("succ_serial","9"); advance(100)
assert(name(seed)=="air")
protected={}; claims[key(pos(-1,0,0))]=true
metadata(p):set_string("succ_serial","9"); advance(100)
assert(name(seed)=="air")
end)
test("any supported placement marker opts the plot out of nature",function()
for _,fieldname in ipairs({"owner","planter","placed_by","player_placed","hearth_placed","hearth_player_placed","planted_at","third_party:hand_placed"}) do
fields={}; metadata(p):set_string(fieldname,"1")
baseline(); advance(100); advance(100)
assert(name(p)=="air",fieldname)
end
end)
test("builds, farms, water and nonnatural plants are never overwritten",function()
for _,k in ipairs({"wall","crop_1","water_source","sapling","berry_bush"}) do
world,fields={},{}
put(p,k); baseline(); advance(100); advance(100)
assert(name(p)=="hearth:"..k,k)
end
world,fields={},{}; put(pos(1),"crop_4"); baseline(); advance(100); advance(100)
assert(name(p)=="air", "adjacent farm buffer")
end)
test("unloaded source, support, or destination never cause writes or force loading",function()
baseline("mature_tree",2000)
missing[key(p)]=true; advance(100); assert(writes==0)
missing={}; missing[key(pos(0,0,0))]=true; advance(100); assert(writes==0)
missing={}; missing[key(pos(-1,1,0))]=true
metadata(p):set_string("succ_serial","9"); advance(100)
assert(writes==0)
end)
test("work and node reads remain bounded after enormous frame stalls",function()
real_positions=true
reads=0; local work=sim.step(1000000000)
assert(work==8 and probes==8)
assert(reads<3000,"bounded stencil reads: "..reads)
players=false; reads=0
assert(sim.step(999999)==0 and reads==0)
end)
test("fixed total budget visits every column fairly across multiple players",function()
local columns, counts, current, first = {{},{}}, {0,0}, 0, false
api.get_connected_players=function()
local result={}
for i=1,2 do
local who=i
result[i]={get_pos=function() current=who; first=true; counts[who]=counts[who]+1; return pos(who*100,1,0) end}
end
return result
end
api.get_node_or_nil=function(q)
if first then columns[current][key(q)]=true; first=false end
return nil
end
for _=1,157 do assert(sim.step(1)==8) end
for i=1,2 do
local count=0; for _ in pairs(columns[i]) do count=count+1 end
assert(count==625 and counts[i]==628)
end
assert(sim.CACHE_LIMIT>=625*10 and sim.MAX_GAP>=625*10/sim.BUDGET)
end)
test("vegetation reload alone invalidates age even if its supporting block stayed loaded",function()
baseline("mature_tree",259100)
lbm.action(p); advance(100)
assert(name(p)=="hearth:mature_tree" and field(p,"succ_age")==259100)
end)
test("no catchup on long absence, disconnect, unload, restart or clock rewind",function()
baseline("tree_seedling",21500)
advance(864000); assert(name(p)=="hearth:tree_seedling" and field(p,"succ_age")==21500)
players=false; sim.step(1); players=true
advance(100); assert(field(p,"succ_age")==21500)
lbm.action(pos(0,0,0)); advance(100); assert(field(p,"succ_age")==21500)
load(); advance(100); assert(field(p,"succ_age")==21500)
advance(-100); assert(field(p,"succ_age")==21500)
advance(100); assert(name(p)=="hearth:young_tree")
end)
test("failed dispersal attempts survive restart without reroll or advancing age",function()
baseline("mature_tree",2000)
metadata(p):set_string("succ_serial","9")
protected[key(pos(-1,1,0))]=true; advance(100)
assert(field(p,"succ_serial")==10)
local age,due=field(p,"succ_age"),field(p,"succ_next")
protected={}; load(); sim.step(1)
assert(field(p,"succ_serial")==10 and field(p,"succ_next")==due)
advance(30)
assert(field(p,"succ_serial")==10 and name(pos(-1,1,0))=="air")
assert(field(p,"succ_age")==age+30)
end)
test("cold weather pauses recruitment and stage growth without catchup",function()
temperature=-5; baseline(); advance(100); advance(100)
assert(name(p)=="air")
put(p,"tree_seedling",true); metadata(p):set_string("succ_age","21600"); advance(100)
assert(name(p)=="hearth:tree_seedling")
end)
test("habitat is read only and malformed API coordinates fail closed",function()
put(p,"meadow_grass",true); put(pos(1),"wildflowers",true); put(pos(-1),"fern",true)
local h=sim.habitat(p)
assert(h.grass==1 and h.flowers==1 and h.cover==1 and h.loaded)
assert(writes==0 and metadata(p):get_string("succ_age")=="")
for _,invalid in ipairs({{}, {x=0/0,y=1,z=0}, {x=math.huge,y=1,z=0}, {x=999999,y=1,z=0}}) do
assert(not sim.forage(invalid) and not sim.deposit(invalid) and not sim.habitat(invalid).loaded)
end
end)
test("season swaps preserve natural identity and active age; winter pauses perennial senescence",function()
baseline("meadow_grass",1000)
season,temperature="Winter",-5
advance(100)
assert(name(p)=="hearth:meadow_grass_winter" and field(p,"succ_age")==1000)
assert(metadata(p):get_string("succ_natural")=="1" and sim.habitat(p).grass==1)
for _=1,7 do advance(100) end
assert(name(p)=="hearth:meadow_grass_winter" and field(p,"succ_age")==1000)
season,temperature="Spring",15; advance(100)
assert(name(p)=="hearth:meadow_grass" and field(p,"succ_age")==1100)
end)
test("spring greens recruit moist sunny gaps, mushrooms autumn shade; dormant plants are occupied",function()
season="Spring"; baseline(); advance(100); advance(100)
assert(name(p)=="hearth:spring_greens_patch_spring")
metadata(p):set_string("succ_harvest_ready_at",tostring(now+86400))
season="Summer"; advance(100)
assert(name(p)=="hearth:spring_greens_patch" and field(p,"succ_harvest_ready_at")>now)
assert(not sim.deposit(p) or name(p)=="hearth:spring_greens_patch")
reset(); season,light,humidity="Autumn",8,70
baseline(); advance(100); advance(100)
assert(name(p)=="hearth:autumn_mushrooms_patch_autumn")
reset(); season,light,humidity="Autumn",15,70
baseline(); advance(100); advance(100)
assert(name(p)=="hearth:meadow_grass")
end)
test("forage dispersal respects niches, occupation and durable harvest metadata",function()
season="Spring"; baseline("spring_greens_patch_spring",1000)
metadata(p):set_string("succ_harvest_ready_at",tostring(now+86400))
metadata(p):set_string("succ_serial","2")
advance(100)
assert(name(pos(0,1,-1))=="hearth:spring_greens_patch_spring")
assert(field(p,"succ_harvest_ready_at")>now)
metadata(p):set_string("succ_serial","2"); advance(100)
assert(name(pos(0,1,-1))=="hearth:spring_greens_patch_spring")
end)
test("tree mesh growth denies foreign, marked, claimed and unloaded target volume",function()
for _, obstacle in ipairs({"foreign","placed","claimed","unloaded"}) do
reset(); baseline("young_tree",86300)
local q=pos(2,4,0)
if obstacle=="foreign" then put(q,"wall") end
if obstacle=="placed" then metadata(q):set_string("player_placed","1") end
if obstacle=="claimed" then claims[key(q)]=true end
if obstacle=="unloaded" then missing[key(q)]=true end
advance(100)
assert(name(p)=="hearth:young_tree",obstacle)
end
end)
test("leafless winter canopy admits more light than summer foliage",function()
put(p,"mature_tree",true)
local summer=sim.habitat(p)
season="Winter"
local winter=sim.habitat(p)
assert(winter.canopy<summer.canopy and winter.light>summer.light)
assert(math.abs(winter.canopy-0.15)<0.0001)
end)
test("winter mature canopy still sheds litter and decomposition returns nutrients",function()
season,temperature="Winter",-5; baseline("mature_tree",2000)
metadata(p):set_string("succ_serial","4"); advance(100)
assert(name(pos(-2,1,-1))=="hearth:litter")
put(p,"litter",true); metadata(p):set_string("succ_age","1700")
advance(100)
assert(name(p)=="air" and field(pos(0,0,0),"succ_nutrients")==1)
end)
test("seasonal probes reconcile old canopy and handplaced plants within twelve heights",function()
season="Autumn"
put(pos(0,5,0),"leaves"); put(p,"wildflowers")
metadata(p):set_string("player_placed","1")
metadata(p):set_string("succ_age","912")
sim.step(1)
assert(name(pos(0,5,0))=="hearth:leaves_autumn")
assert(name(p)=="hearth:wildflowers_autumn" and field(p,"succ_age")==912)
assert(metadata(p):get_string("player_placed")=="1")
end)
test("season appearance has a fixed 96 swap ceiling after a stalled frame",function()
season="Winter"; real_positions=true
api.get_node_or_nil=function(q)
reads=reads+1
return world[key(q)] or {name="hearth:leaves"}
end
assert(sim.step(999999)==8)
assert(writes==96 and reads<400)
end)
test("incomplete habitat and protected source write no ecological metadata",function()
baseline("tree_seedling",1000)
local age,serial,due=field(p,"succ_age"),field(p,"succ_serial"),field(p,"succ_next")
missing[key(pos(2,4,2))]=true
advance(100)
assert(field(p,"succ_age")==age and field(p,"succ_serial")==serial and field(p,"succ_next")==due)
assert(writes==0)
missing={}; protected[key(p)]=true; advance(100)
assert(field(p,"succ_age")==age and field(p,"succ_serial")==serial and field(p,"succ_next")==due)
assert(writes==0)
end)
local function diverse()
hearth.diversity=dofile(target:gsub("[^/\\]+$","diversity.lua"))(hearth,api)
load()
end
test("unmarked mapgen deadwood is adopted and decays after two active hours with finite capped nutrients",function()
diverse(); humidity=20; put(p,"deadwood"); baseline()
assert(metadata(p):get_string("succ_natural")=="1" and field(p,"succ_age")==0)
for _=1,71 do advance(100) end
assert(name(p)=="hearth:deadwood" and field(p,"succ_age")==7100)
assert(field(pos(0,0,0),"succ_nutrients")==0)
advance(100)
assert(name(p)=="hearth:litter" and field(p,"succ_age")==0)
assert(field(pos(0,0,0),"succ_nutrients")==3)
local before=writes; sim.step(999999)
assert(writes==before and field(pos(0,0,0),"succ_nutrients")==3)
for _=1,18 do advance(100) end
assert(name(p)=="air" and field(pos(0,0,0),"succ_nutrients")==4)
advance(1); assert(field(pos(0,0,0),"succ_nutrients")==4)
reset(); diverse(); humidity=20
metadata(pos(0,0,0)):set_string("succ_nutrients","7")
put(p,"deadwood"); baseline(nil,7100); advance(100)
assert(name(p)=="hearth:litter" and field(pos(0,0,0),"succ_nutrients")==8)
end)
test("adopted deadwood never catches up across absence, disconnect, reload, restart or clock rewind",function()
diverse(); humidity=20; put(p,"deadwood"); baseline(nil,7100)
advance(864000); assert(name(p)=="hearth:deadwood" and field(p,"succ_age")==7100)
players=false; sim.step(1); players=true; advance(100)
assert(field(p,"succ_age")==7100)
lbm.action(p); advance(100); assert(field(p,"succ_age")==7100)
lbm.action(pos(0,0,0)); advance(100); assert(field(p,"succ_age")==7100)
load(); advance(100); assert(field(p,"succ_age")==7100)
advance(-100); assert(field(p,"succ_age")==7100)
assert(field(pos(0,0,0),"succ_nutrients")==0)
advance(100)
assert(name(p)=="hearth:litter" and field(pos(0,0,0),"succ_nutrients")==3)
end)
test("species stages keep birch identity and deadwood records its originating species",function()
diverse(); baseline("birch_seedling",21500); advance(100)
assert(name(p)=="hearth:birch_young" and metadata(p):get_string("succ_species")=="birch")
metadata(p):set_string("succ_age","30000"); advance(100)
assert(name(p)=="hearth:birch_young","young trees must not take herb senescence")
metadata(p):set_string("succ_age","86300"); advance(100)
assert(name(p)=="hearth:birch_mature")
metadata(p):set_string("succ_age","259100"); advance(100)
assert(name(p)=="hearth:deadwood" and metadata(p):get_string("succ_species")=="birch")
metadata(p):set_string("succ_age","7100"); advance(100)
assert(name(p)=="hearth:litter" and metadata(p):get_string("succ_species")=="birch")
end)
test("species seeds retain parent identity and reject an incompatible destination soil",function()
diverse(); baseline("birch_mature",2000)
metadata(p):set_string("succ_serial","9"); advance(100)
assert(name(pos(-1,1,0))=="hearth:birch_seedling")
reset(); diverse(); put(pos(0,0,0),"podzol"); put(pos(-1,0,0),"peat")
baseline("pine_mature",2000); metadata(p):set_string("succ_serial","9"); advance(100)
assert(name(pos(-1,1,0))~="hearth:pine_seedling") -- litter may share the attempt
end)
test("pine is evergreen while species deciduous canopies lose winter shade",function()
diverse(); put(p,"pine_mature",true); put(pos(1,2,0),"pine_leaves")
local summer=sim.habitat(p); season="Winter"
local winter=sim.habitat(p); assert(winter.canopy==summer.canopy)
put(p,"oak_mature",true); put(pos(1,2,0),"oak_leaves_winter")
assert(math.abs(sim.habitat(p).canopy-0.1725)<0.0001)
end)
test("species growth checks the full five-by-five-by-six target volume",function()
for _,obstacle in ipairs({"foreign","placed","claimed","unloaded","external"}) do
reset(); diverse(); baseline("birch_young",86300)
local q=pos(2,6,0)
if obstacle=="foreign" then put(q,"wall") end
if obstacle=="placed" then metadata(q):set_string("player_placed","1") end
if obstacle=="claimed" then claims[key(q)]=true end
if obstacle=="unloaded" then missing[key(q)]=true end
if obstacle=="external" then protected[key(q)]=true end
advance(100); assert(name(p)=="hearth:birch_young",obstacle)
end
end)
test("forest, acidic, alpine and saturated soils recruit live understory without hours of waiting",function()
local cases={{"podzol",15,30,"heather"},{"alluvium",15,90,"sedge"},
{"peat",15,90,"sedge"},{"forest_floor",8,70,"moss"},{"alpine_turf",15,30,"heather"},
{"granite",15,10,"lichen"}}
for _,c in ipairs(cases) do
reset(); diverse(); put(pos(0,0,0),c[1]); light,humidity=c[2],c[3]
baseline(); advance(100); advance(100)
assert(name(p)=="hearth:"..c[4],c[1].." got "..name(p))
assert(name(pos(0,0,0))=="hearth:"..c[1],"never rewrite supporting terrain")
end
end)
test("wet understory recruits willow and alder instead of silently turning into oak",function()
for _,c in ipairs({{"alluvium","willow"},{"peat","alder"},{"podzol","pine"}}) do
reset(); diverse(); put(pos(0,0,0),c[1]); humidity=c[1]=="podzol" and 30 or 90
baseline(c[1]=="podzol" and "heather" or "sedge",7100); advance(100)
assert(name(p)=="hearth:"..c[2].."_seedling",name(p))
end
end)
test("scheduler adopts worldgen herbs but never player blocks or placed species trees",function()
diverse(); put(p,"clover"); baseline(); advance(100)
assert(metadata(p):get_string("succ_natural")=="1" and field(p,"succ_age")==100)
for _,k in ipairs({"birch_log","birch_leaves","birch_mature"}) do
reset(); diverse(); put(p,k)
if k=="birch_mature" then metadata(p):set_string("player_placed","1") end
baseline(); advance(100)
assert(metadata(p):get_string("succ_natural")=="" and field(p,"succ_age")==0,k)
end
end)
test("clover fixes only two finite nitrogen units per support across restart and recolonization",function()
diverse(); baseline("clover",900)
for _=1,5 do advance(100) end
local support=pos(0,0,0)
assert(field(support,"succ_nutrients")==2 and field(support,"succ_fixed_nitrogen")==2)
metadata(support):set_string("succ_nutrients","0")
load(); sim.step(1); advance(100)
assert(field(support,"succ_nutrients")==0)
metadata(p):set_string("succ_natural",""); put(p,"clover"); advance(100)
assert(field(support,"succ_nutrients")==0 and field(support,"succ_fixed_nitrogen")==2)
end)
test("natural clover and sedge feed wildlife but dormant variants, flowers and player plants do not",function()
diverse(); put(p,"clover",true); assert(sim.forage(p))
put(pos(1,0,0),"alluvium"); put(pos(1),"sedge",true); assert(sim.forage(p))
season="Winter"; put(p,"clover_winter",true); put(pos(1),"sedge_winter",true)
assert(not sim.forage(p))
season="Summer"; put(p,"bluebell",true); put(pos(1),"clover")
metadata(pos(1)):set_string("succ_natural",""); assert(not sim.forage(p))
end)
test("habitat exposes bounded nutrients and pollination without changing metadata",function()
diverse(); put(p,"clover",true); put(pos(1),"wildflowers",true)
metadata(pos(0,0,0)):set_string("succ_nutrients","999999")
local h=sim.habitat(p)
assert(h.nutrients==8 and h.pollination==2 and h.edible==1 and h.ground=="grass")
assert(field(pos(0,0,0),"succ_nutrients")==999999 and writes==0)
end)
test("species foliage respects the same total eight-column budget and ninety-six appearance swaps",function()
diverse(); season="Autumn"; real_positions=true
api.get_node_or_nil=function(q) reads=reads+1; return world[key(q)] or {name="hearth:alder_leaves"} end
assert(sim.step(999999)==8 and writes==96)
end)
test("species seeds respect source, destination and support authority without empty-name claim access",function()
for _,mode in ipairs({"source","destination","support","external","marker","ambiguous"}) do
reset(); diverse(); baseline("birch_mature",2000)
hearth.claim_allowed=function() error("forbidden empty-name player access") end
local seed=pos(-1,1,0)
if mode=="source" then claims[key(p)]=true end
if mode=="destination" then claims[key(seed)]=true end
if mode=="support" then claims[key(pos(-1,0,0))]=true end
if mode=="external" then protected[key(seed)]=true end
if mode=="marker" then metadata(seed):set_string("third_party:placed","1") end
if mode=="ambiguous" then hearth.claim_owner_at=function(q) return nil,key(q)==key(seed) end end
metadata(p):set_string("succ_serial","9"); advance(100)
assert(name(seed)~="hearth:birch_seedling",mode)
end
end)
test("species age pauses across winter unload and restart without losing identity",function()
diverse(); baseline("oak_seedling",21500)
season,temperature="Winter",-5; advance(100)
assert(name(p)=="hearth:oak_seedling_winter" and field(p,"succ_age")==21500)
load(); advance(100); assert(field(p,"succ_age")==21500)
lbm.action(p); season,temperature="Spring",15; advance(100)
assert(name(p)=="hearth:oak_seedling_spring" and field(p,"succ_age")==21500)
advance(100)
assert(name(p)=="hearth:oak_young_spring" and metadata(p):get_string("succ_species")=="oak")
end)
local function catalogue()
hearth.diversity=dofile(target:gsub("[^/\\]+$","diversity.lua"))(hearth,api)
hearth.botany=dofile(target:gsub("[^/\\]+$","botany.lua"))(hearth,api)
hearth.species_effects=dofile(target:gsub("[^/\\]+$","species_effects.lua"))(hearth,api)
hearth.biomes=dofile(target:gsub("[^/\\]+$","biomes.lua"))(hearth,api,function() end,{})
-- Exercise one plot per frame; the other seven still consume the global
-- budget but have no valid player position. Separate tests cover fairness.
api.get_connected_players=function()
return {{get_pos=function()
local index=probes%625; local visit=probes%8==0; probes=probes+1
if not visit then return nil end
return pos(p.x+12-index%25,p.y,p.z+12-math.floor(index/25))
end}}
end
load()
end
local function vector(r)
local n=r.niche
local soil=next(n.substrates)
if n.aquatic=="surface" or n.aquatic=="submerged" then soil="alluvium" end
return {loaded=true,ground=soil,substrate=r.id=="hornwort" and "water" or soil,
light=(n.light[1]+n.light[2])/2,moisture=(n.moisture[1]+n.moisture[2])/2,
ph=(n.ph[1]+n.ph[2])/2,water=n.water[1],aquatic=n.aquatic,freshwater=true,
nutrients=2,host=n.hosts and next(n.hosts) or nil,canopy=0,grass=0,flowers=0,nursery=0,root_fungi={}}
end
local function support(q,h)
local b=pos(q.x,q.y-1,q.z)
if h.water>0 then
for i=1,h.water do put(pos(q.x,q.y-i,q.z),"water_source") end
put(pos(q.x,q.y-h.water-1,q.z),h.ground)
elseif h.substrate=="bark" then
put(b,(h.host or "oak").."_young",true)
elseif h.substrate=="deadwood" or h.substrate=="litter" then
put(b,h.substrate,true); metadata(b):set_string("succ_species",h.host or "oak")
else put(b,h.ground) end
end
local function supplied_habitat(h)
-- Controlled niche vectors isolate the integration handoff from the
-- independently tested real bounded habitat sampler. Selection is REAL.
sim.habitat=function(q)
local out={}; for k,v in pairs(h) do out[k]=v end
if q.y~=p.y then out.loaded=false end
return out
end
end
test("ecology80 recruits every real returned candidate, spreads it and completes its finite lifecycle",function()
catalogue()
local records=hearth.botany.list()
for index,r in ipairs(records) do
reset(); catalogue()
local h=vector(r)
season=next(r.seasons)
-- Exact position-hash start at this record, also meeting the old
-- woody-gap gate. No fake choose(), random rerolls or ID aliases.
local x=0
while (x*17+7)%80~=index-1 or (x*7)%23~=0 do x=x+1; assert(x<1840) end
p=pos(x,1,0); support(p,h); supplied_habitat(h)
baseline(nil,7100); advance(100)
local candidate=r.group=="trees" and r.id.."_seedling" or r.id
assert(hearth.seasons.kind(name(p))==candidate,r.id.." recruit got "..name(p))
assert(metadata(p):get_string("succ_natural")=="1",r.id)
local q=pos(p.x,p.y,p.z-1); support(q,h)
metadata(p):set_string("succ_age","1000"); metadata(p):set_string("succ_serial","2")
if r.group=="trees" then
-- A mature parent disperses the same species seed, not a
-- generic oak. The subsequent expansion uses real block plans.
put(p,r.id.."_mature",true); metadata(p):set_string("succ_age","2000"); metadata(p):set_string("succ_serial","9")
q=pos(p.x-1,p.y,p.z); support(q,h); advance(100)
assert(hearth.seasons.kind(name(q))==candidate,r.id.." tree spread")
put(q,"air"); metadata(q):set_string("succ_natural","")
put(p,candidate,true); metadata(p):set_string("succ_age","21600"); advance(100)
assert(metadata(p):get_string("succ_tree_stage")=="young",r.id.." young got "..name(p).." age "..field(p,"succ_age").." stress "..field(p,"succ_stress"))
metadata(p):set_string("succ_age","86400"); advance(100)
assert(metadata(p):get_string("succ_tree_stage")=="mature",r.id.." mature")
metadata(p):set_string("succ_age","259200"); advance(100)
assert(name(p)=="hearth:deadwood" and world[key(p)].param2==12,r.id.." fallen")
for id,n in pairs(world) do
if id~=key(p) then assert(metadata((function()
local a,b,c=id:match("([^:]+):([^:]+):([^:]+)"); return pos(tonumber(a),tonumber(b),tonumber(c))
end)()):get_string("succ_component")=="",r.id.." orphan component "..n.name) end
end
else
advance(100)
assert(hearth.seasons.kind(name(q))==candidate,r.id.." spread got "..name(q))
metadata(p):set_string("succ_age","1000000"); advance(100)
assert(name(p)=="air" or name(p)=="hearth:litter",r.id.." senescence")
if name(p)=="hearth:litter" then
metadata(p):set_string("succ_age","1800"); advance(100); assert(name(p)=="air",r.id.." recycling")
end
end
end
end)
test("real habitat distinguishes bank depth, source columns, pH, host and bark without writes",function()
catalogue()
put(pos(1,0,0),"water_source"); put(pos(0,0,0),"alluvium"); humidity=90
local h=sim.habitat(p)
assert(h.water==0 and h.near_water==1 and h.aquatic=="bank" and h.ph==6.5)
put(pos(0,0,0),"water_source"); put(pos(0,-1,0),"water_source"); put(pos(0,-2,0),"alluvium")
h=sim.habitat(p)
assert(h.water==2 and h.ground=="alluvium" and h.substrate=="water" and h.freshwater and h.moisture==1)
put(p,"hornwort",true); assert(sim.habitat(p).aquatic=="submerged")
put(pos(0,0,0),"deadwood",true); metadata(pos(0,0,0)):set_string("succ_species","birch")
h=sim.habitat(p); assert(h.substrate=="deadwood" and h.host=="birch")
put(pos(0,0,0),"oak_young",true)
h=sim.habitat(p); assert(h.substrate=="bark" and h.host=="oak" and h.water==0)
metadata(pos(0,0,0)):set_string("player_placed","1")
assert(not sim.habitat(p).loaded and writes==0)
end)
test("real source-water recruitment and submerged stand decay never clear or refill water",function()
for _,mode in ipairs({"surface","submerged"}) do
reset(); catalogue(); p=pos(mode=="surface" and 0 or 1,1,0)
put(pos(p.x,0,0),"water_source"); put(pos(p.x,-1,0),"alluvium")
baseline(); advance(100); advance(100)
local r=hearth.botany.get(name(p)); assert(r and r.group=="aquatic" and r.niche.aquatic==mode)
assert(name(pos(p.x,0,0))=="hearth:water_source")
metadata(p):set_string("succ_age","21600"); advance(100)
assert(name(p)=="air" and name(pos(p.x,0,0))=="hearth:water_source")
assert(field(pos(p.x,-1,0),"succ_nutrients")==0)
end
end)
test("water placement denies flowing, too deep, unloaded, marked, claimed and protected columns",function()
for _,mode in ipairs({"flowing","deep","unloaded","marked","claimed","external","bottom"}) do
reset(); catalogue()
put(pos(0,0,0),"water_source"); put(pos(0,-1,0),"alluvium")
if mode=="flowing" then put(pos(0,0,0),"water_flowing") end
if mode=="deep" then for y=0,-6,-1 do put(pos(0,y,0),"water_source") end end
if mode=="unloaded" then missing[key(pos(0,-1,0))]=true end
if mode=="marked" then metadata(pos(0,0,0)):set_string("placed_by","builder") end
if mode=="claimed" then claims[key(pos(0,0,0))]=true end
if mode=="external" then protected[key(pos(0,0,0))]=true end
if mode=="bottom" then claims[key(pos(0,-1,0))]=true end
baseline(); advance(100); advance(100)
assert(name(p)=="air" and writes==0,mode)
end
end)
test("real block plans expand atomically, survive restart and collapse only owned cubes",function()
catalogue(); humidity=45
baseline("birch_seedling",21600); advance(100)
assert(metadata(p):get_string("succ_tree_stage")=="young")
local count=0
for _,c in ipairs(hearth.diversity.tree_plan("birch","young")) do
local q=pos(p.x+c.x,p.y+c.y,p.z+c.z)
assert(metadata(q):get_string("succ_component")==key(p)); count=count+1
end
assert(count>10)
load(); sim.step(1); metadata(p):set_string("succ_age","86400"); advance(100)
assert(metadata(p):get_string("succ_tree_stage")=="mature")
local before=writes
local q=pos(0,4,0); metadata(q):set_string("owner","builder")
metadata(p):set_string("succ_age","259200"); advance(100)
assert(name(p)=="hearth:birch_mature" and writes==before)
metadata(q):set_string("owner",""); advance(100)
assert(name(p)=="hearth:deadwood" and world[key(p)].param2==12)
assert(name(pos(0,7,0))=="air")
end)
test("every tree destination and its support fail closed before any plan write",function()
for _,mode in ipairs({"foreign","natural_plant","marker","claim","external","unloaded","support"}) do
reset(); catalogue(); humidity=45; baseline("oak_seedling",21600)
local q=pos(0,4,0)
if mode=="foreign" then put(q,"wall") end
if mode=="natural_plant" then put(q,"yarrow",true) end
if mode=="marker" then metadata(q):set_string("player_placed","1") end
if mode=="claim" then claims[key(q)]=true end
if mode=="external" then protected[key(q)]=true end
if mode=="unloaded" then missing[key(q)]=true end
if mode=="support" then claims[key(pos(0,3,0))]=true end
advance(100)
assert(name(p)=="hearth:oak_seedling" and writes==0,mode)
end
end)
test("chopped ecology root cleans matching components but never mapgen or foreign timber",function()
catalogue(); humidity=45; baseline("oak_seedling",21600); advance(100)
local old={name=name(p)}; put(p,"air"); now=now+1
assert(sim.tree_removed(p,old)); assert(name(pos(0,3,0))=="air")
put(pos(0,3,0),"oak_log"); now=now+1
assert(not sim.tree_removed(p,{name="hearth:oak_young"}))
assert(name(pos(0,3,0))=="hearth:oak_log")
end)
test("catalogue nitrogen caps survive restart and root fungi debit existing soil on faster growth",function()
catalogue(); humidity=45; baseline("clover",1000)
for _=1,5 do advance(100) end
assert(field(pos(0,0,0),"succ_fixed_nitrogen")==2 and field(pos(0,0,0),"succ_nutrients")==2)
load(); sim.step(1); advance(100); assert(field(pos(0,0,0),"succ_nutrients")==2)
reset(); catalogue(); humidity=45; put(pos(0,0,0),"forest_floor")
put(pos(1),"birch_bolete",true); metadata(pos(0,0,0)):set_string("succ_nutrients","1")
baseline("birch_seedling",16100); advance(100)
assert(name(p)=="hearth:birch_young" and field(pos(0,0,0),"succ_nutrients")==0)
reset(); catalogue(); humidity=45; put(pos(1),"birch_bolete",true)
metadata(pos(0,0,0)):set_string("succ_nutrients","1")
baseline("oak_seedling",16100); advance(100)
assert(name(p)=="hearth:oak_seedling" and field(pos(0,0,0),"succ_nutrients")==1)
end)
test("saprotrophs age existing deadwood without minting nutrients or attacking natural or player trees",function()
catalogue(); humidity=70; light=10
put(pos(0,0,0),"deadwood",true); metadata(pos(0,0,0)):set_string("succ_species","birch")
baseline("birch_polypore",1000); advance(100)
assert(field(pos(0,0,0),"succ_age")>0 and field(pos(0,0,0),"succ_nutrients")==0)
assert(field(p,"succ_stress")==0)
metadata(pos(0,0,0)):set_string("owner","builder"); local age=field(pos(0,0,0),"succ_age")
advance(100); assert(field(pos(0,0,0),"succ_age")==age)
reset(); catalogue(); humidity=70; light=10
put(pos(0,0,0),"deadwood",true); metadata(pos(0,0,0)):set_string("succ_species","oak")
baseline("birch_polypore",1000); advance(100)
assert(field(pos(0,0,0),"succ_age")==0,"birch-only fungus must not accelerate oak decay")
end)
test("flowering, browse and nursery use botanical tags while harvested bushes retain cooldowns",function()
catalogue(); humidity=45; put(p,"blackberry",true); put(pos(1),"yarrow",true)
local h=sim.habitat(p); assert(h.pollination==2 and h.edible==1 and h.nursery==1)
metadata(p):set_string("botany_ready_at",tostring(now+86400))
baseline(nil,1000); advance(100); assert(field(p,"succ_age")==1100 and field(p,"botany_ready_at")>now)
assert(sim.forage(p)); assert(name(p)=="air" and field(p,"botany_ready_at")>now)
put(p,"foxglove",true); assert(not sim.forage(p))
season="Spring"; put(p,"rowan_mature",true)
metadata(p):set_string("succ_component",key(p)); metadata(p):set_string("succ_tree_stage","mature")
put(pos(1),"rowan_leaves",true); metadata(pos(1)):set_string("succ_component",key(p))
h=sim.habitat(p)
assert(h.pollination==1 and h.nursery==1 and h.edible==1,"one managed root, not a benefit per leaf")
end)
test("one global tree-plan reservation bounds eight concurrent roots and malformed geometry writes nothing",function()
catalogue(); humidity=45
local positions={pos(0),pos(20),pos(40),pos(60),pos(80),pos(100),pos(120),pos(140)}
local i,visits=0,0
api.get_connected_players=function()
return {{get_pos=function()
i=i%8+1
local index=visits%625; visits=visits+1
local root=positions[i]
return pos(root.x+12-index%25,1,root.z+12-math.floor(index/25))
end}}
end
for _,root in ipairs(positions) do put(root,"oak_seedling",true) end
sim.step(1)
for _,root in ipairs(positions) do metadata(root):set_string("succ_age","21600") end
local calls=0; local original=hearth.diversity.tree_plan
hearth.diversity.tree_plan=function(...) calls=calls+1; return original(...) end
advance(100); assert(calls==1)
local grown=0
for _,root in ipairs(positions) do if metadata(root):get_string("succ_tree_stage")=="young" then grown=grown+1 end end
assert(grown==1 and writes<=sim.MAX_TREE_CELLS)
for _,mode in ipairs({"missing","oversize","foreign","duplicate","offset"}) do
reset(); catalogue(); humidity=45; baseline("oak_seedling",21600)
hearth.diversity.tree_plan=function()
if mode=="missing" then return nil end
local plan={{x=0,y=0,z=0,name="hearth:oak_young"}}
if mode=="oversize" then for _=1,sim.MAX_TREE_CELLS do plan[#plan+1]=plan[1] end end
if mode=="foreign" then plan[1].name="foreign:log" end
if mode=="duplicate" then plan[2]=plan[1] end
if mode=="offset" then plan[1].x=100 end
return plan
end
advance(100); assert(name(p)=="hearth:oak_seedling" and writes==0,mode)
end
end)
test("water stands and botanical understory retain loaded-only ages across restart and reload",function()
for _,plant in ipairs({"duckweed","yarrow"}) do
reset(); catalogue(); humidity=45
if plant=="duckweed" then put(pos(0,0,0),"water_source"); put(pos(0,-1,0),"alluvium") end
baseline(plant,1000); advance(864000); assert(field(p,"succ_age")==1000)
load(); advance(100); assert(field(p,"succ_age")==1000)
lbm.action(p); advance(100); assert(field(p,"succ_age")==1000)
advance(100); assert(field(p,"succ_age")==1100)
end
end)
test("real moss mat sampler changes local moisture and pH once without map or metadata writes",function()
catalogue(); humidity=80; light=10
assert(hearth.botany.suitable("bracken",sim.habitat(p),"Summer"))
put(pos(1,0,0),"alluvium"); put(pos(1),"sphagnum",true)
local before=sim.habitat(p)
assert(before.species_counts.sphagnum==1)
assert(not hearth.botany.suitable("bracken",before,"Summer"),"retention changes a real neighbour niche")
assert(math.abs(before.moisture-before.source_moisture-0.1)<0.000001)
assert(math.abs(before.ph-6.3)<0.000001 and before.source_ph==6.5)
put(pos(-1,0,0),"peat"); put(pos(-1),"sphagnum",true)
put(pos(0,0,1),"peat"); put(pos(0,1,1),"sphagnum",true)
local function snapshot()
local out={}; for id,f in pairs(fields) do for k,v in pairs(f) do out[#out+1]=id..k..v end end
table.sort(out); return table.concat(out,"|")
end
local h=sim.habitat(p); local saved=snapshot()
for _=1,5 do
h=sim.habitat(p)
assert(h.species_counts.sphagnum==3 and math.abs(h.ph-6.1)<0.000001)
assert(math.abs(h.moisture-h.source_moisture-0.2)<0.000001)
end
assert(snapshot()==saved and writes==0 and name(pos(1,0,0))=="hearth:alluvium")
end)
test("moss effects exclude planted protected claimed unsupported and unloaded sources",function()
for _,mode in ipairs({"planted","protected","claimed","support","unsupported","unloaded"}) do
reset(); catalogue(); light=10
local q=pos(1); put(pos(1,0,0),"peat"); put(q,"sphagnum",true)
if mode=="planted" then metadata(q):set_string("player_placed","1") end
if mode=="protected" then protected[key(q)]=true end
if mode=="claimed" then claims[key(q)]=true end
if mode=="support" then claims[key(pos(1,0,0))]=true end
if mode=="unsupported" then put(pos(1,0,0),"air") end
if mode=="unloaded" then missing[key(q)]=true end
local h=sim.habitat(p)
assert(not h.species_counts.sphagnum and h.ph==6.5 and writes==0,mode)
end
end)
test("real floating duckweed shades submerged neighbours but not itself or land and preserves water",function()
catalogue(); light=12
put(pos(0,0,0),"water_source"); put(pos(0,-1,0),"alluvium"); put(p,"hornwort",true)
for _,x in ipairs({-1,1,2}) do
put(pos(x,0,0),"water_source"); put(pos(x,-1,0),"alluvium"); put(pos(x),"duckweed",true)
end
local h=sim.habitat(p)
assert(h.aquatic=="submerged" and h.species_counts.duckweed==3 and h.light==7.5 and h.source_light==12)
assert(sim.habitat(pos(1)).light==12)
assert(sim.habitat(pos(0,1,1)).light==12)
season="Winter"; assert(sim.habitat(p).light==12)
season="Summer"; protected[key(pos(1))]=true
assert(sim.habitat(p).light==9)
for _,x in ipairs({-1,0,1,2}) do assert(name(pos(x,0,0))=="hearth:water_source") end
assert(writes==0)
end)
test("alder all living stages and dog lichen share the durable finite nitrogen budget",function()
for _,id in ipairs({"alder_seedling","alder_young","alder_mature","dog_lichen"}) do
reset(); catalogue()
local r=hearth.botany.get(id); local h=vector(r)
support(p,h); supplied_habitat(h); baseline(id,1000)
for _=1,5 do advance(100) end
local below=pos(0,0,0)
assert(field(below,"succ_fixed_nitrogen")==2 and field(below,"succ_nutrients")==2,id)
metadata(below):set_string("succ_nutrients","0")
load(); supplied_habitat(h); sim.step(1); advance(100)
assert(field(below,"succ_nutrients")==0,id.." reload")
put(p,"clover",true); supplied_habitat(vector(hearth.botany.get("clover")))
advance(100); assert(field(below,"succ_nutrients")==0,id.." shared cap")
end
end)
test("nitrogen never exceeds local four ceiling or writes through source or support protection",function()
for _,mode in ipairs({"fraction","full","source","support"}) do
reset(); catalogue(); humidity=45
local below=pos(0,0,0)
metadata(below):set_string("succ_nutrients",mode=="fraction" and "3.75" or (mode=="full" and "8" or "0"))
baseline("clover",1000)
if mode=="source" then protected[key(p)]=true end
if mode=="support" then claims[key(below)]=true end
advance(100)
local expected=mode=="fraction" and 4 or (mode=="full" and 8 or 0)
assert(field(below,"succ_nutrients")==expected,mode)
assert(field(below,"succ_fixed_nitrogen")== (mode=="fraction" and 0.25 or 0),mode)
end
end)
test("multiple host fungi cannot copy nutrients and exhausted soil cannot fund early growth",function()
for _,budget in ipairs({0,1}) do
reset(); catalogue(); humidity=45
put(pos(0,0,0),"forest_floor"); metadata(pos(0,0,0)):set_string("succ_nutrients",budget)
put(pos(1),"fly_agaric",true); put(pos(-1),"birch_bolete",true)
baseline("birch_seedling",16100); advance(100)
assert(field(pos(0,0,0),"succ_nutrients")==0)
assert(name(p)==(budget==1 and "hearth:birch_young" or "hearth:birch_seedling"))
advance(100); assert(field(pos(0,0,0),"succ_nutrients")==0)
end
end)
test("birch wood fungus exhausts supporting deadwood then litter with exactly one conserved payout each",function()
catalogue(); humidity=70; light=10
put(p,"deadwood",true); metadata(p):set_string("succ_species","birch")
put(pos(0,2,0),"birch_polypore",true)
baseline(nil,7100); advance(100)
assert(name(p)=="hearth:litter" and name(pos(0,2,0))=="air")
assert(field(pos(0,0,0),"succ_nutrients")==3)
metadata(p):set_string("succ_age","1800"); advance(100)
assert(name(p)=="air" and field(pos(0,0,0),"succ_nutrients")==4)
advance(60); assert(field(pos(0,0,0),"succ_nutrients")==4)
end)
os.time=original_time
print("LAST_HEARTH_SUCCESSION_PASS "..passed)
+537
View File
@@ -0,0 +1,537 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as fs from 'node:fs/promises';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { realpath } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { packageGame, readRelease, SOURCE, verifyPackage } from '../scripts/package.mjs';
import { backupWorld, restoreWorld, verifyBackup } from '../scripts/backup.mjs';
import { absolute, atomicDirectory, disjoint, exists, inventory, MANIFEST, relativeName, withLauncherLock } from '../scripts/files.mjs';
import { assertEngineVersion, coordinates, engineExecutable, environmentSettings, fixtureSettings, graphicsSettings, parseEngineVersion, prepareCourse, runtimeHome, snapshotBeforePlay, validateEngine } from '../scripts/launcher.mjs';
import { writeWorld } from '../scripts/run.mjs';
import { isTerrainSetting, terrainSettings } from '../scripts/terrain.mjs';
const exec = promisify(execFile);
const cli = path.join(SOURCE, 'scripts/run.mjs');
const binary = Buffer.from([0, 255, 254, 128, 10, 13, 0, 42]);
async function temporary(t) {
// macOS /var is normally a symlink; resolve the OS-owned temp root before
// testing the deliberately symlink-rejecting filesystem API.
const root = await fs.mkdtemp(path.join(await promisify(realpath)(os.tmpdir()), 'hearth-tooling-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
return root;
}
async function sourceTree(root) {
const source = path.join(root, 'source');
await fs.mkdir(path.join(source, 'mods/hearth'), { recursive: true });
await fs.mkdir(path.join(source, 'tests/fixtures'), { recursive: true });
for (const filename of ['game.conf', 'minetest.conf', 'settingtypes.txt', 'LICENSE.txt', 'release.json', 'tests/fixtures/scenarios.json']) {
await fs.copyFile(path.join(SOURCE, filename), path.join(source, filename));
}
for (const name of ['init', 'model', 'runtime', 'content', 'encounters']) {
await fs.writeFile(path.join(source, 'mods/hearth', name + '.lua'), '-- fixture #abcdef\n');
}
await fs.writeFile(path.join(source, 'mods/hearth/mod.conf'), 'name = hearth\n');
await fs.writeFile(path.join(source, 'secret.env'), 'not part of payload\n');
await fs.writeFile(path.join(source, 'mods/hearth/data.png'), binary);
await fs.mkdir(path.join(source, 'mods/hearth/textures'));
await fs.writeFile(path.join(source, 'mods/hearth/textures/stale.png'), binary);
return source;
}
async function worldTree(root) {
const world = path.join(root, 'old-world');
await fs.mkdir(path.join(world, 'players/empty'), { recursive: true });
await fs.mkdir(path.join(world, 'copy'));
await fs.writeFile(path.join(world, 'world.mt'), 'gameid = last_hearth\nbackend = sqlite3\n');
await fs.writeFile(path.join(world, 'map.sqlite'), binary);
await fs.writeFile(path.join(world, 'players/player.bin'), binary);
return world;
}
function options(root) { return { offline: true, runtime: path.join(root, 'runtime') }; }
test('package twice: identical full inventory and manifest hashes, original assets, no engine', async t => {
const root = await temporary(t), source = await sourceTree(root);
const a = path.join(root, 'a'), b = path.join(root, 'b');
await packageGame(a, { source });
await packageGame(b, { source });
assert.deepEqual(await inventory(a), await inventory(b));
const hash = data => createHash('sha256').update(data).digest('hex');
assert.equal(hash(await fs.readFile(path.join(a, MANIFEST))), hash(await fs.readFile(path.join(b, MANIFEST))));
const manifest = await verifyPackage(a);
assert.ok(manifest.entries.some(entry => entry.path === 'release.json'));
for (const absent of ['scripts', 'tests', '.git', 'README.md', 'secret.env', 'mods/hearth/textures/stale.png']) assert.equal(await exists(path.join(a, absent)), false);
assert.deepEqual(await fs.readFile(path.join(a, 'mods/hearth/data.png')), binary);
assert.deepEqual((await fs.readFile(path.join(a, 'mods/hearth/textures/hearth_abcdef.png'))).subarray(0, 8), Buffer.from([137,80,78,71,13,10,26,10]));
assert.match(await fs.readFile(path.join(a, 'mods/hearth/models/hearth_person.obj'), 'utf8'), /^# Original Last Hearth/);
assert.equal((await readRelease(a)).engine.version, '5.18.0');
assert.equal((await readRelease(a)).version, '0.5.0-m4');
});
test('optional runtime fixture contract is copied verbatim', async t => {
const root = await temporary(t), source = await sourceTree(root);
const bytes = Buffer.from('{"owned_by":"runtime"}\n');
await fs.writeFile(path.join(source, 'tests/fixtures/config.json'), bytes);
await fs.writeFile(path.join(source, 'tests/fixtures/first-session.json'), bytes);
const destination = path.join(root, 'game');
await packageGame(destination, { source });
assert.deepEqual(await fs.readFile(path.join(destination, 'fixtures/config.json')), bytes);
assert.deepEqual(await fs.readFile(path.join(destination, 'fixtures/first-session.json')), bytes);
});
test('package refuses existing/unmanaged output without changing it', async t => {
const root = await temporary(t), source = await sourceTree(root);
const destination = path.join(root, 'shared');
await fs.mkdir(destination);
await fs.writeFile(path.join(destination, 'keep'), binary);
const before = await inventory(destination);
await assert.rejects(packageGame(destination, { source }), /overwrite/);
assert.deepEqual(await inventory(destination), before);
await assert.rejects(packageGame(path.join(source, 'mods/output'), { source }), /overlap/);
});
test('atomic build failure leaves no destination, staging directory or guard', async t => {
const root = await temporary(t), destination = path.join(root, 'new');
await assert.rejects(atomicDirectory(destination, async stage => {
await fs.writeFile(path.join(stage, 'partial'), binary);
throw new Error('injected failure');
}), /injected/);
assert.deepEqual(await fs.readdir(root), []);
});
test('package manifest rejects missing, extra, corrupted files and extra empty dirs', async t => {
const root = await temporary(t), source = await sourceTree(root);
for (const kind of ['missing', 'extra', 'corrupt', 'directory']) {
const destination = path.join(root, kind);
await packageGame(destination, { source });
if (kind === 'missing') await fs.unlink(path.join(destination, 'game.conf'));
if (kind === 'extra') await fs.writeFile(path.join(destination, 'extra'), binary);
if (kind === 'corrupt') await fs.appendFile(path.join(destination, 'game.conf'), 'bad');
if (kind === 'directory') await fs.mkdir(path.join(destination, 'empty-extra'));
await assert.rejects(verifyPackage(destination), /manifest mismatch/);
}
});
test('offline full backup/restore preserves binary bytes, directories and original world', async t => {
const root = await temporary(t), world = await worldTree(root);
const before = await inventory(world), backup = path.join(root, 'snapshot'), restored = path.join(root, 'restored');
await backupWorld(world, backup, options(root));
await verifyBackup(backup);
await restoreWorld(backup, restored, options(root));
assert.deepEqual(await inventory(world), before);
assert.deepEqual(await inventory(restored), before);
assert.deepEqual(await fs.readFile(path.join(restored, 'map.sqlite')), binary);
assert.equal(await exists(path.join(restored, MANIFEST)), false);
await assert.rejects(restoreWorld(backup, world, options(root)), /new world/);
await assert.rejects(backupWorld(world, backup, options(root)), /overwrite/);
assert.deepEqual(await inventory(world), before);
});
test('restore rejects corruption, missing/extra files, empty dirs and unsafe manifest paths atomically', async t => {
const root = await temporary(t), world = await worldTree(root), before = await inventory(world);
for (const kind of ['corrupt', 'missing', 'extra', 'directory', 'traversal']) {
const backup = path.join(root, 'backup-' + kind), restored = path.join(root, 'restore-' + kind);
await backupWorld(world, backup, options(root));
if (kind === 'corrupt') await fs.writeFile(path.join(backup, 'world/map.sqlite'), Buffer.from('broken'));
if (kind === 'missing') await fs.unlink(path.join(backup, 'world/map.sqlite'));
if (kind === 'extra') await fs.writeFile(path.join(backup, 'extra'), binary);
if (kind === 'directory') await fs.mkdir(path.join(backup, 'unexpected'));
if (kind === 'traversal') {
const filename = path.join(backup, MANIFEST), manifest = JSON.parse(await fs.readFile(filename, 'utf8'));
manifest.entries[0].path = '../escape';
await fs.writeFile(filename, JSON.stringify(manifest));
}
await assert.rejects(restoreWorld(backup, restored, options(root)));
assert.equal(await exists(restored), false);
assert.equal(await exists(restored + '.hearth-write-lock'), false);
}
assert.deepEqual(await inventory(world), before);
});
test('offline confirmation and own launcher lock required; active lease permits internal snapshot', async t => {
const root = await temporary(t), world = await worldTree(root), opt = options(root);
await assert.rejects(backupWorld(world, path.join(root, 'no-confirmation'), { runtime: opt.runtime }), /offline/);
await withLauncherLock(opt.runtime, async lease => {
await assert.rejects(backupWorld(world, path.join(root, 'blocked'), opt), /Launcher lock/);
await assert.rejects(backupWorld(world, path.join(root, 'forged'), { ...opt, lease: { runtime: opt.runtime } }), /Launcher lock/);
await backupWorld(world, path.join(root, 'internal'), { ...opt, lease });
});
assert.equal(await exists(path.join(opt.runtime, 'launcher.lock')), false);
});
test('world lock files and SQLite sidecars fail closed, env_meta remains normal data', async t => {
const root = await temporary(t), world = await worldTree(root);
await fs.writeFile(path.join(world, 'env_meta.txt'), 'normal engine metadata');
for (const name of ['world.lock', '.lock', 'world.mt.lock', 'map.sqlite-wal', 'players.sqlite-shm', 'auth.sqlite-journal']) {
await fs.writeFile(path.join(world, name), '');
await assert.rejects(backupWorld(world, path.join(root, 'blocked'), options(root)), /lock or sidecar/);
await fs.unlink(path.join(world, name));
}
await backupWorld(world, path.join(root, 'allowed'), options(root));
});
test('every managed play snapshots existing world without deleting prior snapshots or world data', async t => {
const root = await temporary(t), world = await worldTree(root), runtime = path.join(root, 'runtime');
await fs.mkdir(path.join(runtime, 'worlds'), { recursive: true });
const managed = path.join(runtime, 'worlds/last_hearth');
await fs.rename(world, managed);
const original = await inventory(managed);
await withLauncherLock(runtime, async lease => {
const first = await snapshotBeforePlay(runtime, lease);
await fs.appendFile(path.join(managed, 'world.mt'), 'some_new_setting = true\n');
const second = await snapshotBeforePlay(runtime, lease);
assert.notEqual(first, second);
assert.deepEqual(await inventory(path.join(first, 'world')), original);
assert.deepEqual(await inventory(path.join(second, 'world')), await inventory(managed));
assert.equal((await fs.readdir(path.join(runtime, 'backups'))).length, 2);
await verifyBackup(first);
await verifyBackup(second);
});
await restoreWorld((await fs.readdir(path.join(runtime, 'backups'))).map(name => path.join(runtime, 'backups', name)).sort()[0],
path.join(root, 'restored-before-migration'), options(root));
assert.deepEqual(await inventory(path.join(root, 'restored-before-migration')), original);
});
test('new play world is not snapshotted; nonempty unmanaged world fails closed', async t => {
const root = await temporary(t), runtime = path.join(root, 'runtime');
await withLauncherLock(runtime, async lease => {
assert.equal(await snapshotBeforePlay(runtime, lease), undefined);
assert.equal(await exists(path.join(runtime, 'backups')), false);
await fs.mkdir(path.join(runtime, 'worlds/last_hearth'), { recursive: true });
await fs.writeFile(path.join(runtime, 'worlds/last_hearth/keep'), binary);
await assert.rejects(snapshotBeforePlay(runtime, lease), /unmanaged/);
assert.deepEqual(await fs.readFile(path.join(runtime, 'worlds/last_hearth/keep')), binary);
});
});
test('symlink/junction input, parent and destination rejected', async t => {
const root = await temporary(t), source = await sourceTree(root), world = await worldTree(root);
const outside = path.join(root, 'outside');
await fs.mkdir(outside);
const link = path.join(root, 'linked');
// Windows junctions do not require developer mode or administrator privileges.
await fs.symlink(outside, link, process.platform === 'win32' ? 'junction' : 'dir');
await assert.rejects(packageGame(path.join(link, 'game'), { source }), /link|junction/);
await assert.rejects(packageGame(link, { source }), /link|junction/);
await fs.symlink(outside, path.join(source, 'mods/hearth/escape'), process.platform === 'win32' ? 'junction' : 'dir');
await assert.rejects(packageGame(path.join(root, 'game'), { source }), /link|junction/);
await fs.symlink(outside, path.join(world, 'escape'), process.platform === 'win32' ? 'junction' : 'dir');
await assert.rejects(backupWorld(world, path.join(root, 'snapshot'), options(root)), /link|junction/);
assert.deepEqual(await fs.readdir(outside), []);
});
test('absolute paths, portable names and overlap validation reject traversal', () => {
for (const name of ['../escape', '/absolute', 'a\\b', 'a/../b', 'a//b', 'C:ads', 'CON.txt', 'x.']) assert.throws(() => relativeName(name));
assert.throws(() => absolute('relative'));
assert.throws(() => absolute(path.join(os.tmpdir(), 'a') + path.sep + '..' + path.sep + 'b'));
assert.throws(() => disjoint(os.tmpdir(), path.join(os.tmpdir(), 'world')));
});
test('runtime defaults preserve legacy Windows world path and support HEARTH_HOME on all platforms', () => {
const home = path.resolve(os.tmpdir(), 'person'), local = path.join(home, 'Local');
const legacy = runtimeHome({ LOCALAPPDATA: local }, 'win32', home);
assert.equal(legacy, path.join(local, 'last-hearth-runtime'));
assert.equal(engineExecutable({ LOCALAPPDATA: local }, 'win32'), path.join(legacy, 'luanti-5.18.0-win64/bin/luanti.exe'));
for (const platform of ['win32', 'linux', 'darwin']) {
assert.equal(runtimeHome({ HEARTH_HOME: home }, platform), home);
assert.equal(engineExecutable({ HEARTH_ENGINE: process.execPath }, platform, home), process.execPath);
}
assert.match(runtimeHome({}, 'darwin', home), /Application Support/);
assert.equal(runtimeHome({ XDG_DATA_HOME: local }, 'linux', home), path.join(local, 'last-hearth-runtime'));
assert.throws(() => engineExecutable({}, 'linux', home), /HEARTH_ENGINE/);
assert.throws(() => engineExecutable({ HEARTH_ENGINE: 'relative' }, 'linux', home), /absolute/);
});
test('engine version parsing is exact and actual non-engine executable fails closed', async () => {
assert.equal(parseEngineVersion('Luanti 5.18.0 (Windows)\nUsing LuaJIT\n'), '5.18.0');
assert.equal(parseEngineVersion('Luanti 5.19.0\n'), '5.19.0');
assert.equal(assertEngineVersion('Luanti 5.18.0 (Windows)\n'), '5.18.0');
for (const version of ['5.17.0', '5.19.0', '5.18.1']) assert.throws(() => assertEngineVersion(`Luanti ${version}\n`), /version mismatch/);
for (const text of ['Luanti 5.18.0-dev', 'Minetest 5.18.0', 'prefix Luanti 5.18.0', 'Luanti 5.18.00x', 'v24.0.0']) assert.throws(() => parseEngineVersion(text));
await assert.rejects(validateEngine(process.execPath), /Unrecognized Luanti/);
});
test('fixture contract: first-session real clock, summer/winter fixed UTC/location/weather, production rejects', async () => {
assert.deepEqual(await fixtureSettings('first-session', 'local'), ['fixed_map_seed = 731104']);
for (const [name, utc, weather] of [['summer', 1782043200, 'clear'], ['winter', 1797854400, 'rain']]) {
const settings = await fixtureSettings(name, 'staging');
for (const expected of [`hearth_preview_date = ${utc}`, `hearth_preview_weather = ${weather}`, 'hearth_latitude = 45', 'hearth_longitude = 0']) assert.ok(settings.includes(expected));
}
await assert.rejects(fixtureSettings('summer', 'production'), /Production/);
await assert.rejects(fixtureSettings('__proto__', 'local'), /Unknown/);
assert.deepEqual(environmentSettings(), ['hearth_environment = production']);
assert.deepEqual(environmentSettings('staging', ['hearth_launcher_mode = smoke']), ['hearth_environment = staging', 'hearth_launcher_mode = smoke']);
for (const line of ['hearth_preview_date = 1', 'hearth_preview_weather = clear', 'hearth_launcher_mode = smoke', 'hearth_smoke_token = token']) assert.throws(() => environmentSettings('production', [line]), /Production/);
assert.throws(() => environmentSettings('local', ['a = 1\nb = 2']), /Invalid/);
assert.throws(() => coordinates({ HEARTH_LATITUDE: '45\nfoo=bar', HEARTH_LONGITUDE: '0' }));
});
test('release and npm commands retain M0-M3 tooling entry points', async () => {
const pkg = JSON.parse(await fs.readFile(path.join(SOURCE, 'package.json'), 'utf8'));
assert.equal(pkg.version, '0.5.0-m4');
assert.equal((await readRelease()).version, pkg.version);
assert.equal(pkg.scripts.course, 'node scripts/run.mjs course');
assert.equal(pkg.scripts['test:course'], 'node scripts/run.mjs test:course');
assert.equal(pkg.scripts['test:seeds'], 'node scripts/run.mjs test:seeds');
assert.equal(pkg.scripts.test, 'node --test tests/*.test.mjs');
assert.equal(pkg.scripts.play, 'node scripts/run.mjs play');
assert.equal(pkg.scripts['test:recovery'], 'node scripts/recovery.mjs');
});
test('course creates only fresh stage/course with matching exclusive UUID marker and summer fixture', async t => {
const root = await temporary(t), world = await worldTree(root);
const original = await inventory(world);
const stage = path.join(root, 'session'), secondStage = path.join(root, 'second-session');
await fs.mkdir(stage); await fs.mkdir(secondStage);
const first = await prepareCourse(stage);
const second = await prepareCourse(secondStage, { environment: 'staging' });
assert.equal(first.world, path.join(stage, 'course'));
assert.equal(path.basename(second.world), 'course');
assert.match(first.nonce, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
assert.notEqual(first.nonce, second.nonce);
assert.deepEqual(first.settings, [...await fixtureSettings('summer', 'local'),
'hearth_m1_course = true', `hearth_m1_course_nonce = ${first.nonce}`]);
assert.deepEqual(environmentSettings('local', first.settings).filter(line => /^hearth_environment/.test(line)), ['hearth_environment = local']);
for (const entry of [first, second]) {
assert.deepEqual(await fs.readdir(entry.world), ['.hearth-m1-course-nonce']);
assert.equal(await fs.readFile(path.join(entry.world, '.hearth-m1-course-nonce'), 'utf8'), entry.nonce + '\n');
assert.ok(entry.settings.includes('hearth_preview_date = 1782043200'));
assert.ok(entry.settings.includes('hearth_preview_weather = clear'));
assert.ok(entry.settings.includes('hearth_latitude = 45'));
assert.ok(entry.settings.includes('hearth_longitude = 0'));
}
const before = await inventory(stage);
await assert.rejects(prepareCourse(stage), /overwrite/);
assert.deepEqual(await inventory(stage), before);
assert.deepEqual(await inventory(world), original);
assert.equal(await exists(path.join(root, 'backups')), false);
});
test('course refuses existing empty/unmanaged worlds, linked stages, and production without mutation', async t => {
const root = await temporary(t), stage = path.join(root, 'session');
await fs.mkdir(path.join(stage, 'course'), { recursive: true });
await assert.rejects(prepareCourse(stage), /overwrite/);
await fs.writeFile(path.join(stage, 'course/keep'), binary);
const before = await inventory(stage);
await assert.rejects(prepareCourse(stage), /overwrite/);
for (const environment of ['production', 'invalid']) await assert.rejects(prepareCourse(stage, { environment }), /local\/staging/);
assert.deepEqual(await inventory(stage), before);
const link = path.join(root, 'linked-stage');
await fs.symlink(stage, link, process.platform === 'win32' ? 'junction' : 'dir');
await assert.rejects(prepareCourse(link), /link|junction/);
assert.deepEqual(await inventory(stage), before);
});
test('production rejects presence of all course, recovery, debug and launcher settings, including false/empty', () => {
for (const key of ['hearth_m1_course', 'hearth_m1_course_nonce', 'hearth_m1_course_snag',
'hearth_m1_course_future', 'hearth_recovery_phase', 'hearth_recovery_token', 'hearth_recovery_gameid',
'hearth_debug', 'hearth_debug_future', 'hearth_course_probe_token', 'hearth_launcher_mode', 'hearth_smoke_token']) {
for (const value of ['', 'false', '0', 'test']) {
const line = ` ${key} = ${value}`;
assert.throws(() => environmentSettings('production', [line]), /Production/, key);
assert.deepEqual(environmentSettings('staging', [line]), ['hearth_environment = staging', line]);
}
}
assert.deepEqual(environmentSettings('production', ['hearth_latitude = 45', 'hearth_longitude = 0']),
['hearth_environment = production', 'hearth_latitude = 45', 'hearth_longitude = 0']);
});
test('CLI package/verify work with absent engine and invalid runtime env; backup/restore offline CLI', async t => {
const root = await temporary(t), destination = path.join(root, 'game');
const env = { ...process.env, HEARTH_ENGINE: 'not-an-absolute-engine', HEARTH_HOME: 'not-a-runtime' };
const packaged = await exec(process.execPath, [cli, 'package', destination], { env, timeout: 90_000 });
assert.match(packaged.stdout, /PACKAGE_PASS/);
const verified = await exec(process.execPath, [cli, 'verify', destination], { env, timeout: 30_000 });
assert.match(verified.stdout, /PACKAGE_VERIFY_PASS/);
const world = await worldTree(root), backup = path.join(root, 'backup'), restored = path.join(root, 'restored');
env.HEARTH_HOME = path.join(root, 'runtime');
await exec(process.execPath, [cli, 'backup', world, backup, '--offline'], { env, timeout: 30_000 });
await exec(process.execPath, [cli, 'restore', backup, restored, '--offline'], { env, timeout: 30_000 });
assert.deepEqual(await inventory(world), await inventory(restored));
await assert.rejects(exec(process.execPath, [cli, 'backup', world, path.join(root, 'unconfirmed')], { env, timeout: 10_000 }), /OFFLINE/);
// Version check happens before creating any runtime data or touching the world.
env.HEARTH_ENGINE = process.execPath;
const before = await inventory(world);
await assert.rejects(exec(process.execPath, [cli, 'play'], { env, timeout: 10_000 }), /Unrecognized Luanti/);
for (const command of ['course', 'test:course']) {
await assert.rejects(exec(process.execPath, [cli, command], {
env: { ...env, HEARTH_FIXTURE: 'winter', HEARTH_LATITUDE: 'invalid' }, timeout: 10_000,
}), /Unrecognized Luanti/); // Course ignores inherited fixture/coordinates, but never engine pinning.
}
assert.deepEqual(await inventory(world), before);
assert.deepEqual(await fs.readdir(env.HEARTH_HOME), []);
});
test('new-world terrain preset is pure, explicit, bounded and matches packaged game defaults', async () => {
const first = terrainSettings(true);
assert.deepEqual(terrainSettings(false), []);
assert.throws(() => terrainSettings('true'), /boolean/);
assert.ok(first.includes('mapgen_limit = 4096'));
assert.ok(first.includes('mgv7_spflags = mountains,ridges,nofloatlands,caverns'));
assert.ok(first.includes('mgv7_np_terrain_base = 12, 45, (384, 384, 384), 82341, 5, 0.6, 2.0'));
assert.ok(first.includes('mgv7_np_mount_height = 250, 100, (700, 700, 700), 72449, 3, 0.6, 2.0'));
const defaults = (await fs.readFile(path.join(SOURCE, 'minetest.conf'), 'utf8'))
.split(/\r?\n/).filter(isTerrainSetting).sort();
assert.deepEqual(first.filter(line => !line.startsWith('fixed_map_seed')).sort(), defaults);
assert.equal(new Set(first.map(line => line.split('=')[0].trim())).size, first.length);
first[0] = 'mg_name = flat'; first.push('untrusted = true');
assert.equal(terrainSettings(true)[0], 'mg_name = v7');
for (const line of ['mg_name = v7', ' mgv7_np_terrain_base = 1', 'mg_biome_np_heat = 1',
'fixed_map_seed = 42', 'mapgen_limit = 512', 'water_level = 1']) assert.ok(isTerrainSetting(line), line);
assert.equal(isTerrainSetting('hearth_latitude = 45'), false);
});
test('world creation writes terrain once; subsequent ephemeral configs never retune or reseed saved worlds', async t => {
const root = await temporary(t), world = path.join(root, 'world'), config = path.join(root, 'first.conf');
await writeWorld(world, config, ['hearth_latitude = 45', 'hearth_longitude = 0'],
['fixed_map_seed = 42'], 'local');
const first = await fs.readFile(config, 'utf8');
for (const line of terrainSettings(true)) assert.ok(first.includes(line + '\n'), line);
assert.ok(first.indexOf('fixed_map_seed = 42') > first.indexOf('fixed_map_seed = 731104'));
const worldFile = await fs.readFile(path.join(world, 'world.mt'));
// An old world may have an entirely different mapgen, seed and boundary.
const saved = Buffer.from('mg_name = valleys\nseed = 99\nmapgen_limit = 512\n[end_of_params]\n');
await fs.writeFile(path.join(world, 'map_meta.txt'), saved);
const before = await inventory(world);
const secondConfig = path.join(root, 'second.conf');
await writeWorld(world, secondConfig, ['hearth_latitude = 45'], [
...await fixtureSettings('first-session', 'local'), 'mg_name = flat',
'mgv7_np_mountain = 1', 'water_level = 30', 'mapgen_limit = 4096',
], 'local');
const second = await fs.readFile(secondConfig, 'utf8');
assert.deepEqual(second.split(/\r?\n/).filter(isTerrainSetting), []);
assert.match(second, /hearth_latitude = 45/);
assert.match(second, /secure.enable_security = true/);
assert.deepEqual(await fs.readFile(path.join(world, 'world.mt')), worldFile);
assert.deepEqual(await fs.readFile(path.join(world, 'map_meta.txt')), saved);
assert.deepEqual(await inventory(world), before);
await assert.rejects(writeWorld(world, secondConfig, [], [], 'local'), /EEXIST/);
assert.deepEqual(await inventory(world), before);
});
test('world.mt without map metadata and orphan map metadata each suppress creation overrides', async t => {
const root = await temporary(t);
for (const name of ['world.mt', 'map_meta.txt']) {
const world = path.join(root, name === 'world.mt' ? 'legacy' : 'orphan');
await fs.mkdir(world);
const bytes = Buffer.from(name === 'world.mt' ? 'gameid = last_hearth\n' : 'seed = 123\n[end_of_params]\n');
await fs.writeFile(path.join(world, name), bytes);
const config = path.join(root, path.basename(world) + '.conf');
await writeWorld(world, config, [], ['fixed_map_seed = 731104'], 'local');
assert.deepEqual((await fs.readFile(config, 'utf8')).split(/\r?\n/).filter(isTerrainSetting), []);
assert.deepEqual(await fs.readFile(path.join(world, name)), bytes);
}
});
test('graphics preset defaults to 512 nodes with bounded full-detail send/generate distances', () => {
const expected = distance => [
`viewing_range = ${distance}`, `max_block_send_distance = ${Math.ceil(distance / 16) + 2}`,
`max_block_generate_distance = ${Math.ceil(distance / 16) + 2}`,
'client_mesh_chunk = 2', 'fog_start = 0.65',
];
assert.deepEqual(graphicsSettings({}), expected(512));
for (let distance = 128; distance <= 1024; distance++) {
const settings = graphicsSettings({ HEARTH_VIEW_DISTANCE: String(distance) });
assert.deepEqual(settings, expected(distance));
assert.ok(Number(settings[1].split('=')[1]) <= 66);
assert.equal(new Set(settings.map(line => line.split('=')[0].trim())).size, settings.length);
assert.deepEqual(settings.filter(isTerrainSetting), []);
assert.ok(settings.every(line => !/active|activation|culling|occlusion|simulation/.test(line)));
}
const changed = graphicsSettings({});
changed[0] = 'viewing_range = 1'; changed.push('untrusted = true');
assert.deepEqual(graphicsSettings({}), expected(512));
});
const invalidViewDistances = ['', '127', '1025', '4000', '0', '-512', '+512', '0512',
'512.0', '5e2', '0x200', 'NaN', 'Infinity', ' 512', '512 ', '512\n', '512\r',
'512\nactive_block_range = 100', '512\r\nsecure.enable_security = false',
'512; enable_occlusion_culling = false', '512', '9'.repeat(1000), null, 512, true, {}];
test('graphics rejects malformed, injected and out-of-range values before any world/config write', async t => {
const root = await temporary(t), existing = await worldTree(root);
const before = await inventory(existing);
for (const value of invalidViewDistances) {
const env = { HEARTH_VIEW_DISTANCE: value };
assert.throws(() => graphicsSettings(env), /HEARTH_VIEW_DISTANCE.*128.*1024/);
for (const world of [path.join(root, 'absent'), existing]) {
await assert.rejects(writeWorld(world, path.join(root, 'invalid.conf'), [], [], 'local', 'last_hearth', env),
/HEARTH_VIEW_DISTANCE.*128.*1024/);
}
assert.equal(await exists(path.join(root, 'absent')), false);
assert.equal(await exists(path.join(root, 'invalid.conf')), false);
assert.deepEqual(await inventory(existing), before);
}
});
test('play/course/test configs include unique graphics keys for new and existing worlds without mapgen rewrite', async t => {
const root = await temporary(t);
for (const mode of ['play', 'course', 'test']) {
for (const distance of [512, 768, 1024]) {
for (const existing of [false, true]) {
const directory = path.join(root, `${mode}-${distance}-${existing}`);
await fs.mkdir(directory);
const world = path.join(directory, 'world'), config = path.join(directory, `${mode}.conf`);
if (existing) {
await fs.mkdir(world);
await fs.writeFile(path.join(world, 'world.mt'), 'gameid = last_hearth\nbackend = sqlite3\n');
await fs.writeFile(path.join(world, 'map_meta.txt'), 'mg_name = valleys\nseed = 99\nmapgen_limit = 512\n[end_of_params]\n');
await fs.writeFile(path.join(world, 'map.sqlite'), binary);
}
const before = existing ? await inventory(world) : undefined;
const env = distance === 512 ? {} : { HEARTH_VIEW_DISTANCE: String(distance) };
const extra = mode === 'course' ? [...await fixtureSettings('summer', 'local'), 'hearth_m1_course = true']
: mode === 'test' ? ['hearth_launcher_mode = check', 'max_users = 0'] : [];
await writeWorld(world, config, [], extra, mode === 'test' ? 'staging' : 'local', 'last_hearth', env);
const lines = (await fs.readFile(config, 'utf8')).split(/\r?\n/);
for (const setting of graphicsSettings(env)) {
assert.ok(lines.includes(setting), setting);
const key = setting.split('=')[0].trim();
assert.equal(lines.filter(line => line.split('=')[0].trim() === key).length, 1, key);
}
assert.ok(lines.includes('secure.enable_security = true'));
if (existing) {
assert.deepEqual(lines.filter(isTerrainSetting), []);
assert.deepEqual(await inventory(world), before);
} else {
for (const setting of terrainSettings(true)) assert.ok(lines.includes(setting), setting);
}
}
}
}
});
test('managed graphics overrides cannot duplicate preset keys or bypass the environment bounds', async t => {
const root = await temporary(t), world = path.join(root, 'world'), config = path.join(root, 'play.conf');
for (const line of graphicsSettings({})) {
for (const location of [false, true]) {
await assert.rejects(writeWorld(world, config, location ? [` ${line}`] : [],
location ? [] : [` ${line}`], 'local', 'last_hearth', {}), /launcher-managed/);
assert.equal(await exists(world), false);
assert.equal(await exists(config), false);
}
}
});
test('CLI validates graphics before any engine check or runtime/world writes in every launch mode', async t => {
const root = await temporary(t), runtime = path.join(root, 'runtime');
const env = { ...process.env, HEARTH_HOME: runtime, HEARTH_ENGINE: process.execPath,
HEARTH_VIEW_DISTANCE: '512\nsecure.enable_security = false' };
for (const mode of ['play', 'course', 'test:course', 'check', 'smoke', 'test', 'test:seeds']) {
await assert.rejects(exec(process.execPath, [cli, mode], { env, timeout: 10_000 }), error => {
assert.equal(error.code, 1);
assert.match(error.stderr, /HEARTH_VIEW_DISTANCE.*128.*1024/);
assert.doesNotMatch(error.stderr, /Unrecognized Luanti/);
return true;
});
assert.equal(await exists(runtime), false);
}
});
// Node-only tooling regression suite: never opens a GUI or the original world.
+308
View File
@@ -0,0 +1,308 @@
// Offline only. No test contacts Wikipedia or starts an engine.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { setImmediate } from 'node:timers/promises';
import { LIMITS, USER_AGENT, parseCatalogue, apiURL, allowedURL, fetchBatch,
entriesFromResponse, fetchEntries, excerpt, luaString, serializeBundle, parseBundle, main } from '../scripts/wiki-species.mjs';
const catalogue = parseCatalogue(await readFile(new URL('../mods/hearth/botany.lua', import.meta.url), 'utf8'));
const cached = parseBundle(await readFile(new URL('../mods/hearth/wiki_articles.lua', import.meta.url), 'utf8'));
const retrieved = '2026-11-03T08:00:00.000Z';
const record = catalogue[0];
const intro = 'Quercus robur is a species of flowering plant. This fixture is original test text, not a cached Wikipedia quotation.';
let mockedRequests = 0;
function page(title, changes = {}) {
return { pageid: 201415, ns: 0, title, extract: intro, canonicalurl: 'https://en.wikipedia.org/wiki/' + encodeURIComponent(title.replaceAll(' ', '_')),
lastrevid: 12345, revisions: [{ revid: 12345, timestamp: '2026-11-01T10:00:00Z' }],
contentmodel: 'wikitext', pagelanguage: 'en', ...changes };
}
function payload(pages = [page(record.latin)], changes = {}) {
return { batchcomplete: true, query: { pages, ...changes } };
}
function jsonResponse(data, headers = {}) {
return new Response(JSON.stringify(data), { headers: { 'content-type': 'application/json; charset=utf-8', ...headers } });
}
function mock(fn) { return async (...args) => { mockedRequests++; return fn(...args); }; }
test('bounded non-executing Lua parser finds the actual 80 Latin names and IDs', async () => {
assert.equal(catalogue.length, 80);
assert.equal(new Set(catalogue.map(r => r.id)).size, 80);
assert.deepEqual(catalogue.find(r => r.id === 'wood_blewit'), { id: 'wood_blewit', latin: 'Collybia nuda' });
const source = await readFile(new URL('../mods/hearth/botany.lua', import.meta.url), 'utf8');
for (const bad of [source.replace('Quercus robur', 'https://127.0.0.1/'),
source.replace('Quercus robur', 'Quercus robur|User:Private'),
source.replace('Quercus robur', 'Quercus robur&action=edit'),
source.replace('add("oak"', 'add("constructor"'),
source.replace('add("birch"', 'add("oak"'),
source.replace('"Betula pendula"', '"Quercus robur"'),
source.replace('add("oak",', 'add(variable,'), source + '\nadd("extra","Extra","Abies alba",',
source.repeat(10), 'os.execute("not executed")']) assert.throws(() => parseCatalogue(bad));
assert.equal(parseCatalogue(source + '\nos.execute("never evaluated")').length, 80);
});
test('URL construction rejects URL/query/title injection before any request', async () => {
for (const name of ['https://evil.example/', '//127.0.0.1/', '../Quercus robur',
'Quercus robur|Betula pendula', 'Quercus robur&action=edit', 'Quercus robur#x',
'Quercus robur\n', 'Quercus%20robur', 'User:Somebody', 'Quercus robur?x=1']) {
assert.throws(() => apiURL([name]));
await assert.rejects(fetchBatch([name], { fetchImpl: () => { throw new Error('must not fetch'); } }), /Invalid Latin/);
}
assert.throws(() => apiURL([]));
assert.throws(() => apiURL(Array(11).fill('Quercus robur')));
const u = apiURL(['Quercus robur', 'Collybia nuda']);
assert.equal(u.origin, 'https://en.wikipedia.org');
assert.equal(u.pathname, '/w/api.php');
assert.equal(u.searchParams.get('titles'), 'Quercus robur|Collybia nuda');
assert.equal(u.searchParams.get('explaintext'), '1');
assert.equal(u.searchParams.get('exintro'), '1');
assert.equal(u.searchParams.get('rvprop'), 'ids|timestamp');
});
test('strict HTTPS allowlist excludes credentials, ports, fragments and lookalike hosts', () => {
for (const u of ['http://en.wikipedia.org/wiki/X', 'https://en.wikipedia.org.evil.example/',
'https://en.wikipedia.org@evil.example/', 'https://user:pass@en.wikipedia.org/',
'https://en.wikipedia.org:8443/', 'https://en.wikipedia.org/#x',
'https://127.0.0.1/', 'https://en.m.wikipedia.org/', 'file:///etc/passwd',
'https://evil.example/?next=https://en.wikipedia.org']) assert.throws(() => allowedURL(u));
assert.equal(allowedURL('https://en.wikipedia.org/wiki/Quercus_robur').hostname, 'en.wikipedia.org');
});
test('built-in fetch contract uses manual redirects, omitted credentials and bounded signals', async () => {
const result = await fetchBatch([record.latin], { fetchImpl: mock((url, options) => {
assert.equal(new URL(url).searchParams.get('titles'), record.latin);
assert.equal(options.redirect, 'manual');
assert.equal(options.credentials, 'omit');
assert.equal(options.referrerPolicy, 'no-referrer');
assert.equal(options.headers['User-Agent'], USER_AGENT);
assert.match(USER_AGENT, /LastHearth.*offline/i);
assert.equal(Object.keys(options.headers).length, 2);
assert.ok(options.signal instanceof AbortSignal);
return jsonResponse(payload());
}) });
assert.equal(result.query.pages[0].pageid, 201415);
assert.equal(LIMITS.requestMs, 15000);
assert.equal(LIMITS.totalMs, 90000);
});
test('redirect SSRF and query changes are rejected without a second request', async () => {
for (const location of ['https://evil.example/', 'http://en.wikipedia.org/w/api.php',
'//127.0.0.1/admin', 'https://en.wikipedia.org@127.0.0.1/',
'/wiki/Quercus_robur', '/w/api.php?action=edit', 'https://u:p@en.wikipedia.org/w/api.php']) {
let calls = 0;
await assert.rejects(fetchBatch([record.latin], { fetchImpl: mock(() => {
calls++; return new Response(null, { status: 302, headers: { location } });
}) }));
assert.equal(calls, 1);
}
});
test('same allowed endpoint redirects are bounded to two, sharing one timeout', async () => {
let calls = 0;
const signals = new Set();
await assert.rejects(fetchBatch([record.latin], { fetchImpl: mock((url, options) => {
calls++; signals.add(options.signal);
return new Response(null, { status: 307, headers: { location: url } });
}) }), /Too many/);
assert.equal(calls, 3);
assert.equal(signals.size, 1);
});
test('unexpected automatic redirect response URL is rejected', async () => {
await assert.rejects(fetchBatch([record.latin], { fetchImpl: mock(() => {
const r = jsonResponse(payload());
Object.defineProperty(r, 'url', { value: 'https://evil.example/' }); return r;
}) }), /Only credential-free/);
});
test('HTTP errors, API warnings, malformed JSON and HTML are failures, not notes', async () => {
for (const r of [new Response('rate limit', { status: 429 }), new Response('error', { status: 503 }),
new Response('<html>error</html>', { headers: { 'content-type': 'text/html' } }),
new Response('{bad', { headers: { 'content-type': 'application/json' } }),
new Response(new Uint8Array([0xff]), { headers: { 'content-type': 'application/json' } })]) {
await assert.rejects(fetchBatch([record.latin], { fetchImpl: mock(() => r) }));
}
for (const bad of [{ error: { code: 'maxlag' } }, { ...payload(), warnings: { extracts: 'failure' } },
{ ...payload(), batchcomplete: false }, { query: { pages: [] } }]) {
assert.throws(() => entriesFromResponse([record], bad, retrieved));
}
});
test('advertised and streamed response limits both enforce 2 MiB', async () => {
await assert.rejects(fetchBatch([record.latin], { fetchImpl: mock(() => jsonResponse(payload(), {
'content-length': String(LIMITS.responseBytes + 1) })) }), /byte limit/);
let cancelled = false;
const stream = new ReadableStream({ start(c) { c.enqueue(new Uint8Array(LIMITS.responseBytes + 1)); }, cancel() { cancelled = true; } });
await assert.rejects(fetchBatch([record.latin], { fetchImpl: mock(() => new Response(stream, {
headers: { 'content-type': 'application/json' } })) }), /byte limit/);
assert.equal(cancelled, true);
});
test('aborting a stalled fetch or stalled response body is bounded and cancels body', async () => {
const a = new AbortController();
const request = fetchBatch([record.latin], { signal: a.signal, fetchImpl: mock(() => new Promise(() => {})) });
a.abort(new Error('bounded request')); await assert.rejects(request, /bounded request/);
const b = new AbortController(); let cancelled = false;
const body = new ReadableStream({ pull() { b.abort(new Error('bounded body')); }, cancel() { cancelled = true; } }, { highWaterMark: 0 });
await assert.rejects(fetchBatch([record.latin], { signal: b.signal, fetchImpl: mock(() => new Response(body, {
headers: { 'content-type': 'application/json' } })) }), /bounded body/);
assert.equal(cancelled, true);
});
test('source title must match exact Latin or a returned redirect chain; no guessed aliases', () => {
const r = { id: 'wood_blewit', latin: 'Collybia nuda' };
const data = payload([page('Lepista nuda')], { redirects: [{ from: 'Collybia nuda', to: 'Lepista nuda' }] });
const [e] = entriesFromResponse([r], data, retrieved);
assert.equal(e.latin, 'Collybia nuda'); assert.equal(e.title, 'Lepista nuda');
assert.equal(e.url, 'https://en.wikipedia.org/wiki/Lepista_nuda');
assert.equal(e.revision_url, 'https://en.wikipedia.org/w/index.php?oldid=12345');
assert.match(e.history_url, /title=Lepista_nuda&action=history$/);
assert.equal(e.attribution, 'Wikipedia contributors, "Lepista nuda"');
assert.equal(e.license, 'CC BY-SA 4.0');
assert.throws(() => entriesFromResponse([r], payload([page('Lepista nuda')]), retrieved), /Unverified/);
for (const redirects of [[{ from: r.latin, to: 'Lepista nuda', tofragment: 'Species' }],
[{ from: r.latin, to: 'Lepista nuda' }, { from: 'Lepista nuda', to: r.latin }],
[{ from: r.latin, to: 'Lepista nuda' }, { from: r.latin, to: 'Clitocybe nuda' }]]) {
assert.throws(() => entriesFromResponse([r], payload([page('Lepista nuda')], { redirects }), retrieved));
}
});
test('disambiguation, non-mainspace, language, title and canonical URL mismatch fail closed', () => {
for (const changes of [{ pageprops: { disambiguation: '' } }, { ns: 1 }, { invalid: true },
{ pagelanguage: 'fr' }, { contentmodel: 'json' }, { title: 'Quercus' },
{ canonicalurl: 'https://evil.example/wiki/Quercus_robur' },
{ canonicalurl: 'https://en.wikipedia.org/wiki/Betula_pendula' },
{ canonicalurl: 'https://en.wikipedia.org/wiki/Quercus_robur?x=1' },
{ canonicalurl: 'https://en.wikipedia.org/wiki/Quercus_robur#section' }]) {
assert.throws(() => entriesFromResponse([record], payload([page(record.latin, changes)]), retrieved));
}
});
test('page IDs and revisions must be positive finite safe integers with matching current revision', () => {
for (const n of [0, -1, 1.5, NaN, Infinity, '12345', null, Number.MAX_SAFE_INTEGER + 1]) {
assert.throws(() => entriesFromResponse([record], payload([page(record.latin, { pageid: n })]), retrieved));
assert.throws(() => entriesFromResponse([record], payload([page(record.latin, { revisions: [{ revid: n, timestamp: '2026-11-01T10:00:00Z' }] })]), retrieved));
}
for (const changes of [{ lastrevid: 54321 }, { revisions: [] }, { revisions: [{ revid: 12345, timestamp: 'not a date' }] },
{ revisions: [{ revid: 12345, timestamp: '2026-02-31T10:00:00Z' }] },
{ revisions: [{ revid: 12345, timestamp: '2027-01-01T10:00:00Z' }] }]) {
assert.throws(() => entriesFromResponse([record], payload([page(record.latin, changes)]), retrieved));
}
});
test('only an explicit missing species page becomes original_notes, never a fake excerpt/revision', () => {
const r = { id: 'old_mans_beard', latin: 'Usnea subfloridana' };
const missing = { ns: 0, title: r.latin, missing: true, canonicalurl: 'https://en.wikipedia.org/wiki/Usnea_subfloridana', contentmodel: 'wikitext', pagelanguage: 'en' };
const [e] = entriesFromResponse([r], payload([missing]), retrieved);
assert.equal(e.kind, 'original_notes'); assert.equal(e.excerpt, false);
assert.equal(e.revision, undefined); assert.equal(e.pageid, undefined); assert.equal(e.revision_url, undefined);
assert.match(e.text, /not a Wikipedia excerpt/); assert.equal(e.license, 'CC0 1.0');
assert.throws(() => entriesFromResponse([r], payload([]), retrieved));
assert.throws(() => entriesFromResponse([r], payload([{ ...missing, missing: 'true' }]), retrieved));
assert.deepEqual(parseBundle(serializeBundle([e])), [e]);
});
test('plaintext excerpt cap preserves exact Unicode prefix on a boundary and labels shortening', () => {
const full = 'A botanical observation.\n' + 'A longer sentence about a species. '.repeat(500);
const e = excerpt(full);
assert.ok(Array.from(e.text).length <= 8000); assert.equal(e.truncated, true);
assert.ok(full.startsWith(e.text)); assert.match(e.text, /[.!?]$/);
const unicode = 'Species ' + String.fromCodePoint(0x1f333).repeat(1000) + ' ecology. '.repeat(1000);
const u = excerpt(unicode);
assert.ok(Array.from(u.text).length <= 8000 && !u.text.includes('\ufffd'));
assert.equal(excerpt(intro).text, intro); assert.equal(excerpt(intro).truncated, false);
for (const bad of ['', '<script>alert(1)</script>' + intro, intro + '\0', intro + ' [[article]]']) assert.throws(() => excerpt(bad));
});
test('untrusted Wikipedia text is exclusively UTF-8 byte literals, never executable Lua', () => {
const attack = 'Species text " }; os.execute("bad"); --\n]=] [[ return function() end \\123 ' + String.fromCodePoint(0xe9, 0x1f333);
const entry = entriesFromResponse([record], payload(), retrieved)[0];
// Exercise the serializer directly even for text rejected by plaintext policy.
entry.text = attack;
const source = serializeBundle([entry]);
assert.match(luaString(attack), /^"(?:\\\d{3})*"$/);
assert.ok(!source.includes('os.execute') && !source.includes('return function'));
assert.equal(parseBundle(source)[0].text, attack);
assert.equal(serializeBundle(parseBundle(source)), source);
assert.throws(() => parseBundle(source.replace('return {', 'os.execute("bad")\nreturn {')));
assert.throws(() => parseBundle(source.replace('\\083', '\\999')));
assert.throws(() => serializeBundle([{ ...entry, revision: Infinity }]));
assert.throws(() => serializeBundle([{ ...entry, revision: '12345' }]));
});
test('80 titles use exactly eight mocked batches at concurrency <=2 and stable catalogue order', async () => {
let active = 0, peak = 0, calls = 0;
const seen = [];
const entries = await fetchEntries(catalogue, { now: () => new Date(retrieved), fetchImpl: mock(async url => {
calls++; active++; peak = Math.max(peak, active);
const names = new URL(url).searchParams.get('titles').split('|'); seen.push(...names);
assert.ok(names.length <= 10); await setImmediate(); active--;
return jsonResponse(payload(names.map(name => page(name))));
}) });
assert.equal(calls, 8); assert.equal(peak, 2);
assert.deepEqual(seen, catalogue.map(r => r.latin));
assert.deepEqual(entries.map(r => r.id), catalogue.map(r => r.id));
assert.equal(entries.length, 80);
for (const records of [[], [...catalogue, { id: 'extra', latin: 'Abies alba' }]]) {
await assert.rejects(fetchEntries(records, { fetchImpl: () => { throw new Error('must not fetch'); } }), /scope/);
}
});
test('batch failure aborts other workers and never launches the remaining catalogue', async () => {
let calls = 0, otherSignal;
await assert.rejects(fetchEntries(catalogue, { fetchImpl: mock((url, options) => {
calls++;
if (calls === 1) return new Response('error', { status: 503 });
otherSignal = options.signal; return new Promise(() => {});
}) }), /HTTP 503/);
assert.equal(calls, 2); assert.equal(otherSignal.aborted, true);
});
test('shipped bundle contains 78 real intros and two honest missing-page notes for all 80 species', () => {
assert.equal(cached.length, 80);
assert.deepEqual(cached.map(({ id, latin }) => ({ id, latin })), catalogue);
assert.equal(cached.filter(e => e.kind === 'wikipedia_intro').length, 78);
assert.deepEqual(cached.filter(e => e.kind === 'original_notes').map(e => e.latin), ['Usnea subfloridana', 'Cladonia chlorophaea']);
for (const e of cached) {
assert.ok(Array.from(e.text).length <= 8000);
assert.ok(!/<\/?[A-Za-z][^>]*>/.test(e.text));
if (e.kind === 'wikipedia_intro') {
assert.equal(e.license, 'CC BY-SA 4.0');
assert.ok(Number.isSafeInteger(e.pageid) && e.pageid > 0);
assert.ok(Number.isSafeInteger(e.revision) && e.revision > 0);
assert.ok(e.history_url.endsWith('&action=history'));
assert.ok(e.revision_url.endsWith('?oldid=' + e.revision));
assert.match(e.attribution, /^Wikipedia contributors/);
}
}
assert.equal(cached.find(e => e.id === 'meadow_fescue').title, 'Lolium pratense');
assert.equal(cached.find(e => e.id === 'wood_blewit').title, 'Collybia nuda');
});
test('import, help, invalid CLI arguments and offline --check perform zero network requests', async () => {
const original = globalThis.fetch;
let network = 0;
globalThis.fetch = () => { network++; throw new Error('Offline test attempted network'); };
try {
await import('../scripts/wiki-species.mjs?offline-import-test');
await main(['--help']);
for (const args of [[], ['--refresh', '--url', 'https://evil.example/'], ['https://evil.example/'], ['--output=../other.lua']]) {
await assert.rejects(main(args), /Usage/);
}
await main(['--check']);
assert.equal(network, 0);
} finally { globalThis.fetch = original; }
});
test('CLI refresh HTTP failure preserves the existing cache byte for byte', async () => {
const url = new URL('../mods/hearth/wiki_articles.lua', import.meta.url);
const before = await readFile(url), original = globalThis.fetch;
let calls = 0;
globalThis.fetch = mock(() => { calls++; return new Response('unavailable', { status: 503 }); });
try { await assert.rejects(main(['--refresh']), /HTTP 503/); }
finally { globalThis.fetch = original; }
assert.ok(calls > 0 && calls <= 2);
assert.deepEqual(await readFile(url), before);
});
test.after(() => console.log(`WIKI_SPECIES_OFFLINE_COUNTS: 21 tests; real network requests: 0; mocked requests: ${mockedRequests}.`));
+298
View File
@@ -0,0 +1,298 @@
-- Standalone Lua 5.1/LuaJIT (also Lua 5.3) fauna contract tests.
local target=assert(arg[1],"wildlife.lua path required")
local passed=0
local function test(name,fn)
local ok,err=pcall(fn)
assert(ok,name..": "..tostring(err)); passed=passed+1
print("ok "..passed.." - "..name)
end
local function fixture()
local h={defs={},objects={},players={},steps={},habitat=true,plants=true,natural=true,forages=0,deposits=0,
blocked=function() return false end,terrain=function(p) return p.y<=3 and "hearth:grass" or "air" end}
local api={}
function api.register_entity(n,d) h.defs[n]=d end
function api.register_globalstep(fn) h.steps[#h.steps+1]=fn end
function api.get_connected_players() return h.players end
function api.get_node_or_nil(p)
if h.unloaded and h.unloaded(p) then return nil end
local n=h.terrain(p)
if n=="air" and p.y==4 and h.plants then n="hearth:meadow_grass" end
return {name=n}
end
function api.get_meta() return {get_string=function(_,key) return key=="succ_natural" and h.natural and "1" or "" end} end
function api.is_protected(p) return h.blocked(p) end
function api.add_item() error("wildlife must never drop items") end
function h.player(x,z)
local p={pos={x=x or 0,y=4,z=z or 0}}
function p:get_pos() return self.pos end
function p:punch() error("animals must never punch players") end
h.players[#h.players+1]=p; return p
end
function h.add(kind,x,z,payload,restore)
local def=assert(h.defs["hearth:"..kind])
local e={name="hearth:"..kind}
local obj={pos={x=x or 12,y=3.5,z=z or 0},entity=e}
e.object=obj
function obj:get_pos() return not self.removed and self.pos or nil end
function obj:get_luaentity() return self.entity end
function obj:set_pos(p) self.pos=p; self.moves=(self.moves or 0)+1 end
function obj:set_velocity(v) self.velocity=v end
function obj:set_acceleration(v) self.acceleration=v end
function obj:set_armor_groups(v) self.armor=v end
function obj:set_yaw(y) self.yaw=y end
function obj:remove() self.removed=true; def.on_deactivate(e) end
h.objects[#h.objects+1]=obj
def.on_activate(e,payload or "",restore or 0)
return e,obj
end
function api.add_entity(p,name)
local _,obj=h.add(name:match(":(.+)$"),p.x,p.z)
return obj
end
local hearth={claim_owner_at=function(p)
if h.ambiguous and h.ambiguous(p) then return nil,true end
if h.claim and h.claim(p) then return "owner",false end
return nil,false
end,claim_allowed=function() error("empty-name claim_allowed must never be used") end}
hearth.succession={habitat=function(p)
local available=h.habitat and (not h.habitat_at or h.habitat_at(p))
return {loaded=not h.habitat_unloaded,grass=available and 8 or 0,cover=0}
end,
forage=function(p)
assert(not h.blocked(p)); h.forages=h.forages+1
return h.feed_ok~=false
end,
deposit=function(p) assert(not h.blocked(p)); h.deposits=h.deposits+1; return true end}
h.hearth=hearth; h.api=api; h.result=dofile(target)(hearth,api)
function h.tick(n,dt)
for _=1,n do
local snapshot={}
for _,o in ipairs(h.objects) do snapshot[#snapshot+1]=o end
for _,o in ipairs(snapshot) do if not o.removed then h.defs[o.entity.name].on_step(o.entity,dt or 1) end end
for _,fn in ipairs(h.steps) do fn(dt or 1) end
end
end
function h.stats() return h.result.status() end
return h
end
test("three original grounded meshes, body collision, gravity, immortal and nonpersistent",function()
local h=fixture(); h.player()
for _,kind in ipairs({"hare","deer","fox"}) do
local d=h.defs["hearth:"..kind]; local p=d.initial_properties
assert(p.visual=="mesh" and p.mesh=="hearth_"..kind..".obj" and p.textures[1]=="hearth_"..kind..".png")
assert(p.visual_size.x==1 and p.visual_size.y==1 and p.collisionbox[2]==0)
assert(p.physical and p.collide_with_objects and p.static_save==false and p.pointable==false)
local e,o=h.add(kind,12)
assert(not o.removed and o.acceleration.y<0 and o.armor.immortal==1)
assert(d.on_punch(e,h.players[1])==true and not o.removed)
end
end)
test("activation rejects payloads, restores, invalid positions and duplicate accounting",function()
local h=fixture(); h.player()
local _,a=h.add("hare",12,0,"forged"); assert(a.removed)
local _,b=h.add("hare",12,0,"",1); assert(b.removed)
local _,c=h.add("hare",0/0); assert(c.removed)
local e=h.add("hare",12); h.defs[e.name].on_activate(e,"")
assert(h.stats().total==1)
end)
test("synchronous native activation enforces player and local caps",function()
local h=fixture(); h.player()
for _=1,20 do h.add("hare",12) end
assert(h.stats().total==6)
-- No player nearby is not an unlimited admission path.
local _,o=h.add("deer",1000); assert(o.removed)
local snap=h.stats(); snap.hare=999; assert(h.stats().hare==6)
local j=fixture(); j.player(-30); j.player(30)
for _=1,10 do j.add("deer",0) end
assert(j.stats().deer==6,"local cap spans overlapping player neighborhoods")
end)
test("global and species caps include distant active creatures until deactivation",function()
local h=fixture()
for i=1,12 do h.player(i*120) end
for i=1,30 do h.add("hare",math.ceil(i/3)*120+12) end
assert(h.stats().hare==16)
for i=1,20 do h.add("deer",math.ceil(i/3)*120+12) end
assert(h.stats().deer==10)
for i=1,12 do h.add("fox",(i+5)*120+12) end
assert(h.stats().total==32 and h.stats().fox==6)
for _,o in ipairs(h.objects) do if not o.removed then o.pos.x=o.pos.x+10000 end end
h.add("fox",132); assert(h.stats().total==32)
local e=h.objects[1].entity
h.defs[e.name].on_deactivate(e); h.defs[e.name].on_deactivate(e)
assert(h.stats().total==31)
end)
test("habitat recruitment is slow, empty or unloaded habitat admits nothing",function()
local h=fixture(); h.player(); h.tick(29); assert(h.stats().total==0)
h.tick(1); assert(h.stats().hare==1)
local j=fixture(); j.player(); j.habitat=false; j.tick(120); assert(j.stats().total==0)
j.habitat=true; j.habitat_unloaded=true; j.tick(60); assert(j.stats().total==0)
local k=fixture(); k.player(); k.plants=false
k.hearth.succession.habitat=function() return {loaded=true,grass=0,cover=8} end
k.tick(60); assert(k.stats().total==0,"tree cover without edible plants must not recruit")
end)
test("natural grazing earns food then delayed dung; planted lookalikes never graze",function()
local h=fixture(); h.player(); local e=h.add("deer",12)
h.tick(7); assert(h.forages==0 and h.deposits==0)
h.tick(1); assert(h.forages==1 and e.food==1 and e.hunger<15)
h.tick(7); assert(h.deposits==0)
h.tick(1); assert(h.deposits==1 and h.stats().dung==1)
local j=fixture(); j.player(); j.add("hare",12); j.natural=false; j.tick(20)
assert(j.forages==0 and j.deposits==0)
end)
test("two fed mature animals breed once, consume both parents' food, and cool down",function()
local h=fixture(); h.player(); local a=h.add("deer",12); local b=h.add("deer",13)
-- Time and meals are established explicitly; no instant birth on activation.
h.tick(2); assert(h.stats().births==0)
for _,e in ipairs({a,b}) do e.age=60; e.food=3; e.hunger=0; e.breed_at=0 end
h.tick(2); assert(h.stats().births==1 and h.stats().deer==3)
assert(a.food==0 and b.food==0)
h.tick(2); assert(h.stats().births==1)
end)
test("empty habitat and unsuccessful forage cannot fund breeding",function()
local h=fixture(); h.player(); local a=h.add("hare",12); local b=h.add("hare",13)
for _,e in ipairs({a,b}) do e.age=60;e.food=3;e.hunger=0;e.breed_at=0 end
h.habitat=false; h.tick(10); assert(h.stats().births==0 and h.forages==0)
local j=fixture(); j.player(); j.feed_ok=false
local c=j.add("deer",12); local d=j.add("deer",13)
c.age=60;d.age=60; j.tick(25)
assert(c.food==0 and d.food==0 and j.stats().births==0 and j.deposits==0)
end)
test("predation consumes finite registered hares once and has a meal cooldown",function()
local h=fixture(); h.player(); local fox=h.add("fox",12); local prey,o=h.add("hare",12.4)
-- Hold prey until the predator's turn to isolate accounting from flight.
prey.next_ai=999; fox.feed_at=0
h.tick(2); assert(o.removed and h.stats().meals==1 and h.stats().hare==0 and fox.food==1)
h.defs[prey.name].on_deactivate(prey); assert(h.stats().hare==0)
h.tick(10); assert(h.stats().meals==1 and h.stats().births==0)
local prey2,o2=h.add("hare",fox.object.pos.x+0.4,fox.object.pos.z); prey2.next_ai=999
fox.hunger=30; h.tick(2); assert(not o2.removed and h.stats().meals==1)
end)
test("predator reproduction requires two meals from each mature parent",function()
local h=fixture(); h.player(); local a=h.add("fox",12); local b=h.add("fox",13)
for _,e in ipairs({a,b}) do e.age=60;e.breed_at=0;e.hunger=0 end
h.tick(2); assert(h.stats().births==0)
a.food=2;b.food=1; h.tick(2); assert(h.stats().births==0)
b.food=2; h.tick(2); assert(h.stats().births==1 and a.food==0 and b.food==0)
end)
test("players and predators trigger safe flight with correct +Z mesh yaw",function()
local h=fixture(); h.player(10); local _,o=h.add("hare",12)
h.tick(2); assert(o.pos.x>12 and math.abs(o.yaw+math.pi/2)<0.001)
assert(o.velocity.x==0 and o.velocity.y==0)
local j=fixture(); j.player(); local _,p=j.add("deer",12); j.add("fox",10)
j.tick(2); assert(p.pos.x>12)
end)
test("all callback neighbors deny claims, ambiguous overlaps and external protection",function()
for _,mode in ipairs({"claim","ambiguous","blocked"}) do
local h=fixture(); h.player(); local e=h.add("hare",12)
h[mode]=function(p) return p.x>=13 end
e.feed_at=0; e.dung_at=0; h.tick(2)
assert(h.forages==0 and h.deposits==0,"callback crossed "..mode)
local _,o=h.add("fox",14); assert(o.removed)
end
local h=fixture(); h.player(); h.hearth.external_protected=function() return false end
h.api.is_protected=function() error("must prefer external protection over wrapped claim API") end
local _,o=h.add("hare",12); assert(not o.removed)
end)
test("water, cliffs, buildings, unloaded cells and newly claimed sources stop movement",function()
for _,mode in ipairs({"water","cliff","building","unloaded"}) do
local h=fixture(); h.player(10); local _,o=h.add("hare",12)
local original=h.terrain
if mode=="unloaded" then h.unloaded=function(p) return p.x>=13 end
else h.terrain=function(p)
if p.x>=13 then
if mode=="water" then return "hearth:water_source" end
if mode=="cliff" then return "air" end
if mode=="building" and p.y>=4 then return "hearth:wall" end
end
return original(p)
end end
h.tick(4); assert(o.pos.x<12.5,"unsafe crossing: "..mode)
end
local h=fixture(); h.player(); local _,o=h.add("deer",12)
h.claim=function() return true end; h.tick(1); assert(o.removed and h.forages==0)
end)
test("protected prey cannot be consumed through a boundary",function()
local h=fixture(); h.player(); local fox=h.add("fox",12); local prey,o=h.add("hare",13)
prey.next_ai=999; fox.feed_at=0; h.claim=function(p) return p.x>=13 end
-- Predator AI only: the prey's own guard would despawn it, not feed the fox.
for _=1,2 do h.steps[1](1) end
assert(not o.removed and h.stats().meals==0 and fox.food==0)
end)
test("loaded lifetime, gradual hunger and deactivation release slots without drops",function()
local h=fixture(); h.player(); local e,o=h.add("hare",12)
for _,dt in ipairs({-1,0/0,math.huge,"bad"}) do h.defs[e.name].on_step(e,dt) end
assert(e.age==0 and e.hunger==20)
h.defs[e.name].on_step(e,100000); assert(e.age==1 and not o.removed)
e.age=899; h.defs[e.name].on_step(e,1); assert(o.removed and h.stats().total==0)
local a,b=h.add("deer",12); a.hunger=99
h.defs[a.name].on_step(a,1); assert(not b.removed)
h.defs[a.name].on_step(a,1); assert(b.removed and h.stats().total==0)
end)
test("shared AI plus recruitment never exceeds 12 actions per second or catches up",function()
local h=fixture()
for i=1,8 do h.player(i*120) end
for i=1,16 do h.add("hare",math.ceil(i/3)*120+12) end
for i=1,10 do h.add("deer",math.ceil(i/3)*120+12) end
for _=1,10 do
local before=h.stats().actions; h.tick(1); assert(h.stats().actions-before<=12)
end
local before=h.stats().actions
h.steps[1](0/0); h.steps[1](math.huge); h.steps[1](-2)
assert(h.stats().actions==before)
h.steps[1](100000); assert(h.stats().actions-before<=12)
assert(h.stats().actions>20,"shared scheduler must make progress")
end)
test("earned forage and four finite prey meals fund births rather than free reproduction",function()
local h=fixture(); h.player(); local a=h.add("hare",12); local b=h.add("hare",13)
-- Keep the mating pair in one patch; the AI still earns every meal itself.
for _,e in ipairs({a,b}) do
e.object.set_pos=function() end
end
h.tick(65)
assert(h.stats().grazed>=6 and h.stats().births==1 and h.stats().hare>=3)
assert(a.food==0 and b.food==0,"both parents must pay from earned forage")
local j=fixture(); j.player(); local f=j.add("fox",12); local g=j.add("fox",13)
for _,e in ipairs({f,g}) do e.age=60;e.breed_at=0;e.next_ai=999 end
for _,hunter in ipairs({f,f,g,g}) do
local p=hunter.object.pos
local prey=j.add("hare",p.x+0.2,p.z); prey.next_ai=999
hunter.next_ai=0; hunter.feed_at=0; hunter.hunger=30
j.tick(2); hunter.next_ai=999
end
assert(j.stats().meals==4 and j.stats().hare==0 and f.food==2 and g.food==2)
f.next_ai=0; f.hunger=0; g.hunger=0; j.tick(2)
assert(j.stats().births==1 and j.stats().fox==3 and f.food==0 and g.food==0)
end)
test("wandering selects loaded habitat and refused capped births conserve parent food",function()
local h=fixture(); h.player(); local e,o=h.add("deer",12)
h.habitat_at=function(p) return p.z>=1 end
h.tick(2); assert(o.pos.z>0 and o.moves==1)
local j=fixture(); j.player(); local a=j.add("hare",12); local b=j.add("hare",13)
for _=1,4 do j.add("deer",15) end
for _,parent in ipairs({a,b}) do parent.age=60;parent.food=3;parent.hunger=0;parent.breed_at=0 end
j.tick(2)
assert(j.stats().births==0 and j.stats().total==6 and a.food==3 and b.food==3)
end)
test("canonical species ecosystem herbs feed on expanded ground but not in dormant winter",function()
for _,ground in ipairs({"forest_floor","podzol","alluvium","peat","alpine_turf"}) do
local h=fixture(); local season="Summer"
h.hearth.climate_at=function() return {season=season} end
h.hearth.seasons=dofile(target:gsub("[^/\\]+$","seasons.lua"))(h.hearth,h.api)
h.hearth.diversity=dofile(target:gsub("[^/\\]+$","diversity.lua"))(h.hearth,h.api)
h.terrain=function(p) return p.y<=3 and "hearth:"..ground or (p.y==4 and "hearth:clover" or "air") end
h.hearth.succession.habitat=function() return {loaded=true,grass=0,cover=8,edible=8} end
h.player(); local _,o=h.add("hare",12); assert(not o.removed,ground)
h.tick(8); assert(h.forages==1,ground)
season="Winter"
h.terrain=function(p) return p.y<=3 and "hearth:"..ground or (p.y==4 and "hearth:clover_winter" or "air") end
h.tick(20); assert(h.forages==1,"dormant clover grazed on "..ground)
end
end)
test("winter canonical grass cannot be admitted as a free meal or breeding habitat",function()
local h=fixture(); h.hearth.climate_at=function() return {season="Winter"} end
h.hearth.seasons=dofile(target:gsub("[^/\\]+$","seasons.lua"))(h.hearth,h.api)
h.player(); local _,o=h.add("hare",12); assert(o.removed)
h.tick(60); assert(h.forages==0 and h.stats().total==0)
end)
print("PASS "..passed.." bounded wildlife tests")
+470
View File
@@ -0,0 +1,470 @@
// Engine-independent art contracts. Decode the actual PNG bytes independently;
// check geometry/masks, not just filenames, magic bytes or color diversity.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as fs from 'node:fs/promises';
import { realpath } from 'node:fs';
import { promisify } from 'node:util';
import os from 'node:os';
import path from 'node:path';
import { inflateSync } from 'node:zlib';
import { createHash } from 'node:crypto';
import { BOTANY_ART_MANIFEST, generateAssets, png, artContactSheetPNG } from '../scripts/assets.mjs';
import { PALETTE, PLANT_NAMES, SEASONAL_PLANT_NAMES, WORLD_NAMES, ITEM_NAMES, ANIMAL_NAMES,
BIOME_WORLD_NAMES, SPECIES_NAMES, ROCK_NAMES, SPECIES_SEASONAL_NAMES, UNDERSTORY_NAMES,
DORMANT_UNDERSTORY_NAMES, BOTANY_CATALOGUE, BOTANY_ART_NAMES, BOTANY_SPRITE_NAMES, EVERGREEN_NAMES,
worldArt, contactSheet, animalOBJ, animalBounds } from '../scripts/world-art.mjs';
const digest = data => createHash('sha256').update(data).digest('hex');
const pixel = (image,x,y) => [...image.data.subarray((y*image.width+x)*4,(y*image.width+x+1)*4)];
const pixels = image => Array.from({length:image.width*image.height},(_,i)=>pixel(image,i%image.width,Math.floor(i/image.width)));
const tile = (art,name) => art.get(`hearth_${name}.png`);
function crc32(bytes) {
let crc=0xffffffff;
for(const b of bytes) {
crc^=b;
for(let i=0;i<8;i++) crc=(crc>>>1)^((crc&1)?0xedb88320:0);
}
return (crc^0xffffffff)>>>0;
}
function decodePNG(bytes) {
assert.deepEqual([...bytes.subarray(0,8)],[137,80,78,71,13,10,26,10]);
let offset=8, width, height;
const chunks=[], compressed=[];
while(offset<bytes.length) {
assert.ok(offset+12<=bytes.length);
const length=bytes.readUInt32BE(offset), end=offset+8+length;
assert.ok(end+4<=bytes.length);
const type=bytes.toString('ascii',offset+4,offset+8), data=bytes.subarray(offset+8,end);
assert.equal(crc32(bytes.subarray(offset+4,end)),bytes.readUInt32BE(end));
chunks.push(type);
if(type==='IHDR') {
assert.equal(length,13); width=data.readUInt32BE(0); height=data.readUInt32BE(4);
assert.deepEqual([...data.subarray(8)],[8,6,0,0,0]);
} else if(type==='IDAT') compressed.push(data);
else { assert.equal(type,'IEND'); assert.equal(length,0); assert.equal(end+4,bytes.length); }
offset=end+4;
}
assert.deepEqual(chunks,['IHDR','IDAT','IEND']);
const raw=inflateSync(Buffer.concat(compressed),{maxOutputLength:512*1024*4+1024});
assert.equal(raw.length,height*(width*4+1));
const data=new Uint8Array(width*height*4);
for(let y=0;y<height;y++) {
assert.equal(raw[y*(width*4+1)],0);
data.set(raw.subarray(y*(width*4+1)+1,(y+1)*(width*4+1)),y*width*4);
}
return {width,height,data};
}
test('named manifest covers all requested world, ecology, craftitem, wildlife and interface art with no collisions',()=> {
const art=worldArt();
const requiredWorld='stone soil grass_top grass_side sand log_top log_side leaves planks wall roof thatch foundation frame stairs door window vent storage pantry reeds berry_bush berry_bush_bare sapling grain_patch crop_1 crop_2 crop_3 crop_4 clay_bank iron_ore_vein water fire fire_lit torch sticks loose_stone bedroll drying_rack fish_trap weir_stone meadow_grass wildflowers fern litter fungi deadwood tree_seedling young_tree mature_tree dung leaves_spring leaves_autumn leaves_winter meadow_grass_dormant fern_dormant wildflowers_dormant spring_greens spring_greens_dormant autumn_mushrooms autumn_mushrooms_dormant'.split(' ');
const requiredItems='berries grain grain_seed raw_food cooked_food fish_bait raw_fish cooked_fish fish_drying_charge dried_fish iron_ore iron_ingot charcoal charcoal_charge smelt_charge clay clay_jar clay_jar_charge dried_food dried_food_charge cloak vessel water_raw water_clean firestarter iron_axe spring_greens autumn_mushrooms'.split(' ');
const species=['oak','birch','pine','willow','alder','beech','aspen','rowan','hazel','spruce'], rocks=['granite','basalt','limestone','slate','sandstone'];
const understory=['clover','nettle','heather','bluebell','sedge','bulrush','moss','lichen'];
const biome=[...rocks,...rocks.map(n=>`${n}_bricks`),
...'forest_floor podzol alluvium peat alpine_turf gravel scree snow quartz_vein copper_ore_vein coal_ore_vein'.split(' '),
...species.flatMap(s=>['log_top','log_side','leaves','planks'].map(n=>`${s}_${n}`))];
const seasonal=species.filter(s=>s!=='pine' && s!=='spruce').flatMap(s=>['spring','autumn','winter'].map(n=>`${s}_leaves_${n}`));
const dormant=understory.slice(0,6).map(n=>`${n}_dormant`);
assert.deepEqual(SPECIES_NAMES,species); assert.deepEqual(ROCK_NAMES,rocks);
assert.deepEqual(BIOME_WORLD_NAMES,biome); assert.equal(biome.length,61);
assert.deepEqual(UNDERSTORY_NAMES,understory); assert.deepEqual(DORMANT_UNDERSTORY_NAMES,dormant);
assert.deepEqual(SPECIES_SEASONAL_NAMES,seasonal);
assert.deepEqual([...WORLD_NAMES].sort(),[...requiredWorld,...biome,...seasonal,...understory,...dormant].sort());
assert.deepEqual([...ITEM_NAMES].sort(),[...requiredItems,'quartz','copper_ore','coal','copper_ingot','copper_charge','copper_pick'].sort());
const names=[...WORLD_NAMES.map(n=>`hearth_${n}.png`),...ITEM_NAMES.map(n=>`hearth_item_${n}.png`),
'hearth_hotbar.png','hearth_selected.png','hearth_crosshair.png',...ANIMAL_NAMES.map(n=>`hearth_${n}.png`),
...BOTANY_SPRITE_NAMES.map(n=>`hearth_${n}.png`)];
assert.equal(new Set(names).size,names.length); assert.deepEqual([...art.keys()],names);
assert.equal(art.size,360); assert.equal(WORLD_NAMES.length,160); assert.equal(ITEM_NAMES.length,34);
for(const [name,image] of art) {
assert.match(name,/^hearth_[a-z0-9_]+\.png$/); assert.equal(image.width,32); assert.equal(image.height,32);
assert.equal(image.data.length,4096); assert.ok(image.data.some(n=>n>0));
}
});
test('all 80 exact catalogue IDs and Latin identities match the actual Lua records, with two manifest states each',async()=> {
const source=await fs.readFile(new URL('../mods/hearth/botany.lua',import.meta.url),'utf8');
const rows=[...source.matchAll(/^\s*add\("([a-z_]+)","[^"]+","([^"]+)"/gm)].map(([,id,latin])=>({id,latin}));
assert.equal(rows.length,80); assert.equal(new Set(rows.map(r=>r.id)).size,80);
assert.equal(new Set(rows.map(r=>r.latin)).size,80);
assert.deepEqual(BOTANY_ART_NAMES,rows.map(r=>r.id));
assert.deepEqual(BOTANY_CATALOGUE.map(({id,latin})=>({id,latin})),rows);
const groups=['trees','shrubs','grasses','herbs','ferns_mosses','fungi','aquatic','lichens'];
for(const [i,g] of groups.entries()) assert.deepEqual(BOTANY_CATALOGUE.slice(i*10,i*10+10).map(r=>r.group),Array(10).fill(g));
assert.deepEqual(BOTANY_ART_NAMES.slice(0,10),SPECIES_NAMES);
assert.equal(BOTANY_ART_MANIFEST.length,160);
assert.deepEqual(BOTANY_ART_MANIFEST.map(r=>r.filename),BOTANY_SPRITE_NAMES.map(n=>`hearth_${n}.png`));
for(const r of BOTANY_ART_MANIFEST) {
assert.equal(r.botanicalLatin,rows.find(row=>row.id===r.id).latin);
assert.match(r.morphology,/[a-z].*[; ]/); assert.equal(r.license,'MIT');
assert.deepEqual([r.width,r.height],[32,32]); assert.ok(['active','dormant'].includes(r.state));
}
});
test('80 recognizable botanical cutouts have unique silhouettes, crisp padded alpha, and 80 distinct bloom-free inactive drawings',()=> {
const art=worldArt(), activeMasks=new Set(), dormantMasks=new Set(), allBytes=new Set();
for(const id of BOTANY_ART_NAMES) {
const active=tile(art,`botany_${id}`), dormant=tile(art,`botany_${id}_dormant`);
const mask=image=>Buffer.from(pixels(image).map(p=>p[3]));
activeMasks.add(digest(mask(active))); dormantMasks.add(digest(mask(dormant)));
assert.notDeepEqual(mask(active),mask(dormant),`${id}: changed growth form, not just RGB`);
for(const [state,image] of [['active',active],['dormant',dormant]]) {
const ps=pixels(image), count=ps.filter(p=>p[3]).length;
assert.ok(count>=15 && count<800,`${id} ${state}: cutout, not a square (${count})`);
assert.ok(ps.every(p=>p[3]===0 || p[3]===255));
for(let j=0;j<32;j++) for(const [x,y] of [[j,0],[j,31],[0,j],[31,j]]) assert.equal(pixel(image,x,y)[3],0);
const colors=new Set(ps.filter(p=>p[3]).map(p=>p.slice(0,3).join()));
assert.ok(colors.size>=2 && colors.size<=14,`${id} ${state}: small botanical palette`);
allBytes.add(digest(image.data));
if(state==='dormant') assert.ok(ps.every(p=>!p[3] || !(p[1]>p[0] && p[1]>p[2])),`${id}: dry inactive state`);
}
}
assert.equal(activeMasks.size,80,'no two taxa masquerade as the same recoloured silhouette');
assert.equal(dormantMasks.size,80,'each inactive species retains its own structure');
assert.equal(allBytes.size,160);
});
test('botanical diagnostic landmarks distinguish flowers, pinnate ferns, moss cushions, fungi and true floating lily pads',()=> {
const art=worldArt(), image=id=>tile(art,`botany_${id}`), has=(id,color)=>pixels(image(id)).some(p=>p[3] && p.slice(0,3).join()===PALETTE[color].join());
for(const id of ['oak','birch','pine','willow','alder','beech','aspen','rowan','hazel','spruce'])
assert.ok(pixels(image(id)).some((p,i)=>p[3] && Math.floor(i/32)<10),'tree fieldguide specimen has diagnostic upper growth');
assert.ok(has('bluebell','mauve')); assert.ok(has('foxglove','wine')); assert.ok(has('cowslip','gold'));
assert.ok(has('fly_agaric','cream') && has('fly_agaric','rust'));
assert.ok(has('shaggy_inkcap','ink')); assert.ok(has('turkey_tail','earth'));
assert.ok(has('polypody','ochre')); assert.ok(has('haircap_moss','copper'));
for(const id of ['yellow_waterlily','white_waterlily']) {
const pad=image(id);
assert.equal(pixel(pad,5,23)[3],255,'broad floating pad, not horns');
assert.equal(pixel(pad,10,26)[3],0,'radial basal notch in the pad');
assert.equal(pixel(pad,1,1)[3],0,'no painted water square');
}
assert.ok(has('white_waterlily','cream') && has('yellow_waterlily','gold'));
assert.ok(has('sunburst_lichen','copper')); assert.ok(has('lichen','barkDark'));
assert.ok(pixels(image('hornwort')).filter(p=>p[3]).length>100,'rootless forked submerged whorls');
});
test('approved leaf PNG bytes survive the catalogue extension without rewriting the existing leaf designs',()=> {
const hashes={
leaves:'0fdd70c561c7a9be4c7a2d6cae08dfc7b3467cf767d2ee84c9aa17fe4e52d1c4',
oak_leaves:'af2dfa295be79d2bc714aa57ae10c55f688d053c9b02cbbcf5bc84c8385dc582',
birch_leaves:'3ee8bedf16d9f7caae1b1b86d3160ac1057f935f77e89b6721c25fb108fcfdc2',
pine_leaves:'f38ab9e868e7b954edf94e0d8673e57d8ffffbf2dd2e7699aa43c5abb1b8fb22',
willow_leaves:'f9ac6a8d21976e42ff680d6eaf08b339b88f22b6ac1d8e6846eaa278be6caf73',
alder_leaves:'8fc4d476ddb52ddafa9dc662b5ba9e4bb509e9104da0f51f33e153697c3eb08d',
}, art=worldArt();
for(const [name,hash] of Object.entries(hashes)) {
const image=tile(art,name);
assert.equal(digest(png(32,(x,y)=>pixel(image,x,y))),hash,name);
}
});
test('every tile is deterministic original clustered palette art, not independent speckle noise or a recolored square',()=> {
const first=worldArt(), second=worldArt();
const allowed=new Set(Object.values(PALETTE).map(c=>c.join(','))), unique=new Set();
for(const [name,image] of first) {
assert.deepEqual(image.data,second.get(name).data); unique.add(digest(image.data));
const colors=new Set();
for(const p of pixels(image)) if(p[3]) {
assert.ok(allowed.has(p.slice(0,3).join(',')),`${name}: explicit palette only`); colors.add(p.slice(0,3).join(','));
} else assert.deepEqual(p,[0,0,0,0],`${name}: no hidden RGB in cutout texels`);
assert.ok(colors.size>=2 && colors.size<=14,`${name}: restrained per-tile palette`);
}
assert.equal(unique.size,first.size,'no identical stand-in art');
// Color-independent boundary masks prove different material geometries, rather
// than the same noisy grid with swapped RGB values.
const structures=new Set();
for(const name of ['stone','soil','grass_top','sand','log_top','log_side','planks','wall','roof','clay_bank','water']) {
const image=tile(first,name), edges=[]; let equal=0, comparisons=0;
for(let y=0;y<31;y++) for(let x=0;x<31;x++) {
for(const [dx,dy] of [[1,0],[0,1]]) {
const same=pixel(image,x,y).join()===pixel(image,x+dx,y+dy).join();
edges.push(same?0:1); equal+=Number(same); comparisons++;
}
}
assert.ok(equal/comparisons>.48,`${name}: contiguous hand-authored clusters, not hash noise`);
structures.add(digest(Buffer.from(edges)));
}
assert.equal(structures.size,11);
});
test('approved leaf pixels are preserved; natural ground has no masonry grout, broad stripes or outlined blade symbols',()=> {
const art=worldArt();
assert.equal(digest(tile(art,'leaves').data),'6ca3e11b7e8e98f46220db92a4dbe729515835c3ae39c76ac3003fa5494f3f4c');
const rows=image=>Array.from({length:32},(_,y)=>Array.from({length:32},(_,x)=>pixel(image,x,y).join()));
for(const name of ['stone',...ROCK_NAMES]) {
const image=tile(art,name);
assert.ok(pixels(image).every(p=>p[3]===255));
assert.ok(!pixels(image).some(p=>p.slice(0,3).join()===PALETTE.outline.join()),`${name}: no mortar outlines`);
assert.ok(rows(image).filter(row=>new Set(row).size===1).length<4,`${name}: interrupted mineral microfacets`);
if(name!=='stone') {
const brick=tile(art,`${name}_bricks`);
assert.equal(new Set(rows(brick)[11]).size,1,'crafted masonry has an explicit bed joint');
assert.notDeepEqual(image.data,brick.data);
}
}
const soil=tile(art,'soil'), turf=tile(art,'grass_top');
for(const name of ['loamShade','loamGlow']) {
const color=PALETTE[name].join();
assert.ok(rows(soil).every(row=>row.filter(p=>p.startsWith(color+',')).length<24),'soil lenses never form broad solid strata');
}
const greens=new Set(['turf','turfShade','turfGlow','leaf','sage'].map(c=>PALETTE[c].join()));
assert.ok(pixels(turf).every(p=>p[3]===255 && greens.has(p.slice(0,3).join())),'dense unoutlined natural turf only');
for(const [x0,y0] of [[0,0],[16,0],[0,16],[16,16]]) {
const colors=new Set();
for(let y=y0;y<y0+16;y++) for(let x=x0;x<x0+16;x++) colors.add(pixel(turf,x,y).join());
assert.ok(colors.size>=4,'mosaic is distributed across every quadrant, not isolated giant blades');
}
const side=tile(art,'grass_side'), humus=PALETTE.humus.join();
assert.ok(Array.from({length:10},(_,i)=>i+5).some(y=>rows(side)[y].some(p=>p.startsWith(humus+','))),'roots extend below the turf cap');
assert.deepEqual(pixel(side,16,31),pixel(soil,16,31),'mineral soil continues below organic transition');
});
test('understory and species leaves have botanical identities, leafless winters and non-blooming dormant silhouettes',()=> {
const art=worldArt(), opaque=name=>pixels(tile(art,name)).filter(p=>p[3]),
has=(name,color)=>opaque(name).some(p=>p.slice(0,3).join()===PALETTE[color].join());
for(const name of DORMANT_UNDERSTORY_NAMES) {
const active=name.replace(/_dormant$/,'');
assert.ok(opaque(name).length<opaque(active).length,`${name}: reduced living mass`);
assert.ok(opaque(name).every(p=>!(p[1]>p[0] && p[1]>p[2])),`${name}: spent straw/bark, not green`);
for(const color of ['mauve','bud','rose','wine','gold']) assert.ok(!has(name,color),`${name}: no flowers`);
}
assert.ok(has('clover','cream')); assert.ok(has('bluebell','mauve')); assert.ok(has('heather','bud'));
assert.ok(has('nettle','leafLight')); assert.ok(has('bulrush','bark'));
assert.ok(!art.has('hearth_moss_dormant.png') && !art.has('hearth_lichen_dormant.png'));
for(const s of SPECIES_NAMES.filter(s=>!EVERGREEN_NAMES.includes(s))) {
const winter=`${s}_leaves_winter`;
assert.ok(opaque(winter).length<opaque(`${s}_leaves`).length,'winter has open woody tracery');
assert.ok(opaque(winter).every(p=>!(p[1]>p[0] && p[1]>p[2])));
assert.ok(has(`${s}_leaves_spring`,'bud'));
assert.ok(has(`${s}_leaves_autumn`,'wine'));
}
for(const s of EVERGREEN_NAMES) for(const season of ['spring','autumn','winter']) assert.ok(!art.has(`hearth_${s}_leaves_${season}.png`));
for(const name of ['quartz_vein','copper_ore_vein','coal_ore_vein'])
assert.ok(pixels(tile(art,name)).every(p=>p[3]===255),'complete opaque host rock, not an overlay');
assert.ok(has('copper_ore_vein','verdigris')); assert.ok(has('item_copper_ore','verdigris'));
assert.ok(has('item_copper_ingot','copperGlow')); assert.ok(has('item_copper_pick','copperGlow'));
assert.notDeepEqual(tile(art,'item_copper_charge').data,tile(art,'item_copper_ore').data);
});
test('actual content craftitems and explicit material texture references have generated art, not compatibility swatches',async()=> {
const text=await fs.readFile(new URL('../mods/hearth/content.lua',import.meta.url),'utf8'), art=worldArt();
for(const match of text.matchAll(/api\.register_craftitem\("hearth:([a-z0-9_]+)"/g))
assert.ok(art.has(`hearth_item_${match[1]}.png`),`craftitem ${match[1]} has an authored icon`);
for(const match of text.matchAll(/item_image\("([a-z0-9_]+)"\)/g))
assert.ok(art.has(`hearth_item_${match[1]}.png`),`explicit icon ${match[1]}`);
const biomes=await fs.readFile(new URL('../mods/hearth/biomes.lua',import.meta.url),'utf8');
for(const name of ROCK_NAMES) assert.ok(biomes.includes(`"${name}"`) && art.has(`hearth_${name}_bricks.png`));
for(const name of ['quartz','copper_ore','coal']) assert.ok(art.has(`hearth_item_${name}.png`));
});
test('plant and item masks have empty borders/corners, crisp alpha and recognizable non-square negative space',()=> {
const art=worldArt(), masks=new Set();
for(const name of [...PLANT_NAMES,...ITEM_NAMES.map(n=>`item_${n}`)]) {
const image=tile(art,name), ps=pixels(image), opaque=ps.filter(p=>p[3]===255).length;
assert.ok(ps.every(p=>p[3]===0 || p[3]===255),name);
assert.ok(opaque>=15 && opaque<800,`${name}: silhouette occupancy ${opaque}`);
assert.equal(pixel(image,0,0)[3],0,name); assert.equal(pixel(image,31,31)[3],0,name);
const mask=Buffer.from(ps.map(p=>p[3])); masks.add(digest(mask));
if(name.startsWith('item_')) {
for(let i=0;i<32;i++) for(const [x,y] of [[i,0],[0,i],[31,i],[i,31]])
assert.equal(pixel(image,x,y)[3],0,`${name}: inventory padding`);
}
}
assert.ok(masks.size>=32,'different plant/item silhouettes, not generic masked squares');
const bush=tile(art,'berry_bush'), bare=tile(art,'berry_bush_bare');
assert.deepEqual(pixels(bush).map(p=>p[3]),pixels(bare).map(p=>p[3]),'berries change fruit, not foliage silhouette');
assert.notDeepEqual(bush.data,bare.data);
const growth=[1,2,3,4].map(n=>pixels(tile(art,`crop_${n}`)).filter(p=>p[3]).length);
assert.ok(growth[0]<growth[1] && growth[1]<growth[2] && growth[2]<growth[3],`crop progression ${growth}`);
});
test('material motifs convey construction, growth and processing without relying solely on color',()=> {
const art=worldArt();
assert.equal(pixel(tile(art,'log_top'),0,0).slice(0,3).join(),PALETTE.barkDark.join());
const cut=tile(art,'log_top');
for(const [dx,dy] of [[1,0],[0,1],[-1,0],[0,-1]]) {
const ray=Array.from({length:13},(_,r)=>pixel(cut,14+dx*r,16+dy*r).join());
const changes=ray.slice(1).filter((color,i)=>color!==ray[i]).length;
assert.ok(changes>=5,'multiple growth rings on every radius, not an arbitrary pair of unequal pixels');
}
assert.deepEqual(pixel(tile(art,'planks'),0,0).slice(0,3),PALETTE.barkLight,'subdued plank joints');
assert.deepEqual(pixel(tile(art,'door'),25,17).slice(0,3),PALETTE.gold,'door latch');
for(const [a,b] of [['stone','iron_ore_vein'],['fire','fire_lit'],['grass_top','grass_side'],
['storage','pantry'],['item_water_raw','item_water_clean'],['item_raw_fish','item_dried_fish']])
assert.notDeepEqual(tile(art,a).data,tile(art,b).data,`${a} vs ${b}`);
const window=tile(art,'window'), alphas=new Set(pixels(window).map(p=>p[3]));
assert.deepEqual([...alphas].sort((a,b)=>a-b),[0,95,255]);
assert.equal(pixel(window,0,0)[3],0); assert.equal(pixel(window,5,5)[3],95);
});
test('seasonal botanical silhouettes show sage buds, copper/wine autumn, bare winter twigs and bloom-free dormancy',()=> {
const art=worldArt(), opaque=name=>pixels(tile(art,name)).filter(p=>p[3]);
const has=(name,color)=>opaque(name).some(p=>p.slice(0,3).join()===PALETTE[color].join());
const green=p=>p[1]>p[0] && p[1]>p[2];
for(const name of SEASONAL_PLANT_NAMES) assert.ok(art.has(`hearth_${name}.png`));
for(const color of ['springDark','springLeaf','springLight','bud']) assert.ok(has('leaves_spring',color));
const buds=opaque('leaves_spring').filter(p=>p.slice(0,3).join()===PALETTE.bud.join()).length;
assert.ok(buds>=6 && buds<30,'buds are a subtle accent, not a pink canopy');
for(const color of ['copper','ochre','wine']) assert.ok(has('leaves_autumn',color));
for(const name of ['leaves_autumn','leaves_winter','meadow_grass_dormant','fern_dormant',
'wildflowers_dormant','spring_greens_dormant','autumn_mushrooms_dormant']) {
assert.ok(opaque(name).every(p=>!green(p)),`${name}: no living green`);
for(const color of ['rose','mauve','gold','bud']) assert.ok(!has(name,color),`${name}: no bloom`);
}
const mask=name=>Buffer.from(pixels(tile(art,name)).map(p=>p[3]));
assert.ok(opaque('leaves_winter').length<opaque('leaves').length/2,'open woody negative space');
for(const [active,dormant] of [['meadow_grass','meadow_grass_dormant'],['fern','fern_dormant'],
['wildflowers','wildflowers_dormant'],['spring_greens','spring_greens_dormant'],
['autumn_mushrooms','autumn_mushrooms_dormant']]) {
assert.notDeepEqual(mask(active),mask(dormant),'dormancy changes silhouette, not only RGB');
assert.ok(opaque(dormant).length<opaque(active).length,`${dormant}: visibly reduced growth`);
}
assert.ok(has('spring_greens','springLight'));
assert.ok(has('autumn_mushrooms','copper') && has('autumn_mushrooms','cream'));
assert.ok(!has('autumn_mushrooms_dormant','copper'),'no dormant pickable caps');
for(const name of ['spring_greens','autumn_mushrooms']) {
assert.notDeepEqual(tile(art,name).data,tile(art,`item_${name}`).data,'picked bundle has its own icon');
assert.notDeepEqual(mask(name),mask('fungi'),'seasonal forage does not masquerade as decomposer fungi');
}
});
test('water tiles seamlessly on both axes with sparse restrained ripple clusters; ground strips match horizontal seams',()=> {
const art=worldArt(), water=tile(art,'water');
for(let i=0;i<32;i++) {
assert.deepEqual(pixel(water,0,i),pixel(water,31,i));
assert.deepEqual(pixel(water,i,0),pixel(water,i,31));
}
const base=PALETTE.water.join(), ripples=pixels(water).filter(p=>p.slice(0,3).join()!==base).length;
assert.ok(ripples>30 && ripples<100); assert.ok(pixels(water).every(p=>p[3]===255));
for(const name of ['sand','clay_bank']) for(let y=0;y<32;y++)
assert.deepEqual(pixel(tile(art,name),0,y),pixel(tile(art,name),31,y),`${name}: continuous strata`);
// Exposed grass must keep a soil body and a foliage cap on every side.
const side=tile(art,'grass_side');
assert.ok([PALETTE.turfShade,PALETTE.turf,PALETTE.turfGlow].some(c=>c.join()===pixel(side,16,0).slice(0,3).join()));
assert.ok(pixel(side,16,31)[0]>pixel(side,16,31)[1]);
});
function parseOBJ(text) {
const vertices=[], uv=[], faces=[], groups=new Map(); let current;
for(const line of text.trim().split('\n')) {
const [kind,...parts]=line.split(' ');
if(kind==='g') { current={vertices:[],faces:[]}; groups.set(parts[0],current); }
if(kind==='v') { const v=parts.map(Number); vertices.push(v); current.vertices.push(v); }
if(kind==='vt') uv.push(parts.map(Number));
if(kind==='f') {
const face=parts.map(p=>p.split('/').map(Number)); faces.push(face); current.faces.push(face);
}
assert.ok(['#','g','v','vt','f'].includes(kind),'no material/path loading directives');
}
return {vertices,uv,faces,groups};
}
test('wildlife meshes have outward nondegenerate indexed triangles, semantic UVs, bounded counts and stable node bounds',()=> {
const unique=new Set();
for(const name of ANIMAL_NAMES) {
const text=animalOBJ(name); assert.equal(text,animalOBJ(name)); unique.add(text);
const {vertices,uv,faces,groups}=parseOBJ(text), bounds=animalBounds(name);
assert.ok(vertices.length>=100 && vertices.length<=300); assert.ok(faces.length>=150 && faces.length<=450);
assert.ok(vertices.flat().every(Number.isFinite)); assert.equal(uv.length,vertices.length/2);
assert.ok(uv.flat().every(n=>Number.isFinite(n) && n>0 && n<1));
assert.equal(Math.min(...vertices.map(v=>v[1])),0,'feet are exactly at ground');
for(let i=0;i<3;i++) {
assert.ok(Math.abs(Math.min(...vertices.map(v=>v[i]))/10-bounds.min[i])<1e-6);
assert.ok(Math.abs(Math.max(...vertices.map(v=>v[i]))/10-bounds.max[i])<1e-6);
assert.ok(bounds.max[i]-bounds.min[i]<2,'less than two nodes on every axis');
}
assert.deepEqual(bounds.visualSize,{x:1,y:1});
const referenced=new Set();
for(const group of groups.values()) {
const center=[0,1,2].map(i=>group.vertices.reduce((s,v)=>s+v[i],0)/group.vertices.length);
for(const face of group.faces) {
assert.equal(face.length,3); assert.equal(new Set(face.map(p=>p[0])).size,3);
for(const [v,t] of face) {
assert.ok(v>=1 && v<=vertices.length && t>=1 && t<=uv.length); referenced.add(v);
}
const [a,b,c]=face.map(([id])=>vertices[id-1]), u=b.map((n,i)=>n-a[i]), v=c.map((n,i)=>n-a[i]);
const normal=[u[1]*v[2]-u[2]*v[1],u[2]*v[0]-u[0]*v[2],u[0]*v[1]-u[1]*v[0]];
assert.ok(Math.hypot(...normal)>1e-4,'nonzero triangle area');
assert.ok(normal.reduce((sum,n,i)=>sum+n*(a[i]-center[i]),0)>0,'outward face winding');
// All triangle UVs stay inside the same 8px semantic atlas patch.
const cells=face.map(([,id])=>uv[id-1].map(n=>Math.floor(n*4)).join());
assert.equal(new Set(cells).size,1,'no UV crossing into another material');
}
}
assert.equal(referenced.size,vertices.length);
}
assert.equal(unique.size,3);
for(const bad of ['../hare','toString','constructor','wolf']) {
assert.throws(()=>animalOBJ(bad),/Unknown animal/); assert.throws(()=>animalBounds(bad),/Unknown animal/);
}
});
test('species-specific silhouettes retain hare long ears, fox long bushy tail, deer hooves/antlers and leg clearance',()=> {
const hare=parseOBJ(animalOBJ('hare')), fox=parseOBJ(animalOBJ('fox')), deer=parseOBJ(animalOBJ('deer'));
const extent=(group,axis)=>Math.max(...group.vertices.map(v=>v[axis]))-Math.min(...group.vertices.map(v=>v[axis]));
assert.ok(extent(hare.groups.get('ear_left'),1)>3*extent(hare.groups.get('ear_left'),0));
assert.ok(extent(fox.groups.get('tail'),2)>3.5);
assert.ok(extent(fox.groups.get('tail'),0)>2*extent(fox.groups.get('leg_1_1.9'),0));
assert.ok(deer.groups.has('antler_1') && deer.groups.has('point_-1') && deer.groups.has('hoof_1_2.4'));
assert.ok(Math.min(...deer.groups.get('body').vertices.map(v=>v[1]))>=4);
assert.ok(animalBounds('deer').max[1]>animalBounds('hare').max[1]*1.6);
});
test('PNG writer supports rectangular contact sheets, validates bounded dimensions/RGBA, and independently decodes exact atlas cells',()=> {
const art=worldArt(), sheet=contactSheet(art), pngBytes=artContactSheetPNG(art), decoded=decodePNG(pngBytes);
assert.equal(sheet.width,512); assert.equal(sheet.height,Math.ceil(art.size/16)*32);
assert.equal(decoded.width,sheet.width); assert.equal(decoded.height,sheet.height); assert.deepEqual(decoded.data,sheet.data);
const ordered=[...art.entries()].sort(([a],[b])=>a<b?-1:a>b?1:0);
ordered.forEach(([,image],i)=> {
for(const [x,y] of [[0,0],[15,15],[31,31]]) {
const p=pixel(image,x,y), output=pixel(decoded,(i%16)*32+x,Math.floor(i/16)*32+y);
assert.equal(output[3],255);
if(p[3]===255) assert.deepEqual(output,p,'opaque art copied without scaling/filtering');
}
});
// Every seasonal tile is included, not merely present in the named manifest.
for(const name of SEASONAL_PLANT_NAMES) {
const index=ordered.findIndex(([key])=>key===`hearth_${name}.png`), image=tile(art,name);
for(let y=0;y<32;y++) for(let x=0;x<32;x++) if(pixel(image,x,y)[3]===255)
assert.deepEqual(pixel(decoded,index%16*32+x,Math.floor(index/16)*32+y),pixel(image,x,y));
}
assert.deepEqual(pngBytes,artContactSheetPNG());
const small=decodePNG(png(3,()=>[1,2,3,4],5)); assert.equal(small.width,3); assert.equal(small.height,5);
for(const [w,h] of [[0,1],[1,-1],[1.5,2],[Infinity,1],[2049,1],[2048,2048]])
assert.throws(()=>png(w,()=>[0,0,0,0],h),/dimensions/);
for(const color of [[1,2,3],[-1,0,0,255],[256,0,0,255],[0,0,NaN,255],[0,0,0,.5]])
assert.throws(()=>png(1,()=>color),/RGBA/);
for(const images of [new Map(),new Map([['bad',{width:32,height:32,data:new Uint8Array(4)}]]),
new Map(Array.from({length:513},(_,i)=>[i,art.values().next().value])),{},null])
assert.throws(()=>contactSheet(images),/1\.\.512 RGBA 32px tiles/);
assert.equal(contactSheet(new Map(Array.from({length:512},(_,i)=>[String(i),art.values().next().value]))).height,1024);
assert.deepEqual(contactSheet(new Map([...art].reverse())),sheet,'filename sorting makes the review legend stable');
});
test('generateAssets bundles every named art/model and contact sheet from a minimal fixture source with guarded output paths',async t=> {
const root=await fs.mkdtemp(path.join(await promisify(realpath)(os.tmpdir()),'hearth-world-art-'));
t.after(()=>fs.rm(root,{recursive:true,force:true}));
const source=path.join(root,'source'), destination=path.join(root,'output');
await fs.mkdir(path.join(source,'mods/hearth'),{recursive:true});
await fs.writeFile(path.join(source,'mods/hearth/init.lua'),'-- fixture: no named asset references or source binaries\n');
await generateAssets(destination,source);
const textures=path.join(destination,'mods/hearth/textures'), art=worldArt();
for(const [name,image] of art) {
const decoded=decodePNG(await fs.readFile(path.join(textures,name)));
assert.equal(decoded.width,32); assert.equal(decoded.height,32); assert.deepEqual(decoded.data,image.data,name);
}
assert.deepEqual(await fs.readFile(path.join(textures,'hearth_art_contact_sheet.png')),artContactSheetPNG());
for(const name of ANIMAL_NAMES)
assert.equal(await fs.readFile(path.join(destination,`mods/hearth/models/hearth_${name}.obj`),'utf8'),animalOBJ(name));
// New named assets use the existing safePath writer, including direct calls.
const outside=path.join(root,'outside'); await fs.mkdir(outside);
await fs.writeFile(path.join(outside,'sentinel'),'unchanged');
const linked=path.join(root,'linked'), models=path.join(linked,'mods/hearth/models');
await fs.mkdir(path.dirname(models),{recursive:true});
await fs.symlink(outside,models,process.platform==='win32'?'junction':'dir');
await assert.rejects(generateAssets(linked,source),/symbolic link|junction/);
assert.deepEqual(await fs.readdir(outside),['sentinel']);
assert.equal(await fs.readFile(path.join(outside,'sentinel'),'utf8'),'unchanged');
});