389 lines
26 KiB
JavaScript
Executable File
389 lines
26 KiB
JavaScript
Executable File
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');
|
||
}
|
||
});
|