Ruined
This commit is contained in:
+13
-5
@@ -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)}`));
|
||||
});
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
Executable
+246
@@ -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);
|
||||
});
|
||||
Executable
+109
@@ -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
@@ -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));
|
||||
});
|
||||
|
||||
|
||||
Executable
+269
@@ -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
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+43
-13
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user