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

195 lines
12 KiB
JavaScript
Executable File

// Pure asset checks only. No engine, external art, network or live-world writes.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { ATLAS } from '../scripts/media.mjs';
import { CHARACTER_OPTIONS as O, CHARACTER_ART_MANIFEST as M, DEFAULT_CHARACTER,
CHARACTER_PORTRAITS, characterArt, characterLayer, characterLayers, characterTexture,
characterMesh, compositeCharacter, portraitSheet } from '../scripts/character-art.mjs';
const hash = image=>createHash('sha256').update(image.data).digest('hex');
const pixel = (im,tile,x,y)=> {
const [tx,ty]=ATLAS[tile], i=((ty*8+y)*im.width+tx*8+x)*4;
return [...im.data.subarray(i,i+4)];
};
const alpha = im=>im.data.reduce((n,value,i)=>n+(i%4===3 && value===255 ? 1 : 0),0);
test('exact public option IDs and deterministic filenames include every Cartesian combination',()=> {
assert.deepEqual(O,{
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','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','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,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);
for (const r of M) {
assert.match(r.filename,/^hearth_char_[a-z_]+\.png$/); assert.equal(r.width,64); assert.equal(r.height,64);
assert.equal(r.license,'MIT'); assert.match(r.author,/original/);
}
});
test('all layers are native 64 RGBA, crisp alpha, no hidden RGB; only named none layers are empty',()=> {
for (const r of M) {
const im=characterLayer(r.filename);
assert.equal(im.width,64); assert.equal(im.height,64); assert.equal(im.data.length,16384);
assert.equal(hash(im),hash(characterLayer(r.filename)));
for (let i=0;i<im.data.length;i+=4) {
assert.ok(im.data[i+3]===0 || im.data[i+3]===255,r.filename);
if (!im.data[i+3]) assert.deepEqual([...im.data.subarray(i,i+3)],[0,0,0]);
}
assert.equal(alpha(im)===0,r.empty,r.filename);
if (r.kind==='skin') assert.equal(alpha(im),4096);
else assert.ok(alpha(im)<4096);
}
assert.equal(M.filter(r=>r.empty).length,21);
});
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','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])
assert.ok(tiles.has([Math.floor(x/8),Math.floor(y/8)].join(',')),r.filename);
}
});
test('all six skins differ meaningfully on every exposed tile, not clothes or tools',()=> {
const layers=O.skin.map(id=>characterLayer(`hearth_char_skin_${id}.png`));
for (let i=1;i<layers.length;i++) {
let changed=0, delta=0;
for (let y=0;y<64;y++) for (let x=0;x<64;x++) {
const offset=(y*64+x)*4, a=[...layers[i-1].data.subarray(offset,offset+3)], b=[...layers[i].data.subarray(offset,offset+3)];
if (a.some((n,c)=>n!==b[c])) { changed++; delta+=a.reduce((n,v,c)=>n+Math.abs(v-b[c]),0); }
}
assert.equal(changed,256,'four exposed 8px head/hand tiles'); assert.ok(delta/changed>50);
for (const tile of ['face','hair','side','skin']) assert.notDeepEqual(pixel(layers[i],tile,3,4),pixel(layers[i-1],tile,3,4));
for (const tile of ['tunic','boot','trousers','metal','wood','wrap'])
assert.deepEqual(pixel(layers[i],tile,3,4),pixel(layers[0],tile,3,4));
}
});
test('all nonempty style/color layers are distinct; every appearance axis changes the composed character',()=> {
const seen=new Set();
for (const r of M.filter(r=>!r.empty)) {
const key=hash(characterLayer(r.filename)); assert.ok(!seen.has(key),r.filename); seen.add(key);
}
const axes={skin:O.skin,face:O.faces,eyes:O.eyes,hair:O.hairStyles,hairColor:O.hairColors,
beard:O.facialHair,garment:O.garments,cloth:O.clothColors,trousers:O.trousers,boots:O.boots,
accent:O.accents,accentColor:O.clothColors,detail:O.details};
for (const [key,values] of Object.entries(axes))
assert.equal(new Set(values.map(id=>hash(compositeCharacter({[key]:id})))).size,values.length,key);
});
test('face linework is skin-neutral, eyes keep visible 8px landmarks, and no-hair scalp really uses chosen skin',()=> {
for (const id of O.faces) {
const im=characterLayer(`hearth_char_face_${id}.png`);
assert.deepEqual(pixel(im,'face',3,4),[0,0,0,0]);
assert.ok(alpha(im)>=5 && alpha(im)<=12,'linework, not a painted skin mask');
assert.equal(pixel(im,'face',2,3)[3],0,'iris reserved');
}
for (const id of O.eyes) {
const im=characterLayer(`hearth_char_eyes_${id}.png`); assert.equal(alpha(im),2);
assert.equal(pixel(im,'face',2,3)[3],255); assert.equal(pixel(im,'face',5,3)[3],255);
}
for (const skin of O.skin) {
const im=compositeCharacter({skin,hair:'none'}), base=characterLayer(`hearth_char_skin_${skin}.png`);
for (const tile of ['hair','side','skin']) assert.deepEqual(pixel(im,tile,4,4),pixel(base,tile,4,4));
}
});
test('layer names and order match Luanti composition; garments cannot erase long shoulder hair',()=> {
const names=characterLayers({hair:'long',garment:'coat',cloth:'rust'});
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_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[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);
const independent=new Uint8Array(16384);
for (const name of names) {
const data=characterLayer(name).data;
for (let i=0;i<data.length;i+=4) if (data[i+3]) independent.set(data.subarray(i,i+4),i);
}
assert.deepEqual(im.data,independent);
});
test('old held-tool swatches are byte-exact in all bases and final characters, without global tint',()=> {
for (const skin of O.skin) {
const base=characterLayer(`hearth_char_skin_${skin}.png`), combined=compositeCharacter({skin});
for (const [tile,rgb] of [['metal',[177,195,191]],['wood',[146,100,59]],['wrap',[201,169,116]]])
for (let y=0;y<8;y++) for (let x=0;x<8;x++) {
const [tx,ty]=ATLAS[tile], grain=(((tx*8+x)*13+(ty*8+y)*7)%5)-2;
const expected=[...rgb.map(n=>n+grain),255];
assert.deepEqual(pixel(base,tile,x,y),expected); assert.deepEqual(pixel(combined,tile,x,y),expected);
}
}
});
test('clothing has tailored collars, rolled cuffs, leather seams, plackets, boot soles and accent masks',()=> {
const garment=id=>characterLayer(`hearth_char_garment_${id}_teal.png`);
assert.notDeepEqual(pixel(garment('tunic'),'tunic',2,0),pixel(garment('tunic'),'tunic',2,2),'collar');
assert.notDeepEqual(pixel(garment('workshirt'),'sleeve',3,5),pixel(garment('workshirt'),'sleeve',3,4),'rolled cuff');
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,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]);
});
test('eight contact-sheet cells are assembled clothed portraits sampled from actual composited atlases',()=> {
const sheet=portraitSheet(); assert.equal(sheet.width,512); assert.equal(sheet.height,64);
assert.equal(CHARACTER_PORTRAITS.length,8); assert.equal(alpha(sheet),512*64);
assert.equal(hash(sheet),hash(portraitSheet()));
const cells=new Set();
CHARACTER_PORTRAITS.forEach((p,index)=> {
const im=compositeCharacter(p), cell=[];
for (let y=0;y<64;y++) for (let x=0;x<64;x++) {
const offset=(y*512+index*64+x)*4; cell.push(...sheet.data.subarray(offset,offset+4));
}
cells.add(createHash('sha256').update(new Uint8Array(cell)).digest('hex'));
const sample=(x,y)=>[...sheet.data.subarray((y*512+index*64+x)*4,(y*512+index*64+x+1)*4)];
assert.deepEqual(sample(28,12),pixel(im,'face',2,4));
assert.deepEqual(sample(32,32),pixel(im,'tunic',4,4));
assert.deepEqual(sample(0,0),[30,34,32,255]);
});
assert.equal(cells.size,8);
});
test('pure renderers return fresh buffers and allowlisted choices reject traversal and prototype keys',()=> {
const first=compositeCharacter(), digest=hash(first); first.data.fill(0);
assert.equal(hash(compositeCharacter()),digest);
const layer=characterLayer(M[0].filename), original=hash(layer); layer.data.fill(0);
assert.equal(hash(characterLayer(M[0].filename)),original);
assert.equal(characterMesh(),'hearth_person.b3d');
assert.equal(characterMesh('compact'),'hearth_person_compact.b3d');
assert.equal(characterMesh('broad'),'hearth_person_broad.b3d');
for (const bad of ['../sand','toString','constructor','__proto__','',null,{},0]) {
assert.throws(()=>characterLayer(bad)); assert.throws(()=>characterMesh(bad));
assert.throws(()=>compositeCharacter({skin:bad}));
}
for (const bad of [null,[],1,'sand',{unknown:'sand'},JSON.parse('{"__proto__":{}}'),
Object.create({skin:'sand'}),{skin:undefined},{[Symbol('skin')]:'sand'}])
assert.throws(()=>characterLayers(bad));
assert.deepEqual(DEFAULT_CHARACTER,{body:'balanced',skin:'sand',face:'calm',eyes:'hazel',hair:'sidepart',
hairColor:'chestnut',beard:'none',garment:'tunic',cloth:'teal',trousers:'earth',boots:'walnut',
accent:'scarf',accentColor:'sage',detail:'none'});
});