This commit is contained in:
2026-11-05 09:57:11 -05:00
parent d34388bf2c
commit 89e392cc2e
81 changed files with 12410 additions and 1135 deletions
+13 -5
View File
@@ -9,6 +9,7 @@ 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 { FAUNA_ART_MANIFEST } from '../scripts/fauna-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';
@@ -142,7 +143,7 @@ for (const profile of CHARACTER_OPTIONS.bodies) test(`B3D ${profile} has bounded
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(model.vertices.length,384); assert.equal(model.triangles.length,192);
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);
@@ -195,8 +196,10 @@ test('all body profiles share exact rig, rigid assignments, UVs and 100 keys; on
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);
assert.ok(widths.length===CHARACTER_OPTIONS.bodies.length && widths.every((value,i)=>i===0||widths[i-1]<value));
assert.ok(Math.max(...balanced.bones.get('Head').weights.map(([id])=>balanced.vertices[id][0]))<=1.6);
assert.ok(Math.max(...balanced.bones.get('Head').weights.map(([id])=>balanced.vertices[id][2]))>1.5,
'added nose and ears stay within the existing broad head collision envelope');
for (const bad of ['toString','../broad','BALANCED',null,{},0]) assert.throws(()=>playerB3D(bad));
});
@@ -396,7 +399,7 @@ test('all mod Lua colors are scanned recursively; scripts and non-Lua are not in
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);
assert.equal(names.filter(n=>n.startsWith('hearth_char_')).length,CHARACTER_ART_MANIFEST.length);
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);
@@ -428,7 +431,7 @@ test('original Ogg media is part of the verified package manifest without a runt
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);
const {entries} = await verifyPackage(destination,{requireBundled:false});
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]);
@@ -440,6 +443,11 @@ test('original Ogg media is part of the verified package manifest without a runt
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 {id,mesh,texture} of FAUNA_ART_MANIFEST) {
assert.ok(entries.some(e=>e.path===`mods/hearth/models/${mesh}`),`${id} model`);
assert.ok(entries.some(e=>e.path===`mods/hearth/textures/${texture}`),`${id} texture`);
}
assert.ok(entries.some(e=>e.path==='fauna-art.json'));
for (const body of CHARACTER_OPTIONS.bodies)
assert.ok(entries.some(e=>e.path===`mods/hearth/models/${characterMesh(body)}`));
});
+7 -7
View File
@@ -132,8 +132,8 @@ test("habitat helper resolves qualified nodes and rejects unknown surfaces", fun
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")
test("thirteen 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,crab_apple,wild_cherry,field_maple")
for _, species in ipairs(f.m.species_order) do
local log=f.nodes["hearth:"..species.."_log"]
local leaf=f.nodes["hearth:"..species.."_leaves"]
@@ -169,8 +169,8 @@ test("geological stone remains useful masonry and canonical foundations", functi
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)
test("all 49 terrain recipes have unique shapeless inputs including multiplicity", function()
local f=loaded(); assert(#f.crafts==49)
local seen={}
for _, craft in ipairs(f.crafts) do
assert(craft.type=="shapeless")
@@ -356,8 +356,8 @@ test("world texture contract is explicit for every new node", function()
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())
-- Thirteen woods each have their own end grain, bark, foliage and planks.
assert(count(textures)==74, "unexpected world tile count "..count(textures))
assert(f.registrations()==60, "unexpected private node count "..f.registrations())
end)
print("PASS " .. passed .. " biome/geology checks")
+89 -16
View File
@@ -33,9 +33,9 @@ local function reset()
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,
local inv={room_for_item=function(_,list,item) assert(list=="main" and (item=="hearth:berries 2" or item=="hearth:berries 3")); return room end,
add_item=function(_,list,item)
assert(list=="main" and item=="hearth:berries 2"); added=added+2-leftover
assert(list=="main" and (item=="hearth:berries 2" or item=="hearth:berries 3")); added=added+tonumber(item:match("(%d+)$"))-leftover
return {get_count=function() return leftover end}
end}
local player={is_player=function() return true end,get_inventory=function() return inv end}
@@ -56,12 +56,12 @@ 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)
test("exactly one hundred disjoint real taxa in eight uneven groups",function()
assert(#B.groups==8 and #B.catalogue==100 and #B.list()==100)
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)
assert(#B.list(g)==({trees=13,shrubs=14,grasses=10,herbs=22,ferns_mosses=11,fungi=10,aquatic=10,lichens=10})[g],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)
@@ -69,6 +69,21 @@ test("exactly eighty disjoint real taxa in eight ordered groups of ten",function
end
end
end)
test("twenty expansion identities match the lifecycle contract with nitrogen and flower seasons",function()
local L=dofile(target:gsub("[^/\\]+$","lifecycle.lua"))
local ids={"dandelion","daisy","red_clover","birdsfoot_trefoil","ribwort_plantain","selfheal","knapweed",
"oxeye_daisy","field_scabious","teasel","cow_parsley","garlic_mustard","honeysuckle","ivy","gorse","broom",
"crab_apple","wild_cherry","field_maple","holly_fern"}
assert(#ids==20)
for _,id in ipairs(ids) do
local r=B.get(id); local p=L.plant(id)
assert(r and p and r.latin==p.latin and r.group==p.group and not r.food,id)
end
for _,id in ipairs({"red_clover","birdsfoot_trefoil","gorse","broom"}) do assert(B.nitrogen(id,0)==1) end
assert(B.pollinator("ivy","Autumn") and not B.pollinator("ivy","Spring"))
assert(B.pollinator("gorse","Winter") and not B.pollinator("ribwort_plantain","Summer"))
assert(B.seeding("garlic_mustard","Summer") and not B.seeding("garlic_mustard","Spring"))
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",
@@ -94,7 +109,7 @@ test("catalogue snapshots and getters cannot alter internal policy",function()
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()
test("all one hundred 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()
@@ -213,7 +228,7 @@ test("nitrogen is capped and fungi transfer only an existing positive budget",fu
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()
test("register exactly seventy-nine 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)
@@ -222,7 +237,7 @@ test("register exactly sixty-two missing understory nodes, never legacy or trees
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)
assert(count==79); 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
@@ -253,17 +268,19 @@ test("harvest checks permission before metadata or inventory and returns at most
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)
assert(ok and reason==2 and added==2 and tonumber(fields[key(p)].botany_ready_at)==time+365.2425*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(not B.harvest(p,player) and added==2)
time=time+86400*400
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)
assert(not ok and reason=="inventory" and fields[key(p)].botany_ready_at=="" 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))
@@ -296,11 +313,11 @@ test("rightclick returns item stack and reentrant inventory cannot duplicate",fu
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()
test("the actual position selector can return each of all hundred 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
while (x*17+7)%100~=index-1 do x=x+1; assert(x<100) 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)))
@@ -317,7 +334,7 @@ test("submerged candidates work as separate air-cell stands, never as liquid nod
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()
test("all seventy-nine 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]
@@ -335,7 +352,7 @@ test("all sixty-two new understory policies match actual activity and dormant ar
end
end
end
assert(count==62)
assert(count==79)
policies.holly.active.Winter=false; assert(B.active("holly","Winter"))
assert(next(policies.juniper.definitions)==nil and next(policies.holly.definitions)==nil)
end)
@@ -354,7 +371,7 @@ test("harvest samples one validated epoch before climate and uses the same coold
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)
assert(tonumber(fields[key(p)].botany_ready_at)==1782043200+365.2425*86400)
end)
test("only exact owned seasonal aliases harvest in an active fruit season",function()
bush("bilberry_winter"); season="Winter"
@@ -375,6 +392,62 @@ test("southern berry seasons use the solar model at the validated epoch",functio
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)
assert(tonumber(fields[key(p)].botany_ready_at)==time+365.2425*86400)
end)
test("shared annual fruit readiness rejects an animal debit and has no daily refill",function()
bush(); local m=A.get_meta(p)
m:set_string("botany_ready_at",tostring(time+365.2425*86400))
assert(not B.harvest(p,player) and added==0)
time=time+86400; assert(not B.harvest(p,player))
time=time+(365.2425-1)*86400; assert(B.harvest(p,player) and added==2)
end)
test("one unexpired pollination credit adds only one berry and is spent once",function()
bush(); local m=A.get_meta(p)
m:set_string("succ_pollinated_until",tostring(time+86400))
local ok,n=B.harvest(p,player); assert(ok and n==3 and added==3)
assert(m:get_string("succ_pollinated_until")=="0")
assert(not B.harvest(p,player) and added==3)
time=time+365.2425*86400; ok,n=B.harvest(p,player)
assert(ok and n==2 and added==5)
end)
test("expired pollination is not a bonus and invalid credits fail closed",function()
bush(); local m=A.get_meta(p)
for _,v in ipairs({"broken","-1",tostring(time+86401),"inf"}) do
m:set_string("succ_pollinated_until",v)
local before=writes; assert(not B.harvest(p,player) and added==0 and writes==before)
end
m:set_string("succ_pollinated_until",tostring(time))
local ok,n=B.harvest(p,player); assert(ok and n==2 and added==2)
end)
test("empty fruit cannot be manufactured by fresh pollination",function()
bush(); local m=A.get_meta(p)
m:set_string("botany_ready_at",tostring(time+86400))
m:set_string("succ_pollinated_until",tostring(time+86400))
local before=writes; assert(not B.harvest(p,player) and writes==before and added==0)
assert(tonumber(m:get_string("succ_pollinated_until"))==time+86400)
end)
test("inventory rejection restores fruit and credit; partial delivery spends both",function()
bush(); local m=A.get_meta(p); local pollen=tostring(time+86400)
m:set_string("succ_pollinated_until",pollen)
room=false; assert(not B.harvest(p,player))
assert(m:get_string("botany_ready_at")=="" and m:get_string("succ_pollinated_until")==pollen)
room=true; leftover=3; assert(not B.harvest(p,player))
assert(m:get_string("botany_ready_at")=="" and m:get_string("succ_pollinated_until")==pollen)
leftover=2; local ok,n=B.harvest(p,player); assert(ok and n==1 and added==1)
assert(m:get_string("succ_pollinated_until")=="0" and not B.harvest(p,player))
end)
test("both inventory callbacks observe reserved fruit and pollination credit",function()
bush(); local m=A.get_meta(p); m:set_string("succ_pollinated_until",tostring(time+86400))
local old_room,old_add=inv.room_for_item,inv.add_item
local function check()
assert(not B.harvest(p,player))
assert(m:get_string("succ_pollinated_until")=="0")
end
inv.room_for_item=function(...) check(); return old_room(...) end
inv.add_item=function(...) check(); return old_add(...) end
local ok,n=B.harvest(p,player)
inv.room_for_item,inv.add_item=old_room,old_add
assert(ok and n==3 and added==3)
end)
assert(passed==44,"Unexpected focused botany integration count")
print("BOTANY_SPEC_PASS "..passed)
+13 -13
View File
@@ -14,23 +14,23 @@ const pixel = (im,tile,x,y)=> {
};
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',()=> {
test('exact public option IDs and 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'],
bodies:['compact','lean','balanced','broad','sturdy'],skin:['porcelain','sand','honey','olive','umber','ebony'],
faces:['calm','angular','soft','weathered','lined'],eyes:['hazel','brown','green','blue','grey'],
hairStyles:['cropped','sidepart','wavy','braids','long','shaved','none','curly','topknot','undercut'],
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'],
facialHair:['none','stubble','moustache','short_beard','full_beard','goatee','braided_beard'],
garments:['tunic','workshirt','jerkin','coat','hooded_cloak','travel_coat','wool_mantle'],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'],
accents:['none','scarf','apron','sash','vest','satchel','tool_belt','pauldrons','hood'],details:['none','freckles','scar','freckles_scar','birthmark'],
});
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.equal(M.length,expected.length); assert.equal(new Set(expected).size,expected.length);
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);
@@ -59,7 +59,7 @@ test('all layers are native 64 RGBA, crisp alpha, no hidden RGB; only named none
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']};
accent:['tunic','back','sleeve','face','hair','side'],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])
@@ -116,10 +116,10 @@ test('layer names and order match Luanti composition; garments cannot erase long
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']);
'hearth_char_hair_long_chestnut.png','hearth_char_beard_none_chestnut.png',
'hearth_char_accent_scarf_sage.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]);
const im=compositeCharacter({hair:'long',garment:'coat',cloth:'rust'}), hair=characterLayer(names[6]);
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);
@@ -150,7 +150,7 @@ test('clothing has tailored collars, rolled cuffs, leather seams, plackets, boot
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);
assert.equal(new Set(masks.map(bytes=>createHash('sha256').update(bytes).digest('hex'))).size,O.accents.length);
for (const id of O.boots) assert.deepEqual(pixel(characterLayer(`hearth_char_boots_${id}.png`),'boot',0,7),[30,28,26,255]);
});
+22 -5
View File
@@ -56,8 +56,8 @@ local function harness()
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}
local counts = {body=5,skin=6,face=5,eyes=5,hair=10,hairColor=6,beard=7,garment=7,cloth=8,
trousers=4,boots=3,accent=9,accentColor=8,detail=5}
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)
@@ -88,7 +88,7 @@ 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(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_hair_sidepart_chestnut.png^hearth_char_beard_none_chestnut.png^hearth_char_accent_scarf_sage.png^hearth_char_detail_none.png")
assert(not C.preview({}))
end)
test("bounded silent JSON roundtrip refuses malformed oversize nested and invalid data", function()
@@ -157,6 +157,14 @@ test("draft tabs and rotations never publish player properties or metadata", fun
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("two-digit dropdown indices select the expanded cosmetic catalogue safely", function()
local h=harness(); local p=h.player(); h.C.show(p)
assert(#h.C.options().hair==10)
assert(h.C.handle(p,{tab_2=true}))
assert(h.C.handle(p,{pick_hair="10",pick_hairColor="6",pick_beard="7"}))
assert(h.C.state(p).draft.hair=="undercut" and h.C.state(p).draft.beard=="braided_beard")
assert(not h.C.handle(p,{pick_hair="00"}))
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]
@@ -169,7 +177,7 @@ test("apply writes first then publishes and saved data survives a new session",
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(h.C.handle(p,{pick_skin="6",pick_body="4",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)
@@ -212,5 +220,14 @@ test("stale sessions reconnects closed drafts and disconnected objects cannot sa
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")
test("creator tooltip text passes through escaping without changing model semantics",function()
local h=harness(); local p=h.player(); local seen={}
local escape=h.api.formspec_escape
h.api.formspec_escape=function(s) seen[s]=true; return escape(s) end
h.C.show(p)
local text="Drag to turn the preview. Only this model rotates."
assert(seen[text] and h.screens[#h.screens].spec:find("tooltip[traveler;"..escape(text).."]",1,true))
assert(h.screens[#h.screens].spec:find(";0,180;true;true;0,19;12]",1,true))
end)
assert(total==20,"Unexpected character test count")
print("PASS " .. total .. " character creator tests")
+74 -12
View File
@@ -266,7 +266,7 @@ test("biome and diversity integration is unique, complete and ordered before sea
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(#hearth.biomes.species_order,13)
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
@@ -286,7 +286,7 @@ test("biome and diversity integration is unique, complete and ordered before sea
end
assert(index["hearth:oak_leaves"] < index["hearth:oak_seedling"])
assert(index["hearth:lichen"] < index["hearth:oak_leaves_winter"])
eq(#hearth.botany.catalogue,80)
eq(#hearth.botany.catalogue,100)
local botany_nodes=0
for _,record in ipairs(hearth.botany.catalogue) do
assert(nodes[record.node],record.node)
@@ -297,7 +297,7 @@ test("biome and diversity integration is unique, complete and ordered before sea
eq(def.tiles[1],record.texture); eq(def.inventory_image,record.texture); eq(def.wield_image,record.texture)
end
end
eq(botany_nodes,62)
eq(botany_nodes,79)
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)
@@ -372,12 +372,12 @@ test("all species and rocks keep single-item products and pair-only mass-preserv
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()
test("all 87 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)
eq(#crafts,87)
local signatures, outputs={},{}
for i, def in ipairs(crafts) do
assert(def.type==nil or def.type=="shapeless", "unsupported craft type")
@@ -405,7 +405,7 @@ test("all 78 registered recipes close uniquely with exact output names and count
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)
eq(output_count,55)
for i, expected in ipairs(signatures) do
local matches=0
for j, candidate in ipairs(signatures) do
@@ -965,14 +965,16 @@ test("botanical silhouettes have crisp alpha and waving without losing ecosystem
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)
test("all 23 fauna register the actual animated catalogue assets at node scale", function()
for _, r in ipairs(hearth.fauna.list()) do
local def = assert(entities[r.entity], r.id)
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, "mesh"); eq(props.mesh, r.mesh)
eq(#props.textures, 1); eq(props.textures[1], r.texture)
eq(props.visual_size.x, 1); eq(props.visual_size.y, 1)
eq(props.collisionbox[2], 0); eq(props.static_save, false)
eq(props.collisionbox[2], 0); eq(props.static_save, true)
eq(r.frames,100); eq(r.fps,24)
assert(def.get_staticdata and def.on_step)
end
end)
test("local art registrars do not wrap global APIs or require optional mock registries", function()
@@ -1068,5 +1070,65 @@ test("drying racks require a shared claim and cloaks toggle persistent player pr
eq(player_meta:get_int("hearth:cloak"), 0)
end)
test("real factory wiring publishes calendar policy and the exact wildlife fauna catalogue",function()
local L=assert(hearth.lifecycle)
eq(L.SECONDS_PER_DAY,86400); eq(L.DAYS_PER_YEAR,365.2425)
eq(#L.ids("plant"),100); eq(#L.ids("animal"),23)
eq(hearth.fauna,hearth.wildlife.fauna)
eq(#hearth.fauna.list(),23)
local status=hearth.wildlife.status(); eq(status.species_count,23)
local breeding={bank_vole=true,wood_mouse=true,blackbird=true,mallard=true,hare=true,fox=true}
for _,r in ipairs(hearth.fauna.list()) do
assert(entities[r.entity] and status.counts[r.id]==0,r.id)
assert(r.profile and r.profile.reproduction_enabled==(breeding[r.id]==true),r.id)
assert(not hearth.fauna.can_breed(r.id,1782043200,1782043200,"female"),"Newborn must not breed: "..r.id)
end
-- UTC dates: 2026-06-21 and 2027-06-21, NOT dates built in local time.
local phase=L.phase(L.plant("oak"),1782043200,1813579200)
eq(phase.age_days,365); eq(phase.death_due,false)
local groups={trees=13,shrubs=14,grasses=10,herbs=22,ferns_mosses=11,fungi=10,aquatic=10,lichens=10}
for _,r in ipairs(hearth.botany.list()) do
local profile=assert(L.plant(r.id,r.group)); eq(profile.id,r.id)
eq(profile.kind,"plant"); eq(profile.group,r.group)
assert(type(profile.confidence)=="string" and type(profile.evidence)=="table")
for _,field in ipairs({"germination_days","juvenile_days","maturity_days","lifespan_days"}) do
local range=profile[field]
assert(range==false or (type(range)=="table" and range.min>=0 and range.max>=range.min
and type(range.confidence)=="string" and range.source_url~=nil),r.id..":"..field)
end
local start=assert(L.phase(profile,1782043200,1782043200)); eq(start.age_days,0)
local after=assert(L.phase(profile,1782043200,1813579200)); eq(after.age_days,365)
eq(after.death_due,false); assert(L.resolve(profile,731104),r.id)
eq(#hearth.botany.list(r.group),groups[r.group])
assert(nodes[r.node],r.node)
if r.group=="trees" then
for _,part in ipairs({"seedling","young","mature","log","leaves","planks"}) do
eq(hearth.botany.kind("hearth:"..r.id.."_"..part),r.id)
end
end
end
end)
test("actual pollination and frugivore transactions share the human annual fruit budget",function()
local old_climate,old_allowed=hearth.climate_at,hearth.claim_allowed
hearth.climate_at=function() return {season="Summer"} end
hearth.claim_allowed=function() return true end
claim=false; now=1782043200
local p=pos(0,1,0); put(pos(),"hearth:grass"); put(p,"hearth:bilberry")
local m=core.get_meta(p); m:set_string("succ_natural","1")
assert(hearth.succession.pollinate(p)) -- requires lifecycle captured at construction
eq(tonumber(m:get_string("succ_pollinated_until")),now+86400)
local ok,n=hearth.botany.harvest(p,player); assert(ok); eq(n,3)
eq(m:get_string("succ_pollinated_until"),"0")
local deadline=now+365.2425*86400
eq(tonumber(m:get_string("botany_ready_at")),deadline)
assert(not hearth.succession.frugivore(p,"bilberry"))
now=deadline; assert(hearth.succession.frugivore(p,"bilberry"))
eq(tonumber(m:get_string("botany_ready_at")),now+365.2425*86400)
assert(not hearth.botany.harvest(p,player))
hearth.climate_at,hearth.claim_allowed=old_climate,old_allowed
end)
os.time = real_time
assert(passed==40,"Unexpected focused content integration count")
print("1.." .. passed)
+2 -2
View File
@@ -40,9 +40,9 @@ local function register()
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()
test("exact thirteen species and eight legacy 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(table.concat(D.species_order,",")=="oak,birch,pine,willow,alder,beech,aspen,rowan,hazel,spruce,crab_apple,wild_cherry,field_maple")
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])
+72 -11
View File
@@ -67,14 +67,18 @@ local function test(name, fn)
end
local planted_at = 100000
test("crop climate uses historical samples, freezing pauses growth, and catch-up is capped", function()
test("grain needs 120 real growing days and samples the whole interval with bounded work", 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.crop_stage(planted_at,planted_at+119*86400)<4)
assert(ecology.crop_stage(planted_at,planted_at+120*86400)==4)
assert(ecology.climate_progress(planted_at, planted_at + 90 * 86400)
== ecology.climate_progress(planted_at, planted_at + 30 * 86400))
> ecology.climate_progress(planted_at, planted_at + 30 * 86400))
local calls=0; local climate=hearth.climate_at
hearth.climate_at=function(epoch) calls=calls+1; return climate(epoch) end
assert(ecology.crop_stage(planted_at,planted_at+10000*86400)==4 and calls==366)
hearth.climate_at=climate
assert(ecology.climate_progress("nan", planted_at + 1) == 0)
assert(ecology.climate_progress(planted_at, math.huge) == 0)
end)
@@ -83,16 +87,17 @@ test("crop stages derive from a durable planting timestamp independent of update
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)
local stage = ecology.crop_stage(planted_at, planted_at + 30 * 86400)
assert(stage >= 2 and stage <= 4)
local same_late_stage = ecology.crop_stage(planted_at, planted_at + 2 * 86400)
local same_late_stage = ecology.crop_stage(planted_at, planted_at + 30 * 86400)
assert(stage == same_late_stage)
now = planted_at + 30 * 86400
now = planted_at + 120 * 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()
test("berry regrowth is annual, Summer gated and harvest does not destroy provenance", function()
local p = {x=2,y=1,z=0}
nodes[key(p)] = {name="hearth:berry_bush"}
meta(p):set_string("succ_natural","1")
inventory_room = false
ecology.harvest_berries(p, player)
assert(nodes[key(p)].name == "hearth:berry_bush" and delivered == 0)
@@ -100,11 +105,13 @@ test("berry regrowth is a single absolute deadline and harvest does not destroy
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)
assert(ready == now + 365.2425*86400 and meta(p):get_string("succ_natural")=="1")
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
season="Winter"; assert(ecology.refresh_bush(p)==true)
season="Summer"
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()
@@ -130,13 +137,15 @@ test("immature or unauthorized crops cannot be harvested", function()
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
test("generic sapling waits one real year, respects claims, and writes a bounded young tree once", function()
now = 2000000000
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")
assert(ecology.grow_sapling(p)==true and nodes[key(p)].name=="hearth:sapling")
now=now+365.2425*86400
permitted = false
assert(ecology.grow_sapling(p) == true and nodes[key(p)].name == "hearth:sapling")
permitted = true
@@ -188,5 +197,57 @@ test("forage availability checks the calendar even before appearance has reconci
assert(not reload.harvest_seasonal(mushrooms,player) and delivered==1)
end)
test("berry and seasonal reservations precede reentrant inventory callbacks",function()
local berry,patch={x=30,y=1,z=0},{x=31,y=1,z=0}
nodes[key(berry)]={name="hearth:berry_bush"}
nodes[key(patch)]={name="hearth:spring_greens_patch_spring"}
local original=inventory.add_item
local inside=false
inventory.add_item=function(self,list,item)
assert(not inside,"a nested harvest reached inventory")
inside=true
assert(not ecology.harvest_berries(berry,player))
assert(not ecology.harvest_seasonal(patch,player))
inside=false
return original(self,list,item)
end
season="Spring"
-- Lock the other resource so both recursive calls are unambiguously denied.
meta(patch):set_string("succ_harvest_ready_at",tostring(now+86400))
assert(ecology.harvest_berries(berry,player))
meta(patch):set_string("succ_harvest_ready_at","")
assert(ecology.harvest_seasonal(patch,player))
inventory.add_item=original
end)
test("failed delivery restores readiness and malformed fruit or patch clocks never refill",function()
local p={x=32,y=1,z=0}
local original=inventory.add_item
inventory.add_item=function(_,_,item) return make_stack(item:get_name().." "..item:get_count()) end
nodes[key(p)]={name="hearth:berry_bush"}
assert(not ecology.harvest_berries(p,player) and meta(p):get_string("ready_at")=="")
inventory.add_item=original
for _,bad in ipairs({"broken","-1","inf","nan"}) do
nodes[key(p)]={name="hearth:berry_bush"}; meta(p):set_string("ready_at",bad)
local before=delivered
assert(not ecology.harvest_berries(p,player) and delivered==before)
nodes[key(p)]={name="hearth:berry_bush_bare"}
assert(not ecology.refresh_bush(p) and nodes[key(p)].name=="hearth:berry_bush_bare")
nodes[key(p)]={name="hearth:spring_greens_patch_spring"}
meta(p):set_string("succ_harvest_ready_at",bad); season="Spring"
assert(not ecology.harvest_seasonal(p,player) and delivered==before)
end
end)
test("annual berry lock survives adapter restart, node variants and a one-day return",function()
local p={x=33,y=1,z=0}
nodes[key(p)]={name="hearth:berry_bush"}
assert(ecology.harvest_berries(p,player))
local due=meta(p):get_string("ready_at"); now=now+86400
local reload=dofile(target)(hearth,api,construction)
assert(reload.refresh_bush(p) and meta(p):get_string("ready_at")==due)
local before=delivered; reload.harvest_berries(p,player); assert(delivered==before)
-- Even a mistakenly restored full-looking node cannot reset its receipt.
nodes[key(p)]={name="hearth:berry_bush"}
assert(not reload.harvest_berries(p,player) and delivered==before)
end)
os.time = real_time
print("PASS " .. passed .. " bounded ecology tests")
+11 -9
View File
@@ -133,7 +133,7 @@ local function registry_check()
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")
assert(#hearth.diversity.species_order == 13, "Expected thirteen timber species")
for _, species in ipairs(hearth.diversity.species_order) do
local name = species .. "_log"
timber[#timber + 1] = name
@@ -145,7 +145,7 @@ local function registry_check()
"Bare hands unexpectedly harvest " .. name)
end
local catalogue = hearth.botany.list()
assert(#catalogue == 80, "Expected all 80 botanical species")
assert(#catalogue == 100, "Expected all 100 botanical species")
for _, record in ipairs(catalogue) do
assert(core.registered_nodes[record.node], "Missing native botanical species: " .. record.node)
end
@@ -168,7 +168,7 @@ local function registry_check()
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="
core.log("action", "Last Hearth native botany/wood: species=100, timber_species=13, 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",
@@ -325,13 +325,15 @@ local function worldgen_registry_check()
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
for setting, expected in pairs({mgv7_np_terrain_base={12,45,900,384,900}, mgv7_np_terrain_alt={-8,24,900,384,900},
mgv7_np_height_select={-4,14,1400,320,1400}, mgv7_np_mount_height={250,100,700,700,700},
mgv7_np_mountain={-0.6,1,320,280,320}}) 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)
and actual.spread.x == expected[3] and actual.spread.y == expected[4]
and actual.spread.z == expected[5], "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")
core.log("action", "Last Hearth worldgen registry: biomes=10, ores=9; saved Epic-inspired v7 terrain base=12/45/900x384x900, alt=-8/24, height_select=-4/14, mount=250/100, mapgen_limit=4096")
end
local function unit_check()
@@ -346,7 +348,7 @@ local function unit_check()
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
"m2_processes", "m2_rest", "recipe", "botany", "species_effects", "species_reader", "character", "lifecycle", "fauna"}) do
assert(lookup["tests/" .. module .. "_spec.lua"], "Missing regression suite: " .. module)
end
local test_modules = {m2_processes = "processes", m2_rest = "runtime", recipe = "interface"}
@@ -391,7 +393,7 @@ local function unit_check()
count = count + 1
end
end
assert(count == 29, "Expected all 29 regression suites; got " .. count)
assert(count == 31, "Expected all 31 regression suites; got " .. count)
core.log("action", "Last Hearth: " .. count .. " isolated unit suites executed")
end
+246
View File
@@ -0,0 +1,246 @@
// Independent little-endian B3D reader + forward kinematics. No encoder helpers,
// engine mocks or assumptions that a magic header implies a working animation.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { inflateSync } from 'node:zlib';
import { FAUNA_NAMES, FAUNA_ANIMATIONS, FAUNA_ART_MANIFEST, FAUNA_MATERIALS,
faunaB3D, faunaTexture, faunaBounds, faunaPortraitSheet } from '../scripts/fauna-art.mjs';
import { png } from '../scripts/assets.mjs';
import { worldArt, animalOBJ } from '../scripts/world-art.mjs';
const names='hare deer fox rabbit red_squirrel bank_vole wood_mouse hedgehog badger roe_deer stoat weasel otter robin blackbird blue_tit barn_owl mallard common_frog smooth_newt brown_trout bumblebee earthworm'.split(' ');
const hash=b=>createHash('sha256').update(b).digest('hex');
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);
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 near=(a,b,epsilon=1e-5)=>assert.ok(Math.abs(a-b)<epsilon,`${a} != ${b}`);
function parse(bytes) {
const nodes=[],vertices=[],triangles=[]; let anim,texture,meshCount=0,brushCount=0;
const integer=p=>bytes.readInt32LE(p);
const floats=(p,n)=>Array.from({length:n},(_,i)=>bytes.readFloatLE(p+i*4));
function string(p,end) {
const stop=bytes.indexOf(0,p); assert.ok(stop>=p&&stop<end);
return [bytes.toString('ascii',p,stop),stop+1];
}
function chunks(start,end,parent=null) {
let p=start;
while(p<end) {
assert.ok(p+8<=end);
const tag=bytes.toString('ascii',p,p+4),size=integer(p+4),a=p+8,b=a+size;
assert.ok(size>=0&&b<=end); p=b;
if(tag==='BB3D') { assert.equal(integer(a),1); chunks(a+4,b); }
else if(tag==='TEXS') {
const [name,next]=string(a,b); texture=name;
assert.deepEqual([integer(next),integer(next+4)],[1,2]);
assert.deepEqual(floats(next+8,5),[0,0,1,1,0]); assert.equal(next+28,b);
} else if(tag==='BRUS') {
brushCount++; assert.equal(integer(a),1); const [,next]=string(a+4,b);
assert.deepEqual(floats(next,5),[1,1,1,1,0]);
assert.deepEqual([integer(next+20),integer(next+24),integer(next+28)],[1,0,0]);
assert.equal(next+32,b);
} else if(tag==='NODE') {
const [name,next]=string(a,b), transform=floats(next,10);
const node={name,parent,pos:transform.slice(0,3),weights:[],keys:[]}; nodes.push(node);
assert.deepEqual(transform.slice(3),[1,1,1,1,0,0,0]); chunks(next+40,b,node);
} else if(tag==='MESH') { meshCount++; assert.equal(integer(a),0); chunks(a+4,b,parent); }
else if(tag==='VRTS') {
assert.deepEqual([integer(a),integer(a+4),integer(a+8)],[1,1,2]);
assert.equal((size-12)%32,0);
for(let q=a+12;q<b;q+=32) vertices.push(floats(q,8));
} else if(tag==='TRIS') {
assert.equal(integer(a),0); assert.equal((size-4)%12,0);
for(let q=a+4;q<b;q+=12) triangles.push([integer(q),integer(q+4),integer(q+8)]);
} else if(tag==='BONE') {
assert.ok(parent); assert.equal(size%8,0);
for(let q=a;q<b;q+=8) parent.weights.push([integer(q),bytes.readFloatLE(q+4)]);
} else if(tag==='KEYS') {
assert.ok(parent); assert.equal(integer(a),5); assert.equal((size-4)%32,0);
for(let q=a+4;q<b;q+=32) parent.keys.push({frame:integer(q),pos:floats(q+4,3),rotation:floats(q+16,4)});
} else if(tag==='ANIM') {
assert.ok(!anim); assert.equal(size,12); anim=[integer(a),integer(a+4),bytes.readFloatLE(a+8)];
} else assert.fail(`Unexpected B3D chunk ${tag}`);
}
assert.equal(p,end);
}
chunks(0,bytes.length);
assert.equal(bytes.toString('ascii',0,4),'BB3D'); assert.equal(integer(4)+8,bytes.length);
assert.equal(meshCount,1); assert.equal(brushCount,1);
return {nodes,vertices,triangles,anim,texture};
}
function multiply([w,x,y,z],[a,b,c,d]) {
return [w*a-x*b-y*c-z*d,w*b+x*a+y*d-z*c,w*c-x*d+y*a+z*b,w*d+x*c-y*b+z*a];
}
function rotate(q,v) { return multiply(multiply(q,[0,...v]),[q[0],-q[1],-q[2],-q[3]]).slice(1); }
function skin(mesh,frame) {
const states=new Map(),output=[];
for(const node of mesh.nodes) {
const p=states.get(node.parent), key=node.keys[frame];
const bind=node.pos.map((v,i)=>v+(p?.bind[i]||0));
const q=p?multiply(p.q,key.rotation):key.rotation;
const pos=p?rotate(p.q,key.pos).map((v,i)=>v+p.pos[i]):key.pos;
states.set(node,{bind,q,pos});
for(const [id,weight] of node.weights) {
const point=rotate(q,sub(mesh.vertices[id].slice(0,3),bind)).map((v,i)=>v+pos[i]);
output[id]=point.map(v=>v*weight);
}
}
return output;
}
test('exact immutable 23-species/5-clip handoff, provenance, fresh deterministic outputs and fail-closed inputs',()=> {
assert.deepEqual(FAUNA_NAMES,names);
assert.deepEqual(FAUNA_ANIMATIONS,{idle:[0,19,24],locomotion:[20,39,24],feed:[40,59,24],flight_swim:[60,79,24],rest:[80,99,24]});
assert.equal(FAUNA_ART_MANIFEST.length,23); assert.equal(FAUNA_MATERIALS.length,16);
for(const entry of FAUNA_ART_MANIFEST) {
assert.ok(Object.isFrozen(entry)&&Object.isFrozen(entry.bounds.min));
assert.equal(entry.mesh,`hearth_fauna_${entry.id}.b3d`); assert.equal(entry.texture,`hearth_fauna_${entry.id}.png`);
assert.deepEqual(entry.bounds,faunaBounds(entry.id)); assert.equal(entry.unitsPerNode,10); assert.equal(entry.visualSize,1);
assert.equal(entry.license,'MIT'); assert.ok(entry.posture.length>15); assert.deepEqual(entry.clips,FAUNA_ANIMATIONS);
assert.deepEqual(faunaB3D(entry.id),faunaB3D(entry.id)); assert.deepEqual(faunaTexture(entry.id),faunaTexture(entry.id));
const altered=faunaTexture(entry.id); altered.data.fill(0); assert.ok(faunaTexture(entry.id).data.some(v=>v));
}
for(const bad of ['../hare','constructor','toString','Hare',null,{},'wolf']) {
for(const fn of [faunaB3D,faunaTexture,faunaBounds]) assert.throws(()=>fn(bad),/Unknown fauna species/);
}
for(const bad of [-1,100,.5,NaN,null,'0']) assert.throws(()=>faunaPortraitSheet(bad),/Unknown fauna frame/);
const art=worldArt(); assert.equal(art.size,421);
for(const id of names) assert.ok(!art.has(`hearth_fauna_${id}.png`),'64px fauna atlas must not enter the 512-tile world sheet');
for(const id of ['hare','fox','deer']) assert.match(animalOBJ(id),/^# Original/,'legacy OBJ API remains available');
});
test('independent B3D parse validates all vertices, outward winding, normals, inset semantic UVs, normalized weights and bind bounds',()=> {
const unique=new Set();
for(const id of names) {
const bytes=faunaB3D(id),m=parse(bytes); unique.add(hash(bytes));
assert.ok(bytes.length<180000&&bytes.length>20000); assert.deepEqual(m.anim,[0,100,24]);
assert.equal(m.texture,`hearth_fauna_${id}.png`);
assert.equal(m.nodes.filter(n=>!n.parent).length,1); assert.equal(m.nodes[0].name,'Root');
assert.equal(new Set(m.nodes.map(n=>n.name)).size,m.nodes.length);
assert.ok(m.nodes.length>=6&&m.nodes.length<=30); assert.ok(m.vertices.length>=144&&m.vertices.length<=1600);
assert.equal(m.vertices.length%24,0); assert.equal(m.triangles.length,m.vertices.length/2);
const weights=new Float64Array(m.vertices.length),references=new Set();
for(const n of m.nodes) for(const [v,w] of n.weights) {
assert.ok(v>=0&&v<m.vertices.length&&w>0&&w<=1); weights[v]+=w;
}
for(const w of weights) near(w,1);
assert.ok(m.vertices.flat().every(Number.isFinite));
for(const [a,b,c] of m.triangles) {
assert.equal(new Set([a,b,c]).size,3);
for(const v of [a,b,c]) { assert.ok(v>=0&&v<m.vertices.length); references.add(v); }
const vs=[a,b,c].map(i=>m.vertices[i]),normal=cross(sub(vs[1].slice(0,3),vs[0].slice(0,3)),sub(vs[2].slice(0,3),vs[0].slice(0,3)));
assert.ok(Math.hypot(...normal)>1e-6);
const first=Math.floor(a/24)*24,box=m.vertices.slice(first,first+24),center=[0,1,2].map(i=>box.reduce((sum,v)=>sum+v[i],0)/24);
assert.ok(dot(normal,sub(vs[0].slice(0,3),center))>0,`${id}: outward solid winding`);
const tiles=vs.map(v=>v.slice(6).map(n=>Math.floor(n*4)).join()); assert.equal(new Set(tiles).size,1);
for(const v of vs) {
near(Math.hypot(...v.slice(3,6)),1); near(dot(normal,v.slice(3,6))/Math.hypot(...normal),1);
for(const u of v.slice(6)) {
assert.ok(u>0&&u<1); const px=u*64%16; assert.ok(px>=.5&&px<=15.5);
}
}
}
assert.equal(references.size,m.vertices.length);
const bound=faunaBounds(id);
near(bound.min[1],0);
for(let i=0;i<3;i++) {
near(Math.min(...m.vertices.map(v=>v[i]))/10,bound.min[i]);
near(Math.max(...m.vertices.map(v=>v[i]))/10,bound.max[i]);
assert.ok(bound.max[i]>bound.min[i]&&bound.max[i]-bound.min[i]<2);
}
}
assert.equal(unique.size,23);
});
test('every bone contains all 100 real poses; independent skinning proves each clip moves weighted geometry with no root motion or scale keys',()=> {
for(const id of names) {
const m=parse(faunaB3D(id));
for(const n of m.nodes) {
assert.equal(n.keys.length,100);
n.keys.forEach((key,i)=> {
assert.equal(key.frame,i+1); assert.ok([...key.pos,...key.rotation].every(Number.isFinite));
near(Math.hypot(...key.rotation),1);
if(n.name==='Root') { assert.deepEqual(key.pos,[0,0,0]); assert.deepEqual(key.rotation,[1,0,0,0]); }
});
}
for(const [clip,[a,b]] of Object.entries(FAUNA_ANIMATIONS)) {
const poses=Array.from({length:20},(_,i)=>skin(m,a+i));
assert.ok(poses.flat(2).every(Number.isFinite));
let displacement=0;
for(let f=1;f<20;f++) for(let v=0;v<m.vertices.length;v++)
displacement=Math.max(displacement,Math.hypot(...sub(poses[f][v],poses[0][v])));
assert.ok(displacement>.001,`${id} ${clip}: genuine skeletal displacement`);
for(let v=0;v<m.vertices.length;v++) for(let i=0;i<3;i++) near(poses[0][v][i],poses[b-a][v][i],2e-5);
}
const body=m.nodes.find(n=>n.name==='Body');
for(const key of body.keys) near(key.pos[0],body.pos[0]);
}
});
test('authored anatomy and chained joint motion distinguish ears, antlers, wings, fins, spines, mustelids and a twelve-segment earthworm',()=> {
const models=Object.fromEntries(names.map(id=>[id,parse(faunaB3D(id))]));
const node=(id,name)=>models[id].nodes.find(n=>n.name===name);
const extent=(id,name,axis)=> {
const values=node(id,name).weights.map(([v])=>models[id].vertices[v][axis]); return Math.max(...values)-Math.min(...values);
};
assert.ok(extent('hare','Ear_-1',1)>extent('rabbit','Ear_-1',1)*1.5);
assert.ok(faunaBounds('deer').max[1]>faunaBounds('roe_deer').max[1]);
assert.ok(node('deer','Antler_-1').parent===node('deer','Head'));
assert.ok(extent('red_squirrel','Tail',1)>extent('red_squirrel','Tail',0)*2,'raised slender squirrel brush');
near(extent('fox','Tail',1),extent('fox','Tail',0));
assert.ok(extent('wood_mouse','Tail_Tip',2)>extent('bank_vole','Tail_Tip',2)*2);
assert.ok(extent('stoat','Body',2)>extent('weasel','Body',2));
for(const id of ['hare','deer','fox','badger','otter','common_frog','smooth_newt']) {
assert.equal(node(id,'Shin_-1_-1').parent,node(id,'Leg_-1_-1'));
assert.notDeepEqual(node(id,'Leg_-1_-1').keys[24].rotation,node(id,'Leg_1_-1').keys[24].rotation);
}
for(const id of ['robin','blackbird','blue_tit','barn_owl','mallard','bumblebee']) {
const wing=node(id,'Wing_-1'); assert.ok(wing.weights.length);
assert.notDeepEqual(wing.keys[60].rotation,wing.keys[65].rotation);
assert.notDeepEqual(wing.keys[65].rotation,node(id,'Wing_1').keys[65].rotation);
if(id!=='bumblebee') assert.equal(node(id,'Wing_Tip_-1').parent,wing);
}
for(const id of ['otter','smooth_newt','brown_trout']) {
const tail=node(id,'Tail'),tip=node(id,'Tail_Tip'); assert.equal(tip.parent,tail);
assert.notDeepEqual(tail.keys[60].rotation,tail.keys[65].rotation);
}
const segments=models.earthworm.nodes.filter(n=>n.name.startsWith('Segment_')); assert.equal(segments.length,12);
segments.slice(1).forEach((n,i)=>assert.equal(n.parent,segments[i]));
assert.notDeepEqual(segments[0].keys[24].rotation,segments[5].keys[24].rotation);
assert.ok(models.hedgehog.vertices.length>models.bank_vole.vertices.length,'spine geometry, not only a colour change');
});
test('64px semantic RGBA atlases encode exact opaque pixels, distinct species, badger stripes, otter cream and bee bands',()=> {
const distinct=new Set(),pixel=(image,x,y)=>[...image.data.slice((y*64+x)*4,(y*64+x+1)*4)];
const material=(id,name,x,y)=>pixel(faunaTexture(id),FAUNA_MATERIALS.indexOf(name)%4*16+x,Math.floor(FAUNA_MATERIALS.indexOf(name)/4)*16+y);
for(const id of names) {
const image=faunaTexture(id); assert.deepEqual([image.width,image.height,image.data.length],[64,64,16384]);
distinct.add(hash(image.data)); assert.ok(image.data.filter((_,i)=>i%4===3).every(v=>v===255));
const bytes=png(64,(x,y)=>pixel(image,x,y)),parts=[];
assert.deepEqual([...bytes.slice(16,24)],[0,0,0,64,0,0,0,64]);
for(let i=8;i<bytes.length;) { const size=bytes.readUInt32BE(i); if(bytes.toString('ascii',i+4,i+8)==='IDAT') parts.push(bytes.slice(i+8,i+8+size)); i+=size+12; }
const raw=inflateSync(Buffer.concat(parts),{maxOutputLength:16448});
assert.equal(raw.length,16448);
for(let y=0;y<64;y++) { assert.equal(raw[y*257],0); assert.deepEqual(raw.slice(y*257+1,y*257+257),Buffer.from(image.data.slice(y*256,y*256+256))); }
}
assert.equal(distinct.size,23);
assert.notDeepEqual(material('badger','face',1,8),material('badger','face',8,8));
assert.deepEqual(material('badger','face',1,8),material('badger','face',14,8));
assert.notDeepEqual(material('otter','belly',8,8),material('otter','coat',8,8));
assert.notDeepEqual(material('bumblebee','stripe',8,1),material('bumblebee','stripe',8,5));
assert.notDeepEqual(material('mallard','wing',8,8),material('mallard','wing',1,8));
});
test('portrait sheet rasterizes all 23 actual skinned meshes at the selected frame with deterministic padded cells',()=> {
const image=faunaPortraitSheet(); assert.deepEqual([image.width,image.height,image.data.length],[512,768,1572864]);
assert.deepEqual(image,faunaPortraitSheet()); assert.notEqual(hash(image.data),hash(faunaPortraitSheet(65).data));
const cells=new Set();
for(let i=0;i<23;i++) {
const cell=Buffer.alloc(128*128*4);
for(let y=0;y<128;y++) cell.set(image.data.slice(((Math.floor(i/4)*128+y)*512+i%4*128)*4,((Math.floor(i/4)*128+y)*512+i%4*128+128)*4),y*128*4);
cells.add(hash(cell));
const colors=new Set(); for(let j=0;j<cell.length;j+=4) { assert.equal(cell[j+3],255); colors.add(cell.slice(j,j+3).join()); }
assert.ok(colors.size>4,`${names[i]}: actual shaded mesh, not an empty paper tile`);
}
assert.equal(cells.size,23);
});
+109
View File
@@ -0,0 +1,109 @@
-- Standalone Lua, no engine. Pass the fixed catalogue path as arg[1].
local target=assert(arg[1],"fauna.lua path required")
local root=target:gsub("[^/\\]+$","")
local L=dofile(root.."lifecycle.lua")
local F=dofile(target)({lifecycle=L},{})
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("exact stable 23 ID catalogue and strict canonical lookup",function()
local expected="hare deer fox rabbit red_squirrel bank_vole wood_mouse hedgehog badger roe_deer stoat weasel otter robin blackbird blue_tit barn_owl mallard common_frog smooth_newt brown_trout bumblebee earthworm"
local ids={}; for _,r in ipairs(F.list()) do ids[#ids+1]=r.id; assert(F.kind(r.entity)==r.id) end
assert(table.concat(ids," ")==expected and #F.catalog()==23)
for _,v in ipairs({"../fox","fox.b3d","hearth:fox_extra","FOX",string.rep("a",2049)}) do assert(not F.get(v)) end
assert(not F.kind({name=42}) and not F.kind(false))
end)
test("species sources and lifecycle evidence are copied, not invented",function()
for _,r in ipairs(F.list()) do
assert(r.source_url:match("^https://en.wikipedia.org/wiki/"))
assert(r.profile.id==r.id and r.profile.kind=="animal")
if r.id~="deer" then assert(r.latin and #F.source(r.id)>=2) end
end
assert(F.get("deer").latin==false and F.get("deer").profile.confidence=="unverified_identity")
local r=F.get("fox"); r.prey.deer=true; r.bounds.max[2]=999; r.profile.maturity_days={min=1,max=1}
assert(not F.get("fox").prey.deer and F.get("fox").bounds.max[2]<1)
local sourced=F.get("fox").profile.maturity_days
assert(sourced and sourced.min==304 and sourced.max==304,"fox maturity remains the sourced real-day profile")
end)
test("exact authored asset convention, five clips, 24fps and measured bounds",function()
for _,r in ipairs(F.list()) do
assert(r.mesh=="hearth_fauna_"..r.id..".b3d" and r.texture=="hearth_fauna_"..r.id..".png")
assert(r.frames==100 and r.fps==24 and r.eye_height>=.15)
for i,name in ipairs({"idle","locomotion","feed","flight_swim","rest"}) do
assert(r.clips[name][1]==(i-1)*20 and r.clips[name][2]==i*20-1)
end
assert(r.bounds.min[2]==0 and r.bounds.max[2]>0 and r.bounds.min[3]<0)
end
assert(F.get("deer").bounds.max[2]==1.5947 and F.get("fox").bounds.min[3]==-.8624)
assert(F.get("earthworm").speed==20/3600)
end)
test("per-species solar rest schedules and sourced winter dormancy are explicit",function()
for _,r in ipairs(F.list()) do
assert(type(r.sleep)=="table" and type(r.sleep.kind)=="string",r.id)
assert(({day=true,night=true,crepuscular=true,any=true})[r.activity],r.id)
end
assert(F.get("robin").sleep.sleep_below==-6 and F.get("barn_owl").sleep.sleep_above==6)
assert(F.get("deer").sleep.active_between[1]==-8 and F.get("bank_vole").sleep.rest_hours[1]==12)
assert(F.get("hedgehog").winter_dormant and F.get("common_frog").winter_dormant
and F.get("smooth_newt").winter_dormant)
assert(not F.get("hare").winter_dormant)
end)
test("calendar ages are real days, never 60-second maturity or 900-second death",function()
local p=assert(F.phase("bank_vole",100,100+60)); assert(p.phase=="dependent")
p=assert(F.phase("bank_vole",100,100+56*86400)); assert(p.phase=="mature" and p.age_days==56)
p=assert(F.phase("bank_vole",100,100+1000*86400)); assert(not p.death_due)
assert(not F.phase("hare",200,100))
assert(F.phase("earthworm",0,900).phase=="juvenile")
end)
test("only complete source-backed profiles authorize mature seasonal reproduction",function()
local enabled={bank_vole=true,wood_mouse=true,blackbird=true,mallard=true,hare=true,fox=true}
for _,r in ipairs(F.list()) do
local season=r.id=="fox" and "Winter" or "Summer"
assert(F.can_breed(r.id,0,1800000000,"female",season)== (enabled[r.id] or false),r.id)
assert(r.profile.reproduction_enabled==(enabled[r.id] or false))
assert(not F.can_breed(r.id,1800000000-60,1800000000,"female","Summer"))
end
assert(not F.can_breed("missing",0,1800000000,"male"))
assert(F.get("rabbit").profile.gestation_days.min==30)
assert(F.get("wood_mouse").profile.gestation_days.max==26)
assert(F.get("hare").profile.gestation_days.max==42 and F.get("fox").profile.gestation_days.max==53)
assert(not F.can_breed("rabbit",0,1800000000,"female","Spring"))
end)
test("hemisphere-aware seasons not northern months gate both sexes",function()
local south=dofile(target)({lifecycle=L,climate_at=function() return {season="Spring"} end},{})
assert(south.can_breed("wood_mouse",0,1794000000,"male")) -- November southern spring
assert(not south.can_breed("wood_mouse",0,1794000000,"male","Winter"))
assert(not south.can_breed("wood_mouse",0,1794000000,"other","Spring"))
assert(not F.can_breed("wood_mouse",0,1794000000,"female")) -- no climate, fail closed
assert(F.get("mallard").profile.lifespan_days==false)
assert(F.can_breed("mallard",0,1794000000,"female","Spring"))
end)
test("actual UTC breeding windows never turn into reproductive authority",function()
assert(F.breeding_window("roe_deer",7).within_window)
assert(not F.breeding_window("roe_deer",11).within_window)
local fox_july=F.breeding_window("fox",7)
assert(fox_july.known and not fox_july.within_window,
"fox breeding is sourced for winter months, not July")
assert(F.breeding_window("blackbird",3).within_window)
assert(not F.breeding_window("blackbird",1).within_window)
end)
test("food web has concrete registered prey and distinct ecological functions",function()
for _,r in ipairs(F.list()) do for id in pairs(r.prey) do assert(F.get(id),id) end end
assert(F.get("fox").prey.rabbit and F.get("fox").prey.hare)
assert(F.get("stoat").prey.rabbit and F.get("weasel").prey.bank_vole)
assert(F.get("barn_owl").prey.wood_mouse and not F.get("barn_owl").prey.rabbit)
assert(F.get("otter").prey.brown_trout and F.get("badger").prey.earthworm)
assert(F.get("bumblebee").foods.nectar and not F.get("bumblebee").foods.seed)
assert(F.get("earthworm").foods.litter and not next(F.get("brown_trout").prey))
end)
test("unknown starvation is not fabricated; explicit rabbit upper bound is days",function()
for _,r in ipairs(F.list()) do
assert(r.feed_seconds>=300 and r.reserve_seconds>=86400)
if r.id=="rabbit" then assert(r.starvation_days==8 and r.starvation_source:match("#Diet$"))
else assert(r.starvation_days==false) end
end
end)
assert(passed==10)
print("PASS "..passed.." fauna catalogue tests")
+31 -15
View File
@@ -11,6 +11,7 @@ import { PALETTE, SPECIES_NAMES, EVERGREEN_NAMES, worldArt } from '../scripts/wo
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';
import { FAUNA_ART_MANIFEST, FAUNA_NAMES, FAUNA_ANIMATIONS, faunaB3D, faunaTexture, faunaPortraitSheet } from '../scripts/fauna-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]);
@@ -165,14 +166,14 @@ test('all four seasonal atlases preserve byte-exact bark and opaque 64x32 materi
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',()=> {
test('deprecated 23-species-mesh inventory stays frozen; all thirteen cube species have 46 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);
assert.equal(meshes.length,23); assert.equal(atlases.length,46); 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`));
@@ -249,14 +250,15 @@ test('all species atlases use exact own bark and own seasonal leaves, opaque und
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]:
beech:'leafDark',aspen:'leafDark',rowan:'leafDark',hazel:'leafDark',spruce:'pineShade',
crab_apple:'leafDark',wild_cherry:'leafDark',field_maple:'leafDark'}[s]:
{spring:'springDark',autumn:'autumnDark',winter:'barkDark'}[season];
assert.deepEqual([...image.data.slice(right,right+4)],[...PALETTE[color],255]);
}
}
}
}
assert.equal(distinct.size,34);
assert.equal(distinct.size,SPECIES_ATLAS_NAMES.length);
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/);
@@ -278,7 +280,7 @@ test('minimal fixture generation and integrity-verified package include every se
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 [,s,season='summer']=name.match(/^([a-z]+(?:_[a-z]+)*)_atlas(?:_(spring|autumn|winter))?$/), 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);
}
@@ -288,15 +290,29 @@ test('minimal fixture generation and integrity-verified package include every se
...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),
...FAUNA_ART_MANIFEST.map(entry=>entry.texture),'hearth_fauna_contact_sheet.png',
'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.equal(expectedTextures.length,675,'complete strict inventory: world + atlases + characters + fauna + two swatches');
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');
{format:1,images:BOTANY_ART_MANIFEST},'all 200 botanical image identities are packaged');
assert.deepEqual(JSON.parse(await fs.readFile(path.join(destination,'fauna-art.json'),'utf8')),
{format:1,animations:FAUNA_ANIMATIONS,animals:FAUNA_ART_MANIFEST,
portraitSheet:{filename:'hearth_fauna_contact_sheet.png',width:512,height:768,frame:9,columns:4,cellSize:128,order:FAUNA_NAMES}});
for(const entry of FAUNA_ART_MANIFEST) {
assert.deepEqual(await fs.readFile(path.join(destination,'mods/hearth/models',entry.mesh)),faunaB3D(entry.id));
const image=faunaTexture(entry.id);
assert.deepEqual(await fs.readFile(path.join(destination,'mods/hearth/textures',entry.texture)),
png(64,(x,y)=>image.data.subarray((y*64+x)*4,(y*64+x+1)*4)));
}
const portraits=faunaPortraitSheet();
assert.deepEqual(await fs.readFile(path.join(destination,'mods/hearth/textures/hearth_fauna_contact_sheet.png')),
png(portraits.width,(x,y)=>portraits.data.subarray((y*portraits.width+x)*4,(y*portraits.width+x+1)*4),portraits.height));
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');
.map(n=>`hearth_${n}.obj`).concat('hearth_person.b3d','hearth_person_compact.b3d','hearth_person_broad.b3d',
...FAUNA_ART_MANIFEST.map(entry=>entry.mesh));
assert.equal(expectedModels.length,63,'37 OBJ files plus three player builds plus 23 fauna rigs');
assert.deepEqual((await fs.readdir(path.join(destination,'mods/hearth/models'))).sort(),expectedModels.sort());
}
await check(out);
@@ -305,7 +321,7 @@ test('minimal fixture generation and integrity-verified package include every se
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 packaged=path.join(root,'package'); await packageGame(packaged,{source}); await check(packaged); await verifyPackage(packaged,{requireBundled:false});
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');
@@ -313,9 +329,9 @@ test('minimal fixture generation and integrity-verified package include every se
assert.deepEqual(await fs.readdir(outside),[]);
});
test('read-only cube diversity and biome contracts cover ten exact species; review layout is documented',async()=> {
test('read-only cube diversity and biome contracts cover thirteen 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));
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"\}/);
@@ -323,16 +339,16 @@ test('read-only cube diversity and biome contracts cover ten exact species; revi
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));
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,/512×864/); 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/);
assert.match(current,/421/); assert.match(current,/200/); assert.match(current,/botanicalLatin/);
for(const s of SPECIES_NAMES) assert.ok(current.includes(s));
});
+269
View File
@@ -0,0 +1,269 @@
-- Standalone Lua 5.1+; policy only, never starts Luanti.
local target=assert(arg[1],"lifecycle.lua path required")
local L=dofile(target)
local passed=0
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 eq(a,b) assert(a==b,tostring(a).." ~= "..tostring(b)) end
local function url(s) return type(s)=="string" and s:match("^https://[^/]+/.+") end
local DAY,YEAR=86400,365.2425
local function at(p,days) return assert(L.phase(p,0,days*DAY)) end
local new_plants={
dandelion="Taraxacum officinale",daisy="Bellis perennis",red_clover="Trifolium pratense",
birdsfoot_trefoil="Lotus corniculatus",ribwort_plantain="Plantago lanceolata",selfheal="Prunella vulgaris",
knapweed="Centaurea nigra",oxeye_daisy="Leucanthemum vulgare",field_scabious="Knautia arvensis",
teasel="Dipsacus fullonum",cow_parsley="Anthriscus sylvestris",garlic_mustard="Alliaria petiolata",
honeysuckle="Lonicera periclymenum",ivy="Hedera helix",gorse="Ulex europaeus",broom="Cytisus scoparius",
crab_apple="Malus sylvestris",wild_cherry="Prunus avium",field_maple="Acer campestre",holly_fern="Polystichum setiferum",
}
local new_animals="rabbit red_squirrel bank_vole wood_mouse hedgehog badger roe_deer stoat weasel otter robin blackbird blue_tit barn_owl mallard common_frog smooth_newt brown_trout bumblebee earthworm"
test("module constants retain unaccelerated real seconds",function()
eq(L.VERSION,1); eq(L.SECONDS_PER_DAY,DAY); eq(L.DAYS_PER_YEAR,YEAR)
eq(at(L.plant("oak"),1).age_seconds,86400)
end)
test("all hundred actual catalogue records receive group baselines",function()
local path=target:gsub("[^/\\]+$","botany.lua")
local B=dofile(path)({},{}); local records=B.list(); eq(#records,100)
for _,r in ipairs(records) do
local p=assert(L.plant(r.id,r.group)); eq(p.group,r.group); eq(p.pending,false)
assert(p.germination_days~=nil and p.juvenile_days~=nil and p.maturity_days~=nil and p.lifespan_days~=nil)
assert(L.phase(p,0,0),r.id); assert(L.resolve(p,731104),r.id)
end
end)
test("twenty registered plant IDs preserve exact botanical contract",function()
local count=0
for id,latin in pairs(new_plants) do
local p=assert(L.plant(id)); eq(p.latin,latin); eq(p.pending,false); count=count+1
end
eq(count,20); eq(#L.ids("plant"),100)
end)
test("twenty registered animals plus three legacy identity slots",function()
local count=0
for id in new_animals:gmatch("%S+") do
local p=assert(L.animal(id)); eq(p.id,id); eq(p.pending,false)
assert(type(p.latin)=="string" and url(p.source_url)); count=count+1
end
eq(count,20); eq(#L.ids("animal"),23)
for _,id in ipairs({"hare","fox","deer"}) do eq(L.animal(id).pending,false) end
end)
test("ten trees expose species evidence without laundering fallback timings",function()
local count=0
for id in ("oak birch pine willow alder beech aspen rowan hazel spruce"):gmatch("%S+") do
local p=L.plant(id,"trees"); assert(p.latin and url(p.source_url))
assert(p.germination_days.confidence=="policy_fallback")
assert(p.juvenile_days.confidence=="policy_fallback")
assert(p.maturity_days.min>=3*YEAR); assert(p.lifespan_days.min>=60*YEAR)
count=count+1
end
eq(count,10); eq(L.plant("birch").maturity_days.confidence,"policy_fallback")
eq(L.plant("alder").maturity_days.confidence,"policy_fallback")
end)
test("verified oak onset is forty real years not forty seconds",function()
local p=L.plant("oak"); eq(p.maturity_days.min,40*YEAR)
assert(url(p.maturity_days.source_url)); eq(p.maturity_days.confidence,"source_reported")
eq(at(p,30).phase,"seed"); eq(at(p,40*YEAR-1).phase,"juvenile")
eq(at(p,40*YEAR).phase,"mature"); eq(at(p,40*YEAR).reproductive_ready,false)
end)
test("verified rowan and beech distinguish onset and heavy crops",function()
eq(L.plant("rowan").maturity_days.max,10*YEAR)
local p=L.plant("beech"); eq(p.maturity_days.min,10*YEAR); eq(p.maturity_days.max,30*YEAR)
eq(p.maturity_days.statistic,"onset_to_heavy_crop")
end)
test("alder composite longevity is not a universal hundred-year constant",function()
local p=L.plant("alder"); eq(p.lifespan_days.min,60*YEAR); eq(p.lifespan_days.max,160*YEAR)
eq(p.lifespan_days.statistic,"composite_envelope"); assert(url(p.evidence.lifespan_context.source_url))
end)
test("all source-reported numerical claims carry field-level references",function()
local fields={"germination_days","juvenile_days","maturity_days","lifespan_days","gestation_days","incubation_days","larval_days","birth_count"}
local claims=0
for _,kind in ipairs({"plant","animal"}) do for _,id in ipairs(L.ids(kind)) do
local p=kind=="plant" and L.plant(id) or L.animal(id)
for _,name in ipairs(fields) do
local r=p[name]
if r then
assert(r.min>=0 and r.max>=r.min and r.max<math.huge)
assert(type(r.note)=="string" and #r.note>0)
if r.confidence=="source_reported" then
assert(url(r.source_url)); assert(url(p.evidence[name].source_url)); claims=claims+1
else eq(r.confidence,"policy_fallback") end
end
end
for _,e in pairs(p.evidence) do assert(url(e.source_url) and #e.note>0); eq(e.reviewed,"2026-11-03") end
end end
assert(claims>=35)
end)
test("verified thirty-day rabbit value is gestation not maturity",function()
local p=L.animal("rabbit"); eq(p.gestation_days.min,30); eq(p.gestation_days.max,37)
eq(p.maturity_days,false); eq(at(p,30).phase,"dependent")
assert(p.evidence.maturity_context.note:find("Conflicting",1,true))
eq(at(p,30).reproductive_ready,false)
end)
test("bank vole maturity weeks differ from weaning days",function()
local p=L.animal("bank_vole"); eq(p.juvenile_days.max,25)
eq(p.maturity_days.min,42); eq(p.maturity_days.max,56)
eq(at(p,24).phase,"dependent"); eq(at(p,30).phase,"juvenile"); eq(at(p,56).phase,"mature")
end)
test("wood mouse and squirrel retain independently verified gestation",function()
eq(L.animal("wood_mouse").gestation_days.min,21); eq(L.animal("wood_mouse").gestation_days.max,26)
eq(L.animal("red_squirrel").gestation_days.min,38); eq(L.animal("red_squirrel").gestation_days.max,39)
eq(at(L.animal("wood_mouse"),30).phase,"juvenile")
end)
test("frogs are not sexually mature at metamorphosis",function()
local p=L.animal("common_frog"); eq(p.larval_days.max,98); eq(p.maturity_days.max,3*YEAR)
eq(p.incubation_days.min,14); eq(at(p,98).phase,"juvenile"); eq(at(p,98).reproductive_ready,false)
end)
test("earthworm natural lifespan stays unknown and maturity takes years",function()
local p=L.animal("earthworm"); eq(p.lifespan_days,false)
eq(p.maturity_days.min,YEAR); eq(p.maturity_days.max,2*YEAR)
eq(at(p,30).phase,"juvenile"); eq(p.reproduction,"paired_hermaphrodite_cocoon")
end)
test("generic deer cannot inherit a roe deer life history",function()
local p=L.animal("deer"); eq(p.latin,false); eq(p.source_url,false); eq(p.maturity_days,false)
eq(p.confidence,"unverified_identity"); assert(L.animal("roe_deer").latin)
end)
test("winter appearance never declares death including annual representation",function()
for _,id in ipairs({"dandelion","daisy","garlic_mustard","foxglove"}) do
local p=L.plant(id); local s=L.seasonal(p,"Winter"); eq(s.dormant,true); eq(s.death_due,false)
end
local p=L.plant("daisy"); p.life_cycle="annual"
eq(L.seasonal(p,"Winter").death_due,false)
eq(L.seasonal(L.plant("pine"),"Winter").dormant,false)
eq(L.seasonal(L.plant("daisy"),"Spring").dormant,false)
end)
test("biennial leaves survive their first winter",function()
local p=L.plant("garlic_mustard"); eq(p.life_cycle,"biennial")
eq(p.life_cycle_confidence,"source_reported"); eq(at(p,200).death_due,false)
eq(L.seasonal(p,"Winter").death_due,false)
eq(L.plant("teasel").life_cycle_confidence,"policy_fallback")
end)
test("averages and record ages are never mandatory death deadlines",function()
for _,p in ipairs({L.animal("rabbit"),L.plant("oak"),L.animal("brown_trout")}) do
local s=at(p,p.lifespan_days.max+1); eq(s.beyond_lifespan_range,true); eq(s.death_due,false)
end
end)
test("age retains unloaded calendar gaps while work is bounded separately",function()
local gap=40*YEAR*DAY; local e=L.elapsed(100,100+gap,8)
eq(e.elapsed_seconds,gap); eq(e.work_seconds,8); eq(e.deferred_seconds,gap-8)
eq(L.phase(L.plant("oak"),100,100+gap).phase,"mature")
eq(L.elapsed(100,100,0).elapsed_seconds,0)
end)
test("clock reversal is explicit and never silently rebaselines age",function()
local p=L.plant("oak"); local value,reason=L.phase(p,100,99)
eq(value,nil); eq(reason,"clock_reversed")
value,reason=L.elapsed(100,99,8); eq(value,nil); eq(reason,"clock_reversed")
eq(L.phase(p,100,100).age_seconds,0)
end)
test("NaN infinities negatives strings and beyond-safe clocks fail closed",function()
local p=L.plant("oak")
for _,v in ipairs({0/0,math.huge,-math.huge,-1,"100",L.MAX_CLOCK+2,1e300}) do
eq(L.phase(p,v,100),nil); eq(L.phase(p,0,v),nil)
eq(L.elapsed(0,v,8),nil); eq(L.elapsed(0,100,v),nil)
end
eq(L.phase(p,nil,100),nil); eq(L.elapsed(0,100,nil),nil)
end)
test("largest valid age is constant-work and is not truncated",function()
local s=assert(L.phase(L.plant("oak"),0,L.MAX_CLOCK))
eq(s.age_seconds,L.MAX_CLOCK); assert(s.age_days>1000000000); eq(s.death_due,false)
local e=assert(L.elapsed(0,L.MAX_CLOCK,1)); eq(e.work_seconds,1); eq(e.elapsed_seconds,L.MAX_CLOCK)
end)
test("duration selection is deterministic bounded and salt dependent",function()
local r={min=10,max=40}
local a=L.duration(r,731104,"oak:maturity_days"); eq(a,L.duration(r,731104,"oak:maturity_days"))
assert(a>=10 and a<=40); assert(a~=L.duration(r,731105,"oak:maturity_days"))
assert(a~=L.duration(r,731104,"birch:maturity_days")); eq(L.duration({min=30,max=30},0,""),30)
end)
test("invalid duration ranges and seeds are rejected",function()
for _,r in ipairs({{min=-1,max=2},{min=3,max=2},{min=0,max=math.huge},{min=0,max=1e300},{min=0/0,max=2},{}}) do
eq(L.duration(r,0,"x"),nil)
end
for _,seed in ipairs({-1,.5,2147483647,math.huge,0/0,"1"}) do
eq(L.duration({min=1,max=2},seed,"x"),nil); eq(L.resolve(L.animal("deer"),seed),nil)
end
eq(L.duration({min=1,max=2},0,string.rep("x",65)),nil)
end)
test("resolved profiles preserve range provenance and ordered stages",function()
for _,kind in ipairs({"plant","animal"}) do for _,id in ipairs(L.ids(kind)) do
local p=kind=="plant" and L.plant(id) or L.animal(id)
for _,seed in ipairs({0,1,731104,2147483646}) do
local r=assert(L.resolve(p,seed)); eq(r.selection_seed,seed)
local previous=0
for _,name in ipairs({"germination_days","juvenile_days","maturity_days","lifespan_days"}) do
local n=r.selected_days[name]
if n then assert(n>=p[name].min and n<=p[name].max and n>=previous); previous=n end
end
assert(L.phase(r,0,100)); eq(p.selected_days,nil)
end
end end
end)
test("profiles nested evidence lists and IDs are fresh defensive copies",function()
local p=L.plant("oak"); p.maturity_days.min=1; p.evidence.maturity_days.note="changed"
eq(L.plant("oak").maturity_days.min,40*YEAR)
assert(L.plant("oak").evidence.maturity_days.note~="changed")
local a=L.animal("roe_deer"); a.breeding_months[1]=1; eq(L.animal("roe_deer").breeding_months[1],7)
local ids=L.ids("plant"); ids[1]="changed"; assert(L.ids("plant")[1]~="changed")
end)
test("identity and group validation never invent profiles",function()
for _,id in ipairs({"hearth:oak","../oak","Oak","oak_mature","unknown",string.rep("a",65),true,1}) do
eq(L.plant(id,"trees"),nil); eq(L.animal(id),nil)
end
eq(L.plant("oak","herbs"),nil); eq(L.ids("other"),nil)
end)
test("malformed profile values and stage ordering fail safely",function()
local p=L.plant("oak"); p.maturity_days={min=1,max=2}; eq(L.phase(p,0,0),nil)
p=L.plant("oak"); p.selected_days=true; eq(L.phase(p,0,0),nil)
p=L.plant("oak"); p.selected_days={maturity_days=0/0}; eq(L.phase(p,0,0),nil)
p=L.plant("oak"); p.maturity_days={min=1,max=math.huge}; eq(L.phase(p,0,0),nil)
eq(L.phase({},0,0),nil); eq(L.seasonal(L.animal("rabbit"),"Winter"),nil)
eq(L.seasonal(L.plant("oak"),"winter"),nil)
end)
test("breeding windows describe mating or nesting not immediate births",function()
local p=L.animal("roe_deer"); eq(p.reproduction,"delayed_implantation"); eq(p.gestation_days,false)
eq(L.breeding_window(p,7).within_window,true); eq(L.breeding_window(p,1).within_window,false)
eq(L.breeding_window(p,7).eligible,false)
eq(L.breeding_window(L.animal("blue_tit"),4).within_window,true)
eq(L.breeding_window(L.animal("blue_tit"),12).within_window,false)
eq(L.breeding_window(L.animal("barn_owl"),4).known,false)
eq(L.breeding_window(p,0),nil); eq(L.breeding_window(p,13),nil); eq(L.breeding_window(p,math.huge),nil)
end)
test("six verified basic profiles enable reproduction without claiming all taxa complete",function()
local enabled={bank_vole=true,wood_mouse=true,blackbird=true,mallard=true,hare=true,fox=true}; local count=0
for _,id in ipairs(L.ids("animal")) do
local p=L.animal(id); eq(p.reproduction_enabled,enabled[id] or false)
if enabled[id] then
count=count+1; assert(p.maturity_days and p.birth_count)
assert(p.gestation_days or p.incubation_days); assert(p.postpartum_days>=21)
end
eq(p.feeding_interval_seconds,nil); eq(p.movement_speed,nil)
end
eq(count,6); eq(L.animal("bank_vole").gestation_days.max,24)
eq(L.animal("blue_tit").incubation_days.max,14); eq(L.animal("blue_tit").maturity_days,false)
eq(L.animal("bumblebee").reproduction,"colony_caste_dependent")
local hare,fox=L.animal("hare"),L.animal("fox")
eq(hare.maturity_days.min,236); eq(hare.gestation_days.max,42); eq(hare.juvenile_days.max,30)
eq(hare.birth_count.max,8); eq(hare.reproduction_enabled,true); eq(hare.postpartum_days,30)
eq(fox.maturity_days.min,304); eq(fox.gestation_days.max,53); eq(fox.juvenile_days.min,56)
eq(fox.birth_count.max,9); eq(fox.reproduction_enabled,true); eq(fox.breeding_seasons.Winter,true)
end)
test("hedgehog and amphibian winter dormancy is explicit and never a death rule",function()
for _,id in ipairs({"hedgehog","common_frog","smooth_newt"}) do
local p=L.animal(id); eq(p.winter_dormant,true)
assert(L.phase(p,0,L.MAX_CLOCK).death_due==false)
end
eq(L.animal("hare").winter_dormant,nil)
end)
test("loading and calling policy does not pollute globals or read the clock",function()
local before={}; for k,v in pairs(_G) do before[k]=v end
local saved_time,saved_random=os.time,math.random
os.time=function() error("unexpected clock read") end; math.random=function() error("unexpected random call") end
local ok,err=pcall(function()
local fresh=dofile(target); fresh.resolve(fresh.plant("oak"),123)
fresh.phase(fresh.animal("wood_mouse"),0,100); fresh.elapsed(0,100,8)
end)
os.time=saved_time; math.random=saved_random; assert(ok,err)
for k,v in pairs(_G) do eq(v,before[k]) end
for k,v in pairs(before) do eq(_G[k],v) end
end)
eq(passed,32)
print("PASS "..passed.." lifecycle checks")
+176 -13
View File
@@ -13,7 +13,7 @@ local items = {
["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 species = {"oak", "birch", "pine", "willow", "alder", "beech", "aspen", "rowan", "hazel", "spruce", "crab_apple", "wild_cherry", "field_maple"}
local rocks = {"granite", "basalt", "limestone", "slate", "sandstone"}
for _, s in ipairs(species) do
items["hearth:"..s.."_log"] = {description=s.." log"}
@@ -61,6 +61,11 @@ function core.show_formspec(name, form, spec) screens[#screens+1]={name=name,for
hearth = {sessions={alice={}}, model={location=function() end}}
dofile(target)
local player={get_player_name=function() return "alice" end}
local player_metadata={}
function player:get_meta()
return {get_string=function(_,key) return player_metadata[key] or "" end,
set_string=function(_,key,value) player_metadata[key]=value end}
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 = {}
@@ -264,14 +269,16 @@ test("ecosystem command and guide button only read one habitat and status snapsh
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}
return {hare=3,deer=2,fox=1,total=6,global_cap=32,local_cap=6,player_cap=6,
recruitment={attempts=8,successes=2,last_reason="admitted"}}
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
"Pollinator-support plants: 7", "Nutrient reserve: 2 / 8", "pine and spruce stay evergreen", "deciduous canopy thins",
"Admissions: 2 / 8 attempts", "Latest outcome: admitted"}) do
assert(latest().spec:find(text,1,true),text)
end
registered.fields(player,"hearth:guide",{ecosystem_survey=true})
@@ -323,14 +330,14 @@ 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()
test("actual botanical catalogue displays one hundred species in ten ten-row pages and eight uneven groups", function()
hearth.botany=actual_botany
local records, groups = actual_botany.list(), {}
assert(#records==80)
for page=1,8 do
assert(#records==100)
for page=1,10 do
hearth.botanical_guide(player,"",page)
local spec,rows=latest().spec,0
assert(spec:find("Page "..page.." / 8 / 80 matches",1,true))
assert(spec:find("Page "..page.." / 10 / 100 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
@@ -339,7 +346,8 @@ test("actual botanical catalogue displays eighty species in eight ten-row pages"
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)
local sizes={trees=13,shrubs=14,grasses=10,herbs=22,ferns_mosses=11,fungi=10,aquatic=10,lichens=10}
local count=0; for g,n in pairs(groups) do assert(n==sizes[g]); 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)
@@ -376,7 +384,7 @@ test("botanical query escaping literal search and finite pagination bounds", fun
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)
hearth.botanical_guide(player,"",math.huge); assert(hearth.sessions.alice.botany_page==10)
local record=actual_botany.list()[1]; record.common=query
hearth.botany={list=function() return {record} end}
hearth.botanical_guide(player,"",1)
@@ -487,7 +495,7 @@ test("HUD accepts legacy mocks without optional hotbar image setters", function(
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")
assert(total==41,"The original checks plus three new tree recipe searches 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")
@@ -501,9 +509,9 @@ test("inspect command uses exact species and rejects absent session unknown and
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))
assert(latest().spec:find("tooltip[species_choice;Species article;#181825;#cdd6f4]",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")
assert(latest().form=="hearth:species" and hearth.sessions.alice.species_reader.id==actual_botany.list()[12].id)
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})
@@ -566,5 +574,160 @@ test("inventory appearance entry opens only the requesting player's creator", fu
hearth.character = nil
registered.fields(player,"",{character_creator=true}); assert(called == 1)
end)
assert(total==44,"Unexpected recipe/journal/character integration count")
assert(total==47,"Unexpected recipe/journal/character integration count")
test("inventory and journal tooltips contain escaped text, never an invalid third argument",function()
local specs={}
hearth.inventory(player); specs[#specs+1]=player.inventory_spec
local text="Preview woodland clothing and features. Cosmetic only; Save / Apply to keep changes."
assert(player.inventory_spec:find("tooltip[character_creator;"..core.formspec_escape(text)..";#181825;#cdd6f4]",1,true))
hearth.guide(player,6); specs[#specs+1]=latest().spec
hearth.recipes(player,"",1); specs[#specs+1]=latest().spec
hearth.location_form(player); specs[#specs+1]=latest().spec
hearth.botanical_guide(player,"",1); specs[#specs+1]=latest().spec
hearth.ecosystem(player); specs[#specs+1]=latest().spec
local n=0
for _,spec in ipairs(specs) do for body in spec:gmatch("tooltip%[(.-)%]") do
local clean=body:gsub("\\.",""); local _,separators=clean:gsub(";","")
assert(separators==1 or separators==3,"invalid tooltip arity: "..body); n=n+1
end end
assert(n>=10)
end)
test("survey loops actual counts for all 23 animals without changing the world",function()
hearth.lifecycle=dofile(sibling.."lifecycle.lua")
hearth.fauna=dofile(sibling.."fauna.lua")(hearth,{})
local counts={}; for i,r in ipairs(hearth.fauna.list()) do counts[r.id]=i end
local calls=0
hearth.wildlife={status=function() calls=calls+1; return {counts=counts,total=64,global_cap=64,local_cap=12,player_cap=16} end}
hearth.ecosystem(player); assert(calls==1)
for id,n in pairs(counts) do
local title=({hare="Hares",deer="Deer",fox="Foxes"})[id] or id:gsub("_"," ")
assert(latest().spec:find(title..": "..n,1,true),id)
end
assert(latest().spec:find("Calendar age includes unloaded time",1,true))
end)
test("actual fauna guide shows all identities sources and conservative lifecycle status read-only",function()
assert(commands.fauna.func("alice",""))
local records=hearth.fauna.list()
assert(#records==23 and latest().form=="hearth:fauna")
for page=1,5 do
hearth.fauna_guide(player,"",page)
assert(latest().spec:find("Page "..page.." / 5 / 23 matches",1,true))
for i=(page-1)*5+1,math.min(#records,page*5) do
local r=records[i]
assert(latest().spec:find(core.formspec_escape(r.source_url),1,true),r.id)
assert(latest().spec:find(core.formspec_escape(r.latin or "UNKNOWN species identity (legacy deer)"),1,true))
end
assert(latest().spec:find("automatic breeding disabled",1,true))
assert(latest().spec:find("no lifespan-endpoint death",1,true))
end
hearth.fauna_guide(player,"rabbit",1)
assert(latest().spec:find("Maturity: UNKNOWN",1,true))
hearth.fauna_guide(player,"bank_vole",1)
assert(latest().spec:find(core.formspec_escape("42-56 real days [source_reported]"),1,true))
assert(not commands.fauna.func("offline",""))
end)
test("fauna search pagination routing and escaping stay bounded and session-owned",function()
local query="];button[0,0;1,1;attack;Injected]"
hearth.fauna_guide(player,query,0/0)
assert(latest().spec:find(core.formspec_escape(query),1,true) and not latest().spec:find(";attack;Injected]",1,true))
assert(latest().spec:find("No animal matches",1,true))
hearth.fauna_guide(player,string.rep("x",80),1); assert(#hearth.sessions.alice.fauna_query==48)
hearth.fauna_guide(player,"",math.huge); assert(hearth.sessions.alice.fauna_page==5)
registered.fields(player,"hearth:guide",{fauna_index=true}); assert(hearth.sessions.alice.fauna_page==1)
registered.fields(player,"hearth:fauna",{next=true}); assert(hearth.sessions.alice.fauna_page==2)
registered.fields(player,"hearth:fauna",{previous=true}); assert(hearth.sessions.alice.fauna_page==1)
registered.fields(player,"hearth:fauna",{search=true,query="Lutra lutra"}); assert(latest().spec:find("otter",1,true))
registered.fields(player,"hearth:fauna",{botany_index=true}); assert(latest().form=="hearth:botany")
registered.fields(player,"hearth:botany",{fauna_index=true}); assert(latest().form=="hearth:fauna")
registered.fields(player,"hearth:fauna",{field_notes=true}); assert(latest().form=="hearth:guide")
registered.fields(player,"hearth:ecosystem",{fauna_index=true}); assert(latest().form=="hearth:fauna")
local original=hearth.fauna
local r=original.list()[1]; r.common=query; r.source_url=query
hearth.fauna={list=function() return {r} end}; hearth.fauna_guide(player,"",1)
assert(latest().spec:find(core.formspec_escape(query),1,true) and not latest().spec:find(";attack;Injected]",1,true))
hearth.fauna=nil; hearth.fauna_guide(player,"",1); assert(latest().spec:find("catalogue unavailable",1,true))
hearth.fauna=original
local session,n=hearth.sessions.alice,#screens; hearth.sessions.alice=nil
assert(not commands.fauna.func("alice","")); hearth.fauna_guide(player,"",1)
registered.fields(player,"hearth:fauna",{next=true}); assert(#screens==n)
hearth.sessions.alice=session
end)
test("fauna species selection and live inspection show bounded read-only species details",function()
local records=hearth.fauna.list(); local before=hearth.wildlife.status()
hearth.fauna_guide(player,"",1)
assert(latest().spec:find("species_choice;hare,deer,fox,rabbit,red_squirrel",1,true))
registered.fields(player,"hearth:fauna",{species_choice="3",species_details=true})
assert(latest().form=="hearth:fauna_detail" and latest().spec:find("Vulpes vulpes",1,true))
assert(latest().spec:find("Modeled prey:",1,true) and latest().spec:find("Winter rest policy:",1,true))
registered.fields(player,"hearth:fauna_detail",{fauna_index=true})
assert(latest().form=="hearth:fauna" and hearth.sessions.alice.fauna_page==1)
local now=os.time()
assert(hearth.inspect_fauna(player,"hare",{born_at=now-500*86400,origin="born",foodreserve=.42,
resident=true,observation="rest"}))
local spec=latest().spec
for _,text in ipairs({"Lepus europaeus","Lifecycle: mature","model years","real calendar days",
"Coarse modeled food reserve (policy, not biology): 42%","Current animation observation: resting",
"persistent registered resident","Modeled diet: browse","Activity / sleep policy: night / solar_night",
"Winter rest policy:","Modeled behavior:","Modeled prey:","Reproduction: enabled only within the coarse model",
"Active loaded animals of this species:"}) do
assert(spec:find(core.formspec_escape(text),1,true),text)
end
local after=hearth.wildlife.status()
assert(after.total==before.total and after.counts.hare==before.counts.hare,"panel reads status without game changes")
local original_get=hearth.fauna.get; local injection="];button[0,0;1,1;attack;Injected]"
hearth.fauna.get=function(id)
local r=original_get(id)
if id=="hare" then r.common=injection; r.latin=injection; r.source_url=injection end
return r
end
hearth.inspect_fauna(player,"hare",nil)
assert(latest().spec:find(core.formspec_escape(injection),1,true) and not latest().spec:find(";attack;Injected]",1,true))
hearth.fauna.get=original_get
local session,count=hearth.sessions.alice,#screens; hearth.sessions.alice=nil
assert(not hearth.inspect_fauna(player,"hare",nil) and #screens==count)
hearth.sessions.alice=session
local impostor={get_player_name=function() return "alice" end}
assert(not hearth.inspect_fauna(impostor,"hare",nil) and #screens==count)
assert(not hearth.inspect_fauna(player,"hare",setmetatable({},{})) and #screens==count)
assert(not hearth.inspect_fauna(player,"unknown",nil) and #screens==count)
assert(#records==23)
end)
test("wildlife encounters add canonical per-player field-journal entries; catalogue browsing does not",function()
local key="hearth:fauna_observations_v1"
player_metadata[key]=""
hearth.fauna_guide(player,"",1)
assert(latest().spec:find("0 / 23 species recorded",1,true))
hearth.inspect_fauna(player,"hare",{born_at=os.time()-86400,origin="established",foodreserve=.5,
resident=true,observation="awake"})
assert(player_metadata[key]=="hare")
assert(latest().spec:find("Field journal: first recorded encounter",1,true))
hearth.inspect_fauna(player,"fox",{born_at=os.time()-86400,origin="established",foodreserve=.5,
resident=true,observation="awake"})
assert(player_metadata[key]=="fox,hare","journal storage is canonical and sorted")
hearth.inspect_fauna(player,"hare",{born_at=os.time()-86400,origin="established",foodreserve=.5,
resident=true,observation="awake"})
assert(latest().spec:find("already recorded",1,true))
hearth.inspect_fauna(player,"deer",nil)
assert(player_metadata[key]=="fox,hare","catalogue profile selection is not a sighting")
hearth.fauna_guide(player,"",1)
assert(latest().spec:find("2 / 23 species recorded",1,true))
assert(latest().spec:find("recorded encounter",1,true))
player_metadata[key]="hare,unknown"
assert(hearth.inspect_fauna(player,"deer",{born_at=os.time()-86400,origin="established",foodreserve=.5,
resident=true,observation="awake"}))
assert(player_metadata[key]=="hare,unknown","malformed journal data fails closed without overwriting")
player_metadata[key]=nil
end)
test("calendar guidance never promises accelerated saplings daily berries or complete biology",function()
hearth.recipes(player,"Renewal",1)
for _,s in ipairs({"365.2425 days","no 24-hour refill","at most one berry","one real year","UNKNOWN"}) do
assert(latest().spec:find(s,1,true),s)
end
hearth.guide(player,6)
for _,s in ipairs({"100 named species","23 animal identities","eight uneven","real years to decades","fungi do not accelerate growth","disable breeding"}) do
assert(latest().spec:find(s,1,true),s)
end
assert(not latest().spec:find("eight categories of ten",1,true))
end)
assert(total==54,"Unexpected focused integration count")
print("PASS "..total.." recipe discovery tests")
+4 -2
View File
@@ -95,11 +95,13 @@ test("callbacks, authorization, drops and geometry survive every variant", funct
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"}
data={succ_age="8412",succ_natural="1",succ_birth_at="1700000000",succ_lifecycle_version="1",
succ_seed_epoch=tostring(now+86400),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_birth_at=="1700000000" and data.succ_lifecycle_version=="1" and data.succ_seed_epoch==tostring(now+86400))
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()
@@ -182,7 +184,7 @@ test("actual botany factory installs all new policies before seasonal registrati
end
end
end
assert(count==62)
assert(count==79)
end)
test("botany leaves diversity-owned taxa and block tree policies untouched", function()
local diversity=dofile(target:gsub("[^/\\]+$","diversity.lua"))(hearth,api)
+20 -4
View File
@@ -2,6 +2,7 @@
local target=assert(arg[1],"species_effects.lua path required")
local root=target:gsub("[^/\\]+$","")
local hearth={}
hearth.lifecycle=dofile(root.."lifecycle.lua")
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}))
@@ -12,20 +13,21 @@ 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()
test("all100 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)
local article=hearth.wiki_articles[r.id]
assert(s.identity_revision==(article and article.revision))
assert(s.identity_revision_url==(article and article.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(total==100)
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()
@@ -86,4 +88,18 @@ test("source and description results cannot mutate the reviewed policy",function
local sources=E.sources(); sources.alder.note="wrong"
assert(E.source("alder").note~="wrong")
end)
test("effect text matches calendar policy without laundering identity references into timing evidence",function()
assert(entry("clover","nitrogenfixer").game:find("90 real calendar days",1,true))
local fungus=entry("fly_agaric","ectomycorrhiza").game
assert(fungus:find("No accelerated growth",1,true) and not fungus:find("16200",1,true))
assert(entry("birch_polypore","saprotroph").game:find("90 real days",1,true))
assert(entry("bilberry","pollinator").game:find("at most one extra berry",1,true))
for _,r in ipairs(hearth.botany.list()) do
local text=entry(r.id,"lifecycle").game
assert(text:find("source_reported, policy_fallback or UNKNOWN",1,true))
assert(text:find("unloaded time",1,true) and text:find("never authorize automatic death",1,true))
assert(not text:find("15 minutes",1,true))
end
end)
assert(passed==8)
print("LAST_HEARTH_SPECIES_EFFECTS_PASS "..passed)
+3 -3
View File
@@ -82,7 +82,7 @@ test("empty ray and invalid hit positions do not read map",function()
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()
test("all hundred entries render with honest ninety-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]
@@ -98,7 +98,7 @@ test("all eighty entries render with honest seventy-eight excerpt and two note h
assert(not a.revision and latest():find(api.formspec_escape(a.text),1,true))
end
end
assert(excerpts==78 and notes==2)
assert(excerpts==98 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
@@ -119,7 +119,7 @@ test("current effects use actual describe records with simulated descriptive sep
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("90 real calendar days",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()
+318 -87
View File
@@ -54,6 +54,7 @@ local function reset()
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)
hearth.lifecycle=dofile(target:gsub("[^/\\]+$","lifecycle.lua"))
os.time=function() return now end
load()
end
@@ -67,6 +68,13 @@ local function baseline(k,age)
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 DAY,YEAR=86400,365.2425*86400
local function aged(seconds,q)
q=q or p
metadata(q):set_string("succ_initial_age_seconds",tostring(seconds))
metadata(q):set_string("succ_birth_at",tostring(now))
metadata(q):set_string("succ_last_observed_at",tostring(now))
end
local function test(label,fn)
reset()
local ok,err=pcall(fn)
@@ -116,15 +124,15 @@ test("dry exposed fern dies, while river moisture changes its niche",function()
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()
test("generic tree needs a real year to establish and ten to mature, never dies at a short deadline",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:tree_seedling" and field(p,"succ_age")==21500)
aged(YEAR); advance(100)
assert(name(p)=="hearth:young_tree" and field(p,"succ_age")==21500)
aged(10*YEAR); 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)
aged(10000*YEAR); advance(100)
assert(name(p)=="hearth:mature_tree" and field(pos(0,0,0),"succ_nutrients")==0)
end)
test("established sparse meadow eventually recruits a native tree",function()
baseline("meadow_grass",7100); advance(100)
@@ -159,9 +167,9 @@ test("grass spreads locally but never replaces occupied flower or crop cells",fu
end)
test("moist shaded litter seeds fungi, then returns finite nutrients",function()
light=10
baseline("litter",300); advance(100)
baseline("litter",300); metadata(p):set_string("succ_decay_initial","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)
now=now+90*DAY; advance(100); 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)
@@ -174,23 +182,23 @@ test("dung decomposes, fertilizes soil and does not instantly sprout a forest",f
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()
test("grazing debits one partial canopy ration, 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)
assert(name(p)=="hearth:meadow_grass" and name(pos(1))=="hearth:fern")
assert(field(p,"succ_browse_ready_at")==now+7*DAY)
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()
test("grazing retains the perennial and shares seven-day regrowth across animals",function()
baseline("meadow_grass")
assert(sim.forage(p))
advance(100); advance(100)
assert(name(p)=="air")
advance(100)
assert(name(p)=="hearth:meadow_grass")
assert(not sim.forage(p))
load(); assert(not sim.forage(p))
now=now+7*DAY
assert(sim.forage(p) and name(p)=="hearth:meadow_grass")
end)
test("source claims and external protection block all simulation writes",function()
baseline("mature_tree",259100)
@@ -274,15 +282,18 @@ test("vegetation reload alone invalidates age even if its supporting block staye
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()
test("calendar anchor survives absence, disconnect, unload and restart; rollback pauses at high water",function()
baseline("tree_seedling",21500)
local born=field(p,"succ_birth_at")
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")
advance(100); assert(name(p)=="hearth:tree_seedling")
assert(field(p,"succ_birth_at")==born and field(p,"succ_last_observed_at")==now)
advance(YEAR); 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)
@@ -294,7 +305,7 @@ test("failed dispersal attempts survive restart without reroll or advancing age"
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)
assert(field(p,"succ_age")==age and field(p,"succ_last_observed_at")==now)
end)
test("cold weather pauses recruitment and stage growth without catchup",function()
temperature=-5; baseline(); advance(100); advance(100)
@@ -372,7 +383,7 @@ test("winter mature canopy still sheds litter and decomposition returns nutrient
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)
advance(100); now=now+90*DAY; advance(100); 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()
@@ -410,54 +421,56 @@ 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()
test("unmarked mapgen deadwood decays after a calendar year 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)
now=now+YEAR; advance(100); 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
now=now+90*DAY; advance(100); advance(100)
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)
put(p,"deadwood"); baseline(nil,7100); now=now+YEAR; advance(100); 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()
test("deadwood calendar survives absence, disconnect, reload and restart; rollback pauses",function()
diverse(); humidity=20; put(p,"deadwood"); baseline(nil,7100)
advance(864000); assert(name(p)=="hearth:deadwood" and field(p,"succ_age")==7100)
advance(864000); assert(name(p)=="hearth:deadwood")
local born=field(p,"succ_decay_at")
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)
lbm.action(p); advance(100)
lbm.action(pos(0,0,0)); advance(100)
load(); advance(100)
local high=field(p,"succ_decay_seen_at")
advance(-100); assert(field(p,"succ_decay_seen_at")==high)
assert(field(p,"succ_decay_at")==born)
assert(field(pos(0,0,0),"succ_nutrients")==0)
advance(100)
now=now+YEAR; advance(100); 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)
test("species stages keep one birth anchor, real profile thresholds and never die at lifespan endpoints",function()
diverse(); baseline("birch_seedling",21500); aged(5*YEAR); advance(100)
assert(name(p)=="hearth:birch_young" and metadata(p):get_string("succ_species")=="birch")
local born=field(p,"succ_birth_at")
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)
aged(20*YEAR); 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")
aged(1000*YEAR); advance(100)
assert(name(p)=="hearth:birch_mature" and metadata(p):get_string("succ_species")=="birch")
assert(born>0 and metadata(p):get_string("succ_lifecycle_origin")=="legacy_established")
end)
test("species seeds retain parent identity and reject an incompatible destination soil",function()
diverse(); baseline("birch_mature",2000)
diverse(); season="Autumn"; baseline("birch_mature",2000)
metadata(p):set_string("succ_serial","9"); advance(100)
assert(name(pos(-1,1,0))=="hearth:birch_seedling")
assert(hearth.seasons.kind(name(pos(-1,1,0)))=="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
@@ -555,8 +568,9 @@ test("species seeds respect source, destination and support authority without em
assert(name(seed)~="hearth:birch_seedling",mode)
end
end)
test("species age pauses across winter unload and restart without losing identity",function()
test("calendar age continues through winter while visible stage growth waits for spring",function()
diverse(); baseline("oak_seedling",21500)
aged(5*YEAR)
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)
@@ -611,9 +625,10 @@ local function supplied_habitat(h)
return out
end
end
test("ecology80 recruits every real returned candidate, spreads it and completes its finite lifecycle",function()
test("ecology100 recruits every exact candidate with calendar anchors and no automatic death",function()
catalogue()
local records=hearth.botany.list()
assert(#records==100)
for index,r in ipairs(records) do
reset(); catalogue()
local h=vector(r)
@@ -621,40 +636,40 @@ test("ecology80 recruits every real returned candidate, spreads it and completes
-- 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
while (x*17+7)%100~=index-1 or (x*7)%23~=0 do x=x+1; assert(x<2300) 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)
assert(metadata(p):get_string("succ_lifecycle_version")=="1" and field(p,"succ_birth_at")==now,r.id)
local profile=hearth.lifecycle.plant(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")
put(p,r.id.."_mature",true); aged(profile.maturity_days.max*DAY); metadata(p):set_string("succ_serial","9")
season="Autumn"
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)
put(p,candidate,true); aged(profile.juvenile_days.max*DAY); 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)
aged(profile.maturity_days.max*DAY); 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
aged(10000*YEAR); advance(100)
assert(hearth.seasons.kind(name(p))==r.id.."_mature",r.id.." must not die")
else
season=r.seeding.Autumn and "Autumn" or "Summer"
if not r.seasons[season] then season=next(r.seasons) end
aged(profile.maturity_days and profile.maturity_days.max*DAY or 10000*YEAR)
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
if profile.maturity_days and r.seeding[season] then
assert(hearth.seasons.kind(name(q))==candidate,r.id.." spread got "..name(q))
else assert(name(q)=="air",r.id.." unknown maturity must not spawn") end
aged(10000*YEAR); advance(100)
assert(hearth.seasons.kind(name(p))==candidate,r.id.." no guessed senescence")
end
end
end)
@@ -674,15 +689,15 @@ test("real habitat distinguishes bank depth, source columns, pH, host and bark w
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()
test("real source-water recruitment and very old submerged stands 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")
aged(10000*YEAR); advance(100)
assert(hearth.botany.kind(name(p))==r.id and name(pos(p.x,0,0))=="hearth:water_source")
assert(field(pos(p.x,-1,0),"succ_nutrients")==0)
end
end)
@@ -701,9 +716,9 @@ test("water placement denies flowing, too deep, unloaded, marked, claimed and pr
assert(name(p)=="air" and writes==0,mode)
end
end)
test("real block plans expand atomically, survive restart and collapse only owned cubes",function()
test("real block plans expand atomically, survive restart and never collapse from old age",function()
catalogue(); humidity=45
baseline("birch_seedling",21600); advance(100)
baseline("birch_seedling",21600); aged(5*YEAR); 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
@@ -711,19 +726,20 @@ test("real block plans expand atomically, survive restart and collapse only owne
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)
load(); sim.step(1); aged(20*YEAR); 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)
metadata(p):set_string("succ_seed_epoch",tostring(now+YEAR))
metadata(p):set_string("succ_litter_epoch",tostring(now+YEAR))
aged(1000*YEAR); 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")
assert(name(p)=="hearth:birch_mature" and 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)
reset(); catalogue(); humidity=45; baseline("oak_seedling",21600); aged(5*YEAR)
local q=pos(0,4,0)
if mode=="foreign" then put(q,"wall") end
if mode=="natural_plant" then put(q,"yarrow",true) end
@@ -737,14 +753,14 @@ test("every tree destination and its support fail closed before any plan write",
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)
catalogue(); humidity=45; baseline("oak_seedling",21600); aged(5*YEAR); 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()
test("catalogue nitrogen caps survive restart and root fungi never accelerate calendar 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)
@@ -752,6 +768,8 @@ test("catalogue nitrogen caps survive restart and root fungi debit existing soil
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_seedling" and field(pos(0,0,0),"succ_nutrients")==1)
aged(5*YEAR); 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")
@@ -762,7 +780,7 @@ test("saprotrophs age existing deadwood without minting nutrients or attacking n
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(pos(0,0,0),"succ_decay_bonus")>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)
@@ -775,8 +793,9 @@ test("flowering, browse and nursery use botanical tags while harvested bushes re
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)
baseline(nil,1000); metadata(p):set_string("succ_seed_epoch",tostring(now+YEAR))
advance(100); assert(field(p,"succ_age")==1000 and field(p,"botany_ready_at")>now)
assert(sim.forage(p)); assert(name(p)=="hearth:blackberry" 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")
@@ -784,6 +803,25 @@ test("flowering, browse and nursery use botanical tags while harvested bushes re
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("autumn and winter browse debit a shared partial ration without human fruiting",function()
catalogue(); season="Winter"; put(p,"blackberry",true)
assert(not hearth.botany.edible("blackberry","Winter"))
local ready=now+7*86400
assert(sim.forage(p,"winter_browse"))
assert(name(p)=="hearth:blackberry" and field(p,"succ_browse_ready_at")==ready)
assert(not sim.forage(p,"winter_browse"),"one resident cannot eat the same plant twice during its rest")
season="Summer"; now=ready
assert(sim.forage(p,"herbivore"),"the same plant becomes browseable after its shared rest window")
assert(field(p,"succ_browse_ready_at")==now+7*86400)
end)
test("animal seasonal browse can never fell a mature tree or feed from toxic plants",function()
catalogue(); season="Winter"; put(p,"oak_mature",true)
local before=name(p); local age=field(p,"succ_age"); local cooldown=field(p,"succ_browse_ready_at")
assert(not sim.forage(p,"winter_browse"))
assert(name(p)==before and field(p,"succ_age")==age and field(p,"succ_browse_ready_at")==cooldown)
put(p,"foxglove",true); before=name(p)
assert(not sim.forage(p,"winter_browse") and name(p)==before)
end)
test("one global tree-plan reservation bounds eight concurrent roots and malformed geometry writes nothing",function()
catalogue(); humidity=45
@@ -799,7 +837,7 @@ test("one global tree-plan reservation bounds eight concurrent roots and malform
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
for _,root in ipairs(positions) do aged(5*YEAR,root) 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)
@@ -807,7 +845,7 @@ test("one global tree-plan reservation bounds eight concurrent roots and malform
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)
reset(); catalogue(); humidity=45; baseline("oak_seedling",21600); aged(5*YEAR)
hearth.diversity.tree_plan=function()
if mode=="missing" then return nil end
local plan={{x=0,y=0,z=0,name="hearth:oak_young"}}
@@ -820,14 +858,14 @@ test("one global tree-plan reservation bounds eight concurrent roots and malform
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()
test("water stands and botanical understory retain calendar anchors 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)
advance(100); assert(field(p,"succ_age")==1000 and field(p,"succ_last_observed_at")==now)
end
end)
test("real moss mat sampler changes local moisture and pH once without map or metadata writes",function()
@@ -887,7 +925,7 @@ test("alder all living stages and dog lichen share the durable finite nitrogen b
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)
support(p,h); supplied_habitat(h); baseline(id,1000); aged(90*DAY)
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)
@@ -912,27 +950,220 @@ test("nitrogen never exceeds local four ceiling or writes through source or supp
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()
test("multiple host fungi cannot copy nutrients or accelerate growth even with a resource budget",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)
assert(field(pos(0,0,0),"succ_nutrients")==budget)
assert(name(p)=="hearth:birch_seedling")
aged(5*YEAR); advance(100)
assert(field(pos(0,0,0),"succ_nutrients")==0 and name(p)=="hearth:birch_young")
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)
baseline(nil,7100); now=now+YEAR; advance(100); 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)
now=now+90*DAY; advance(100); 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)
test("new seedlings never reinterpret legacy seconds and stage swaps retain one calendar birthday",function()
catalogue(); humidity=45
baseline("crab_apple_seedling",999999)
local born=field(p,"succ_birth_at")
assert(metadata(p):get_string("succ_lifecycle_origin")=="legacy_established")
assert(field(p,"succ_age")==999999 and born==now)
aged(5*YEAR); advance(100)
local birthday=field(p,"succ_birth_at")
assert(hearth.seasons.kind(name(p))=="crab_apple_young")
advance(100); assert(field(p,"succ_birth_at")==birthday and field(p,"succ_age")==999999)
end)
test("seed-age biennials cannot supply flowers before calendar maturity",function()
catalogue(); season="Spring"; humidity=60; light=8
baseline("garlic_mustard",1000)
aged(100*DAY)
assert(sim.habitat(p).pollination==0)
aged(730*DAY)
assert(sim.habitat(p).pollination==1)
season="Winter"; assert(sim.habitat(p).pollination==0)
end)
test("calendar metadata corruption versions and rollback fail closed without losing the high water",function()
for _,fieldname in ipairs({"succ_birth_at","succ_initial_age_seconds","succ_last_observed_at","succ_lifecycle_seed","succ_seed_epoch"}) do
reset(); catalogue(); baseline("oak_seedling",30000)
metadata(p):set_string(fieldname,"broken")
local before=writes; advance(100)
assert(writes==before and field(p,"succ_age")==30000,fieldname)
end
reset(); catalogue(); baseline("oak_seedling",30000)
metadata(p):set_string("succ_lifecycle_version","2"); advance(100)
assert(metadata(p):get_string("succ_lifecycle_version")=="2" and field(p,"succ_age")==30000)
reset(); catalogue(); baseline("oak_seedling",30000); advance(100)
local high=field(p,"succ_last_observed_at"); advance(-50)
assert(field(p,"succ_last_observed_at")==high)
advance(100); assert(field(p,"succ_last_observed_at")==now)
end)
test("worldgen botanical flora and full block roots adopt established provenance without replacing canopies",function()
for _,id in ipairs({"dandelion","garlic_mustard","holly_fern","crab_apple_log","wild_cherry_log","field_maple_log","log"}) do
reset(); catalogue(); humidity=45; put(p,id)
baseline(); advance(100)
assert(name(p)=="hearth:"..id,id)
assert(metadata(p):get_string("succ_lifecycle_origin")=="worldgen_established",id)
assert(field(p,"succ_birth_at")==now-100 and metadata(p):get_string("succ_lifecycle_version")=="1",id)
assert(field(p,"succ_initial_age_seconds")>0,id)
end
end)
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,"|").."/writes="..writes
end
test("nectar is a shared hourly debit and pollen is one expiring boolean, never a seed or fruit grant",function()
catalogue(); put(p,"blackberry",true)
assert(sim.pollinate(p))
assert(field(p,"succ_nectar_ready_at")==now+3600 and field(p,"succ_pollinated_until")==now+DAY)
local saved=snapshot()
for _=1,20 do assert(not sim.pollinate(p)) end
assert(snapshot()==saved and field(p,"botany_ready_at")==0 and writes==0)
load(); assert(not sim.pollinate(p))
now=now+3600; assert(sim.pollinate(p))
assert(field(p,"succ_pollinated_until")==now-3600+DAY,"no additive/extended pollen credit")
now=now+DAY; assert(sim.pollinate(p) and field(p,"succ_pollinated_until")==now+DAY)
assert(not sim.disperse_seed(pos(1),"oak"))
end)
test("nectar and fruit deny immature plants, out-of-season food, and malformed shared timestamps",function()
catalogue(); baseline("blackberry"); aged(DAY)
local saved=snapshot()
assert(not sim.pollinate(p) and not sim.frugivore(p) and snapshot()==saved)
aged(10*YEAR); season="Winter"; saved=snapshot()
assert(not sim.pollinate(p) and not sim.frugivore(p) and snapshot()==saved)
season="Summer"
for _,bad in ipairs({"broken","-1","inf","nan"}) do
metadata(p):set_string("succ_nectar_ready_at",bad)
metadata(p):set_string("botany_ready_at",bad)
saved=snapshot(); assert(not sim.pollinate(p) and not sim.frugivore(p) and snapshot()==saved,bad)
end
end)
test("frugivore spends one whole existing botanical fruit batch and shares its human lock across restart",function()
catalogue(); put(p,"blackberry",true)
assert(not sim.frugivore(p,"raspberry"))
assert(sim.frugivore(p,"blackberry") and field(p,"botany_ready_at")==now+YEAR)
assert(name(p)=="hearth:blackberry" and writes==0)
local saved=snapshot()
for _=1,20 do assert(not sim.frugivore(p)) end
load(); assert(not sim.frugivore(p) and snapshot()==saved)
now=now+DAY; assert(not sim.frugivore(p),"no daily fruit refill")
local B=hearth.botany
hearth.construction={allowed=function() return true end}
local human={is_player=function() return true end,get_inventory=function() error("spent fruit cannot reach inventory") end}
assert(not B.harvest(p,human))
now=now+YEAR; season="Winter"; assert(not sim.frugivore(p))
season="Summer"; assert(sim.frugivore(p))
end)
test("all resource hooks reject source and support claims protection markers and missing nodes without metadata writes",function()
for _,hook in ipairs({"pollinate","frugivore","granivore","detritivore","forage"}) do
for _,mode in ipairs({"source_claim","support_claim","source_protect","support_protect","source_mark","support_mark","source_missing","support_missing","ambiguous"}) do
reset(); catalogue()
put(p,hook=="granivore" and "oak_mature" or (hook=="detritivore" and "litter" or "blackberry"),true)
if hook=="granivore" then season="Autumn" end
local q=mode:match("^support") and pos(0,0,0) or p
if mode:find("claim") then claims[key(q)]=true end
if mode:find("protect") then protected[key(q)]=true end
if mode:find("mark") then metadata(q):set_string("third_party:placed","1") end
if mode:find("missing") then missing[key(q)]=true end
if mode=="ambiguous" then hearth.claim_owner_at=function(q) return nil,key(q)==key(p) end end
local saved=snapshot(); assert(not sim[hook](p) and snapshot()==saved,hook.." "..mode)
end
end
end)
test("two annual nuts fund one meal and a separate one-use local cache; species IDs alone authorize nothing",function()
catalogue(); season="Autumn"; put(p,"oak_mature",true)
assert(not sim.disperse_seed(pos(1),"oak"))
local fed,cache=sim.granivore(p,"oak")
assert(fed and cache=="oak" and field(p,"succ_nuts_remaining")==0)
assert(sim.disperse_seed(pos(1),cache))
assert(hearth.seasons.kind(name(pos(1)))=="oak_seedling")
assert(metadata(pos(1)):get_string("succ_lifecycle_origin")=="natural_seed")
assert(not sim.disperse_seed(pos(-1),cache) and not sim.granivore(p))
load(); assert(not sim.granivore(p) and not sim.disperse_seed(pos(-1),"oak"))
now=now+YEAR; local fed2,cache2=sim.granivore(p); assert(fed2 and cache2=="oak")
end)
test("one remaining nut grants a meal but no cache and immature or wrong-season roots have no seed budget",function()
catalogue(); season="Autumn"; put(p,"hazel_mature",true)
metadata(p):set_string("succ_nuts_year",os.date("!*t",now).year)
metadata(p):set_string("succ_nuts_remaining","1")
local fed,cache=sim.granivore(p); assert(fed and cache==nil and not sim.disperse_seed(pos(1),"hazel"))
reset(); catalogue(); season="Autumn"; baseline("oak_seedling"); aged(YEAR)
local saved=snapshot(); assert(not sim.granivore(p) and snapshot()==saved)
put(p,"oak_mature",true); aged(40*YEAR); season="Summer"
saved=snapshot(); assert(not sim.granivore(p) and snapshot()==saved)
end)
test("failed or delayed caching burns paid authority; donors and destinations cannot be cloned",function()
for _,mode in ipairs({"claim","support","marker","occupied","unloaded","donor","delay","wrong","far"}) do
reset(); catalogue(); season="Autumn"; put(p,"hazel_mature",true)
assert(sim.granivore(p)); local q=pos(1)
if mode=="claim" then claims[key(q)]=true end
if mode=="support" then claims[key(pos(1,0,0))]=true end
if mode=="marker" then metadata(q):set_string("owner","builder") end
if mode=="occupied" then put(q,"meadow_grass",true) end
if mode=="unloaded" then missing[key(q)]=true end
if mode=="donor" then metadata(p):set_string("player_placed","1") end
if mode=="delay" then now=now+1 end
if mode=="far" then q=pos(200) end
local saved=snapshot()
assert(not sim.disperse_seed(q,mode=="wrong" and "oak" or "hazel") and snapshot()==saved,mode)
assert(not sim.disperse_seed(pos(-1),"hazel") and not sim.granivore(p),mode)
end
reset(); catalogue(); season="Autumn"; put(p,"oak_mature",true); assert(sim.granivore(p))
load(); assert(not sim.disperse_seed(pos(1),"oak"),"restart discards authority, not debits")
end)
test("litter transfers ten finite tenths, retains its patch until exhausted and cannot pay again after restart",function()
put(p,"litter",true)
for i=1,10 do
assert(sim.detritivore(p),i)
assert(math.abs(field(pos(0,0,0),"succ_nutrients")-i/10)<0.000001)
if i<10 then assert(name(p)=="hearth:litter"); load() end
end
assert(name(p)=="air" and field(p,"succ_litter_remaining")==0)
local saved=snapshot(); assert(not sim.detritivore(p) and snapshot()==saved)
load(); assert(not sim.detritivore(p) and field(pos(0,0,0),"succ_nutrients")<1.000001)
end)
test("partial litter feeding and normal calendar decay share exactly one mineral payout",function()
put(p,"litter",true); assert(sim.detritivore(p)); baseline()
now=now+90*DAY; advance(100); advance(100)
assert(name(p)=="air" and math.abs(field(pos(0,0,0),"succ_nutrients")-1)<0.000001)
end)
test("last litter unit refuses a protected dependent fungus or full soil without a debit",function()
catalogue(); put(p,"litter",true); metadata(p):set_string("succ_litter_remaining","0.1")
put(pos(0,2,0),"wood_blewit",true); protected[key(pos(0,2,0))]=true
local saved=snapshot(); assert(not sim.detritivore(p) and snapshot()==saved)
protected={}; metadata(pos(0,0,0)):set_string("succ_nutrients","8")
saved=snapshot(); assert(not sim.detritivore(p) and snapshot()==saved)
end)
test("resource APIs fail closed on malformed positions clocks receipts and nonroot tree components",function()
catalogue(); put(p,"blackberry",true)
for _,hook in ipairs({"pollinate","frugivore","granivore","detritivore","forage","disperse_seed"}) do
for _,bad in ipairs({{}, {x=0/0,y=1,z=0}, {x=math.huge,y=1,z=0}, {x=40000,y=1,z=0}}) do
local saved=snapshot(); assert(not sim[hook](bad,"oak") and snapshot()==saved,hook)
end
end
api.now=function() return 0/0 end
for _,hook in ipairs({"pollinate","frugivore","granivore","detritivore","forage"}) do
local saved=snapshot(); assert(not sim[hook](p) and snapshot()==saved,hook)
end
api.now=nil; season="Autumn"; baseline("oak_mature"); aged(40*YEAR)
put(p,"oak_planks",true)
local saved=snapshot(); assert(not sim.granivore(p) and snapshot()==saved)
put(p,"oak_mature",true)
for _,bad in ipairs({"broken","-1","0.5","3"}) do
metadata(p):set_string("succ_nuts_remaining",bad)
saved=snapshot(); assert(not sim.granivore(p) and snapshot()==saved,bad)
end
end)
os.time=original_time
print("LAST_HEARTH_SUCCESSION_PASS "..passed)
+28 -9
View File
@@ -64,7 +64,7 @@ test('package twice: identical full inventory and manifest hashes, original asse
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);
const manifest = await verifyPackage(a,{requireBundled:false});
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);
@@ -114,7 +114,7 @@ test('package manifest rejects missing, extra, corrupted files and extra empty d
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/);
await assert.rejects(verifyPackage(destination,{requireBundled:false}), /manifest mismatch/);
}
});
@@ -370,19 +370,38 @@ test('CLI package/verify work with absent engine and invalid runtime env; backup
test('new-world terrain preset is pure, explicit, bounded and matches packaged game defaults', async () => {
const first = terrainSettings(true);
const expected = [
'mg_name = v7', 'fixed_map_seed = 731104', 'water_level = 32',
'mapgen_limit = 4096',
'mg_flags = caves,nodungeons,light,biomes,ores',
'mgv7_spflags = mountains,ridges,nofloatlands,caverns',
'mgv7_mount_zero_level = 0',
'mgv7_np_terrain_base = 12, 45, (900, 384, 900), 82341, 5, 0.6, 2.0',
'mgv7_np_terrain_alt = -8, 24, (900, 384, 900), 5934, 5, 0.6, 2.0',
'mgv7_np_terrain_persist = 0.6, 0.1, (1200, 1200, 1200), 539, 3, 0.6, 2.0',
'mgv7_np_height_select = -4, 14, (1400, 320, 1400), 4213, 6, 0.7, 2.0',
'mgv7_np_filler_depth = 0, 1.2, (150, 150, 150), 261, 3, 0.7, 2.0',
'mgv7_np_mount_height = 250, 100, (700, 700, 700), 72449, 3, 0.6, 2.0',
'mgv7_np_mountain = -0.6, 1, (320, 280, 320), 5333, 5, 0.63, 2.0',
'mgv7_np_ridge_uwater = 0, 1, (700, 700, 700), 85039, 5, 0.6, 2.0',
'mgv7_np_ridge = 0, 1, (100, 100, 100), 6467, 4, 0.75, 2.0',
'mg_biome_np_heat = 50, 50, (256, 256, 256), 5349, 3, 0.5, 2.0',
'mg_biome_np_humidity = 50, 50, (256, 256, 256), 842, 3, 0.5, 2.0',
'epic_terrain_enable = true',
];
assert.deepEqual(first, expected);
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);
.split(/\r?\n/).filter(isTerrainSetting).filter(line => !line.startsWith('epic_terrain_enable')).sort();
assert.deepEqual(first.filter(line => !line.startsWith('fixed_map_seed') && !line.startsWith('epic_terrain_enable')).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',
for (const line of ['mg_name = v7', ' mgv7_np_terrain_base = 1', 'mg_biome_np_heat = 1', 'epic_terrain_enable = true',
'fixed_map_seed = 42', 'mapgen_limit = 512', 'water_level = 1']) assert.ok(isTerrainSetting(line), line);
assert.equal(isTerrainSetting('hearth_latitude = 45'), false);
});
@@ -405,7 +424,7 @@ test('world creation writes terrain once; subsequent ephemeral configs never ret
'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.deepEqual(second.split(/\r?\n/).filter(isTerrainSetting), ['epic_terrain_enable = false']);
assert.match(second, /hearth_latitude = 45/);
assert.match(second, /secure.enable_security = true/);
assert.deepEqual(await fs.readFile(path.join(world, 'world.mt')), worldFile);
@@ -424,7 +443,7 @@ test('world.mt without map metadata and orphan map metadata each suppress creati
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(config, 'utf8')).split(/\r?\n/).filter(isTerrainSetting), ['epic_terrain_enable = false']);
assert.deepEqual(await fs.readFile(path.join(world, name)), bytes);
}
});
@@ -497,7 +516,7 @@ test('play/course/test configs include unique graphics keys for new and existing
}
assert.ok(lines.includes('secure.enable_security = true'));
if (existing) {
assert.deepEqual(lines.filter(isTerrainSetting), []);
assert.deepEqual(lines.filter(isTerrainSetting), ['epic_terrain_enable = false']);
assert.deepEqual(await inventory(world), before);
} else {
for (const setting of terrainSettings(true)) assert.ok(lines.includes(setting), setting);
+47 -10
View File
@@ -8,6 +8,17 @@ import { LIMITS, USER_AGENT, parseCatalogue, apiURL, allowedURL, fetchBatch,
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 additions = [
['crab_apple', 'Malus sylvestris'], ['wild_cherry', 'Prunus avium'], ['field_maple', 'Acer campestre'],
['honeysuckle', 'Lonicera periclymenum'], ['ivy', 'Hedera helix'], ['gorse', 'Ulex europaeus'],
['broom', 'Cytisus scoparius'], ['dandelion', 'Taraxacum officinale'], ['daisy', 'Bellis perennis'],
['red_clover', 'Trifolium pratense'], ['birdsfoot_trefoil', 'Lotus corniculatus'],
['ribwort_plantain', 'Plantago lanceolata'], ['selfheal', 'Prunella vulgaris'],
['knapweed', 'Centaurea nigra'], ['oxeye_daisy', 'Leucanthemum vulgare'],
['field_scabious', 'Knautia arvensis'], ['teasel', 'Dipsacus fullonum'],
['cow_parsley', 'Anthriscus sylvestris'], ['garlic_mustard', 'Alliaria petiolata'],
['holly_fern', 'Polystichum setiferum'],
].map(([id, latin]) => ({ id, latin }));
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.';
@@ -25,10 +36,13 @@ function jsonResponse(data, 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);
test('bounded non-executing Lua parser finds the actual 100 Latin names and multi-underscore IDs', async () => {
assert.equal(LIMITS.species, 100);
assert.equal(catalogue.length, 100);
assert.equal(new Set(catalogue.map(r => r.id)).size, 100);
assert.deepEqual(catalogue.find(r => r.id === 'wood_blewit'), { id: 'wood_blewit', latin: 'Collybia nuda' });
assert.deepEqual(catalogue.find(r => r.id === 'sweet_vernal_grass'), { id: 'sweet_vernal_grass', latin: 'Anthoxanthum odoratum' });
assert.deepEqual(catalogue.filter(r => additions.some(a => a.id === r.id)), additions);
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'),
@@ -37,8 +51,9 @@ test('bounded non-executing Lua parser finds the actual 80 Latin names and IDs',
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.replace(/^\s*add\("holly_fern"[^\r\n]*/m, ''),
source.repeat(10), 'os.execute("not executed")']) assert.throws(() => parseCatalogue(bad));
assert.equal(parseCatalogue(source + '\nos.execute("never evaluated")').length, 80);
assert.equal(parseCatalogue(source + '\nos.execute("never evaluated")').length, 100);
});
test('URL construction rejects URL/query/title injection before any request', async () => {
@@ -230,7 +245,7 @@ test('untrusted Wikipedia text is exclusively UTF-8 byte literals, never executa
assert.throws(() => serializeBundle([{ ...entry, revision: '12345' }]));
});
test('80 titles use exactly eight mocked batches at concurrency <=2 and stable catalogue order', async () => {
test('100 titles use exactly ten 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 => {
@@ -239,13 +254,18 @@ test('80 titles use exactly eight mocked batches at concurrency <=2 and stable c
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.equal(calls, 10); 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);
assert.equal(entries.length, 100);
assert.deepEqual(parseBundle(serializeBundle(entries)), entries);
for (const records of [[], [...catalogue, { id: 'extra', latin: 'Abies alba' }]]) {
await assert.rejects(fetchEntries(records, { fetchImpl: () => { throw new Error('must not fetch'); } }), /scope/);
assert.throws(() => serializeBundle(records), /scope/);
}
const extra = { ...entries[0], id: 'extra', latin: 'Abies alba' };
const oversized = serializeBundle(entries).replace(/}\n$/, serializeBundle([extra]).split('return {\n')[1]);
assert.throws(() => parseBundle(oversized), /scope/);
});
test('batch failure aborts other workers and never launches the remaining catalogue', async () => {
@@ -258,10 +278,20 @@ test('batch failure aborts other workers and never launches the remaining catalo
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);
test('shipped bundle retains 78 old intros and two notes plus 20 real new intros for all 100 species', () => {
assert.equal(cached.length, 100);
assert.deepEqual(cached.map(({ id, latin }) => ({ id, latin })), catalogue);
assert.equal(cached.filter(e => e.kind === 'wikipedia_intro').length, 78);
assert.equal(cached.filter(e => e.kind === 'wikipedia_intro').length, 98);
const old = cached.filter(e => !additions.some(a => a.id === e.id));
assert.equal(old.length, 80);
assert.equal(old.filter(e => e.kind === 'wikipedia_intro').length, 78);
for (const addition of additions) {
const e = cached.find(e => e.id === addition.id);
assert.equal(e.latin, addition.latin);
assert.equal(e.kind, 'wikipedia_intro');
assert.equal(e.excerpt, true);
assert.ok(e.text.length > 30);
}
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);
@@ -273,6 +303,13 @@ test('shipped bundle contains 78 real intros and two honest missing-page notes f
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(e.license_url, 'https://creativecommons.org/licenses/by-sa/4.0/');
assert.ok(Date.parse(e.revision_timestamp) <= Date.parse(e.retrieved));
} else {
assert.equal(e.kind, 'original_notes');
assert.equal(e.license, 'CC0 1.0');
assert.equal(e.excerpt, false);
for (const field of ['pageid', 'revision', 'revision_url', 'revision_timestamp']) assert.equal(e[field], undefined);
}
}
assert.equal(cached.find(e => e.id === 'meadow_fescue').title, 'Lolium pratense');
+972 -256
View File
File diff suppressed because it is too large Load Diff
+43 -13
View File
@@ -61,7 +61,8 @@ test('named manifest covers all requested world, ecology, craftitem, wildlife an
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 species=['oak','birch','pine','willow','alder','beech','aspen','rowan','hazel','spruce',
'crab_apple','wild_cherry','field_maple'], 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(' '),
@@ -69,7 +70,7 @@ test('named manifest covers all requested world, ecology, craftitem, wildlife an
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(BIOME_WORLD_NAMES,biome); assert.equal(biome.length,73);
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());
@@ -78,24 +79,28 @@ test('named manifest covers all requested world, ecology, craftitem, wildlife an
'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);
assert.equal(art.size,421); assert.equal(WORLD_NAMES.length,181); 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()=> {
test('all 100 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.equal(rows.length,100); assert.equal(new Set(rows.map(r=>r.id)).size,100);
assert.equal(new Set(rows.map(r=>r.latin)).size,100);
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);
const counts=[13,14,10,22,11,10,10,10]; let offset=0;
for(const [i,g] of groups.entries()) {
assert.deepEqual(BOTANY_CATALOGUE.slice(offset,offset+counts[i]).map(r=>r.group),Array(counts[i]).fill(g));
offset+=counts[i];
}
assert.deepEqual(BOTANY_ART_NAMES.slice(0,13),SPECIES_NAMES);
assert.equal(BOTANY_ART_MANIFEST.length,200);
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);
@@ -104,7 +109,7 @@ test('all 80 exact catalogue IDs and Latin identities match the actual Lua recor
}
});
test('80 recognizable botanical cutouts have unique silhouettes, crisp padded alpha, and 80 distinct bloom-free inactive drawings',()=> {
test('100 recognizable botanical cutouts have unique silhouettes, crisp padded alpha, and 100 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`);
@@ -122,9 +127,9 @@ test('80 recognizable botanical cutouts have unique silhouettes, crisp padded al
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);
assert.equal(activeMasks.size,100,'no two taxa masquerade as the same recoloured silhouette');
assert.equal(dormantMasks.size,100,'each inactive species retains its own structure');
assert.equal(allBytes.size,200);
});
test('botanical diagnostic landmarks distinguish flowers, pinnate ferns, moss cushions, fungi and true floating lily pads',()=> {
@@ -161,6 +166,31 @@ test('approved leaf PNG bytes survive the catalogue extension without rewriting
}
});
test('twenty additions retain exact contract identities and diagnostic flowers, climbing structures, seedheads and fern forks',()=> {
const additions='crab_apple wild_cherry field_maple honeysuckle ivy gorse broom dandelion daisy red_clover birdsfoot_trefoil ribwort_plantain selfheal knapweed oxeye_daisy field_scabious teasel cow_parsley garlic_mustard holly_fern'.split(' ');
const art=worldArt(),has=(id,color)=>pixels(tile(art,`botany_${id}`)).some(p=>p[3]&&p.slice(0,3).join()===PALETTE[color].join());
assert.equal(additions.length,20);
for(const id of additions) {
assert.ok(BOTANY_ART_NAMES.includes(id));
assert.equal(BOTANY_ART_MANIFEST.filter(r=>r.id===id).length,2);
const active=tile(art,`botany_${id}`),dry=tile(art,`botany_${id}_dormant`);
assert.notDeepEqual(active.data,dry.data);
const upper=pixels(active).filter((p,i)=>p[3]&&Math.floor(i/32)<18).length;
assert.ok(upper>8,`${id}: diagnostic upper specimen, not only a generic basal tuft`);
}
for(const [id,colors] of Object.entries({honeysuckle:['cream','gold','rose'],ivy:['ochre','barkLight'],
gorse:['gold','ochre'],broom:['gold','leafLight'],dandelion:['gold','ochre'],daisy:['cream','gold'],
red_clover:['wine','mauve'],birdsfoot_trefoil:['gold','ochre'],ribwort_plantain:['cream','bark'],
selfheal:['wine','mauve'],knapweed:['wine','rose'],oxeye_daisy:['cream','gold'],
field_scabious:['mauve','rose'],teasel:['wood','mauve'],cow_parsley:['cream','sage'],
garlic_mustard:['cream','straw'],holly_fern:['ochre','leafLight']}))
for(const color of colors) assert.ok(has(id,color),`${id}: ${color} diagnostic landmark`);
// Ivy's climbing support/rootlets remain through dormancy; honey tubes are
// narrow at the mouth, unlike a daisy's radial head or a teasel's seed cone.
for(const y of [8,12,16,20,24]) assert.ok([12,13,14].some(x=>pixel(tile(art,'botany_ivy'),x,y)[3]===255));
assert.ok(pixels(tile(art,'botany_garlic_mustard_dormant')).filter(p=>p[3]).length>50);
});
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();