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

501 lines
33 KiB
JavaScript
Executable File

// Engine-independent art contracts. Decode the actual PNG bytes independently;
// check geometry/masks, not just filenames, magic bytes or color diversity.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as fs from 'node:fs/promises';
import { realpath } from 'node:fs';
import { promisify } from 'node:util';
import os from 'node:os';
import path from 'node:path';
import { inflateSync } from 'node:zlib';
import { createHash } from 'node:crypto';
import { BOTANY_ART_MANIFEST, generateAssets, png, artContactSheetPNG } from '../scripts/assets.mjs';
import { PALETTE, PLANT_NAMES, SEASONAL_PLANT_NAMES, WORLD_NAMES, ITEM_NAMES, ANIMAL_NAMES,
BIOME_WORLD_NAMES, SPECIES_NAMES, ROCK_NAMES, SPECIES_SEASONAL_NAMES, UNDERSTORY_NAMES,
DORMANT_UNDERSTORY_NAMES, BOTANY_CATALOGUE, BOTANY_ART_NAMES, BOTANY_SPRITE_NAMES, EVERGREEN_NAMES,
worldArt, contactSheet, animalOBJ, animalBounds } from '../scripts/world-art.mjs';
const digest = data => createHash('sha256').update(data).digest('hex');
const pixel = (image,x,y) => [...image.data.subarray((y*image.width+x)*4,(y*image.width+x+1)*4)];
const pixels = image => Array.from({length:image.width*image.height},(_,i)=>pixel(image,i%image.width,Math.floor(i/image.width)));
const tile = (art,name) => art.get(`hearth_${name}.png`);
function crc32(bytes) {
let crc=0xffffffff;
for(const b of bytes) {
crc^=b;
for(let i=0;i<8;i++) crc=(crc>>>1)^((crc&1)?0xedb88320:0);
}
return (crc^0xffffffff)>>>0;
}
function decodePNG(bytes) {
assert.deepEqual([...bytes.subarray(0,8)],[137,80,78,71,13,10,26,10]);
let offset=8, width, height;
const chunks=[], compressed=[];
while(offset<bytes.length) {
assert.ok(offset+12<=bytes.length);
const length=bytes.readUInt32BE(offset), end=offset+8+length;
assert.ok(end+4<=bytes.length);
const type=bytes.toString('ascii',offset+4,offset+8), data=bytes.subarray(offset+8,end);
assert.equal(crc32(bytes.subarray(offset+4,end)),bytes.readUInt32BE(end));
chunks.push(type);
if(type==='IHDR') {
assert.equal(length,13); width=data.readUInt32BE(0); height=data.readUInt32BE(4);
assert.deepEqual([...data.subarray(8)],[8,6,0,0,0]);
} else if(type==='IDAT') compressed.push(data);
else { assert.equal(type,'IEND'); assert.equal(length,0); assert.equal(end+4,bytes.length); }
offset=end+4;
}
assert.deepEqual(chunks,['IHDR','IDAT','IEND']);
const raw=inflateSync(Buffer.concat(compressed),{maxOutputLength:512*1024*4+1024});
assert.equal(raw.length,height*(width*4+1));
const data=new Uint8Array(width*height*4);
for(let y=0;y<height;y++) {
assert.equal(raw[y*(width*4+1)],0);
data.set(raw.subarray(y*(width*4+1)+1,(y+1)*(width*4+1)),y*width*4);
}
return {width,height,data};
}
test('named manifest covers all requested world, ecology, craftitem, wildlife and interface art with no collisions',()=> {
const art=worldArt();
const requiredWorld='stone soil grass_top grass_side sand log_top log_side leaves planks wall roof thatch foundation frame stairs door window vent storage pantry reeds berry_bush berry_bush_bare sapling grain_patch crop_1 crop_2 crop_3 crop_4 clay_bank iron_ore_vein water fire fire_lit torch sticks loose_stone bedroll drying_rack fish_trap weir_stone meadow_grass wildflowers fern litter fungi deadwood tree_seedling young_tree mature_tree dung leaves_spring leaves_autumn leaves_winter meadow_grass_dormant fern_dormant wildflowers_dormant spring_greens spring_greens_dormant autumn_mushrooms autumn_mushrooms_dormant'.split(' ');
const requiredItems='berries grain grain_seed raw_food cooked_food fish_bait raw_fish cooked_fish fish_drying_charge dried_fish iron_ore iron_ingot charcoal charcoal_charge smelt_charge clay clay_jar clay_jar_charge dried_food dried_food_charge cloak vessel water_raw water_clean firestarter iron_axe spring_greens autumn_mushrooms'.split(' ');
const species=['oak','birch','pine','willow','alder','beech','aspen','rowan','hazel','spruce',
'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(' '),
...species.flatMap(s=>['log_top','log_side','leaves','planks'].map(n=>`${s}_${n}`))];
const seasonal=species.filter(s=>s!=='pine' && s!=='spruce').flatMap(s=>['spring','autumn','winter'].map(n=>`${s}_leaves_${n}`));
const dormant=understory.slice(0,6).map(n=>`${n}_dormant`);
assert.deepEqual(SPECIES_NAMES,species); assert.deepEqual(ROCK_NAMES,rocks);
assert.deepEqual(BIOME_WORLD_NAMES,biome); assert.equal(biome.length,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());
assert.deepEqual([...ITEM_NAMES].sort(),[...requiredItems,'quartz','copper_ore','coal','copper_ingot','copper_charge','copper_pick'].sort());
const names=[...WORLD_NAMES.map(n=>`hearth_${n}.png`),...ITEM_NAMES.map(n=>`hearth_item_${n}.png`),
'hearth_hotbar.png','hearth_selected.png','hearth_crosshair.png',...ANIMAL_NAMES.map(n=>`hearth_${n}.png`),
...BOTANY_SPRITE_NAMES.map(n=>`hearth_${n}.png`)];
assert.equal(new Set(names).size,names.length); assert.deepEqual([...art.keys()],names);
assert.equal(art.size,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 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,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'];
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);
assert.match(r.morphology,/[a-z].*[; ]/); assert.equal(r.license,'MIT');
assert.deepEqual([r.width,r.height],[32,32]); assert.ok(['active','dormant'].includes(r.state));
}
});
test('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`);
const mask=image=>Buffer.from(pixels(image).map(p=>p[3]));
activeMasks.add(digest(mask(active))); dormantMasks.add(digest(mask(dormant)));
assert.notDeepEqual(mask(active),mask(dormant),`${id}: changed growth form, not just RGB`);
for(const [state,image] of [['active',active],['dormant',dormant]]) {
const ps=pixels(image), count=ps.filter(p=>p[3]).length;
assert.ok(count>=15 && count<800,`${id} ${state}: cutout, not a square (${count})`);
assert.ok(ps.every(p=>p[3]===0 || p[3]===255));
for(let j=0;j<32;j++) for(const [x,y] of [[j,0],[j,31],[0,j],[31,j]]) assert.equal(pixel(image,x,y)[3],0);
const colors=new Set(ps.filter(p=>p[3]).map(p=>p.slice(0,3).join()));
assert.ok(colors.size>=2 && colors.size<=14,`${id} ${state}: small botanical palette`);
allBytes.add(digest(image.data));
if(state==='dormant') assert.ok(ps.every(p=>!p[3] || !(p[1]>p[0] && p[1]>p[2])),`${id}: dry inactive state`);
}
}
assert.equal(activeMasks.size,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',()=> {
const art=worldArt(), image=id=>tile(art,`botany_${id}`), has=(id,color)=>pixels(image(id)).some(p=>p[3] && p.slice(0,3).join()===PALETTE[color].join());
for(const id of ['oak','birch','pine','willow','alder','beech','aspen','rowan','hazel','spruce'])
assert.ok(pixels(image(id)).some((p,i)=>p[3] && Math.floor(i/32)<10),'tree fieldguide specimen has diagnostic upper growth');
assert.ok(has('bluebell','mauve')); assert.ok(has('foxglove','wine')); assert.ok(has('cowslip','gold'));
assert.ok(has('fly_agaric','cream') && has('fly_agaric','rust'));
assert.ok(has('shaggy_inkcap','ink')); assert.ok(has('turkey_tail','earth'));
assert.ok(has('polypody','ochre')); assert.ok(has('haircap_moss','copper'));
for(const id of ['yellow_waterlily','white_waterlily']) {
const pad=image(id);
assert.equal(pixel(pad,5,23)[3],255,'broad floating pad, not horns');
assert.equal(pixel(pad,10,26)[3],0,'radial basal notch in the pad');
assert.equal(pixel(pad,1,1)[3],0,'no painted water square');
}
assert.ok(has('white_waterlily','cream') && has('yellow_waterlily','gold'));
assert.ok(has('sunburst_lichen','copper')); assert.ok(has('lichen','barkDark'));
assert.ok(pixels(image('hornwort')).filter(p=>p[3]).length>100,'rootless forked submerged whorls');
});
test('approved leaf PNG bytes survive the catalogue extension without rewriting the existing leaf designs',()=> {
const hashes={
leaves:'0fdd70c561c7a9be4c7a2d6cae08dfc7b3467cf767d2ee84c9aa17fe4e52d1c4',
oak_leaves:'af2dfa295be79d2bc714aa57ae10c55f688d053c9b02cbbcf5bc84c8385dc582',
birch_leaves:'3ee8bedf16d9f7caae1b1b86d3160ac1057f935f77e89b6721c25fb108fcfdc2',
pine_leaves:'f38ab9e868e7b954edf94e0d8673e57d8ffffbf2dd2e7699aa43c5abb1b8fb22',
willow_leaves:'f9ac6a8d21976e42ff680d6eaf08b339b88f22b6ac1d8e6846eaa278be6caf73',
alder_leaves:'8fc4d476ddb52ddafa9dc662b5ba9e4bb509e9104da0f51f33e153697c3eb08d',
}, art=worldArt();
for(const [name,hash] of Object.entries(hashes)) {
const image=tile(art,name);
assert.equal(digest(png(32,(x,y)=>pixel(image,x,y))),hash,name);
}
});
test('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();
for(const [name,image] of first) {
assert.deepEqual(image.data,second.get(name).data); unique.add(digest(image.data));
const colors=new Set();
for(const p of pixels(image)) if(p[3]) {
assert.ok(allowed.has(p.slice(0,3).join(',')),`${name}: explicit palette only`); colors.add(p.slice(0,3).join(','));
} else assert.deepEqual(p,[0,0,0,0],`${name}: no hidden RGB in cutout texels`);
assert.ok(colors.size>=2 && colors.size<=14,`${name}: restrained per-tile palette`);
}
assert.equal(unique.size,first.size,'no identical stand-in art');
// Color-independent boundary masks prove different material geometries, rather
// than the same noisy grid with swapped RGB values.
const structures=new Set();
for(const name of ['stone','soil','grass_top','sand','log_top','log_side','planks','wall','roof','clay_bank','water']) {
const image=tile(first,name), edges=[]; let equal=0, comparisons=0;
for(let y=0;y<31;y++) for(let x=0;x<31;x++) {
for(const [dx,dy] of [[1,0],[0,1]]) {
const same=pixel(image,x,y).join()===pixel(image,x+dx,y+dy).join();
edges.push(same?0:1); equal+=Number(same); comparisons++;
}
}
assert.ok(equal/comparisons>.48,`${name}: contiguous hand-authored clusters, not hash noise`);
structures.add(digest(Buffer.from(edges)));
}
assert.equal(structures.size,11);
});
test('approved leaf pixels are preserved; natural ground has no masonry grout, broad stripes or outlined blade symbols',()=> {
const art=worldArt();
assert.equal(digest(tile(art,'leaves').data),'6ca3e11b7e8e98f46220db92a4dbe729515835c3ae39c76ac3003fa5494f3f4c');
const rows=image=>Array.from({length:32},(_,y)=>Array.from({length:32},(_,x)=>pixel(image,x,y).join()));
for(const name of ['stone',...ROCK_NAMES]) {
const image=tile(art,name);
assert.ok(pixels(image).every(p=>p[3]===255));
assert.ok(!pixels(image).some(p=>p.slice(0,3).join()===PALETTE.outline.join()),`${name}: no mortar outlines`);
assert.ok(rows(image).filter(row=>new Set(row).size===1).length<4,`${name}: interrupted mineral microfacets`);
if(name!=='stone') {
const brick=tile(art,`${name}_bricks`);
assert.equal(new Set(rows(brick)[11]).size,1,'crafted masonry has an explicit bed joint');
assert.notDeepEqual(image.data,brick.data);
}
}
const soil=tile(art,'soil'), turf=tile(art,'grass_top');
for(const name of ['loamShade','loamGlow']) {
const color=PALETTE[name].join();
assert.ok(rows(soil).every(row=>row.filter(p=>p.startsWith(color+',')).length<24),'soil lenses never form broad solid strata');
}
const greens=new Set(['turf','turfShade','turfGlow','leaf','sage'].map(c=>PALETTE[c].join()));
assert.ok(pixels(turf).every(p=>p[3]===255 && greens.has(p.slice(0,3).join())),'dense unoutlined natural turf only');
for(const [x0,y0] of [[0,0],[16,0],[0,16],[16,16]]) {
const colors=new Set();
for(let y=y0;y<y0+16;y++) for(let x=x0;x<x0+16;x++) colors.add(pixel(turf,x,y).join());
assert.ok(colors.size>=4,'mosaic is distributed across every quadrant, not isolated giant blades');
}
const side=tile(art,'grass_side'), humus=PALETTE.humus.join();
assert.ok(Array.from({length:10},(_,i)=>i+5).some(y=>rows(side)[y].some(p=>p.startsWith(humus+','))),'roots extend below the turf cap');
assert.deepEqual(pixel(side,16,31),pixel(soil,16,31),'mineral soil continues below organic transition');
});
test('understory and species leaves have botanical identities, leafless winters and non-blooming dormant silhouettes',()=> {
const art=worldArt(), opaque=name=>pixels(tile(art,name)).filter(p=>p[3]),
has=(name,color)=>opaque(name).some(p=>p.slice(0,3).join()===PALETTE[color].join());
for(const name of DORMANT_UNDERSTORY_NAMES) {
const active=name.replace(/_dormant$/,'');
assert.ok(opaque(name).length<opaque(active).length,`${name}: reduced living mass`);
assert.ok(opaque(name).every(p=>!(p[1]>p[0] && p[1]>p[2])),`${name}: spent straw/bark, not green`);
for(const color of ['mauve','bud','rose','wine','gold']) assert.ok(!has(name,color),`${name}: no flowers`);
}
assert.ok(has('clover','cream')); assert.ok(has('bluebell','mauve')); assert.ok(has('heather','bud'));
assert.ok(has('nettle','leafLight')); assert.ok(has('bulrush','bark'));
assert.ok(!art.has('hearth_moss_dormant.png') && !art.has('hearth_lichen_dormant.png'));
for(const s of SPECIES_NAMES.filter(s=>!EVERGREEN_NAMES.includes(s))) {
const winter=`${s}_leaves_winter`;
assert.ok(opaque(winter).length<opaque(`${s}_leaves`).length,'winter has open woody tracery');
assert.ok(opaque(winter).every(p=>!(p[1]>p[0] && p[1]>p[2])));
assert.ok(has(`${s}_leaves_spring`,'bud'));
assert.ok(has(`${s}_leaves_autumn`,'wine'));
}
for(const s of EVERGREEN_NAMES) for(const season of ['spring','autumn','winter']) assert.ok(!art.has(`hearth_${s}_leaves_${season}.png`));
for(const name of ['quartz_vein','copper_ore_vein','coal_ore_vein'])
assert.ok(pixels(tile(art,name)).every(p=>p[3]===255),'complete opaque host rock, not an overlay');
assert.ok(has('copper_ore_vein','verdigris')); assert.ok(has('item_copper_ore','verdigris'));
assert.ok(has('item_copper_ingot','copperGlow')); assert.ok(has('item_copper_pick','copperGlow'));
assert.notDeepEqual(tile(art,'item_copper_charge').data,tile(art,'item_copper_ore').data);
});
test('actual content craftitems and explicit material texture references have generated art, not compatibility swatches',async()=> {
const text=await fs.readFile(new URL('../mods/hearth/content.lua',import.meta.url),'utf8'), art=worldArt();
for(const match of text.matchAll(/api\.register_craftitem\("hearth:([a-z0-9_]+)"/g))
assert.ok(art.has(`hearth_item_${match[1]}.png`),`craftitem ${match[1]} has an authored icon`);
for(const match of text.matchAll(/item_image\("([a-z0-9_]+)"\)/g))
assert.ok(art.has(`hearth_item_${match[1]}.png`),`explicit icon ${match[1]}`);
const biomes=await fs.readFile(new URL('../mods/hearth/biomes.lua',import.meta.url),'utf8');
for(const name of ROCK_NAMES) assert.ok(biomes.includes(`"${name}"`) && art.has(`hearth_${name}_bricks.png`));
for(const name of ['quartz','copper_ore','coal']) assert.ok(art.has(`hearth_item_${name}.png`));
});
test('plant and item masks have empty borders/corners, crisp alpha and recognizable non-square negative space',()=> {
const art=worldArt(), masks=new Set();
for(const name of [...PLANT_NAMES,...ITEM_NAMES.map(n=>`item_${n}`)]) {
const image=tile(art,name), ps=pixels(image), opaque=ps.filter(p=>p[3]===255).length;
assert.ok(ps.every(p=>p[3]===0 || p[3]===255),name);
assert.ok(opaque>=15 && opaque<800,`${name}: silhouette occupancy ${opaque}`);
assert.equal(pixel(image,0,0)[3],0,name); assert.equal(pixel(image,31,31)[3],0,name);
const mask=Buffer.from(ps.map(p=>p[3])); masks.add(digest(mask));
if(name.startsWith('item_')) {
for(let i=0;i<32;i++) for(const [x,y] of [[i,0],[0,i],[31,i],[i,31]])
assert.equal(pixel(image,x,y)[3],0,`${name}: inventory padding`);
}
}
assert.ok(masks.size>=32,'different plant/item silhouettes, not generic masked squares');
const bush=tile(art,'berry_bush'), bare=tile(art,'berry_bush_bare');
assert.deepEqual(pixels(bush).map(p=>p[3]),pixels(bare).map(p=>p[3]),'berries change fruit, not foliage silhouette');
assert.notDeepEqual(bush.data,bare.data);
const growth=[1,2,3,4].map(n=>pixels(tile(art,`crop_${n}`)).filter(p=>p[3]).length);
assert.ok(growth[0]<growth[1] && growth[1]<growth[2] && growth[2]<growth[3],`crop progression ${growth}`);
});
test('material motifs convey construction, growth and processing without relying solely on color',()=> {
const art=worldArt();
assert.equal(pixel(tile(art,'log_top'),0,0).slice(0,3).join(),PALETTE.barkDark.join());
const cut=tile(art,'log_top');
for(const [dx,dy] of [[1,0],[0,1],[-1,0],[0,-1]]) {
const ray=Array.from({length:13},(_,r)=>pixel(cut,14+dx*r,16+dy*r).join());
const changes=ray.slice(1).filter((color,i)=>color!==ray[i]).length;
assert.ok(changes>=5,'multiple growth rings on every radius, not an arbitrary pair of unequal pixels');
}
assert.deepEqual(pixel(tile(art,'planks'),0,0).slice(0,3),PALETTE.barkLight,'subdued plank joints');
assert.deepEqual(pixel(tile(art,'door'),25,17).slice(0,3),PALETTE.gold,'door latch');
for(const [a,b] of [['stone','iron_ore_vein'],['fire','fire_lit'],['grass_top','grass_side'],
['storage','pantry'],['item_water_raw','item_water_clean'],['item_raw_fish','item_dried_fish']])
assert.notDeepEqual(tile(art,a).data,tile(art,b).data,`${a} vs ${b}`);
const window=tile(art,'window'), alphas=new Set(pixels(window).map(p=>p[3]));
assert.deepEqual([...alphas].sort((a,b)=>a-b),[0,95,255]);
assert.equal(pixel(window,0,0)[3],0); assert.equal(pixel(window,5,5)[3],95);
});
test('seasonal botanical silhouettes show sage buds, copper/wine autumn, bare winter twigs and bloom-free dormancy',()=> {
const art=worldArt(), opaque=name=>pixels(tile(art,name)).filter(p=>p[3]);
const has=(name,color)=>opaque(name).some(p=>p.slice(0,3).join()===PALETTE[color].join());
const green=p=>p[1]>p[0] && p[1]>p[2];
for(const name of SEASONAL_PLANT_NAMES) assert.ok(art.has(`hearth_${name}.png`));
for(const color of ['springDark','springLeaf','springLight','bud']) assert.ok(has('leaves_spring',color));
const buds=opaque('leaves_spring').filter(p=>p.slice(0,3).join()===PALETTE.bud.join()).length;
assert.ok(buds>=6 && buds<30,'buds are a subtle accent, not a pink canopy');
for(const color of ['copper','ochre','wine']) assert.ok(has('leaves_autumn',color));
for(const name of ['leaves_autumn','leaves_winter','meadow_grass_dormant','fern_dormant',
'wildflowers_dormant','spring_greens_dormant','autumn_mushrooms_dormant']) {
assert.ok(opaque(name).every(p=>!green(p)),`${name}: no living green`);
for(const color of ['rose','mauve','gold','bud']) assert.ok(!has(name,color),`${name}: no bloom`);
}
const mask=name=>Buffer.from(pixels(tile(art,name)).map(p=>p[3]));
assert.ok(opaque('leaves_winter').length<opaque('leaves').length/2,'open woody negative space');
for(const [active,dormant] of [['meadow_grass','meadow_grass_dormant'],['fern','fern_dormant'],
['wildflowers','wildflowers_dormant'],['spring_greens','spring_greens_dormant'],
['autumn_mushrooms','autumn_mushrooms_dormant']]) {
assert.notDeepEqual(mask(active),mask(dormant),'dormancy changes silhouette, not only RGB');
assert.ok(opaque(dormant).length<opaque(active).length,`${dormant}: visibly reduced growth`);
}
assert.ok(has('spring_greens','springLight'));
assert.ok(has('autumn_mushrooms','copper') && has('autumn_mushrooms','cream'));
assert.ok(!has('autumn_mushrooms_dormant','copper'),'no dormant pickable caps');
for(const name of ['spring_greens','autumn_mushrooms']) {
assert.notDeepEqual(tile(art,name).data,tile(art,`item_${name}`).data,'picked bundle has its own icon');
assert.notDeepEqual(mask(name),mask('fungi'),'seasonal forage does not masquerade as decomposer fungi');
}
});
test('water tiles seamlessly on both axes with sparse restrained ripple clusters; ground strips match horizontal seams',()=> {
const art=worldArt(), water=tile(art,'water');
for(let i=0;i<32;i++) {
assert.deepEqual(pixel(water,0,i),pixel(water,31,i));
assert.deepEqual(pixel(water,i,0),pixel(water,i,31));
}
const base=PALETTE.water.join(), ripples=pixels(water).filter(p=>p.slice(0,3).join()!==base).length;
assert.ok(ripples>30 && ripples<100); assert.ok(pixels(water).every(p=>p[3]===255));
for(const name of ['sand','clay_bank']) for(let y=0;y<32;y++)
assert.deepEqual(pixel(tile(art,name),0,y),pixel(tile(art,name),31,y),`${name}: continuous strata`);
// Exposed grass must keep a soil body and a foliage cap on every side.
const side=tile(art,'grass_side');
assert.ok([PALETTE.turfShade,PALETTE.turf,PALETTE.turfGlow].some(c=>c.join()===pixel(side,16,0).slice(0,3).join()));
assert.ok(pixel(side,16,31)[0]>pixel(side,16,31)[1]);
});
function parseOBJ(text) {
const vertices=[], uv=[], faces=[], groups=new Map(); let current;
for(const line of text.trim().split('\n')) {
const [kind,...parts]=line.split(' ');
if(kind==='g') { current={vertices:[],faces:[]}; groups.set(parts[0],current); }
if(kind==='v') { const v=parts.map(Number); vertices.push(v); current.vertices.push(v); }
if(kind==='vt') uv.push(parts.map(Number));
if(kind==='f') {
const face=parts.map(p=>p.split('/').map(Number)); faces.push(face); current.faces.push(face);
}
assert.ok(['#','g','v','vt','f'].includes(kind),'no material/path loading directives');
}
return {vertices,uv,faces,groups};
}
test('wildlife meshes have outward nondegenerate indexed triangles, semantic UVs, bounded counts and stable node bounds',()=> {
const unique=new Set();
for(const name of ANIMAL_NAMES) {
const text=animalOBJ(name); assert.equal(text,animalOBJ(name)); unique.add(text);
const {vertices,uv,faces,groups}=parseOBJ(text), bounds=animalBounds(name);
assert.ok(vertices.length>=100 && vertices.length<=300); assert.ok(faces.length>=150 && faces.length<=450);
assert.ok(vertices.flat().every(Number.isFinite)); assert.equal(uv.length,vertices.length/2);
assert.ok(uv.flat().every(n=>Number.isFinite(n) && n>0 && n<1));
assert.equal(Math.min(...vertices.map(v=>v[1])),0,'feet are exactly at ground');
for(let i=0;i<3;i++) {
assert.ok(Math.abs(Math.min(...vertices.map(v=>v[i]))/10-bounds.min[i])<1e-6);
assert.ok(Math.abs(Math.max(...vertices.map(v=>v[i]))/10-bounds.max[i])<1e-6);
assert.ok(bounds.max[i]-bounds.min[i]<2,'less than two nodes on every axis');
}
assert.deepEqual(bounds.visualSize,{x:1,y:1});
const referenced=new Set();
for(const group of groups.values()) {
const center=[0,1,2].map(i=>group.vertices.reduce((s,v)=>s+v[i],0)/group.vertices.length);
for(const face of group.faces) {
assert.equal(face.length,3); assert.equal(new Set(face.map(p=>p[0])).size,3);
for(const [v,t] of face) {
assert.ok(v>=1 && v<=vertices.length && t>=1 && t<=uv.length); referenced.add(v);
}
const [a,b,c]=face.map(([id])=>vertices[id-1]), u=b.map((n,i)=>n-a[i]), v=c.map((n,i)=>n-a[i]);
const normal=[u[1]*v[2]-u[2]*v[1],u[2]*v[0]-u[0]*v[2],u[0]*v[1]-u[1]*v[0]];
assert.ok(Math.hypot(...normal)>1e-4,'nonzero triangle area');
assert.ok(normal.reduce((sum,n,i)=>sum+n*(a[i]-center[i]),0)>0,'outward face winding');
// All triangle UVs stay inside the same 8px semantic atlas patch.
const cells=face.map(([,id])=>uv[id-1].map(n=>Math.floor(n*4)).join());
assert.equal(new Set(cells).size,1,'no UV crossing into another material');
}
}
assert.equal(referenced.size,vertices.length);
}
assert.equal(unique.size,3);
for(const bad of ['../hare','toString','constructor','wolf']) {
assert.throws(()=>animalOBJ(bad),/Unknown animal/); assert.throws(()=>animalBounds(bad),/Unknown animal/);
}
});
test('species-specific silhouettes retain hare long ears, fox long bushy tail, deer hooves/antlers and leg clearance',()=> {
const hare=parseOBJ(animalOBJ('hare')), fox=parseOBJ(animalOBJ('fox')), deer=parseOBJ(animalOBJ('deer'));
const extent=(group,axis)=>Math.max(...group.vertices.map(v=>v[axis]))-Math.min(...group.vertices.map(v=>v[axis]));
assert.ok(extent(hare.groups.get('ear_left'),1)>3*extent(hare.groups.get('ear_left'),0));
assert.ok(extent(fox.groups.get('tail'),2)>3.5);
assert.ok(extent(fox.groups.get('tail'),0)>2*extent(fox.groups.get('leg_1_1.9'),0));
assert.ok(deer.groups.has('antler_1') && deer.groups.has('point_-1') && deer.groups.has('hoof_1_2.4'));
assert.ok(Math.min(...deer.groups.get('body').vertices.map(v=>v[1]))>=4);
assert.ok(animalBounds('deer').max[1]>animalBounds('hare').max[1]*1.6);
});
test('PNG writer supports rectangular contact sheets, validates bounded dimensions/RGBA, and independently decodes exact atlas cells',()=> {
const art=worldArt(), sheet=contactSheet(art), pngBytes=artContactSheetPNG(art), decoded=decodePNG(pngBytes);
assert.equal(sheet.width,512); assert.equal(sheet.height,Math.ceil(art.size/16)*32);
assert.equal(decoded.width,sheet.width); assert.equal(decoded.height,sheet.height); assert.deepEqual(decoded.data,sheet.data);
const ordered=[...art.entries()].sort(([a],[b])=>a<b?-1:a>b?1:0);
ordered.forEach(([,image],i)=> {
for(const [x,y] of [[0,0],[15,15],[31,31]]) {
const p=pixel(image,x,y), output=pixel(decoded,(i%16)*32+x,Math.floor(i/16)*32+y);
assert.equal(output[3],255);
if(p[3]===255) assert.deepEqual(output,p,'opaque art copied without scaling/filtering');
}
});
// Every seasonal tile is included, not merely present in the named manifest.
for(const name of SEASONAL_PLANT_NAMES) {
const index=ordered.findIndex(([key])=>key===`hearth_${name}.png`), image=tile(art,name);
for(let y=0;y<32;y++) for(let x=0;x<32;x++) if(pixel(image,x,y)[3]===255)
assert.deepEqual(pixel(decoded,index%16*32+x,Math.floor(index/16)*32+y),pixel(image,x,y));
}
assert.deepEqual(pngBytes,artContactSheetPNG());
const small=decodePNG(png(3,()=>[1,2,3,4],5)); assert.equal(small.width,3); assert.equal(small.height,5);
for(const [w,h] of [[0,1],[1,-1],[1.5,2],[Infinity,1],[2049,1],[2048,2048]])
assert.throws(()=>png(w,()=>[0,0,0,0],h),/dimensions/);
for(const color of [[1,2,3],[-1,0,0,255],[256,0,0,255],[0,0,NaN,255],[0,0,0,.5]])
assert.throws(()=>png(1,()=>color),/RGBA/);
for(const images of [new Map(),new Map([['bad',{width:32,height:32,data:new Uint8Array(4)}]]),
new Map(Array.from({length:513},(_,i)=>[i,art.values().next().value])),{},null])
assert.throws(()=>contactSheet(images),/1\.\.512 RGBA 32px tiles/);
assert.equal(contactSheet(new Map(Array.from({length:512},(_,i)=>[String(i),art.values().next().value]))).height,1024);
assert.deepEqual(contactSheet(new Map([...art].reverse())),sheet,'filename sorting makes the review legend stable');
});
test('generateAssets bundles every named art/model and contact sheet from a minimal fixture source with guarded output paths',async t=> {
const root=await fs.mkdtemp(path.join(await promisify(realpath)(os.tmpdir()),'hearth-world-art-'));
t.after(()=>fs.rm(root,{recursive:true,force:true}));
const source=path.join(root,'source'), destination=path.join(root,'output');
await fs.mkdir(path.join(source,'mods/hearth'),{recursive:true});
await fs.writeFile(path.join(source,'mods/hearth/init.lua'),'-- fixture: no named asset references or source binaries\n');
await generateAssets(destination,source);
const textures=path.join(destination,'mods/hearth/textures'), art=worldArt();
for(const [name,image] of art) {
const decoded=decodePNG(await fs.readFile(path.join(textures,name)));
assert.equal(decoded.width,32); assert.equal(decoded.height,32); assert.deepEqual(decoded.data,image.data,name);
}
assert.deepEqual(await fs.readFile(path.join(textures,'hearth_art_contact_sheet.png')),artContactSheetPNG());
for(const name of ANIMAL_NAMES)
assert.equal(await fs.readFile(path.join(destination,`mods/hearth/models/hearth_${name}.obj`),'utf8'),animalOBJ(name));
// New named assets use the existing safePath writer, including direct calls.
const outside=path.join(root,'outside'); await fs.mkdir(outside);
await fs.writeFile(path.join(outside,'sentinel'),'unchanged');
const linked=path.join(root,'linked'), models=path.join(linked,'mods/hearth/models');
await fs.mkdir(path.dirname(models),{recursive:true});
await fs.symlink(outside,models,process.platform==='win32'?'junction':'dir');
await assert.rejects(generateAssets(linked,source),/symbolic link|junction/);
assert.deepEqual(await fs.readdir(outside),['sentinel']);
assert.equal(await fs.readFile(path.join(outside,'sentinel'),'utf8'),'unchanged');
});