Files
last-hearth/tests/flora-art.test.mjs
2026-11-05 09:57:11 -05:00

389 lines
26 KiB
JavaScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as fs from 'node:fs/promises';
import { realpath } from 'node:fs';
import { promisify } from 'node:util';
import path from 'node:path';
import os from 'node:os';
import { inflateSync } from 'node:zlib';
import { FLORA_NAMES, TREE_SEASONS, LEGACY_MESH_SPECIES, SPECIES_MESH_NAMES, SPECIES_ATLAS_NAMES, floraOBJ, floraBounds, treeAtlas } from '../scripts/flora-art.mjs';
import { PALETTE, SPECIES_NAMES, EVERGREEN_NAMES, worldArt } from '../scripts/world-art.mjs';
import { BOTANY_ART_MANIFEST, generateAssets, png } from '../scripts/assets.mjs';
import { packageGame, verifyPackage } from '../scripts/package.mjs';
import { CHARACTER_ART_MANIFEST } from '../scripts/character-art.mjs';
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]);
const dot=(a,b)=>a.reduce((s,n,i)=>s+n*b[i],0);
function parse(text) {
const v=[],vt=[],vn=[],groups=[]; let group;
for(const line of text.trim().split('\n')) {
const [kind,...p]=line.split(' ');
assert.ok(['#','s','v','vt','vn','g','f'].includes(kind),'no external material references');
if(kind==='v') v.push(p.map(Number));
if(kind==='vt') vt.push(p.map(Number));
if(kind==='vn') vn.push(p.map(Number));
if(kind==='g') { group={name:p[0],faces:[]}; groups.push(group); }
if(kind==='f') group.faces.push(p.map(t=>t.split('/').map(Number)));
}
return {v,vt,vn,groups};
}
test('all original deterministic, closed volumetric meshes have finite indexed triangles, outward normals and inset material UVs',()=> {
const distinct=new Set();
for(const name of FLORA_NAMES) {
const text=floraOBJ(name); assert.equal(text,floraOBJ(name)); distinct.add(text);
const {v,vt,vn,groups}=parse(text);
const species=SPECIES_MESH_NAMES.includes(name), triangles=groups.flatMap(g=>g.faces).length;
assert.ok(v.length>50 && v.length<=(species?640:300),`${name}: bounded per-species topology`);
assert.ok(triangles<=(species?1200:600),`${name}: explicit triangle budget`);
for(const values of [v,vt,vn]) assert.ok(values.flat().every(Number.isFinite));
const referenced=new Set();
for(const group of groups) {
const ids=[...new Set(group.faces.flat().map(p=>p[0]))];
const center=[0,1,2].map(i=>ids.reduce((s,id)=>s+v[id-1][i],0)/ids.length);
const edges=new Map(); let volume=0;
for(const f of group.faces) {
assert.equal(f.length,3); assert.equal(new Set(f.map(p=>p[0])).size,3);
for(const [a,t,n] of f) {
assert.ok(a>=1 && a<=v.length && t>=1 && t<=vt.length && n>=1 && n<=vn.length);
referenced.add(a);
const [u,w]=vt[t-1]; assert.ok(w>=.5/32 && w<=31.5/32);
assert.ok(group.name.startsWith('leaf') || group.name.startsWith('foliage')
? u>=32.5/64 && u<=63.5/64 : u>=.5/64 && u<=31.5/64);
}
const [a,b,c]=f.map(p=>v[p[0]-1]), normal=cross(sub(b,a),sub(c,a));
assert.ok(Math.hypot(...normal)>1e-5);
assert.ok(dot(normal,sub(a,center))>0,'outward winding');
assert.ok(dot(normal,vn[f[0][2]-1])/Math.hypot(...normal)>.99999);
volume+=dot(a,cross(b,c))/6;
for(let i=0;i<3;i++) {
const a=f[i][0], b=f[(i+1)%3][0], key=[Math.min(a,b),Math.max(a,b)].join();
const entry=edges.get(key)||[0,0]; entry[0]++; entry[1]+=a<b?1:-1; edges.set(key,entry);
}
}
assert.ok(volume>.001,'solid 3D volume, not a billboard');
for(const edge of edges.values()) assert.deepEqual(edge,[2,0],'closed oriented manifold per lobe/branch');
}
assert.equal(referenced.size,v.length);
const bound=floraBounds(name);
for(let i=0;i<3;i++) {
assert.equal(Math.min(...v.map(p=>p[i]))/10,bound.min[i]);
assert.equal(Math.max(...v.map(p=>p[i]))/10,bound.max[i]);
}
assert.equal(bound.min[1],-.5,'ground is node bottom');
assert.ok(bound.max[0]!==-bound.min[0] || bound.max[2]!==-bound.min[2],'authored asymmetry');
}
assert.equal(distinct.size,FLORA_NAMES.length);
for(const bad of ['../young_tree','constructor','oak',null]) {
assert.throws(()=>floraOBJ(bad),/Unknown flora/); assert.throws(()=>floraBounds(bad),/Unknown flora/);
}
});
test('winter trees are forked wood-only sculptures, with fewer faces and every original trunk/bough preserved exactly',()=> {
const geometry=(mesh,group)=>group.faces.map(face=>face.map(([v,t,n])=>[mesh.v[v-1],mesh.vt[t-1],mesh.vn[n-1]]));
for(const name of ['young_tree','mature_tree']) {
const summer=parse(floraOBJ(name)), winter=parse(floraOBJ(`${name}_winter`));
assert.ok(winter.groups.every(g=>!/^(leaf|foliage)/.test(g.name)),'no foliage geometry');
assert.ok(winter.groups.flatMap(g=>g.faces).length<summer.groups.flatMap(g=>g.faces).length);
const woody=summer.groups.filter(g=>!/^(leaf|foliage)/.test(g.name));
for(const group of woody) {
const retained=winter.groups.find(g=>g.name===group.name);
assert.ok(retained); assert.deepEqual(geometry(winter,retained),geometry(summer,group),group.name);
}
const forks=winter.groups.filter(g=>g.name.startsWith('winter_fork_'));
assert.equal(forks.length,woody.length-1,'one lateral fork per bough');
for(const group of forks) {
const points=geometry(winter,group).flat().map(p=>p[0]);
assert.ok(Math.max(...points.map(p=>p[0]))-Math.min(...points.map(p=>p[0]))>1,'lateral silhouette');
}
const a=floraBounds(name), b=floraBounds(`${name}_winter`);
assert.equal(b.min[1],-.5); assert.ok(b.max[1]> (name==='mature_tree'?2: .9));
for(let i=0;i<3;i++) assert.ok(b.min[i]>=a.min[i] && b.max[i]<=a.max[i],'within existing clearance envelope');
assert.ok(winter.vt.every(([u])=>u<=31.5/64),'all faces sample bark only');
}
assert.ok(!FLORA_NAMES.includes('tree_seedling_winter'),'runtime reuses seedling mesh');
});
test('tree heights, footprints and distinct overlapping foliage lobes respect gameplay scale',()=> {
for(const [name,maxHeight,x,z] of [['tree_seedling',.7,.3,.3],['young_tree',2.2,.65,.65],
['mature_tree',3.5,1.1,1.1],['deadwood',.5,.45,.9]]) {
const {min,max}=floraBounds(name);
assert.ok(max[1]-min[1]<=maxHeight+1e-6,name);
assert.ok(Math.max(Math.abs(min[0]),Math.abs(max[0]))<=x,name);
assert.ok(Math.max(Math.abs(min[2]),Math.abs(max[2]))<=z,name);
}
assert.ok(floraBounds('young_tree').max[1]>=1.3);
assert.equal(floraBounds('mature_tree').max[1],3);
for(const [name,count] of [['tree_seedling',2],['young_tree',4],['mature_tree',5]]) {
const {groups}=parse(floraOBJ(name));
assert.equal(groups.filter(g=>/^(leaf|foliage)/.test(g.name)).length,count);
assert.ok(groups.some(g=>/^(stem|trunk)$/.test(g.name)));
}
});
test('single bark/leaf atlas is deterministic 64x32 straight RGBA with solid canopy underpaint and exact encoded pixels',()=> {
const image=treeAtlas(); assert.deepEqual(image,treeAtlas());
assert.deepEqual([image.width,image.height,image.data.length],[64,32,8192]);
assert.ok(image.data.filter((_,i)=>i%4===3).every(a=>a===255),'opaque lobes do not expose torn faces');
assert.notDeepEqual(image.data.slice(0,128),image.data.slice(128,256));
const bytes=png(64,(x,y)=>image.data.subarray((y*64+x)*4,(y*64+x+1)*4),32);
assert.equal(bytes[25],6,'PNG RGBA color type');
const compressed=[];
for(let i=8;i<bytes.length;) {
const n=bytes.readUInt32BE(i);
if(bytes.toString('ascii',i+4,i+8)==='IDAT') compressed.push(bytes.subarray(i+8,i+8+n));
i+=n+12;
}
const raw=inflateSync(Buffer.concat(compressed),{maxOutputLength:9000});
for(let y=0;y<32;y++) {
assert.equal(raw[y*257],0);
assert.deepEqual(raw.subarray(y*257+1,(y+1)*257),Buffer.from(image.data.subarray(y*256,(y+1)*256)));
}
assert.throws(()=>treeAtlas(new Map()),/requires/);
});
test('all four seasonal atlases preserve byte-exact bark and opaque 64x32 materials with season-specific leaf underpaint',()=> {
const art=worldArt(), summer=treeAtlas(art), unique=new Set();
assert.deepEqual(TREE_SEASONS,['summer','spring','autumn','winter']);
for(const season of TREE_SEASONS) {
const image=treeAtlas(art,season), leaves=art.get(`hearth_leaves${season==='summer'?'':`_${season}`}.png`);
assert.deepEqual(image,treeAtlas(art,season));
assert.deepEqual([image.width,image.height,image.data.length],[64,32,8192]);
unique.add(Buffer.from(image.data).toString('hex'));
for(let y=0;y<32;y++) {
assert.deepEqual(image.data.slice(y*256,y*256+128),summer.data.slice(y*256,y*256+128),'unchanged bark');
for(let x=0;x<32;x++) {
const from=(y*32+x)*4, to=(y*64+x+32)*4;
const color=[...image.data.slice(to,to+4)]; assert.equal(color[3],255);
if(leaves.data[from+3]===255) assert.deepEqual(color,[...leaves.data.slice(from,from+4)]);
else assert.deepEqual(color,[...PALETTE[{summer:'leafDark',spring:'springDark',autumn:'autumnDark',winter:'barkDark'}[season]],255]);
}
}
}
assert.equal(unique.size,TREE_SEASONS.length);
for(const bad of ['Summer','../spring','constructor',null]) assert.throws(()=>treeAtlas(art,bad),/Unknown tree season/);
for(const season of TREE_SEASONS) assert.throws(()=>treeAtlas(new Map(),season),/requires/);
});
test('deprecated 23-species-mesh inventory stays frozen; all 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,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`));
for(const bad of ['pine_mature_winter','oak_seedling_winter','oak_mature_spring','../oak_mature'])
assert.throws(()=>floraOBJ(bad),/Unknown flora/);
});
const points=(mesh,group)=>[...new Set(group.faces.flat().map(p=>p[0]))].sort((a,b)=>a-b).map(i=>mesh.v[i-1]);
const extents=ps=>[0,1,2].map(i=>Math.max(...ps.map(p=>p[i]))-Math.min(...ps.map(p=>p[i])));
test('species architectures convey broad oak, airy slim birch, tapered pine whorls, drooping willow and rounded alder within clearance',()=> {
const meshes=Object.fromEntries(LEGACY_MESH_SPECIES.map(s=>[s,parse(floraOBJ(`${s}_mature`))]));
for(const s of LEGACY_MESH_SPECIES) for(const stage of ['seedling','young','mature']) {
const {min,max}=floraBounds(`${s}_${stage}`), half=stage==='mature'?1.5:stage==='young'?.75:.3;
assert.equal(min[1],-.5);
assert.ok(max[1]-min[1]<=(stage==='mature'?5:stage==='young'?2.5:1));
for(const i of [0,2]) assert.ok(min[i]>=-half && max[i]<=half,`${s}_${stage}: footprint`);
const mesh=parse(floraOBJ(`${s}_${stage}`));
assert.ok(mesh.groups.some(g=>/^(stem|trunk)$/.test(g.name)));
assert.ok(mesh.groups.filter(g=>/^(leaf|foliage)/.test(g.name)).length>=2);
}
const oak=extents(meshes.oak.v), birch=extents(meshes.birch.v), alder=extents(meshes.alder.v);
assert.ok(oak[0]>birch[0]*1.5,'spreading oak versus slim birch');
assert.ok(birch[1]/birch[0]>2.5,'birch is tall and narrow');
assert.ok(oak[0]>alder[0] && alder[1]<oak[1],'alder is compact, rounded and shorter');
const trunkWidth=s=>extents(points(meshes[s],meshes[s].groups.find(g=>g.name==='trunk')))[0];
assert.ok(trunkWidth('birch')<trunkWidth('oak')*.7,'slim birch bole, not just lighter paint');
const pine=meshes.pine;
for(let i=0;i<3;i++) {
const lower=pine.groups.find(g=>g.name===`foliage_tier_${i}_0`), upper=pine.groups.find(g=>g.name===`foliage_tier_${i+1}_0`);
assert.ok(extents(points(pine,lower))[0]>extents(points(pine,upper))[0],'evergreen whorls taper upward');
}
assert.ok(pine.groups.some(g=>g.name==='foliage_leader'));
const willow=meshes.willow, curtains=willow.groups.filter(g=>g.name.startsWith('foliage_curtain_'));
assert.equal(curtains.length,6);
for(const g of curtains) {
const e=extents(points(willow,g)); assert.ok(e[1]>e[0]*3 && e[1]>e[2]*3,'long pendant leaf curtains');
}
const bough=willow.groups.find(g=>g.name==='pendant_0'), ps=points(willow,bough);
const a=ps.slice(0,8).reduce((n,p)=>n+p[1],0)/8, b=ps.slice(8).reduce((n,p)=>n+p[1],0)/8;
assert.ok(a>b+10,'willow terminal wood droops, not only its painted leaves');
});
test('deciduous winter species preserve all woody parts, omit every crown and sample bark only; pine stays evergreen',()=> {
const geometry=(m,g)=>g.faces.map(f=>f.map(([v,t,n])=>[m.v[v-1],m.vt[t-1],m.vn[n-1]]));
for(const s of LEGACY_MESH_SPECIES.filter(s=>s!=='pine')) for(const stage of ['young','mature']) {
const leafy=parse(floraOBJ(`${s}_${stage}`)), bare=parse(floraOBJ(`${s}_${stage}_winter`));
const woody=leafy.groups.filter(g=>!g.name.startsWith('foliage'));
assert.deepEqual(bare.groups.map(g=>g.name),woody.map(g=>g.name),'all forks and pendants retained');
for(const g of woody) assert.deepEqual(geometry(bare,bare.groups.find(b=>b.name===g.name)),geometry(leafy,g));
assert.ok(bare.vt.every(([u])=>u<=31.5/64));
assert.ok(bare.groups.flatMap(g=>g.faces).length<leafy.groups.flatMap(g=>g.faces).length);
const a=floraBounds(`${s}_${stage}`), b=floraBounds(`${s}_${stage}_winter`);
for(let i=0;i<3;i++) assert.ok(b.min[i]>=a.min[i] && b.max[i]<=a.max[i]);
}
assert.ok(parse(floraOBJ('pine_mature')).groups.some(g=>g.name.startsWith('foliage')));
assert.throws(()=>treeAtlas(worldArt(),'winter','pine'),/evergreen/);
});
test('all species atlases use exact own bark and own seasonal leaves, opaque underpaint and half-texel inset UVs',()=> {
const art=worldArt(), distinct=new Set();
for(const s of SPECIES_NAMES) {
const summer=treeAtlas(art,'summer',s), seasons=EVERGREEN_NAMES.includes(s)?['summer']:TREE_SEASONS;
for(const season of seasons) {
const image=treeAtlas(art,season,s), bark=art.get(`hearth_${s}_log_side.png`),
leaf=art.get(`hearth_${s}_leaves${season==='summer'?'':`_${season}`}.png`);
assert.deepEqual(image,treeAtlas(art,season,s));
assert.deepEqual([image.width,image.height,image.data.length],[64,32,8192]);
distinct.add(Buffer.from(image.data).toString('hex'));
for(let y=0;y<32;y++) for(let x=0;x<32;x++) {
const from=(y*32+x)*4, left=(y*64+x)*4, right=left+128;
assert.deepEqual(image.data.slice(left,left+4),bark.data.slice(from,from+4));
assert.deepEqual(image.data.slice(left,left+4),summer.data.slice(left,left+4));
assert.equal(image.data[right+3],255);
if(leaf.data[from+3]) assert.deepEqual(image.data.slice(right,right+4),leaf.data.slice(from,from+4));
else {
const color=season==='summer'?{oak:'leafDark',birch:'leaf',pine:'pineShade',willow:'willowShade',alder:'leafDark',
beech:'leafDark',aspen:'leafDark',rowan:'leafDark',hazel:'leafDark',spruce:'pineShade',
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,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/);
});
test('minimal fixture generation and integrity-verified package include every seasonal texture, atlas and model; linked outputs refused',async t=> {
const root=await fs.mkdtemp(path.join(await promisify(realpath)(os.tmpdir()),'hearth-flora-'));
t.after(()=>fs.rm(root,{recursive:true,force:true}));
const source=path.join(root,'source'), out=path.join(root,'out');
await fs.mkdir(path.join(source,'mods/hearth'),{recursive:true});
for(const name of ['init','model','runtime','content','encounters'])
await fs.writeFile(path.join(source,`mods/hearth/${name}.lua`),'-- minimal fixture\n');
await generateAssets(out,source);
async function check(destination) {
for(const name of FLORA_NAMES) assert.equal(await fs.readFile(path.join(destination,`mods/hearth/models/hearth_${name}.obj`),'utf8'),floraOBJ(name));
const art=worldArt();
for(const season of TREE_SEASONS) {
const atlas=treeAtlas(art,season), expected=png(64,(x,y)=>atlas.data.subarray((y*64+x)*4,(y*64+x+1)*4),32);
assert.deepEqual(await fs.readFile(path.join(destination,`mods/hearth/textures/hearth_tree_atlas${season==='summer'?'':`_${season}`}.png`)),expected);
}
for(const name of SPECIES_ATLAS_NAMES) {
const [,s,season='summer']=name.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);
}
for(const [name,image] of art) assert.deepEqual(await fs.readFile(path.join(destination,'mods/hearth/textures',name)),
png(32,(x,y)=>image.data.subarray((y*32+x)*4,(y*32+x+1)*4)));
const expectedTextures=[...art.keys(),...TREE_SEASONS.map(s=>`hearth_tree_atlas${s==='summer'?'':`_${s}`}.png`),
...SPECIES_ATLAS_NAMES.map(n=>`hearth_${n}.png`),
...['knife','axe','pick','spear'].map(n=>`hearth_icon_${n}.png`),
...CHARACTER_ART_MANIFEST.map(entry=>entry.filename),
...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,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 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',
...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);
for(const name of ['game.conf','minetest.conf','settingtypes.txt','LICENSE.txt']) await fs.writeFile(path.join(source,name),'fixture\n');
await fs.writeFile(path.join(source,'release.json'),JSON.stringify({format:1,gameId:'last_hearth',version:'0.4.0-m3',
engine:{name:'Luanti',version:'5.18.0'},node:'>=22',packageFormat:1,backupFormat:1}));
await fs.mkdir(path.join(source,'tests/fixtures'),{recursive:true});
await fs.writeFile(path.join(source,'tests/fixtures/scenarios.json'),'{}');
const packaged=path.join(root,'package'); await packageGame(packaged,{source}); await check(packaged); await verifyPackage(packaged,{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');
await assert.rejects(generateAssets(out,source),/link|junction|unsafe/i);
assert.deepEqual(await fs.readdir(outside),[]);
});
test('read-only cube diversity and biome contracts cover 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));
assert.deepEqual(species,SPECIES_NAMES);
assert.match(source,/drawtype=woody and "normal" or "plantlike"/);
assert.match(source,/tiles=woody and \{"hearth_"\.\.s\.\."_log_top.png","hearth_"\.\.s\.\."_log_top.png","hearth_"\.\.s\.\."_log_side.png"\}/);
assert.ok(!source.includes('.obj') && !source.includes('_atlas'),'no runtime dependency on deprecated irregular tree meshes');
assert.match(source,/evergreen=s=="pine" or s=="spruce"/);
assert.match(source,/plant~="moss" and plant~="lichen"/,'persistent groundcover has no dormant alias');
const biomes=await fs.readFile(new URL('../mods/hearth/biomes.lua',import.meta.url),'utf8');
const biomeSpecies=biomes.match(/species_order\s*=\s*\{([^}]+)\}/)[1].match(/"[a-z_]+"/g).map(s=>s.slice(1,-1));
assert.deepEqual(biomeSpecies,SPECIES_NAMES);
const art=worldArt();
for(const s of species) for(const face of ['log_top','log_side','leaves','planks'])
assert.ok(art.has(`hearth_${s}_${face}.png`));
const docs=await fs.readFile(new URL('../docs/art-direction.md',import.meta.url),'utf8');
const current=docs.slice(0,docs.indexOf('<details>'));
assert.match(current,/512×864/); assert.match(current,/1–512/); assert.match(current,/16 columns/);
assert.match(current,/ceil\(count\/16\)\*32/);
assert.match(current,/421/); assert.match(current,/200/); assert.match(current,/botanicalLatin/);
for(const s of SPECIES_NAMES) assert.ok(current.includes(s));
});
test('actual seasonal content uses exact generated stems, explicit cube tiles and separate patch/craftitem naming',async()=> {
const text=await fs.readFile(new URL('../mods/hearth/content.lua',import.meta.url),'utf8');
const loop=text.slice(text.indexOf('for _, plant in ipairs({'),text.indexOf('-- Cosmetic migration'));
assert.match(loop,/drawtype = woody and "normal" or "plantlike"/);
assert.match(loop,/visual_scale = woody and 1 or scale/);
assert.match(loop,/walkable = woody/);
assert.match(loop,/buildable_to = not woody/);
assert.match(loop,/tiles = woody and \{world_image\("log_top"\),world_image\("log_top"\),world_image\("log_side"\)\}/);
assert.ok(!loop.includes('.obj'),'retained OBJ exports are not the runtime tree contract');
assert.match(text,/def\.tiles = def\.tiles or/,'registrar preserves explicit mesh tiles without override_item');
for(const name of ['meadow_grass','wildflowers','fern']) assert.ok(loop.includes(`{"${name}"`));
const seasons=await fs.readFile(new URL('../mods/hearth/seasons.lua',import.meta.url),'utf8');
const docs=await fs.readFile(new URL('../docs/ecosystems.md',import.meta.url),'utf8');
const art=worldArt();
for(const stem of ['meadow_grass_dormant','fern_dormant','wildflowers_dormant',
'spring_greens','spring_greens_dormant','autumn_mushrooms','autumn_mushrooms_dormant',
'item_spring_greens','item_autumn_mushrooms']) {
assert.ok(art.has(`hearth_${stem}.png`)); assert.ok(docs.includes(`hearth_${stem}.png`));
}
for(const kind of ['tree_seedling','young_tree','mature_tree','leaves']) for(const season of ['spring','autumn','winter']) {
const mesh=kind==='leaves'?null:`${kind}${season==='winter' && kind!=='tree_seedling'?'_winter':''}`;
if(mesh) assert.ok(FLORA_NAMES.includes(mesh));
if(kind==='leaves') assert.ok(art.has(`hearth_leaves_${season}.png`));
else assert.ok(TREE_SEASONS.includes(season));
}
// Seasonal runtime cube-copy semantics are tested by the seasons owner; this
// suite verifies available bytes, not a stale assertion requiring OBJ usage.
for(const kind of ['spring_greens','autumn_mushrooms']) {
assert.ok(text.includes(`{"${kind}",`));
assert.ok(seasons.includes(`variant("${kind}_patch",`));
assert.ok(text.includes(`api.register_craftitem("hearth:${kind}",`));
assert.ok(!art.has(`hearth_${kind}_patch.png`),'patch node name is not a texture stem');
}
});