Lots of content
This commit is contained in:
Executable
+55
@@ -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")
|
||||
Executable
+478
@@ -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/);
|
||||
});
|
||||
Executable
+363
@@ -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")
|
||||
Executable
+380
@@ -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)
|
||||
Executable
+194
@@ -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'});
|
||||
});
|
||||
Executable
+216
@@ -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")
|
||||
Executable
+239
@@ -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")
|
||||
Executable
+95
@@ -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")
|
||||
Executable
+150
@@ -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
@@ -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
|
||||
|
||||
Executable
+132
@@ -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)
|
||||
Executable
+273
@@ -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")
|
||||
Executable
+279
@@ -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)
|
||||
Executable
+192
@@ -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")
|
||||
Executable
+274
@@ -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
@@ -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)
|
||||
|
||||
Executable
+90
@@ -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
@@ -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
@@ -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 }
|
||||
}
|
||||
}
|
||||
Executable
+372
@@ -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');
|
||||
}
|
||||
});
|
||||
Executable
+122
@@ -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")
|
||||
Executable
+581
@@ -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)
|
||||
Executable
+118
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
Executable
+91
@@ -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")
|
||||
Executable
+320
@@ -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")
|
||||
Executable
+409
@@ -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")
|
||||
Executable
+183
@@ -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")
|
||||
Executable
+570
@@ -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")
|
||||
Executable
+303
@@ -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
|
||||
Executable
+299
@@ -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")
|
||||
Executable
+164
@@ -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")
|
||||
Executable
+244
@@ -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)
|
||||
Executable
+89
@@ -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)
|
||||
Executable
+223
@@ -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")
|
||||
Executable
+938
@@ -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)
|
||||
Executable
+537
@@ -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.
|
||||
Executable
+308
@@ -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}.`));
|
||||
Executable
+298
@@ -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")
|
||||
Executable
+470
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user