Lots of content

This commit is contained in:
2026-11-03 12:41:09 -05:00
parent 6ebe95b208
commit d34388bf2c
126 changed files with 28076 additions and 1027 deletions
+135 -32
View File
@@ -2,6 +2,11 @@
import { deflateSync } from 'node:zlib';
import * as fs from 'node:fs/promises';
import path from 'node:path';
import { ensureDirectory, relativeName, safePath } from './files.mjs';
import { BODY_PROFILES, playerB3D, soundOGG, SOUND_PROFILES, TOOL_NAMES, toolOBJ } from './media.mjs';
import { ANIMAL_NAMES, BOTANY_CATALOGUE, animalOBJ, contactSheet, legacyPixel, worldArt } from './world-art.mjs';
import { FLORA_NAMES, TREE_SEASONS, SPECIES_ATLAS_NAMES, floraOBJ, treeAtlas } from './flora-art.mjs';
import { CHARACTER_ART_MANIFEST, CHARACTER_OPTIONS, characterArt, characterMesh, compositeCharacter, portraitSheet } from './character-art.mjs';
function crc32(data) {
let crc = 0xffffffff;
@@ -17,52 +22,130 @@ function chunk(type, data) {
size.writeUInt32BE(data.length); crc.writeUInt32BE(crc32(body));
return Buffer.concat([size, body, crc]);
}
function png(size, pixel) {
const rows = Buffer.alloc(size * (size * 4 + 1));
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
const color = pixel(x, y), offset = y * (size * 4 + 1) + 1 + x * 4;
export function png(width, pixel, height = width) {
if (!Number.isInteger(width) || !Number.isInteger(height) || width<1 || height<1 ||
width>2048 || height>2048 || width*height>1024*1024 || typeof pixel!=='function')
throw new Error('Invalid bounded PNG dimensions or pixel callback.');
const rows = Buffer.alloc(height * (width * 4 + 1));
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
const color = pixel(x, y), offset = y * (width * 4 + 1) + 1 + x * 4;
if (!color || color.length!==4 || !Array.from(color).every(n=>Number.isInteger(n) && n>=0 && n<=255))
throw new Error('Invalid RGBA pixel.');
rows.set(color, offset);
}
const header = Buffer.alloc(13);
header.writeUInt32BE(size); header.writeUInt32BE(size, 4); header[8] = 8; header[9] = 6;
header.writeUInt32BE(width); header.writeUInt32BE(height, 4); header[8] = 8; header[9] = 6;
return Buffer.concat([Buffer.from([137,80,78,71,13,10,26,10]), chunk('IHDR', header),
chunk('IDAT', deflateSync(rows)), chunk('IEND', Buffer.alloc(0))]);
}
const icons = {
knife: (x,y) => (Math.abs(x + y - 28) < 3 && x > 9 && x < 24) ? [180,185,173,255]
: (Math.abs(x + y - 28) < 2 && x >= 23) ? [119,86,57,255] : null,
axe: (x,y) => Math.abs(x-y) < 2 && x > 7 && x < 27 ? [139,103,65,255]
: x > 5 && x < 20 && y > 4 && y < 14 && x+y < 29 ? [157,164,151,255] : null,
pick: (x,y) => Math.abs(x-y) < 2 && x > 10 && x < 28 ? [139,103,65,255]
: y > 5 && y < 10 && x > 4 && x < 25 ? [157,164,151,255] : null,
spear: (x,y) => Math.abs(x+y-31) < 2 && x > 5 && x < 27 ? [157,124,80,255]
: x > 22 && y < 10 && x-y > 18 ? [185,188,177,255] : null,
function inside(x, y, polygon) {
let hit = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const [a,b] = polygon[i], [c,d] = polygon[j];
if ((b > y) !== (d > y) && x < (c-a) * (y-b) / (d-b) + a) hit = !hit;
}
return hit;
}
const blades = {
knife: [[12,17],[24,3],[23,12],[16,21]],
axe: [[5,7],[14,3],[22,9],[17,17],[12,16],[11,11],[6,12]],
pick: [[4,10],[9,5],[18,5],[27,11],[20,9],[13,9],[8,11]],
spear: [[21,11],[28,2],[27,12],[23,15]],
};
function icon(name, x, y) {
const diagonal = name === 'knife' || name === 'spear';
const shaft = diagonal ? [[4,26],[6,28],[25,9],[23,7]] : [[11,10],[14,9],[27,27],[24,29]];
let color = null;
if (inside(x+.5,y+.5,shaft)) color = (x+y)%5 === 0 ? [88,57,39] : [157,112,65];
if (inside(x+.5,y+.5,blades[name])) color = (x+y)%4 === 0 ? [227,233,220] : [159,181,180];
if (color && ((diagonal && x<12) || (!diagonal && y>21)) && (x+y)%3 === 0) color = [211,183,128];
return color ? [...color,255] : [0,0,0,0];
}
// Only mod Lua is an input. In particular never traverse repository scripts,
// generated assets, tests or symlink/junction targets. Bounds cap accidental input.
async function sourceColors(source) {
const colors = new Set(['c7aa83','947853']);
let entries = 0;
async function visit(directory, depth = 0) {
if (depth > 32) throw new Error('Mod directory nesting limit exceeded.');
await safePath(directory);
for (const name of (await fs.readdir(directory)).sort()) {
relativeName(name);
if (++entries > 10000) throw new Error('Mod source entry limit exceeded.');
const filename = path.join(directory, name), stat = await fs.lstat(filename);
if (stat.isSymbolicLink()) throw new Error('Refusing symbolic link or junction in mod assets input.');
if (stat.isDirectory()) await visit(filename, depth+1);
else if (!stat.isFile()) throw new Error('Mod assets input must contain regular files.');
else if (name.endsWith('.lua')) {
if (stat.size > 2*1024*1024) throw new Error('Mod Lua input exceeds size limit.');
const text = await fs.readFile(await safePath(filename), 'utf8');
for (const match of text.matchAll(/#[0-9a-fA-F]{6}\b/g)) colors.add(match[0].slice(1).toLowerCase());
if (colors.size > 4096) throw new Error('Mod texture color limit exceeded.');
}
}
}
await visit(path.join(source, 'mods'));
return [...colors].sort();
}
async function writeAsset(directory, name, bytes) {
// Packaging supplies a private staging tree in a trusted parent; guard output
// links too, so direct invocation cannot overwrite a linked external asset.
const filename = await safePath(path.join(directory, relativeName(name)));
await fs.writeFile(filename, bytes, {mode: 0o600});
}
// Per-image provenance is included in the hash-verified package, not just in a
// source comment. No inference from filename/common-name or unsafe Lua execution.
export const BOTANY_ART_MANIFEST = Object.freeze(BOTANY_CATALOGUE.flatMap(({id,latin,group,morphology})=>
[false,true].map(dormant=>Object.freeze({id,botanicalLatin:latin,group,morphology,
state:dormant?'dormant':'active',filename:`hearth_botany_${id}${dormant?'_dormant':''}.png`,
width:32,height:32,license:'MIT',author:'The Last Hearth original procedural art'}))));
export async function generateAssets(destination, source) {
const colors = await sourceColors(source);
const textures = path.join(destination, 'mods/hearth/textures');
await fs.mkdir(textures, {recursive:true});
const files = ['content.lua','encounters.lua','runtime.lua'];
const colors = new Set(['c7aa83','947853']);
for (const file of files) {
const text = await fs.readFile(path.join(source, 'mods/hearth', file), 'utf8');
for (const match of text.matchAll(/#[0-9a-fA-F]{6}\b/g)) colors.add(match[0].slice(1).toLowerCase());
}
await ensureDirectory(textures);
for (const color of colors) {
const rgb = [0,2,4].map(i=>parseInt(color.slice(i,i+2),16));
const image = png(32, (x,y)=> {
const noise = ((x*73+y*151+x*y*7) % 19)-9;
const grain = (y % 8 === 0) ? -12 : (x % 13 === 0 ? 5 : 0);
return [...rgb.map(c=>Math.max(0,Math.min(255,c+noise+grain))),255];
});
await fs.writeFile(path.join(textures, `hearth_${color}.png`), image);
const image = png(32, (x,y)=>legacyPixel(color,x,y));
await writeAsset(textures, `hearth_${color}.png`, image);
}
for (const [name, shape] of Object.entries(icons)) {
await fs.writeFile(path.join(textures, `hearth_icon_${name}.png`), png(32,(x,y)=>shape(x,y)||[0,0,0,0]));
const art = worldArt();
await writeAsset(destination, 'botany-art.json', JSON.stringify({format:1,images:BOTANY_ART_MANIFEST},null,2)+'\n');
for (const season of TREE_SEASONS) {
const trees = treeAtlas(art, season), suffix = season==='summer'?'':`_${season}`;
await writeAsset(textures, `hearth_tree_atlas${suffix}.png`, png(trees.width,
(x,y)=>trees.data.subarray((y*trees.width+x)*4,(y*trees.width+x+1)*4), trees.height));
}
for (const name of SPECIES_ATLAS_NAMES) {
const [species, , season='summer'] = name.split('_'), trees=treeAtlas(art,season,species);
await writeAsset(textures, `hearth_${name}.png`, png(trees.width,
(x,y)=>trees.data.subarray((y*trees.width+x)*4,(y*trees.width+x+1)*4), trees.height));
}
for (const [name, image] of art) await writeAsset(textures, name,
png(image.width, (x,y)=>image.data.subarray((y*image.width+x)*4,(y*image.width+x+1)*4), image.height));
// The client uses this engine-defined filename, not the journal's named tile.
const crosshair = art.get('hearth_crosshair.png');
await writeAsset(textures, 'crosshair.png', png(crosshair.width,
(x,y)=>crosshair.data.subarray((y*crosshair.width+x)*4,(y*crosshair.width+x+1)*4), crosshair.height));
await writeAsset(textures, 'hearth_art_contact_sheet.png', artContactSheetPNG(art));
for (const name of TOOL_NAMES) {
await writeAsset(textures, `hearth_icon_${name}.png`, png(32,(x,y)=>icon(name,x,y)));
}
const encode = image => png(image.width,
(x,y)=>image.data.subarray((y*image.width+x)*4,(y*image.width+x+1)*4),image.height);
for (const [name,image] of characterArt()) await writeAsset(textures,name,encode(image));
await writeAsset(textures,'hearth_person.png',encode(compositeCharacter()));
await writeAsset(textures,'hearth_character_contact_sheet.png',encode(portraitSheet()));
await writeAsset(destination,'character-art.json',JSON.stringify({format:1,options:CHARACTER_OPTIONS,
meshes:BODY_PROFILES.map(body=>({body,filename:characterMesh(body)})),images:CHARACTER_ART_MANIFEST},null,2)+'\n');
// A low-poly original player silhouette; stable engine mesh instead of a billboard.
// Kept for older callers; M1 uses hearth_person.b3d and hearth_person.png.
const models = path.join(destination, 'mods/hearth/models');
await fs.mkdir(models, {recursive:true});
await ensureDirectory(models);
for (const name of FLORA_NAMES) await writeAsset(models, `hearth_${name}.obj`, floraOBJ(name));
for (const name of ANIMAL_NAMES) await writeAsset(models, `hearth_${name}.obj`, animalOBJ(name));
let vertices = 0;
const obj = ['# Original Last Hearth block character', 'vt 0 0','vt 1 0','vt 1 1','vt 0 1'];
function box(x0,y0,z0,x1,y1,z1) {
@@ -75,5 +158,25 @@ export async function generateAssets(destination, source) {
box(-2.3,8,-1.3,2.3,14,1.3); box(-2,14,-2,2,18,2);
box(-4,8,-1,-2.3,14,1); box(2.3,8,-1,4,14,1);
box(-2.2,0,-1.2,-0.2,8,1.2); box(0.2,0,-1.2,2.2,8,1.2);
await fs.writeFile(path.join(models,'hearth_person.obj'), obj.join('\n')+'\n');
await writeAsset(models, 'hearth_person.obj', obj.join('\n')+'\n');
for (const body of BODY_PROFILES) await writeAsset(models,characterMesh(body),playerB3D(body));
for (const name of TOOL_NAMES) await writeAsset(models, `hearth_${name}.obj`, toolOBJ(name));
const sounds = path.join(destination, 'mods/hearth/sounds');
await ensureDirectory(sounds);
for (const name of Object.keys(SOUND_PROFILES).sort()) {
await writeAsset(sounds, name+'.ogg', await soundOGG(name));
// Retire only our former generated WAVs when regenerating an existing tree.
const legacy = await safePath(path.join(sounds, name+'.wav'));
try {
if (!(await fs.lstat(legacy)).isFile()) throw new Error('Legacy sound must be a regular file.');
await fs.unlink(legacy);
} catch (error) { if (error.code !== 'ENOENT') throw error; }
}
}
// Pure export for review tooling; generateAssets writes it via the same guarded
// output path as runtime textures. It never writes into the source worktree.
export function artContactSheetPNG(art = worldArt()) {
const image = contactSheet(art);
return png(image.width, (x,y)=>image.data.subarray((y*image.width+x)*4,(y*image.width+x+1)*4), image.height);
}
+70
View File
@@ -0,0 +1,70 @@
// OFFLINE ONLY. Sidecar/launcher locks are conservative signals, not proof that
// an external/local/remote engine is stopped. The operator must stop all engines.
// Snapshots contain private world data: keep them on an access-controlled volume.
// SHA256 detects corruption, not malicious replacement; it is not a signature.
import * as fs from 'node:fs/promises';
import path from 'node:path';
import { absolute, atomicDirectory, copyTree, disjoint, exists, inventory, json, safePath,
validLease, verifyManifest, withLauncherLock, writeManifest } from './files.mjs';
export async function assertWorldOffline(world) {
const entries = await inventory(world);
if (!entries.some(entry => entry.path === 'world.mt' && entry.type === 'file')) throw new Error('Not a world: missing world.mt.');
if (entries.some(entry => /(?:^|\/)(?:lock|\.lock|world\.lock|launcher\.lock|.*\.lock)$|\.sqlite(?:3)?-(?:wal|shm|journal)$/i.test(entry.path))) {
throw new Error('World/SQLite lock or sidecar exists; stop all engines and resolve it before copying.');
}
return entries;
}
async function offlineOperation(options, action) {
if (options.offline !== true) throw new Error('Explicit --offline confirmation required. Stop every external engine; locks cannot prove remote idleness.');
const runtime = absolute(options.runtime);
if (validLease(options.lease, runtime)) return action();
return withLauncherLock(runtime, action);
}
export async function backupWorld(world, destination, options = {}) {
world = absolute(world); destination = absolute(destination);
disjoint(world, destination);
return offlineOperation(options, async () => {
const before = await assertWorldOffline(world);
return atomicDirectory(destination, async stage => {
await copyTree(world, path.join(stage, 'world'));
const after = await assertWorldOffline(world);
if (json(before) !== json(after) || json(before) !== json(await inventory(path.join(stage, 'world')))) {
throw new Error('World changed during snapshot; stop all engines.');
}
await fs.writeFile(path.join(stage, 'backup.json'), json({ format: 1, gameId: 'last_hearth', engineVersion: '5.18.0' }), { flag: 'wx', mode: 0o600 });
await writeManifest(stage, 'world-backup');
await verifyBackup(stage);
});
});
}
export async function verifyBackup(backup) {
backup = await safePath(absolute(backup));
const manifest = await verifyManifest(backup, 'world-backup');
const metadata = JSON.parse(await fs.readFile(path.join(backup, 'backup.json'), 'utf8'));
if (metadata.format !== 1 || metadata.gameId !== 'last_hearth' || metadata.engineVersion !== '5.18.0' ||
manifest.entries.some(entry => entry.path !== 'backup.json' && entry.path !== 'world' && !entry.path.startsWith('world/'))) {
throw new Error('Unsupported world backup.');
}
await assertWorldOffline(path.join(backup, 'world'));
return manifest;
}
export async function restoreWorld(backup, destination, options = {}) {
backup = absolute(backup); destination = absolute(destination);
disjoint(backup, destination);
return offlineOperation(options, async () => {
await verifyBackup(backup);
if (await exists(destination)) throw new Error('Restore requires a new world destination; existing worlds are never overwritten.');
return atomicDirectory(destination, async stage => {
await copyTree(path.join(backup, 'world'), stage, { intoEmpty: true });
const expected = await inventory(path.join(backup, 'world'));
if (json(await inventory(stage)) !== json(expected)) throw new Error('Restored world checksum mismatch.');
await verifyBackup(backup);
await assertWorldOffline(stage);
});
});
}
+264
View File
@@ -0,0 +1,264 @@
// Original MIT woodland-traveler pixel drawing. Pure, bounded, offline RGBA;
// the shared 64px atlas and named bones remain the runtime attachment contract.
import { ATLAS, BODY_PROFILES } from './media.mjs';
export const CHARACTER_OPTIONS = Object.freeze(Object.fromEntries(Object.entries({
bodies: BODY_PROFILES,
skin: ['porcelain','sand','honey','olive','umber','ebony'],
faces: ['calm','angular','soft','weathered'],
eyes: ['hazel','brown','green','blue','grey'],
hairStyles: ['cropped','sidepart','wavy','braids','long','shaved','none'],
hairColors: ['ash','flax','chestnut','auburn','black','silver'],
facialHair: ['none','stubble','moustache','short_beard','full_beard'],
garments: ['tunic','workshirt','jerkin','coat'],
clothColors: ['sage','teal','slate','mauve','ochre','rust','linen','charcoal'],
trousers: ['earth','slate','moss','charcoal'], boots: ['walnut','black','tan'],
accents: ['none','scarf','apron','sash','vest'],
details: ['none','freckles','scar','freckles_scar'],
}).map(([key, values])=>[key,Object.freeze([...values])])));
const palette = entries => Object.freeze(Object.fromEntries(Object.entries(entries)
.map(([key, value])=>[key,Object.freeze(value)])));
const SKIN = palette({porcelain:[225,190,166],sand:[199,157,120],honey:[177,126,79],
olive:[141,119,88],umber:[114,78,56],ebony:[72,49,42]});
const HAIR = palette({ash:[106,99,87],flax:[177,150,95],chestnut:[94,60,43],
auburn:[131,66,43],black:[35,33,32],silver:[174,177,168]});
const CLOTH = palette({sage:[101,119,88],teal:[57,100,98],slate:[87,101,117],
mauve:[125,94,113],ochre:[164,128,64],rust:[149,78,53],linen:[187,174,144],charcoal:[59,63,64]});
const PANTS = palette({earth:[99,80,61],slate:[74,83,94],moss:[78,88,60],charcoal:[48,50,53]});
const BOOTS = palette({walnut:[79,53,37],black:[37,35,34],tan:[147,109,66]});
const EYES = palette({hazel:[49,59,35],brown:[55,37,29],green:[47,77,53],blue:[52,81,104],grey:[92,100,100]});
const INK = [74,56,48], THREAD = [184,166,126], LEATHER = [85,61,40];
const tint = (color, amount) => color.map(n=>Math.max(0,Math.min(255,n+amount)));
const image = () => ({width:64,height:64,data:new Uint8Array(64*64*4)});
function dot(out, tile, x, y, color) {
const [tx,ty] = ATLAS[tile];
out.data.set([...color,255],((ty*8+y)*64+tx*8+x)*4);
}
function paint(out, tile, draw) {
for (let y=0;y<8;y++) for (let x=0;x<8;x++) {
const color = draw(x,y); if (color) dot(out,tile,x,y,color);
}
}
// Deliberate planes, seam highlights and broken weave, not global colorization.
function fabric(color,x,y) {
return tint(color,(x===0 || x===7 ? -13 : x===1 ? 7 : 0) +
((y===2 || y===5) && x%3===1 ? 4 : 0));
}
function skinLayer(id) {
const out = image();
for (let y=0;y<64;y++) for (let x=0;x<64;x++) out.data.set([57,88,92,255],(y*64+x)*4);
for (const tile of ['face','hair','side','skin']) paint(out,tile,(x,y)=>
tint(SKIN[id],(x===0 || x===7 ? -9 : 0)+(y===7 ? -6 : y===1 ? 4 : 0)));
for (const tile of ['tunic','back','sleeve']) paint(out,tile,(x,y)=>fabric(CLOTH.linen,x,y));
paint(out,'trousers',(x,y)=>fabric(PANTS.earth,x,y));
paint(out,'boot',(x,y)=>fabric(BOOTS.walnut,x,y));
// Byte-exact former tool swatches: shared held OBJ UVs must not change.
for (const [tile,color] of [['metal',[177,195,191]],['wood',[146,100,59]],['wrap',[201,169,116]]])
paint(out,tile,(x,y)=>tint(color,(((ATLAS[tile][0]*8+x)*13+(ATLAS[tile][1]*8+y)*7)%5)-2));
return out;
}
function faceLayer(id) {
const out = image();
// Skin-neutral linework: never a light painted face over darker skin.
const marks = {
calm: [[2,2],[5,2],[4,4],[3,6],[4,6]],
angular: [[1,2],[2,2],[5,2],[6,1],[4,4],[4,5],[2,6],[3,6]],
soft: [[2,2],[5,2],[3,5],[4,5],[3,6]],
weathered: [[1,2],[2,2],[5,2],[6,2],[1,4],[6,4],[4,4],[2,5],[5,5],[3,6],[4,6]],
};
for (const [x,y] of marks[id]) dot(out,'face',x,y,y===6 ? [112,70,58] : INK);
return out;
}
function eyeLayer(id) {
const out = image();
for (const x of [2,5]) dot(out,'face',x,3,EYES[id]);
return out;
}
function hairLayer(style, color) {
const out = image(); if (style==='none') return out;
const base = HAIR[color];
const strand = (x,y)=>tint(base,(x+y*2)%4===0 ? 13 : x%3===0 ? -9 : 0);
const fringe = (x,y) => {
if (style==='shaved') return y===0 && x%2===0;
if (style==='cropped') return y===0 || (y===1 && x%3!==1);
if (style==='sidepart') return y<1 || (y===1 && x<6) || (y===2 && x<2);
if (style==='wavy') return y===0 || (y===1 && x%3!==0) || (y===2 && x===0);
if (style==='braids') return y<1 || (x===0 || x===7) && y<6;
return y<1 || (x<1 || x>6) && y<7;
};
paint(out,'face',(x,y)=>fringe(x,y) ? strand(x,y) : null);
paint(out,'hair',(x,y)=>style==='shaved' && (x+y)%2 ? null : strand(x,y));
paint(out,'side',(x,y)=> {
const limit = {cropped:2,sidepart:3,wavy:4,braids:7,long:8,shaved:1}[style];
if (y>=limit || (y>=3 && x>=3 && x<=5)) return null; // exposed ear
if (style==='shaved' && x%2) return null;
return strand(x,y);
});
if (style==='long' || style==='braids') {
paint(out,'back',(x,y)=>y<4 && (style==='long' ? x>0 && x<7 : x===1 || x===6)
? strand(x,y) : null);
paint(out,'sleeve',(x,y)=>y<2 && (x===0 || x===7) ? strand(x,y) : null);
if (style==='braids') for (const x of [0,7]) dot(out,'face',x,5,THREAD);
}
return out;
}
function beardLayer(style,color) {
const out = image(); if (style==='none') return out;
paint(out,'face',(x,y)=> {
const moustache = y===5 && x>=2 && x<=5;
const lower = y>=6 && x>=1 && x<=6;
const mask = style==='moustache' ? moustache : style==='stubble' ?
(lower && (x+y)%2===0) : style==='short_beard' ? moustache || (lower && x>=2 && x<=5) :
moustache || lower || (y===4 && (x===1 || x===6));
return mask ? tint(HAIR[color],(x+y)%3===0 ? 9 : -4) : null;
});
if (style==='full_beard') paint(out,'side',(x,y)=>y>=5 && x<3 ? HAIR[color] : null);
return out;
}
function garmentLayer(style,color) {
const out = image(), base = CLOTH[color];
for (const tile of ['tunic','back','sleeve']) paint(out,tile,(x,y)=> {
let c = fabric(tint(base,tile==='back' ? -8 : 0),x,y);
if (y===7 || tile==='sleeve' && y===6) c=tint(base,-21); // hem/cuff
if (tile==='tunic') {
if (y===0 && (x===2 || x===5) || y===1 && (x===3 || x===4)) c=THREAD;
if (style==='tunic' && y===6) c=LEATHER;
if (style==='workshirt' && x===4 && y>1 && y<6) c=y%2 ? THREAD : tint(base,-22);
if (style==='workshirt' && y===3 && x>=5 && x<=6) c=tint(base,-25);
if (style==='jerkin' && (x===1 || x===6 || y===5)) c=LEATHER;
if (style==='jerkin' && y===2 && (x===3 || x===4)) c=THREAD;
if (style==='coat' && (x===3 || x===4)) c=tint(base,-28);
if (style==='coat' && x===5 && [2,4,6].includes(y)) c=THREAD;
if (style==='coat' && y===0 && (x===1 || x===6)) c=tint(base,17);
}
if (tile==='back' && (style==='coat' && x===4 || style==='jerkin' && y===5)) c=tint(base,-23);
if (tile==='sleeve' && style==='jerkin' && y>=3) c=fabric(CLOTH.linen,x,y);
if (tile==='sleeve' && style==='workshirt' && y===5) c=THREAD;
return c;
});
return out;
}
function trousersLayer(id) {
const out=image(); paint(out,'trousers',(x,y)=>
y===0 ? LEATHER : x===2 && y>3 ? tint(PANTS[id],-17) : fabric(PANTS[id],x,y));
return out;
}
function bootsLayer(id) {
const out=image(); paint(out,'boot',(x,y)=>y===7 ? [30,28,26] :
y===1 ? tint(BOOTS[id],18) : x===3 && y>1 && y<5 ? THREAD : fabric(BOOTS[id],x,y));
return out;
}
function accentLayer(style,color) {
const out=image(); if (style==='none') return out;
const base=CLOTH[color];
for (const tile of ['tunic','back','sleeve']) paint(out,tile,(x,y)=> {
let mask=false;
if (style==='scarf') mask=y===0 || tile==='tunic' && x===2 && y<4;
if (style==='apron') mask=tile==='tunic' ? x>=1 && x<=6 && y>=2 : tile==='back' && y===3;
if (style==='sash') mask=tile!=='sleeve' && (x===y || x===y+1);
if (style==='vest') mask=tile!=='sleeve' && (tile==='back' || x<=2 || x>=5);
return mask ? fabric(base,x,y) : null;
});
if (style==='apron') for (const x of [3,4,5]) dot(out,'tunic',x,5,tint(base,-23));
if (style==='vest') for (const y of [2,4]) dot(out,'tunic',5,y,THREAD);
return out;
}
function detailLayer(id) {
const out=image();
if (id.includes('freckles')) for (const [x,y] of [[1,4],[2,5],[5,5],[6,4]]) dot(out,'face',x,y,[119,76,51]);
if (id.includes('scar')) for (const [x,y] of [[6,2],[6,3],[7,4]]) dot(out,'face',x,y,[158,109,91]);
return out;
}
const definitions = [
['skin','skin',null,skinLayer], ['face','faces',null,faceLayer], ['eyes','eyes',null,eyeLayer],
['hair','hairStyles','hairColors',hairLayer], ['beard','facialHair','hairColors',beardLayer],
['garment','garments','clothColors',garmentLayer], ['trousers','trousers',null,trousersLayer],
['boots','boots',null,bootsLayer], ['accent','accents','clothColors',accentLayer],
['detail','details',null,detailLayer],
];
export const CHARACTER_ART_MANIFEST = Object.freeze(definitions.flatMap(([kind,choices,colors])=>
CHARACTER_OPTIONS[choices].flatMap(id=>(colors ? CHARACTER_OPTIONS[colors] : [null]).map(color=>
Object.freeze({filename:`hearth_char_${kind}_${id}${color ? '_'+color : ''}.png`,kind,id,color,
width:64,height:64,empty:id==='none',license:'MIT',author:'The Last Hearth original procedural art'})))));
const records = new Map(CHARACTER_ART_MANIFEST.map(record=>[record.filename,record]));
export function characterLayer(filename) {
const record=records.get(filename);
if (!record) throw new Error('Unknown character layer.');
const draw=definitions.find(([kind])=>kind===record.kind)[3];
return draw(record.id,record.color);
}
export function characterArt() {
return new Map(CHARACTER_ART_MANIFEST.map(({filename})=>[filename,characterLayer(filename)]));
}
export const DEFAULT_CHARACTER = Object.freeze({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'});
const fields = Object.freeze({body:'bodies',skin:'skin',face:'faces',eyes:'eyes',hair:'hairStyles',
hairColor:'hairColors',beard:'facialHair',garment:'garments',cloth:'clothColors',trousers:'trousers',
boots:'boots',accent:'accents',accentColor:'clothColors',detail:'details'});
function selection(options) {
if (!options || typeof options!=='object' || Array.isArray(options) ||
![Object.prototype,null].includes(Object.getPrototypeOf(options))) throw new Error('Invalid character options.');
for (const key of Reflect.ownKeys(options)) if (!Object.hasOwn(fields,key)) throw new Error('Unknown character option.');
const result={...DEFAULT_CHARACTER,...options};
for (const [field,group] of Object.entries(fields))
if (!CHARACTER_OPTIONS[group].includes(result[field])) throw new Error('Unknown character choice.');
return result;
}
export function characterLayers(options = {}) {
const p=selection(options), name=stem=>`hearth_char_${stem}.png`;
// Clothing precedes long hair: shoulder locks must not be erased by tunics.
return [name(`skin_${p.skin}`),name(`face_${p.face}`),name(`eyes_${p.eyes}`),
name(`garment_${p.garment}_${p.cloth}`),name(`trousers_${p.trousers}`),name(`boots_${p.boots}`),
name(`accent_${p.accent}_${p.accentColor}`),name(`hair_${p.hair}_${p.hairColor}`),
name(`beard_${p.beard}_${p.hairColor}`),name(`detail_${p.detail}`)];
}
export function characterTexture(options = {}) { return characterLayers(options).join('^'); }
export function characterMesh(body = 'balanced') {
if (!CHARACTER_OPTIONS.bodies.includes(body)) throw new Error('Unknown character body profile.');
return `hearth_person${body==='balanced' ? '' : '_'+body}.b3d`;
}
// Straight RGBA source-over simplifies to a pixel copy: all layers are 0/255.
// Fresh output every call, no writes, caches, global tint, downloads or entities.
export function compositeCharacter(options = {}) {
const out=image();
for (const filename of characterLayers(options)) {
const layer=characterLayer(filename);
for (let i=0;i<out.data.length;i+=4) if (layer.data[i+3]) out.data.set(layer.data.subarray(i,i+4),i);
}
return out;
}
export const CHARACTER_PORTRAITS = Object.freeze([
{}, {skin:'porcelain',hair:'braids',hairColor:'flax',garment:'workshirt',cloth:'sage',accent:'apron',accentColor:'linen',detail:'freckles'},
{skin:'honey',hair:'wavy',hairColor:'auburn',garment:'jerkin',cloth:'ochre',beard:'stubble',accent:'sash',accentColor:'mauve'},
{skin:'olive',face:'angular',hair:'long',hairColor:'black',garment:'coat',cloth:'slate',accent:'vest',accentColor:'rust'},
{skin:'umber',face:'weathered',hair:'cropped',hairColor:'silver',garment:'workshirt',cloth:'linen',beard:'full_beard',accent:'none'},
{skin:'ebony',face:'soft',eyes:'blue',hair:'braids',hairColor:'black',garment:'tunic',cloth:'rust',accent:'scarf',accentColor:'ochre'},
{skin:'sand',hair:'shaved',hairColor:'ash',garment:'coat',cloth:'charcoal',beard:'moustache',accent:'sash',accentColor:'teal',detail:'scar'},
{skin:'umber',hair:'none',hairColor:'chestnut',garment:'jerkin',cloth:'mauve',beard:'short_beard',accent:'vest',accentColor:'sage',detail:'freckles_scar'},
].map(p=>Object.freeze({...p})));
export function portraitSheet() {
const out={width:512,height:64,data:new Uint8Array(512*64*4)};
for (let i=0;i<out.data.length;i+=4) out.data.set([30,34,32,255],i);
CHARACTER_PORTRAITS.forEach((options,index)=> {
const atlas=compositeCharacter(options);
// Front-facing assembled paper-doll portraits, NOT unwrapped atlas squares
// or an engine/3D-render claim. Nearest-neighbor sampling of actual layers.
const blit=(tile,dx,dy,w,h)=> {
const [tx,ty]=ATLAS[tile];
for (let y=0;y<h;y++) for (let x=0;x<w;x++) {
const from=((ty*8+Math.floor(y*8/h))*64+tx*8+Math.floor(x*8/w))*4;
out.data.set(atlas.data.subarray(from,from+4),((dy+y)*out.width+index*64+dx+x)*4);
}
};
blit('face',24,3,16,18); blit('tunic',21,21,22,22);
blit('sleeve',14,22,7,18); blit('sleeve',43,22,7,18);
blit('skin',14,40,7,4); blit('skin',43,40,7,4);
blit('trousers',22,43,9,13); blit('trousers',33,43,9,13);
blit('boot',21,56,10,6); blit('boot',33,56,10,6);
});
return out;
}
+177
View File
@@ -0,0 +1,177 @@
// Local, trusted parent directories are required. No shell, links or shared-tree deletion.
import { constants, createReadStream } from 'node:fs';
import * as fs from 'node:fs/promises';
import { createHash, randomUUID } from 'node:crypto';
import path from 'node:path';
import { setTimeout as retryDelay } from 'node:timers/promises';
export const MANIFEST = 'sha256-manifest.json';
export const json = value => JSON.stringify(value, null, 2) + '\n';
export async function exists(filename) {
try { await fs.lstat(filename); return true; }
catch (error) { if (error.code === 'ENOENT') return false; throw error; }
}
export function absolute(filename) {
if (typeof filename !== 'string' || !path.isAbsolute(filename) || filename.includes('\0') ||
filename.split(/[\\/]/).includes('..')) throw new Error('An absolute path without traversal is required.');
return path.normalize(filename);
}
export function relativeName(name) {
if (typeof name !== 'string' || !name || name.includes('\\') || name.split('/').some(part =>
!part || part === '.' || part === '..' || /[\x00-\x1f<>:"|?*]/.test(part) || /[. ]$/.test(part) ||
/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))) throw new Error('Unsafe portable relative path.');
return name;
}
export async function safePath(filename) {
filename = absolute(filename);
const root = path.parse(filename).root;
let current = root;
for (const part of filename.slice(root.length).split(path.sep).filter(Boolean)) {
current = path.join(current, part);
try {
if ((await fs.lstat(current)).isSymbolicLink()) throw new Error('Refusing symbolic link or junction.');
} catch (error) { if (error.code !== 'ENOENT') throw error; }
}
return filename;
}
export async function ensureDirectory(directory) {
await safePath(directory);
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
await safePath(directory);
if (!(await fs.lstat(directory)).isDirectory()) throw new Error('Expected directory.');
}
export function disjoint(a, b) {
const key = value => process.platform === 'win32' ? value.toLowerCase() : value;
a = key(absolute(a)); b = key(absolute(b));
const inside = (parent, child) => child === parent || child.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep);
if (inside(a, b) || inside(b, a)) throw new Error('Source and destination must not overlap.');
}
export async function inventory(root, omitManifest = false) {
await safePath(root);
if (!(await fs.lstat(root)).isDirectory()) throw new Error('Expected directory.');
const entries = [];
const folded = new Set();
async function visit(directory, prefix = '') {
for (const name of (await fs.readdir(directory)).sort()) {
const relative = relativeName(prefix + name);
if (omitManifest && relative === MANIFEST) continue;
const foldedName = relative.toLowerCase();
if (folded.has(foldedName)) throw new Error('Case-colliding paths are not portable.');
folded.add(foldedName);
const filename = path.join(root, ...relative.split('/'));
const stat = await fs.lstat(filename);
if (stat.isSymbolicLink()) throw new Error('Refusing symbolic link or junction.');
if (stat.isDirectory()) {
entries.push({ path: relative, type: 'directory' });
await visit(filename, relative + '/');
} else if (stat.isFile()) {
const hash = createHash('sha256');
for await (const chunk of createReadStream(filename, { flags: constants.O_RDONLY | (constants.O_NOFOLLOW || 0) })) hash.update(chunk);
entries.push({ path: relative, type: 'file', size: stat.size, sha256: hash.digest('hex') });
} else throw new Error('Only regular files and directories are supported.');
}
}
await visit(root);
return entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
}
export async function copyTree(from, to, { intoEmpty = false, exclude = new Set() } = {}) {
const entries = await inventory(from);
await safePath(to);
if (intoEmpty) {
if (!(await fs.lstat(to)).isDirectory() || (await fs.readdir(to)).length) throw new Error('Copy target must be an empty private stage.');
} else await fs.mkdir(to, { mode: 0o700 });
for (const entry of entries) {
if ([...exclude].some(prefix => entry.path === prefix || entry.path.startsWith(prefix + '/'))) continue;
const target = path.join(to, ...entry.path.split('/'));
if (entry.type === 'directory') await fs.mkdir(target, { mode: 0o700 });
else {
await fs.copyFile(path.join(from, ...entry.path.split('/')), target, constants.COPYFILE_EXCL);
await fs.chmod(target, 0o600);
}
}
return entries;
}
export async function writeManifest(root, kind) {
const manifest = { format: 1, algorithm: 'sha256', kind, entries: await inventory(root, true) };
await fs.writeFile(path.join(root, MANIFEST), json(manifest), { flag: 'wx', mode: 0o600 });
return manifest;
}
export async function verifyManifest(root, kind) {
await safePath(path.join(absolute(root), MANIFEST));
const stat = await fs.lstat(path.join(root, MANIFEST));
if (!stat.isFile() || stat.size > 16 * 1024 * 1024) throw new Error('Invalid manifest file.');
const manifest = JSON.parse(await fs.readFile(path.join(root, MANIFEST), 'utf8'));
if (manifest.format !== 1 || manifest.algorithm !== 'sha256' || manifest.kind !== kind || !Array.isArray(manifest.entries)) {
throw new Error('Unsupported SHA256 manifest.');
}
for (const entry of manifest.entries) relativeName(entry.path);
if (json(manifest.entries) !== json(await inventory(root, true))) throw new Error('SHA256 manifest mismatch: corrupt, missing or extra files/directories.');
return manifest;
}
// Exclusive sibling guard serializes our writers. Parent must not be writable by
// adversaries: Node has no portable rename-no-replace primitive for directories.
export async function atomicDirectory(destination, build) {
destination = await safePath(destination);
if (path.parse(destination).root === destination) throw new Error('Refusing filesystem root destination.');
const parent = path.dirname(destination);
await safePath(parent);
if (!(await fs.lstat(parent)).isDirectory()) throw new Error('Destination parent must exist.');
const guard = destination + '.hearth-write-lock';
const handle = await fs.open(guard, 'wx', 0o600);
let stage;
try {
if (await exists(destination)) throw new Error('Refusing to overwrite an existing or unmanaged destination.');
stage = await fs.mkdtemp(path.join(parent, '.hearth-stage-'));
await fs.chmod(stage, 0o700);
await build(stage);
// Windows virus scanners/indexers can briefly hold newly generated PNGs.
// Retry only transient sharing failures, rechecking nonexistence each time;
// never fall back to copying into or removing an existing destination.
for (let attempt = 0; ; attempt++) {
await safePath(destination);
if (await exists(destination)) throw new Error('Destination appeared during operation; refusing overwrite.');
try { await fs.rename(stage, destination); break; }
catch (error) {
if (process.platform !== 'win32' || !['EPERM', 'EACCES', 'EBUSY'].includes(error.code) || attempt >= 5) throw error;
await retryDelay(25 * 2 ** attempt);
}
}
stage = undefined;
return destination;
} finally {
if (stage) await fs.rm(stage, { recursive: true, force: true });
await handle.close();
await fs.unlink(guard);
}
}
const leases = new WeakSet();
export async function withLauncherLock(runtime, action) {
await ensureDirectory(runtime);
const filename = path.join(runtime, 'launcher.lock');
let handle;
try { handle = await fs.open(filename, 'wx', 0o600); }
catch (error) {
if (error.code === 'EEXIST') throw new Error('Launcher lock exists. Stop the engine before manually clearing a stale lock.');
throw error;
}
const lease = Object.freeze({ runtime: absolute(runtime), id: randomUUID() });
leases.add(lease);
try { await handle.writeFile(`${process.pid}\n`); return await action(lease); }
finally { leases.delete(lease); await handle.close(); await fs.unlink(filename); }
}
export function validLease(lease, runtime) {
return lease && leases.has(lease) && lease.runtime === absolute(runtime);
}
+249
View File
@@ -0,0 +1,249 @@
// Original static broadleaf sculptures. Pure, bounded, deterministic; no MTL,
// external assets, random state or filesystem access. Ten model units per node.
import { PALETTE, SPECIES_NAMES, EVERGREEN_NAMES, worldArt } from './world-art.mjs';
// Deprecated compatibility exports only. New trees are built from cube nodes;
// do not spend geometry on new OBJ files or infer runtime usage from this list.
export const LEGACY_MESH_SPECIES = Object.freeze(['oak','birch','pine','willow','alder']);
export const SPECIES_MESH_NAMES = Object.freeze(LEGACY_MESH_SPECIES.flatMap(s=>[
`${s}_seedling`,`${s}_young`,`${s}_mature`,...(s==='pine'?[]:[`${s}_young_winter`,`${s}_mature_winter`])]));
export const FLORA_NAMES = Object.freeze(['tree_seedling', 'young_tree', 'mature_tree', 'deadwood',
'young_tree_winter', 'mature_tree_winter',...SPECIES_MESH_NAMES]);
export const TREE_SEASONS = Object.freeze(['summer','spring','autumn','winter']);
export const SPECIES_ATLAS_NAMES = Object.freeze(SPECIES_NAMES.flatMap(s=>[
`${s}_atlas`,...(EVERGREEN_NAMES.includes(s)?[]:['spring','autumn','winter'].map(season=>`${s}_atlas_${season}`))]));
const sub = (a,b) => a.map((n,i)=>n-b[i]);
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 dot = (a,b) => a.reduce((s,n,i)=>s+n*b[i],0);
const unit = a => a.map(n=>n/Math.hypot(...a));
const directions = [[1,0],[Math.SQRT1_2,Math.SQRT1_2],[0,1],[-Math.SQRT1_2,Math.SQRT1_2],
[-1,0],[-Math.SQRT1_2,-Math.SQRT1_2],[0,-1],[Math.SQRT1_2,-Math.SQRT1_2]];
const number = n => Number(n.toFixed(6));
class Sculpture {
vertices=[]; uv=[]; normals=[]; faces=[]; groups=[];
solid(name, points, triangles, material) {
const center=[0,1,2].map(i=>points.reduce((s,p)=>s+p[i],0)/points.length);
const start=this.vertices.length;
this.groups.push({name, start, count:points.length, faceStart:this.faces.length});
this.vertices.push(...points.map(p=>p.map(number)));
// Each face has an explicit flat normal and its own inset UV triangle.
// OBJ V is bottom-up; both halves use the full 32px material height.
const u0=(material==='bark'?0.5:32.5)/64, u1=(material==='bark'?31.5:63.5)/64;
for(let ids of triangles) {
ids=[...ids];
let [a,b,c]=ids.map(i=>this.vertices[start+i]);
let normal=cross(sub(b,a),sub(c,a));
if(dot(normal,sub(a,center))<0) {
[ids[1],ids[2]]=[ids[2],ids[1]];
[a,b,c]=ids.map(i=>this.vertices[start+i]); normal=cross(sub(b,a),sub(c,a));
}
if(Math.hypot(...normal)<1e-8) throw new Error('Degenerate flora triangle');
const uvStart=this.uv.length;
this.uv.push([u0,0.5/32],[u1,0.5/32],[(u0+u1)/2,31.5/32]);
this.normals.push(unit(normal).map(number));
this.faces.push(ids.map((id,i)=>[start+id+1,uvStart+i+1,this.normals.length]));
}
}
branch(name, a,b,r0,r1) {
const axis=unit(sub(b,a)), side=unit(cross(axis,Math.abs(axis[1])<.9?[0,1,0]:[0,0,1]));
const up=cross(axis,side), points=[];
for(const [center,r] of [[a,r0],[b,r1]]) for(const [x,z] of directions)
points.push(center.map((n,i)=>n+r*(side[i]*x+up[i]*z)));
// A leaning stem still seats on a horizontal node-bottom cut, not below it.
if(a[1]===-5) for(let i=0;i<8;i++) points[i][1]=-5;
const triangles=[];
for(let i=0;i<8;i++) {
const j=(i+1)%8;
triangles.push([i,j,8+j],[i,8+j,8+i]);
}
for(let i=1;i<7;i++) triangles.push([0,i+1,i],[8,8+i,8+i+1]);
this.solid(name,points,triangles,'bark');
}
crown(name, center, radii, phase=0, shape='broad') {
// Three uneven eight-sided rings and off-axis tips make a lobed volume,
// not a cube, crossed plane or radially identical stack of spheres.
const profile=[1,.88,.94,1,.9,.98,1,.86], points=[];
const rings=shape==='conifer'?[[-.65,.88],[-.4,1],[.25,.48]]:[[-.55,.72],[0,1],[.55,.65]];
for(const [level,width] of rings) directions.forEach(([x,z],i)=> {
const bulge=profile[(i+phase)%8];
points.push([center[0]+x*radii[0]*width*bulge,center[1]+level*radii[1],center[2]+z*radii[2]*width*bulge]);
});
points.push([center[0]-.12*radii[0],center[1]-radii[1],center[2]+.08*radii[2]],
[center[0]+.08*radii[0],center[1]+radii[1],center[2]-.1*radii[2]]);
const triangles=[];
for(let i=0;i<8;i++) {
const j=(i+1)%8;
triangles.push([24,j,i],[25,16+i,16+j]);
for(let ring=0;ring<2;ring++) {
const k=ring*8; triangles.push([k+i,k+j,k+8+j],[k+i,k+8+j,k+8+i]);
}
}
this.solid(name,points,triangles,'leaf');
}
obj() {
const lines=['# Original Last Hearth volumetric ecology; 10 units/node; ground Y=-5','s off'];
for(const v of this.vertices) lines.push(`v ${v.join(' ')}`);
for(const t of this.uv) lines.push(`vt ${t.join(' ')}`);
for(const n of this.normals) lines.push(`vn ${n.join(' ')}`);
for(let i=0;i<this.faces.length;i++) {
const group=this.groups.find(g=>g.faceStart===i);
if(group) lines.push(`g ${group.name}`);
lines.push(`f ${this.faces[i].map(p=>p.join('/')).join(' ')}`);
}
return lines.join('\n')+'\n';
}
}
// Every species has an authored branching architecture, not a rescaled generic
// crown. Mature bounds fit the ecosystem's ±1.5 by five-node clearance. Young
// forms are half-size around ground Y=-5, hence ≤±.75 by 2.5 nodes.
function speciesSculpture(species,stage,winter) {
const m=new Sculpture(), conifer=species==='pine';
if(stage==='seedling') {
const index=SPECIES_NAMES.indexOf(species), lean=(index-2)*.22;
m.branch('stem',[0,-5,0],[lean,.8+index*.2,.2],.22,.07);
m.branch('twig',[0,-2.5,0],[-1.3,-.2,index*.2],.12,.04);
if(conifer) for(let i=0;i<3;i++)
m.crown(`leaf_tier_${i}`,[0,-1.7+i*1.6,0],[2-i*.5,1.6,1.9-i*.5],i,'conifer');
else {
m.crown('leaf_left',[-1.2,-.4,index*.15],[1.3,1.2,1.1],index);
m.crown('leaf_right',[.9,.7+index*.2,-.3],[1.2,conifer?2:1.5,1],index+2);
}
return m;
}
let limbs,clusters;
if(species==='oak') {
m.branch('trunk',[0,-5,0],[.7,23,.4],1.65,.65);
limbs=[[[0,8,0],[-9,24,2]],[[.2,12,0],[9,27,-2]],[[.5,18,0],[-2,35,3]],
[[.3,13,0],[2,25,-9]],[[.3,15,.2],[3,27,9]]];
clusters=[[[-8,26,2],[6.7,7,6]],[[8,28,-2],[6.3,7,6]],[[0,35,2],[7,8.5,6.7]],
[[1,26,-8],[6,6.5,6]],[[3,28,8],[6.2,6,6.4]],[[0,28,0],[6,7,6]]];
} else if(species==='birch') {
m.branch('trunk',[0,-5,0],[1.2,38,-.7],.85,.22);
limbs=[[[.3,16,0],[-4.8,27,1]],[[.6,23,-.2],[5.3,33,-2]],
[[1,29,-.5],[-3,38,2.4]],[[.5,21,-.3],[1.5,29,-5]]];
clusters=[[[-4.5,29,1],[3.6,5.3,3.8]],[[5.2,34,-2],[3.5,5,3.3]],
[[-3,38,2],[3.5,5.3,3.5]],[[1,40,-.8],[3.2,4,3]],[[1,30,-5],[3.5,5,3.3]],
[[2,25,4],[3.3,4,3.4]]];
} else if(conifer) {
m.branch('trunk',[0,-5,0],[.25,43,.1],1.05,.08);
limbs=[];
for(let i=0;i<4;i++) for(const [j,[x,z]] of [[-1,.2],[.6,.8],[.4,-1]].entries()) {
const length=10-i*2.2, y=8+i*8;
limbs.push([[0,y,0],[x*length,y+1,z*length]]);
// Separate tapered whorls, daylight gaps, and drooped lower skirts.
m.crown(`foliage_tier_${i}_${j}`,[x*length*.45,y+3,z*length*.45],
[length*.68,5.8-i*.3,length*.64],i+j,'conifer');
}
m.crown('foliage_leader',[.2,39,.1],[3.3,5.3,3.1],2,'conifer');
clusters=[];
} else if(species==='willow') {
m.branch('trunk',[0,-5,0],[-1,20,.7],1.4,.75);
limbs=[[[0,7,0],[-9,27,1]],[[0,10,0],[9,29,-2]],[[0,14,0],[0,34,3]],
[[0,12,0],[1,26,-9]],[[0,13,0],[3,26,9]]];
clusters=[[[-7,28,1],[6,5,6]],[[7,30,-2],[6,5,6]],[[0,34,2],[6,6,5]],
[[0,27,-7],[6,5,6]],[[3,28,7],[6,5,6]]];
for(const [i,[x,y,z]] of [[-11,23,1],[11,25,-2],[-5,24,-10],[3,23,11],[8,24,8],[-9,23,8]].entries()) {
m.branch(`pendant_${i}`,[x*.75,y+3,z*.75],[x,y-11,z],.17,.045);
if(!winter) m.crown(`foliage_curtain_${i}`,[x,y-4,z],[2.5,9,2.3],i);
}
} else { // Alder: upright multiple leaders and a compact rounded riparian crown.
m.branch('trunk',[0,-5,0],[.3,18,.2],1.15,.5);
limbs=[[[0,2,0],[-5,27,1]],[[0,5,0],[5.8,28,-1]],[[0,10,0],[0,32,3]],
[[0,9,0],[1,25,-5]]];
clusters=[[[-5,27,1],[5,7,4.7]],[[5.5,28,-1],[5,7,5]],[[0,32,3],[5.5,7,5]],
[[1,26,-5],[5,6,5]],[[0,24,1],[6,6,5]]];
}
limbs.forEach(([a,b],i)=> {
m.branch(`bough_${i}`,a,b,conifer?.28:species==='birch'?.3:.6,conifer?.08:.15);
if(!conifer) {
const start=a.map((n,j)=>n+(b[j]-n)*.7), sign=Math.sign(b[0]) || (i%2?-1:1);
m.branch(`twig_${i}`,start,[b[0]+sign*1.8,b[1]+1,b[2]+(i%2?-1.5:1.5)],.15,.035);
}
});
if(!winter) clusters.forEach(([c,r],i)=>m.crown(`foliage_${i}`,c,r,i+SPECIES_NAMES.indexOf(species)));
if(stage==='young') m.vertices=m.vertices.map(([x,y,z])=>[number(x*.5),number((y+5)*.5-5),number(z*.5)]);
return m;
}
function sculpture(name) {
if(!FLORA_NAMES.includes(name)) throw new Error('Unknown flora mesh');
const match=name.match(/^(oak|birch|pine|willow|alder)_(seedling|young|mature)(_winter)?$/);
if(match) return speciesSculpture(match[1],match[2],Boolean(match[3]));
const m=new Sculpture();
if(name==='deadwood') {
m.branch('fallen_trunk',[0,-3.4,-8],[.2,-3.4,8],1.6,1.3);
m.branch('broken_left',[0,-3.1,-2],[-3.9,-1.4,-4.8],.6,.24);
m.branch('broken_right',[.1,-3,2],[3.9,-.6,4.7],.55,.2);
m.branch('fork',[2.2,-1.7,3.5],[3.3,-1.2,6.6],.3,.12);
return m;
}
if(name==='tree_seedling') {
m.branch('stem',[0,-5,0],[.12,.1,-.05],.22,.09);
m.branch('twig', [0,-2.8,0],[-1.2,-1,0],.12,.06);
m.crown('leaf_left',[-1.15,-1.1,.2],[1.5,1.4,1.25]);
m.crown('leaf_right',[1,-.1,-.35],[1.5,2.1,1.4],2);
return m;
}
const winter=name.endsWith('_winter'), mature=name.startsWith('mature_tree');
m.branch('trunk',[0,-5,0],[.15,mature?15:7,.1],mature?1.2:.85,mature?.7:.35);
const limbs=mature ? [
[[0,5,0],[-5,16,1],.75,.28],[[0,8,0],[5.7,19,-1],.65,.24],
[[0,11,0],[-1,23,2.5],.55,.2],[[0,8,0],[1,17,-5],.55,.2],
] : [
[[0,0,0],[-2.8,6,.5],.36,.13],[[0,2,0],[3,8,-.4],.3,.12],
[[0,4,0],[0,10,1.2],.24,.09],
];
limbs.forEach(([a,b,r0,r1],i)=>m.branch(`bough_${i}`,a,b,r0,r1));
if(winter) {
// Preserve every original trunk/bough vertex, UV and normal. Add woody
// lateral forks along the boughs, never a recoloured canopy or leaf planes.
limbs.forEach(([a,b],i)=> {
const start=a.map((n,j)=>n+(b[j]-n)*.62), sign=Math.sign(b[0]-a[0]) || (i%2?-1:1);
const tip=[b[0]+sign*(mature?2.2:1.3),b[1]-.8,b[2]+sign*(mature?1.8:1)];
m.branch(`winter_fork_${i}`,start,tip,mature?.22:.11,mature?.06:.035);
});
return m;
}
const clusters=mature ? [
[[-5,16,1],[6,6,6]],[[5,19,-1],[6,6,6]],[[0,23,1],[6,7,6]],
[[0,16,-5],[6,5.5,6]],[[1,15,5],[6,5,6]],
] : [
[[-2.5,6,.5],[4,4,4]],[[2.5,8,-.4],[4,4.5,4]],
[[0,10.5,1],[3.5,4.5,3.5]],[[.3,6,-2],[3.5,3.5,4]],
];
clusters.forEach(([c,r],i)=>m.crown(`foliage_${i}`,c,r,i*2));
return m;
}
export function floraOBJ(name) { return sculpture(name).obj(); }
export function floraBounds(name) {
const {vertices}=sculpture(name);
return {min:[0,1,2].map(i=>Math.min(...vertices.map(v=>v[i]))/10),
max:[0,1,2].map(i=>Math.max(...vertices.map(v=>v[i]))/10)};
}
export function treeAtlas(art=worldArt(), season='summer', species=null) {
if(!TREE_SEASONS.includes(season)) throw new Error('Unknown tree season');
if(species!==null && !SPECIES_NAMES.includes(species)) throw new Error('Unknown tree species');
if(EVERGREEN_NAMES.includes(species) && season!=='summer') throw new Error('Conifer uses its evergreen summer atlas');
// Two 32px patches, no external MTL. Leaves keep a dark opaque underpaint:
// silhouette holes come from gaps between 3D lobes, never torn polygon faces.
const data=new Uint8Array(64*32*4);
const prefix=species?`${species}_`:'', leaves=`${prefix}leaves${season==='summer'?'':`_${season}`}`;
const underpaint=season==='summer' && species?
{oak:'leafDark',birch:'leaf',pine:'pineShade',willow:'willowShade',alder:'leafDark',
beech:'leafDark',aspen:'leafDark',rowan:'leafDark',hazel:'leafDark',spruce:'pineShade'}[species]:
{summer:'leafDark',spring:'springDark',autumn:'autumnDark',winter:'barkDark'}[season];
for(const [half,name] of [`${prefix}log_side`,leaves].entries()) {
const image=art.get(`hearth_${name}.png`);
if(!image || image.width!==32 || image.height!==32 || image.data.length!==4096)
throw new Error('Tree atlas requires 32px RGBA bark and leaves');
for(let y=0;y<32;y++) for(let x=0;x<32;x++) {
const p=image.data.subarray((y*32+x)*4,(y*32+x+1)*4);
data.set(p[3]===255?p:[...PALETTE[underpaint],255],(y*64+half*32+x)*4);
}
}
return {width:64,height:32,data};
}
+140
View File
@@ -0,0 +1,140 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import * as fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { absolute, atomicDirectory, ensureDirectory, exists, safePath } from './files.mjs';
import { assertWorldOffline, backupWorld } from './backup.mjs';
import { readRelease, SOURCE } from './package.mjs';
const exec = promisify(execFile);
export function runtimeHome(env = process.env, platform = process.platform, home = os.homedir()) {
if (env.HEARTH_HOME !== undefined) return absolute(env.HEARTH_HOME);
if (platform === 'win32') {
if (!env.LOCALAPPDATA) throw new Error('LOCALAPPDATA or HEARTH_HOME is required.');
return path.join(absolute(env.LOCALAPPDATA), 'last-hearth-runtime');
}
if (platform === 'darwin') return path.join(absolute(home), 'Library/Application Support/last-hearth-runtime');
return path.join(env.XDG_DATA_HOME ? absolute(env.XDG_DATA_HOME) : path.join(absolute(home), '.local/share'), 'last-hearth-runtime');
}
export function engineExecutable(env = process.env, platform = process.platform, runtime = runtimeHome(env, platform)) {
if (env.HEARTH_ENGINE !== undefined) return absolute(env.HEARTH_ENGINE);
if (platform !== 'win32') throw new Error('Set HEARTH_ENGINE to an absolute Luanti 5.18.0 executable.');
return path.join(runtime, 'luanti-5.18.0-win64/bin/luanti.exe');
}
export function parseEngineVersion(text) {
const match = /^Luanti ([0-9]+\.[0-9]+\.[0-9]+)(?=\s|$)/m.exec(text);
if (!match) throw new Error('Unrecognized Luanti --version response.');
return match[1];
}
export function assertEngineVersion(text, expected = '5.18.0') {
const actual = parseEngineVersion(text);
if (actual !== expected) throw new Error(`Luanti version mismatch: expected ${expected}, received ${actual}.`);
return actual;
}
export async function validateEngine(executable, { source = SOURCE } = {}) {
executable = await safePath(absolute(executable));
if (!(await fs.lstat(executable)).isFile()) throw new Error('Engine must be a regular executable file.');
const release = await readRelease(source);
const { stdout } = await exec(executable, ['--version'], {
shell: false, windowsHide: true, timeout: 10_000, maxBuffer: 64 * 1024, encoding: 'utf8',
});
return assertEngineVersion(stdout, release.engine.version);
}
export async function snapshotBeforePlay(runtime, lease) {
runtime = absolute(runtime);
const world = path.join(runtime, 'worlds/last_hearth');
await safePath(world);
if (await exists(path.join(world, 'world.mt'))) {
await assertWorldOffline(world);
const backups = path.join(runtime, 'backups');
await ensureDirectory(backups);
const destination = path.join(backups, `prelaunch-${new Date().toISOString().replace(/[:.]/g, '-')}-${randomUUID()}`);
await backupWorld(world, destination, { offline: true, runtime, lease });
return destination;
}
if (await exists(world) && (await fs.readdir(world)).length) throw new Error('Refusing to initialize a nonempty unmanaged world directory.');
return undefined;
}
// Full-detail terrain distance, not LOD or a simulation/activation radius.
export function graphicsSettings(env = process.env) {
const raw = env.HEARTH_VIEW_DISTANCE;
// Validate the entire string (including trailing newlines) before config I/O.
if (raw !== undefined && (typeof raw !== 'string' || !/^[1-9][0-9]{2,3}$/.test(raw) || /[\r\n]/.test(raw))) {
throw new Error('HEARTH_VIEW_DISTANCE must be an integer from 128 to 1024 nodes.');
}
const distance = raw === undefined ? 512 : Number(raw);
if (!Number.isSafeInteger(distance) || distance < 128 || distance > 1024) {
throw new Error('HEARTH_VIEW_DISTANCE must be an integer from 128 to 1024 nodes.');
}
const blocks = Math.min(66, Math.ceil(distance / 16) + 2);
return [
`viewing_range = ${distance}`, `max_block_send_distance = ${blocks}`,
`max_block_generate_distance = ${blocks}`, 'client_mesh_chunk = 2', 'fog_start = 0.65',
];
}
export function coordinates(env = process.env) {
const latitude = env.HEARTH_LATITUDE, longitude = env.HEARTH_LONGITUDE;
if (latitude === undefined && longitude === undefined) return [];
if (latitude === undefined || longitude === undefined) throw new Error('Provide both HEARTH_LATITUDE and HEARTH_LONGITUDE, or neither.');
const parse = (value, limit) => {
if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(value) || !Number.isFinite(Number(value)) || Math.abs(Number(value)) > limit) {
throw new Error('Invalid geographic coordinate.');
}
return Number(value);
};
return [`hearth_latitude = ${parse(latitude, 90)}`, `hearth_longitude = ${parse(longitude, 180)}`];
}
export async function fixtureSettings(name, environment, source = SOURCE) {
if (!['local', 'staging', 'production'].includes(environment)) throw new Error('Invalid hearth_environment.');
if (name === undefined) return [];
if (environment === 'production') throw new Error('Production rejects fixture/test settings.');
if (!['first-session', 'summer', 'winter'].includes(name)) throw new Error('Unknown HEARTH_FIXTURE.');
const filename = await safePath(path.join(source, 'tests/fixtures/scenarios.json'));
const data = JSON.parse(await fs.readFile(filename, 'utf8'));
const fixture = Object.hasOwn(data.scenarios ?? {}, name) ? data.scenarios[name] : undefined;
if (data.format !== 1 || !fixture || fixture.seed !== 731104 ||
!['real-date', 'fixed'].includes(fixture.clock)) throw new Error('Invalid scenario contract.');
const settings = [`fixed_map_seed = ${fixture.seed}`];
if (fixture.clock === 'fixed') {
if (!Number.isSafeInteger(fixture.utc) || fixture.utc <= 0 || !['clear', 'rain'].includes(fixture.weather)) throw new Error('Invalid scenario date/weather.');
settings.push(`hearth_preview_date = ${fixture.utc}`, `hearth_preview_weather = ${fixture.weather}`,
...coordinates({ HEARTH_LATITUDE: String(fixture.latitude), HEARTH_LONGITUDE: String(fixture.longitude) }));
}
return settings;
}
// The parent is the launcher's private, fresh session stage, never the persistent
// worlds directory. Refuse even an empty pre-existing course; no reuse/reset/rm.
// The Lua module enrolls this marker before clock/player code can save anything.
export async function prepareCourse(stage, { environment = 'local', source = SOURCE } = {}) {
if (!['local', 'staging'].includes(environment)) throw new Error('Course requires local/staging, never production.');
stage = await safePath(absolute(stage));
if (!(await fs.lstat(stage)).isDirectory()) throw new Error('Course stage must be an existing private directory.');
const fixture = await fixtureSettings('summer', environment, source);
const world = path.join(stage, 'course'), nonce = randomUUID();
await atomicDirectory(world, async directory => {
await fs.writeFile(path.join(directory, '.hearth-m1-course-nonce'), nonce + '\n', { flag: 'wx', mode: 0o600 });
});
return { world, nonce, settings: [...fixture, 'hearth_m1_course = true', `hearth_m1_course_nonce = ${nonce}`] };
}
// Dedicated engines default to production; the managed SP launcher opts into local.
export function environmentSettings(environment = 'production', extra = []) {
if (!['local', 'staging', 'production'].includes(environment)) throw new Error('Invalid hearth_environment.');
if (extra.some(line => /[\r\n]/.test(line) || /^\s*hearth_environment\s*=/.test(line))) throw new Error('Invalid configuration setting.');
if (environment === 'production' && extra.some(line => /^\s*hearth_(?:preview_|launcher_mode|smoke_token|m1_course|recovery|debug|course_probe)/.test(line))) {
throw new Error('Production rejects fixture/test settings.');
}
return [`hearth_environment = ${environment}`, ...extra];
}
+379
View File
@@ -0,0 +1,379 @@
// Original procedural geometry and audio. Runtime packaging needs no codec.
import * as fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHash, randomInt } from 'node:crypto';
import { createRequire } from 'node:module';
import { spawnSync } from 'node:child_process';
import os from 'node:os';
import { safePath } from './files.mjs';
// Luanti renders mesh coordinates directly: 10 model units = one world node.
export const ANIMATIONS = Object.freeze(Object.fromEntries(Object.entries({
idle: [0, 19, 12], walk: [20, 39, 24], run: [40, 59, 32],
strike: [60, 69, 28], guard: [70, 79, 12], swim: [80, 99, 20],
}).map(([name, range]) => [name, Object.freeze(range)])));
export const PLAYER_HEIGHT = 17.5;
export const SAMPLE_RATE = 22050;
const i32 = (...values) => {
const bytes = Buffer.alloc(values.length * 4);
values.forEach((value, i) => bytes.writeInt32LE(value, i * 4));
return bytes;
};
const f32 = (...values) => {
const bytes = Buffer.alloc(values.length * 4);
values.forEach((value, i) => bytes.writeFloatLE(value, i * 4));
return bytes;
};
const string = value => Buffer.from(value + '\0', 'ascii');
const chunk = (tag, ...parts) => {
const data = Buffer.concat(parts);
return Buffer.concat([Buffer.from(tag, 'ascii'), i32(data.length), data]);
};
// One 64px atlas, 8px tiles, half-texel inset. Front faces look toward +Z.
export const ATLAS = Object.freeze({face: [0, 0], hair: [1, 0], side: [2, 0],
tunic: [0, 1], back: [1, 1], sleeve: [2, 1], trousers: [0, 2],
boot: [1, 2], skin: [2, 2], metal: [0, 3], wood: [1, 3], wrap: [2, 3]});
function uv(tile) {
const [x, y] = ATLAS[tile];
return [[0.5, 7.5], [7.5, 7.5], [7.5, 0.5], [0.5, 0.5]]
.map(([u, v]) => [(x * 8 + u) / 64, (y * 8 + v) / 64]);
}
const faces = [
[[0, 3, 2, 1], [0, 0, -1]], [[4, 5, 6, 7], [0, 0, 1]],
[[0, 1, 5, 4], [0, -1, 0]], [[3, 7, 6, 2], [0, 1, 0]],
[[0, 4, 7, 3], [-1, 0, 0]], [[1, 2, 6, 5], [1, 0, 0]],
];
function box(vertices, triangles, weights, bone, bounds, tiles, taper = [1, 1]) {
const [x0, y0, z0, x1, y1, z1] = bounds;
const corners = [[x0,y0,z0],[x1,y0,z0],[x1,y1,z0],[x0,y1,z0],
[x0,y0,z1],[x1,y0,z1],[x1,y1,z1],[x0,y1,z1]];
for (const corner of corners) if (corner[1] === y0) {
corner[0] = (x0+x1)/2 + (corner[0]-(x0+x1)/2)*taper[0];
corner[2] = (z0+z1)/2 + (corner[2]-(z0+z1)/2)*taper[1];
}
faces.forEach(([indices], face) => {
const [a,b,c] = indices.map(i => corners[i]);
const u = b.map((n,i)=>n-a[i]), v = c.map((n,i)=>n-a[i]);
const cross = [u[1]*v[2]-u[2]*v[1],u[2]*v[0]-u[0]*v[2],u[0]*v[1]-u[1]*v[0]];
const normal = cross.map(n=>n/Math.hypot(...cross));
const first = vertices.length, coords = uv(Array.isArray(tiles) ? tiles[face] : tiles);
indices.forEach((index, i) => {
vertices.push([...corners[index], ...normal, ...coords[i]]);
weights[bone].push(first + i);
});
triangles.push([first, first + 1, first + 2], [first, first + 2, first + 3]);
});
}
// Rigidly weighted low-poly limbs are skeletal animation, not vertex snapshots.
// Child hand bones carry held meshes through the same strike/guard animation.
const skeleton = [
{name: 'Root', parent: null, pos: [0,0,0]},
{name: 'Body', parent: 'Root', pos: [0,8,0]},
{name: 'Head', parent: 'Body', pos: [0,5.5,0]},
{name: 'Arm_Right', parent: 'Body', pos: [-3.1,5,0]},
{name: 'Hand_Right', parent: 'Arm_Right', pos: [0,-4.8,0]},
{name: 'Arm_Left', parent: 'Body', pos: [3.1,5,0]},
{name: 'Hand_Left', parent: 'Arm_Left', pos: [0,-4.8,0]},
{name: 'Leg_Right', parent: 'Root', pos: [-1.15,8,0]},
{name: 'Leg_Left', parent: 'Root', pos: [1.15,8,0]},
];
function pose(name, frame) {
const [clip, [start, end]] = Object.entries(ANIMATIONS).find(([, [a, b]]) => frame >= a && frame <= b);
const t = (frame - start) / (end - start), cycle = Math.sin(t * Math.PI * 2);
let x = 0, y = 0, z = 0, lift = 0;
const right = name.endsWith('Right'), limb = right ? 1 : -1;
if (clip === 'idle') {
if (name === 'Body') lift = 0.055 * (1 - Math.cos(t * Math.PI * 2));
if (name === 'Head') y = 0.045 * cycle;
} else if (clip === 'walk' || clip === 'run') {
const amplitude = clip === 'run' ? 0.9 : 0.5;
if (name.startsWith('Leg_')) x = cycle * amplitude * limb;
if (name.startsWith('Arm_')) x = -cycle * amplitude * limb;
if (name === 'Body') { lift = Math.abs(cycle) * (clip === 'run' ? 0.3 : 0.12); x = clip === 'run' ? -0.12 : 0; }
} else if (clip === 'strike') {
const swing = Math.sin(Math.PI * t);
if (name === 'Arm_Right') { x = -2.1 * swing; z = -0.25 * swing; }
if (name === 'Body') y = 0.25 * swing;
if (name === 'Arm_Left') x = -0.35 * swing;
} else if (clip === 'guard') {
if (name.startsWith('Arm_')) { x = -1.15 + 0.035 * cycle; z = -limb * 0.35; }
if (name === 'Body') x = -0.1;
} else if (clip === 'swim') {
if (name === 'Body') x = -0.95;
if (name.startsWith('Arm_')) { x = -1.5 + cycle * limb * 0.65; z = -limb * 0.25; }
if (name.startsWith('Leg_')) x = cycle * limb * 0.35;
}
// Euler XYZ -> quaternion (B3D order WXYZ). No scale keys, no root motion.
const [cx, cy, cz] = [x,y,z].map(a => Math.cos(a / 2));
const [sx, sy, sz] = [x,y,z].map(a => Math.sin(a / 2));
return {lift, rotation: [cx*cy*cz + sx*sy*sz, sx*cy*cz - cx*sy*sz,
cx*sy*cz + sx*cy*sz, cx*cy*sz - sx*sy*cz]};
}
export const BODY_PROFILES = Object.freeze(['compact', 'balanced', 'broad']);
export function playerB3D(profile = 'balanced') {
if (!BODY_PROFILES.includes(profile)) throw new Error('Unknown character body profile.');
const width = {compact: .92, balanced: 1, broad: 1.08}[profile];
const vertices = [], triangles = [], weights = Object.fromEntries(skeleton.map(b => [b.name, []]));
box(vertices, triangles, weights, 'Body', [-2.15*width,8,-1.18,2.15*width,13.5,1.18],
['back','tunic','tunic','tunic','tunic','tunic'], [profile === 'broad' ? .9 : .86, .86]);
box(vertices, triangles, weights, 'Head', [-1.6,13.5,-1.4,1.6,PLAYER_HEIGHT,1.4], ['hair','face','skin','hair','side','side']);
for (const [side, sign] of [['Right', -1], ['Left', 1]]) {
const x = sign * 3.1;
box(vertices, triangles, weights, `Arm_${side}`, [x-.78*width,8.6,-.84,x+.78*width,13,.84], 'sleeve', [.9,.95]);
box(vertices, triangles, weights, `Hand_${side}`, [x-.7,7.6,-.8,x+.7,8.6,.8], 'skin');
const leg = sign * 1.15;
box(vertices, triangles, weights, `Leg_${side}`, [leg-.91*width,1.5,-.95,leg+.91*width,8,.95], 'trousers', [.9,.94]);
box(vertices, triangles, weights, `Leg_${side}`, [leg-1,0,-1.1,leg+1,1.5,1.5], 'boot');
}
const mesh = chunk('MESH', i32(0),
chunk('VRTS', i32(1, 1, 2), ...vertices.map(v => f32(...v))),
chunk('TRIS', i32(0), ...triangles.map(t => i32(...t))));
function node(bone) {
const keys = [];
for (let frame = 0; frame < 100; frame++) {
const {lift, rotation} = pose(bone.name, frame);
// Irrlicht subtracts one from B3D KEYS frame indices. Lua uses 0..99.
keys.push(i32(frame + 1), f32(bone.pos[0], bone.pos[1] + lift, bone.pos[2], ...rotation));
}
return chunk('NODE', string(bone.name), f32(...bone.pos, 1,1,1, 1,0,0,0),
...(bone.name === 'Root' ? [mesh, chunk('ANIM', i32(0,100), f32(24))] : []),
...(weights[bone.name].length ? [chunk('BONE', ...weights[bone.name].map(id => Buffer.concat([i32(id), f32(1)])))] : []),
chunk('KEYS', i32(5), ...keys), ...skeleton.filter(b => b.parent === bone.name).map(node));
}
return chunk('BB3D', i32(1),
chunk('TEXS', string('hearth_person.png'), i32(1,2), f32(0,0,1,1,0)),
chunk('BRUS', i32(1), string('Hearth cloth and skin'), f32(1,1,1,1,0), i32(1,0,0)),
node(skeleton[0]));
}
export const TOOL_NAMES = Object.freeze(['knife', 'axe', 'pick', 'spear']);
export function toolOBJ(name) {
if (!TOOL_NAMES.includes(name)) throw new Error('Unknown original tool.');
// Grip at origin; +Y is the blade/shaft axis. Attach to Hand_Right with
// rotation {x=90,y=0,z=0} to point along +Z in the neutral player pose.
const vertices = [], triangles = [], weights = {tool: []};
const add = (bounds, tile) => box(vertices, triangles, weights, 'tool', bounds, tile);
const length = {knife: 1.6, axe: 5.2, pick: 5.2, spear: 10}[name];
add([-.16,-1,-.16,.16,length,.16], 'wood');
add([-.22,-.8,-.22,.22,.8,.22], 'wrap');
if (name === 'axe') add([-1.9,3.8,-.22,.5,5.4,.22], 'metal');
if (name === 'pick') add([-2.2,4.4,-.17,2.2,5,.17], 'metal');
if (name === 'knife' || name === 'spear') {
// Tapered, double-sided pointed blade, not a blunt rectangular icon.
const first = vertices.length, base = length - .2, tip = length + (name === 'spear' ? 2.4 : 2.2);
const coords = uv('metal');
for (const z of [-.08,.08]) for (const [i, p] of [[-.5,base],[.5,base],[0,tip]].entries())
vertices.push([p[0],p[1],z,0,0,Math.sign(z),...coords[i]]);
triangles.push([first,first+2,first+1],[first+3,first+4,first+5],
[first,first+1,first+4],[first,first+4,first+3],
[first+1,first+2,first+5],[first+1,first+5,first+4],
[first+2,first,first+3],[first+2,first+3,first+5]);
}
const lines = [`# Original Last Hearth held ${name}; units=10/node; grip=0,0,0; atlas=hearth_person.png`];
vertices.forEach(v => lines.push(`v ${v.slice(0,3).join(' ')}`));
vertices.forEach(v => lines.push(`vt ${v[6]} ${1-v[7]}`));
triangles.forEach(t => lines.push(`f ${t.map(i => `${i+1}/${i+1}`).join(' ')}`));
return lines.join('\n') + '\n';
}
// Fixed profiles form the public Lua sound-name contract (no random variants).
export const SOUND_PROFILES = Object.freeze({
hearth_step_soil: [.13, 90, .75, .24], hearth_step_wood: [.12, 180, .35, .28],
hearth_step_stone: [.10, 620, .50, .23], hearth_step_water: [.22, 300, .90, .20],
hearth_dig_soil: [.20, 80, .90, .28], hearth_dig_wood: [.18, 210, .50, .30],
hearth_dig_stone: [.20, 930, .45, .26], hearth_land: [.24, 65, .60, .34],
hearth_swing_knife: [.12, 700, .95, .18], hearth_swing_axe: [.24, 260, .95, .24],
hearth_swing_spear: [.18, 440, .90, .20], hearth_impact: [.16, 140, .60, .30],
hearth_block: [.20, 820, .35, .24], hearth_denial: [.16, 170, .04, .16],
hearth_fire: [.50, 95, .95, .13],
});
export function soundWAV(name) {
if (!Object.hasOwn(SOUND_PROFILES, name)) throw new Error('Unknown original sound.');
const [duration, frequency, mix, gain] = SOUND_PROFILES[name];
const count = Math.round(SAMPLE_RATE * duration), pcm = Buffer.alloc(count * 2);
// FNV-like name seed and xorshift are reproducible synthesis, NOT security RNG.
let state = 2166136261;
for (const char of name) state = Math.imul(state ^ char.charCodeAt(0), 16777619) >>> 0;
let low = 0;
for (let i = 0; i < count; i++) {
state ^= state << 13; state ^= state >>> 17; state ^= state << 5;
const noise = (state >>> 0) / 2147483648 - 1;
low += .24 * (noise - low);
const t = i / SAMPLE_RATE, phase = i / (count - 1);
const envelope = Math.min(1, i / 80, (count - 1 - i) / 160) * Math.exp(-4 * phase);
const tone = Math.sin(2 * Math.PI * frequency * t) * .75
+ Math.sin(2 * Math.PI * frequency * 1.63 * t) * .25;
const air = name.startsWith('hearth_swing_') ? noise : low;
const crackle = name === 'hearth_fire' && (state >>> 0) % 97 < 3 ? noise : 0;
const sample = gain * envelope * ((1-mix) * tone + mix * air + crackle * .3);
pcm.writeInt16LE(Math.round(Math.max(-1, Math.min(1, sample)) * 32767), i * 2);
}
const header = Buffer.alloc(44);
header.write('RIFF'); header.writeUInt32LE(36 + pcm.length, 4); header.write('WAVEfmt ', 8);
header.writeUInt32LE(16, 16); header.writeUInt16LE(1, 20); header.writeUInt16LE(1, 22);
header.writeUInt32LE(SAMPLE_RATE, 24); header.writeUInt32LE(SAMPLE_RATE * 2, 28);
header.writeUInt16LE(2, 32); header.writeUInt16LE(16, 34);
header.write('data', 36); header.writeUInt32LE(pcm.length, 40);
return Buffer.concat([header, pcm]);
}
// Generated sound-source inventory / minimal build-tool SBOM. The synthesized
// recordings are original MIT-licensed work (repository LICENSE.txt), not audio
// taken from FFmpeg. No third-party encoder code is shipped in the game.
export const AUDIO_PROVENANCE = Object.freeze({
assetLicense: 'MIT', tool: '@ffmpeg/core', version: '0.12.10',
toolLicense: 'GPL-2.0-or-later',
repository: 'https://github.com/ffmpegwasm/ffmpeg.wasm',
tarball: 'https://registry.npmjs.org/@ffmpeg/core/-/core-0.12.10.tgz',
integrity: 'sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA==',
javascriptSHA256: 'b266ab5b952555881dd6310663986994a182acb2b7ff25cf10a25f7a37ac2b21',
wasmSHA256: '9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7',
});
export const SOUND_HASHES = Object.freeze({
hearth_block: '604a7761f20c998c4c32e7a6d2beabd9333802636103a78ba7d9c866e2149b20',
hearth_denial: '491257912169f7fc45c2bacf59e5f8031ad786d9fed423d76fb12d668e9c3b5b',
hearth_dig_soil: '2b9509045b1da605af4a9d52ecc96cfa63f088dead8239dddc603185c2d29c9e',
hearth_dig_stone: '624790a3398b7708602e1f8ca1c0d4c18ff034491cb1ef9be6de4644b85b1ceb',
hearth_dig_wood: '6e827a1d916f9fd2b1ce09681eb433af949388febed55ca395827bb081a7d0cd',
hearth_fire: '811175440918ea29efd79e414a3391e1e0f504b3729268a3a48ef62b173c1d15',
hearth_impact: 'd051cb7e57d5cf497d1d40b3995d295fc5cd150db6578632bf597f171ce91bee',
hearth_land: '14dd5b1c818f74ff8007828ccd9b5addf09e5d0c2a251be5de67ef76c498631e',
hearth_step_soil: '110726200d18155c7a08c6981121a2e63b75227061ded7a77c5641c1dc4157b0',
hearth_step_stone: '333b1e92466909bdcee1214cd416983abe1a088b407f4f6a11efb7e1fddde1a8',
hearth_step_water: '194ce0984bcade40b050db0a76955737c5925593a1916218e56edcf4b0a7de09',
hearth_step_wood: 'ea594a486f8fbea66412521725e65e77103306def95fe6e1c8c54f0fef7a9a02',
hearth_swing_axe: 'c80d750f1b14860565435d321fc898506e8781c013b5645fffbc999115359795',
hearth_swing_knife: '31bc8ef920db76e333e6f6cc46293998ff15792063c73e42092b37407ecee4a2',
hearth_swing_spear: 'e8db0d5239834ff1426af118ca9575b957017ba7b504baa5ba0891e66a2d03e4',
});
const soundDirectory = fileURLToPath(new URL('../mods/hearth/sounds/', import.meta.url));
const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
export async function soundOGG(name) {
if (!Object.hasOwn(SOUND_PROFILES, name)) throw new Error('Unknown original sound.');
const filename = await safePath(path.join(soundDirectory, name + '.ogg'));
const stat = await fs.lstat(filename);
if (!stat.isFile() || stat.size > 65536) throw new Error('Invalid original sound source.');
const bytes = await fs.readFile(filename);
if (sha256(bytes) !== SOUND_HASHES[name]) throw new Error('Original Ogg source hash mismatch: ' + name);
return bytes;
}
// Optional developer-only codec. Set HEARTH_FFMPEG_CORE to the absolute
// dist/umd/ffmpeg-core.js from the pinned package above, installed separately
// with npm --ignore-scripts. Verify BOTH files before executing any JS/WASM.
// This never downloads, invokes a shell, or changes project dependencies.
export async function loadVorbisCodec(filename) {
if (typeof filename !== 'string' || !path.isAbsolute(filename) || path.basename(filename) !== 'ffmpeg-core.js')
throw new Error('HEARTH_FFMPEG_CORE must name the absolute pinned UMD ffmpeg-core.js.');
const javascript = await fs.readFile(await safePath(filename));
const wasmBinary = await fs.readFile(await safePath(filename.replace(/\.js$/, '.wasm')));
if (sha256(javascript) !== AUDIO_PROVENANCE.javascriptSHA256 || sha256(wasmBinary) !== AUDIO_PROVENANCE.wasmSHA256)
throw new Error('Pinned audio toolchain hash mismatch.');
// The upstream web-worker bundle needs only location during initialization.
const oldSelf = globalThis.self;
let core;
try {
globalThis.self = {location: {href: 'file:///ffmpeg-core.js'}};
core = await createRequire(import.meta.url)(filename)({wasmBinary});
} finally {
if (oldSelf === undefined) delete globalThis.self;
else globalThis.self = oldSelf;
}
const messages = [];
core.setLogger(({message}) => {
// This pinned Emscripten wrapper logs exactly Aborted() for exit(0) too.
// Check exec's exit status AND all actual FFmpeg error-level diagnostics.
if (message !== 'Aborted()') messages.push(message);
if (messages.length > 200) messages.shift();
});
function convert(bytes, input, output, options) {
messages.length = 0;
core.FS.writeFile(input, bytes);
try {
const code = core.exec('-hide_banner', '-loglevel', 'error', '-nostdin', '-y',
'-xerror', '-err_detect', 'explode', '-i', input, ...options, output);
if (code !== 0 || messages.length) throw new Error('Audio conversion failed: ' + messages.join('\n'));
return Buffer.from(core.FS.readFile(output));
} finally {
core.reset();
for (const file of [input, output]) try { core.FS.unlink(file); } catch { /* absent after failure */ }
}
}
return {
encode: bytes => convert(bytes, 'source.wav', 'sound.ogg', ['-map_metadata', '-1',
'-ac', '1', '-ar', String(SAMPLE_RATE), '-c:a', 'libvorbis', '-q:a', '3',
'-fflags', '+bitexact', '-flags:a', '+bitexact', '-f', 'ogg']),
decode: bytes => convert(bytes, 'sound.ogg', 'decoded.pcm', ['-map', '0:a:0', '-c:a', 'pcm_s16le', '-f', 's16le']),
};
}
async function encodeSounds() {
const codec = await loadVorbisCodec(process.env.HEARTH_FFMPEG_CORE);
await fs.mkdir(await safePath(soundDirectory), {recursive: true});
const hashes = {};
for (const name of Object.keys(SOUND_PROFILES).sort()) {
const bytes = codec.encode(soundWAV(name));
if (!bytes.equals(codec.encode(soundWAV(name)))) throw new Error('Non-deterministic Vorbis encoding.');
hashes[name] = sha256(bytes);
if (SOUND_HASHES[name] && hashes[name] !== SOUND_HASHES[name]) throw new Error('Regeneration hash mismatch: ' + name);
await fs.writeFile(await safePath(path.join(soundDirectory, name + '.ogg')), bytes, {mode: 0o600});
}
console.log(JSON.stringify(hashes, null, 2));
}
async function verifyEngineSounds() {
// Headless discovery is NOT client playback. Never open a persistent world.
const executable = process.env.HEARTH_ENGINE;
if (!executable || !path.isAbsolute(executable)) throw new Error('HEARTH_ENGINE must be an absolute native Luanti executable.');
const version = spawnSync(await safePath(executable), ['--version'], {encoding: 'utf8', timeout: 10000, windowsHide: true});
if (version.status !== 0 || !/^Luanti 5\.18\.0(?=\s|$)/m.test(version.stdout))
throw new Error('Expected native Luanti 5.18.0: ' + JSON.stringify({status:version.status,stdout:version.stdout,stderr:version.stderr}));
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'hearth-ogg-native-'));
try {
const world = path.join(root, 'world'), game = path.join(world, 'game');
const { packageGame } = await import('./package.mjs');
await fs.mkdir(world);
await packageGame(game);
const probe = path.join(world, 'worldmods/hearth_audio_probe');
await fs.mkdir(probe, {recursive:true});
await fs.writeFile(path.join(probe, 'mod.conf'), 'name = hearth_audio_probe\ndepends = hearth\n');
await fs.writeFile(path.join(probe, 'init.lua'), [
'core.after(0.1, function()',
' core.log("action", "LAST_HEARTH_AUDIO_DISCOVERY_PASS")',
' core.request_shutdown("audio discovery complete", false, 0)',
'end)', '',
].join('\n'));
await fs.writeFile(path.join(world, 'world.mt'), 'gameid = hearth_audio_probe\nbackend = sqlite3\nplayer_backend = sqlite3\nauth_backend = sqlite3\nmod_storage_backend = sqlite3\n');
const config = path.join(root, 'minetest.conf');
await fs.writeFile(config, 'hearth_environment = staging\nhearth_latitude = 45\nhearth_longitude = 0\nbind_address = 127.0.0.1\nserver_announce = false\nmax_users = 0\n');
const result = spawnSync(executable, ['--server', '--info', '--verbose', '--world', world,
'--config', config, '--port', String(randomInt(49152,65536)), '--logfile', path.join(root, 'engine.log')], {
cwd:root, encoding:'utf8', timeout:90000, maxBuffer:8*1024*1024, windowsHide:true,
env:{...process.env, LUANTI_GAME_PATH:root, MINETEST_GAME_PATH:''},
});
const log = result.stdout + '\n' + result.stderr;
const rejected = log.split(/\r?\n/).filter(line=>/ignor|unsupported|disallowed|failed|error/i.test(line) && /hearth_.*\.(?:ogg|wav)/i.test(line));
const accepted = Object.keys(SOUND_PROFILES).filter(name=>new RegExp('Server: [a-f0-9]+ is '+name+'\\.ogg \\(').test(log));
if (result.status !== 0 || !log.includes('LAST_HEARTH_AUDIO_DISCOVERY_PASS') || rejected.length || accepted.length !== 15)
throw new Error('Native audio discovery failed: ' + JSON.stringify({status:result.status,error:result.error?.message,accepted,rejected,tail:log.slice(-8000)}));
console.log(JSON.stringify({engine:'Luanti 5.18.0',headless:true,acceptedOgg:accepted.length,rejectedSounds:rejected.length,
playbackVerified:false,marker:'LAST_HEARTH_AUDIO_DISCOVERY_PASS'}));
} finally { await fs.rm(root, {recursive:true,force:true}); }
}
// Reproduce sources or verify discovery only on explicit developer commands.
// Normal packaging/imports are read-only and need neither codec nor engine.
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
// Do not await at module scope: packageGame imports assets -> media too.
const action = process.argv[2] === '--encode-sounds' ? encodeSounds
: process.argv[2] === '--verify-engine-sounds' ? verifyEngineSounds : null;
if (!action) throw new Error('Expected --encode-sounds or --verify-engine-sounds.');
action().catch(error => { console.error(error); process.exitCode = 1; });
}
+62
View File
@@ -0,0 +1,62 @@
// Engine-independent, reproducible game-directory packaging; no downloads.
import * as fs from 'node:fs/promises';
import { constants } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { generateAssets } from './assets.mjs';
import { absolute, atomicDirectory, copyTree, disjoint, exists, json, relativeName, safePath, verifyManifest, writeManifest } from './files.mjs';
export const SOURCE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
export async function readRelease(source = SOURCE) {
await safePath(path.join(absolute(source), 'release.json'));
const release = JSON.parse(await fs.readFile(path.join(source, 'release.json'), 'utf8'));
if (release.format !== 1 || release.gameId !== 'last_hearth' || !/^\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?$/.test(release.version) ||
release.engine?.name !== 'Luanti' || release.engine?.version !== '5.18.0' || release.node !== '>=22' ||
release.packageFormat !== 1 || release.backupFormat !== 1) throw new Error('Unsupported release contract.');
return release;
}
export async function packageGame(destination, { source = SOURCE } = {}) {
source = absolute(source); destination = absolute(destination);
// dist/ within the repository is supported; no payload source may overlap it.
for (const entry of ['mods', 'fixtures', 'tests', 'scripts']) disjoint(path.join(source, entry), destination);
const release = await readRelease(source);
return atomicDirectory(destination, async stage => {
for (const name of ['game.conf', 'minetest.conf', 'settingtypes.txt', 'LICENSE.txt']) {
const filename = await safePath(path.join(source, name));
if (!(await fs.lstat(filename)).isFile()) throw new Error('Package input must be a regular file.');
await fs.copyFile(filename, path.join(stage, name), constants.COPYFILE_EXCL);
}
await copyTree(path.join(source, 'mods'), path.join(stage, 'mods'), {
exclude: new Set(['hearth/textures', 'hearth/models']),
});
for (const name of ['init', 'model', 'runtime', 'content', 'encounters']) {
if (!await exists(path.join(stage, 'mods/hearth', name + '.lua'))) throw new Error('Game payload is incomplete.');
}
// Generate from the copied inputs, not a changing worktree. Refuse links first.
await generateAssets(stage, stage);
await fs.mkdir(path.join(stage, 'fixtures'));
await safePath(path.join(source, 'tests/fixtures/scenarios.json'));
const scenarios = JSON.parse(await fs.readFile(path.join(source, 'tests/fixtures/scenarios.json'), 'utf8'));
await fs.writeFile(path.join(stage, 'fixtures/scenarios.json'), json(scenarios), { flag: 'wx' });
// Runtime fixture schemas are owned by the game agent; copy JSON contracts
// verbatim, including config.json and first-session/summer/winter fixtures.
for (const name of (await fs.readdir(path.join(source, 'tests/fixtures'))).sort()) {
if (name === 'scenarios.json' || !name.endsWith('.json')) continue;
relativeName(name);
const fixture = await safePath(path.join(source, 'tests/fixtures', name));
if (!(await fs.lstat(fixture)).isFile()) throw new Error('Fixture must be a regular JSON file.');
JSON.parse(await fs.readFile(fixture, 'utf8'));
await fs.copyFile(fixture, path.join(stage, 'fixtures', name), constants.COPYFILE_EXCL);
}
await fs.writeFile(path.join(stage, 'release.json'), json(release), { flag: 'wx' });
await writeManifest(stage, 'game');
await verifyPackage(stage);
});
}
export async function verifyPackage(directory) {
const manifest = await verifyManifest(directory, 'game');
await readRelease(directory);
return manifest;
}
+301
View File
@@ -0,0 +1,301 @@
// Offline, private M0 recovery evidence, NOT a public-readiness certification.
// Only copies are booted. Stop external engines first; locks/process discovery
// cannot prove that a remote machine has not opened a shared world directory.
// SHA256 is integrity evidence, not authentication or encryption. Runtime/backups
// must be on an access-controlled (preferably encrypted) volume, outside webroots.
import * as fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { createHash, randomUUID } from 'node:crypto';
import dgram from 'node:dgram';
import { absolute, disjoint, ensureDirectory, inventory, json, safePath, withLauncherLock } from './files.mjs';
import { assertWorldOffline, backupWorld, restoreWorld, verifyBackup } from './backup.mjs';
import { packageGame, SOURCE, verifyPackage } from './package.mjs';
import { engineExecutable, runtimeHome, validateEngine } from './launcher.mjs';
const exec = promisify(execFile);
const digest = value => createHash('sha256').update(json(value)).digest('hex');
const same = (left, right) => {
if (json(left) !== json(right)) throw new Error('Recovery SHA256 inventory mismatch.');
};
const privateWrite = (filename, value) => fs.writeFile(filename, value, { flag: 'wx', mode: 0o600 });
class RecoveryError extends Error {
constructor(category) { super(category); this.category = category; }
}
const failure = category => new RecoveryError(category);
async function assertNoEnginesOrWriters() {
// Fail closed on process-discovery errors. Never print command lines: they can
// contain private paths, locations or credentials belonging to other apps.
if (process.platform === 'win32') {
// Image names/PIDs only; no nested shell or private command-line discovery.
// The exclusive runtime lease serializes managed Node writers, not unrelated
// Node previews. External/shared-world writers must still be stopped by owner.
const executable = path.join(absolute(process.env.SystemRoot || 'C:\\Windows'), 'System32/tasklist.exe');
const { stdout } = await exec(executable, ['/fo', 'csv', '/nh'], {
shell: false, windowsHide: true, encoding: 'utf8', timeout: 15_000, maxBuffer: 4 * 1024 * 1024,
});
let rows = 0;
for (const line of stdout.trim().split(/\r?\n/)) {
const match = /^"([^"\r\n]+)","(\d+)",/.exec(line);
if (!match) throw failure('process-discovery-invalid');
rows++;
if (/^(?:luanti|minetest)(?:-?server)?\.exe$/i.test(match[1])) throw failure('engine-process-active');
}
if (!rows) throw failure('process-discovery-empty');
} else {
const { stdout } = await exec('/bin/ps', ['-eo', 'pid=,comm=,args='], {
shell: false, encoding: 'utf8', timeout: 10_000, maxBuffer: 4 * 1024 * 1024,
});
for (const line of stdout.split('\n')) {
const match = /^\s*(\d+)\s+(\S+)\s+(.*)$/.exec(line);
if (!match || Number(match[1]) === process.pid) continue;
if (/^(?:luanti|minetest)(?:-?server)?$/i.test(path.basename(match[2])) ||
(/^node(?:js)?$/i.test(path.basename(match[2])) && /scripts\/(?:run|backup|package|recovery)\.mjs/.test(match[3]))) {
throw new Error('Stop all Luanti engines and other Hearth tooling before recovery.');
}
}
}
}
function parseWorld(text) {
const settings = new Map();
for (const line of text.split(/\r?\n/)) {
if (!line.trim() || /^\s*#/.test(line)) continue;
const match = /^\s*([a-zA-Z0-9_]+)\s*=\s*(.*?)\s*$/.exec(line);
if (!match || settings.has(match[1])) throw new Error('Ambiguous world settings; recovery refused.');
settings.set(match[1], match[2]);
}
if (settings.get('gameid') !== 'last_hearth' ||
['backend', 'player_backend', 'auth_backend', 'mod_storage_backend'].some(key => settings.get(key) !== 'sqlite3')) {
throw new Error('Recovery requires an existing last_hearth world with local SQLite backends.');
}
return settings;
}
async function isolatedWorld(world, gameId) {
// Byte equality is checked BEFORE these copy-only changes. Do not execute old
// worldmods (including stale hearth_smoke), or inherit arbitrary world settings.
await assertWorldOffline(world);
parseWorld(await fs.readFile(path.join(world, 'world.mt'), 'utf8'));
await fs.rm(path.join(world, 'worldmods'), { recursive: true, force: true });
const mod = path.join(world, 'worldmods/hearth_recovery');
await ensureDirectory(mod);
await privateWrite(path.join(mod, 'mod.conf'), 'name = hearth_recovery\ndepends = hearth\ndescription = Private offline M0 recovery probe\n');
await fs.copyFile(await safePath(path.join(SOURCE, 'tests/recovery_probe.lua')), path.join(mod, 'probe.lua'));
await privateWrite(path.join(mod, 'init.lua'), 'dofile(core.get_modpath("hearth_recovery") .. "/probe.lua")("probe")\n');
await fs.writeFile(path.join(world, 'world.mt'), [
`gameid = ${gameId}`, 'backend = sqlite3', 'player_backend = sqlite3',
'auth_backend = sqlite3', 'mod_storage_backend = sqlite3',
'load_mod_hearth = true', 'load_mod_hearth_recovery = true', '',
].join('\n'), { mode: 0o600 });
}
async function availablePort() {
const socket = dgram.createSocket('udp4');
try {
await new Promise((resolve, reject) => {
socket.once('error', reject);
socket.bind(0, '127.0.0.1', resolve);
});
return socket.address().port;
} finally { socket.close(); }
}
async function boot(executable, stage, world, phase, token, env, gameId) {
await assertNoEnginesOrWriters();
await assertWorldOffline(world);
const config = path.join(stage, `recovery-${phase}.conf`);
await privateWrite(config, [
'hearth_environment = staging', 'hearth_latitude = 45', 'hearth_longitude = 0',
`hearth_recovery_phase = ${phase}`, `hearth_recovery_token = ${token}`,
`hearth_recovery_gameid = ${gameId}`,
'bind_address = 127.0.0.1', `port = ${await availablePort()}`, 'enable_ipv6 = false',
'max_users = 0', 'server_announce = false', 'enable_pvp = false',
'secure.enable_security = true', 'secure.trusted_mods =', 'secure.http_mods =',
'enable_mod_channels = false', 'time_speed = 0', 'dedicated_server_step = 0.05', '',
].join('\n'));
// Deliberately do not stream raw engine output. It can contain private player
// names, world positions, parser data and URLs. Keep it bounded in memory only;
// the private temporary logfile is deleted with the stage even after failures.
let result;
try {
result = await exec(executable, ['--server', '--world', world, '--gameid', gameId,
'--config', config, '--logfile', path.join(stage, `engine-${phase}.log`)], {
cwd: stage, env: { ...env, LUANTI_GAME_PATH: path.join(stage, 'games') },
shell: false, windowsHide: true, encoding: 'utf8', timeout: 90_000,
killSignal: 'SIGKILL', maxBuffer: 4 * 1024 * 1024,
});
} catch (error) {
const output = String(error.stdout || '') + '\n' + String(error.stderr || '');
throw failure(`${phase}-${probeFailure(output) || (error.killed ? 'engine-timeout' :
error.code === 'ENOENT' ? 'engine-not-found' : 'engine-exit')}`);
}
const output = result.stdout + '\n' + result.stderr;
const marker = `LAST_HEARTH_RECOVERY_PASS ${phase} ${token}`;
if (output.split(marker).length !== 2 || output.includes('LAST_HEARTH_RECOVERY_FAIL')) {
throw failure(`${phase}-${probeFailure(output) || 'shutdown-marker-missing'}`);
}
await assertWorldOffline(world);
const summary = JSON.parse(await fs.readFile(path.join(world, `m0-recovery-${phase}.json`), 'utf8'));
if (summary.format !== 1 || summary.phase !== phase || summary.token !== token ||
!Number.isSafeInteger(summary.existingKeys) || summary.existingKeys < 1 || summary.existingKeys > 4 ||
!Number.isSafeInteger(summary.legacyKeys) || summary.legacyKeys < 1 || summary.legacyKeys > 4 ||
!Number.isSafeInteger(summary.claimCount) || summary.claimCount < 0 ||
summary.noClients !== true || summary.fixture !== true || summary.spawn !== true ||
summary.claims !== true || summary.homes !== true) {
throw new Error('Recovery probe summary is incomplete.');
}
if (!Number.isSafeInteger(summary.verifiedAssertions) || summary.verifiedAssertions < 1) throw failure(`${phase}-assertion-count-missing`);
return summary;
}
function probeFailure(output) {
// Only accept a static assertion/checkpoint label, never parser strings,
// filenames, records, or arbitrary engine errors as a diagnostic label.
const match = /LAST_HEARTH_RECOVERY_FAIL (checkpoint-[a-z-]{1,96})(?=\s|$)/.exec(output);
return match ? `probe-${match[1]}` : undefined;
}
export async function recover({ env = process.env } = {}) {
let step = 'runtime-preflight';
try {
const runtime = await safePath(runtimeHome(env));
// HEARTH_HOME changes tooling storage, never which original world is selected.
if (!env.HEARTH_RECOVERY_SOURCE && !env.LOCALAPPDATA) {
throw new Error('Set HEARTH_RECOVERY_SOURCE or LOCALAPPDATA.');
}
const source = await safePath(absolute(env.HEARTH_RECOVERY_SOURCE ||
path.join(absolute(env.LOCALAPPDATA), 'last-hearth-runtime/worlds/last_hearth')));
const executable = engineExecutable(env, process.platform, runtime);
step = 'process-discovery';
await assertNoEnginesOrWriters();
step = 'engine-validation';
const engineVersion = await validateEngine(executable);
// Refuse to run against partially wired code. The Lua probe also checks the
// actual loaded API: this textual gate alone is not evidence of integration.
const init = await fs.readFile(path.join(SOURCE, 'mods/hearth/init.lua'), 'utf8');
if (!/hearth\.persistence\s*=/.test(init)) throw new Error('Recovery deferred: init must wire hearth.persistence first.');
step = 'runtime-lock';
return await withLauncherLock(runtime, async lease => {
step = 'source-offline-inventory';
await assertNoEnginesOrWriters();
const original = await assertWorldOffline(source);
parseWorld(await fs.readFile(path.join(source, 'world.mt'), 'utf8'));
for (const name of ['m0-recovery-expected.json', 'm0-recovery-fixture.json']) {
if (original.some(entry => entry.path === name)) throw new Error('Source already contains recovery instrumentation.');
}
const backups = path.join(runtime, 'backups');
disjoint(source, backups);
await ensureDirectory(backups);
const token = randomUUID();
// Installed games precede LUANTI_GAME_PATH in Luanti's search order.
// A unique disposable ID prevents an old installed game shadowing this
// verified package, without touching the installation or original world.
const gameId = `last_hearth_recovery_${token.replaceAll('-', '')}`;
const retained = path.join(backups, `m0-validation-${new Date().toISOString().replace(/[:.]/g, '-')}-${token}`);
const stage = await fs.mkdtemp(path.join(runtime, 'recovery-'));
await fs.chmod(stage, 0o700);
disjoint(source, stage);
const options = { offline: true, runtime, lease };
try {
step = 'original-backup';
// Retain the untouched original snapshot, even if a later boot fails.
await backupWorld(source, retained, options);
await verifyBackup(retained);
same(original, await inventory(path.join(retained, 'world')));
const first = path.join(stage, 'restored-first');
step = 'first-restore';
await verifyBackup(retained); // Supplied backup SHA256, BEFORE restore.
await restoreWorld(retained, first, options);
same(original, await inventory(first)); // Includes original player DB bytes.
await ensureDirectory(path.join(stage, 'games'));
const game = path.join(stage, 'games', gameId);
const inputs = await inventory(path.join(SOURCE, 'mods'));
step = 'game-package';
await packageGame(game);
same(inputs, await inventory(path.join(SOURCE, 'mods')));
const packaged = await verifyPackage(game);
// Instrument only the disposable package, before its real hearth init.
// This reads the engine's own ModStorageRef in the hearth mod context,
// capturing exact pre-migration bytes without Node SQLite or global mocks.
const gameInit = path.join(game, 'mods/hearth/init.lua');
await fs.writeFile(gameInit,
'dofile(core.get_worldpath() .. "/worldmods/hearth_recovery/probe.lua")("capture")\n' +
await fs.readFile(gameInit, 'utf8'), { mode: 0o600 });
await isolatedWorld(first, gameId);
step = 'seed-boot';
const seeded = await boot(executable, stage, first, 'seed', token, env, gameId);
step = 'second-backup-restore';
const afterFirst = await assertWorldOffline(first);
const secondBackup = path.join(stage, 'after-first-backup');
await backupWorld(first, secondBackup, options);
await verifyBackup(secondBackup);
same(afterFirst, await inventory(path.join(secondBackup, 'world')));
const second = path.join(stage, 'restored-second');
await restoreWorld(secondBackup, second, options);
same(afterFirst, await inventory(second));
step = 'restart-boot';
const restarted = await boot(executable, stage, second, 'restart', token, env, gameId);
if (seeded.existingKeys !== restarted.existingKeys || seeded.legacyKeys !== restarted.legacyKeys ||
seeded.claimCount !== restarted.claimCount) {
throw new Error('Restart migration evidence mismatch.');
}
step = 'final-integrity-verification';
await assertNoEnginesOrWriters();
same(original, await assertWorldOffline(source));
await verifyBackup(retained);
same(original, await inventory(path.join(retained, 'world')));
// No raw records, player names, filesystem paths, coordinates or URLs in
// the retained report. Credentials are neither generated nor required.
const report = {
format: 1, milestone: 'M2', publicReady: false, engineVersion,
sourceUnchanged: true, backupVerified: true, restoresByteExact: 2,
successfulEngineShutdowns: 2, noClients: true,
verifiedAssertions: { seed: seeded.verifiedAssertions, restart: restarted.verifiedAssertions,
total: seeded.verifiedAssertions + restarted.verifiedAssertions },
existingKeys: seeded.existingKeys, legacyKeys: seeded.legacyKeys, claimCount: seeded.claimCount,
migratedRecordsPreserved: true, immutableLegacyBackups: true,
savedSpawnVerified: true, claimsProtectionVerified: true, homesVerified: true,
playerAdapterFixture: true, nodeMetadataInventoryRestored: true, absoluteFireExpiry: true,
sourceFiles: original.filter(entry => entry.type === 'file').length,
sourceInventorySha256: digest(original),
packageFiles: packaged.entries.filter(entry => entry.type === 'file').length,
packageInventorySha256: digest(packaged.entries),
firstBootInventorySha256: digest(afterFirst),
};
// Never add files to the immutable snapshot, even transiently: interruption
// while publishing a report must not invalidate its exact SHA256 manifest.
step = 'report-publication';
await privateWrite(retained + '-report.json', json(report));
return report;
} finally {
// Never delete source, retained backup, or arbitrary shared paths. An
// engine child is synchronously reaped before this private stage is removed.
try { same(original, await assertWorldOffline(source)); }
finally {
await safePath(stage);
await fs.rm(stage, { recursive: true, force: true });
}
}
});
} catch (error) {
if (error instanceof RecoveryError) throw error;
const code = ['EEXIST', 'ENOENT', 'EACCES', 'EPERM', 'EBUSY', 'ETIMEDOUT'].includes(error.code) ? `-${error.code}` : '';
throw failure(step + code);
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
if (process.argv.length !== 2) throw new Error('Recovery accepts no arguments; use HEARTH_RECOVERY_SOURCE.');
const report = await recover();
process.stdout.write(json(report));
} catch (error) {
// Error objects/engine stderr can carry source paths and original save data.
process.stderr.write(`LAST_HEARTH_RECOVERY_FAIL: ${error instanceof RecoveryError ? error.category : 'invalid-invocation'}; original world was never launched.\n`);
process.exitCode = 1;
}
}
+234 -131
View File
@@ -1,39 +1,100 @@
#!/usr/bin/env node
// Original MIT-licensed launcher. Node built-ins only; never invokes a shell.
import { spawn } from 'node:child_process';
import { randomInt, randomUUID } from 'node:crypto';
import { constants } from 'node:fs';
import { createHash, randomInt, randomUUID } from 'node:crypto';
import * as fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { generateAssets } from './assets.mjs';
import { pathToFileURL } from 'node:url';
import { backupWorld, restoreWorld } from './backup.mjs';
import { absolute, atomicDirectory, copyTree, ensureDirectory, exists, safePath, withLauncherLock } from './files.mjs';
import { packageGame, SOURCE, verifyPackage } from './package.mjs';
import { coordinates, engineExecutable, environmentSettings, fixtureSettings, graphicsSettings, prepareCourse, runtimeHome, snapshotBeforePlay, validateEngine } from './launcher.mjs';
import { isTerrainSetting, terrainSettings } from './terrain.mjs';
const source = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const source = SOURCE;
const command = process.argv[2];
const excluded = new Set(['scripts', 'tests', '.git']);
const marker = '.last-hearth-launcher-managed';
const TEST_TIMEOUT = 90_000;
const PLAY_TIMEOUT = 8 * 60 * 60 * 1000;
const EMBEDDED_MARKER = '.last-hearth-embedded-session.json';
async function exists(filename) {
try { await fs.access(filename, constants.F_OK); return true; }
catch (error) { if (error.code === 'ENOENT') return false; throw error; }
// JSON escapes are not Lua escapes (notably \uXXXX). Encode UTF-8 bytes with
// fixed-width decimal escapes, including quotes, backslashes and control bytes.
const luaString = value => '"' + [...Buffer.from(value)].map(byte => `\\${String(byte).padStart(3, '0')}`).join('') + '"';
async function pinPackage(game, gameId, verified, requireCourse = false) {
const manifest = verified ?? await verifyPackage(game);
const release = JSON.parse(await fs.readFile(path.join(game, 'release.json'), 'utf8'));
const digest = createHash('sha256').update(JSON.stringify(manifest)).digest('hex');
const token = randomUUID();
const marker = `LAST_HEARTH_PACKAGE_LOADED ${token} ${release.version} ${digest}`;
const init = path.join(game, 'mods/hearth/init.lua');
const original = await fs.readFile(init);
// Only the private runtime copy is instrumented. The exact payload was verified
// above; path checks run BEFORE its code and the marker follows mod loading.
// A stale installed game or a worldmod overriding hearth cannot emit this token.
const normalize = value => value.replaceAll('\\', '/').replace(/\/+$/, '');
await fs.writeFile(init, Buffer.concat([Buffer.from([
'do',
' local function normalized(p) return (p:gsub("\\\\", "/"):gsub("/+$", "")) end',
' local info = core.get_game_info()',
` assert(info.id == ${luaString(gameId)} and normalized(info.path) == ${luaString(normalize(game))}, "Launcher game path/id mismatch")`,
` assert(normalized(core.get_modpath("hearth")) == ${luaString(normalize(path.join(game, 'mods/hearth')))}, "Launcher hearth path mismatch")`,
' core.register_on_mods_loaded(function()',
...(requireCourse ? [
' assert(hearth.course and type(hearth.course.report) == "function", "Course module must be wired by the game bootstrap")',
' assert(hearth.storage:get_string("m1_course_world_nonce") == core.settings:get("hearth_m1_course_nonce"), "Course must enroll before clock/players on first load")',
] : []),
` core.log("action", ${luaString(marker)})`,
' end)',
'end', '',
].join('\n')), original]));
return marker;
}
// copyFile avoids fs.cp's realpath failures on Windows SMB shares. Refuse
// symlinks/junctions rather than following them out of the selected source.
async function copyTree(from, to, skip = new Set()) {
const stat = await fs.lstat(from);
if (stat.isSymbolicLink()) throw new Error(`Refusing symbolic link: ${from}`);
if (stat.isDirectory()) {
await fs.mkdir(to, { recursive: true });
for (const entry of await fs.readdir(from)) {
if (!skip.has(entry)) await copyTree(path.join(from, entry), path.join(to, entry), skip);
}
} else if (stat.isFile()) {
await fs.copyFile(from, to);
} else {
throw new Error(`Not a regular file or directory: ${from}`);
async function embeddedOwner(world, runtime) {
const game = await safePath(path.join(world, 'game'));
if (!await exists(game)) return undefined;
const marker = await safePath(path.join(game, EMBEDDED_MARKER));
if (!(await fs.lstat(game)).isDirectory() || !await exists(marker) || !(await fs.lstat(marker)).isFile() ||
(await fs.stat(marker)).size > 4096) throw new Error('Refusing to overwrite an unmanaged embedded world game.');
const owner = JSON.parse(await fs.readFile(marker, 'utf8'));
if (owner.format !== 1 || owner.owner !== 'last-hearth-runner' || owner.world !== world || owner.runtime !== runtime ||
typeof owner.token !== 'string' || !/^[0-9a-f-]{36}$/.test(owner.token)) {
throw new Error('Refusing an unrecognized embedded world game marker.');
}
return owner;
}
async function playGame(world, runtime, packaged, action) {
// Embedded games take precedence in findWorldSubgame(), including the GUI
// client, which otherwise ignores --gameid for an existing world. Never alias
// or rewrite world.mt: its logical last_hearth identity survives even SIGKILL.
const text = await fs.readFile(await safePath(path.join(world, 'world.mt')), 'utf8');
const ids = [...text.matchAll(/^\s*gameid\s*=\s*([^\r\n]*?)\s*$/gm)];
if (ids.length !== 1 || ids[0][1] !== 'last_hearth') throw new Error('Play requires logical world gameid last_hearth.');
const game = path.join(world, 'game');
if (await embeddedOwner(world, runtime)) {
// A hard-killed launcher can leave its private package behind. Snapshot was
// taken already; preserve rather than delete it (including any added files).
await fs.rename(game, path.join(runtime, `preserved-embedded-game-${randomUUID()}`));
}
const token = randomUUID();
let verified;
await atomicDirectory(game, async directory => {
await copyTree(packaged, directory, { intoEmpty: true });
verified = await verifyPackage(directory);
await fs.writeFile(path.join(directory, EMBEDDED_MARKER), JSON.stringify({
format: 1, owner: 'last-hearth-runner', token, world, runtime,
}), { flag: 'wx', mode: 0o600 });
});
try {
// Keep ownership present continuously, including if instrumentation is
// interrupted. Only this verified private copy gains launcher-only files.
const marker = await pinPackage(game, 'last_hearth', verified);
await action(marker);
} finally {
if ((await embeddedOwner(world, runtime))?.token !== token) throw new Error('Embedded game ownership changed; cleanup refused.');
await fs.rm(game, { recursive: true, force: true });
}
}
@@ -41,7 +102,7 @@ async function luaFiles(directory, prefix = '') {
const result = [];
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
if (entry.isSymbolicLink()) throw new Error(`Refusing symbolic link: ${entry.name}`);
if (entry.name === '.git' || entry.name === 'scripts') continue;
if (['.git', 'scripts', 'dist', 'node_modules'].includes(entry.name) || entry.name.startsWith('.nfs')) continue;
const relative = prefix + entry.name;
if (entry.isDirectory()) result.push(...await luaFiles(path.join(directory, entry.name), relative + '/'));
else if (entry.isFile() && entry.name.endsWith('.lua')) result.push(relative);
@@ -49,36 +110,21 @@ async function luaFiles(directory, prefix = '') {
return result.sort();
}
function coordinates() {
const latitude = process.env.HEARTH_LATITUDE;
const longitude = process.env.HEARTH_LONGITUDE;
if (latitude === undefined && longitude === undefined) return [];
if (latitude === undefined || longitude === undefined) {
throw new Error('Provide both HEARTH_LATITUDE and HEARTH_LONGITUDE, or neither.');
}
const parse = (value, limit, name) => {
if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(value)) throw new Error(`Invalid ${name}.`);
const number = Number(value);
if (!Number.isFinite(number) || Math.abs(number) > limit) throw new Error(`${name} out of range.`);
return number;
};
return [
`hearth_latitude = ${parse(latitude, 90, 'latitude')}`,
`hearth_longitude = ${parse(longitude, 180, 'longitude')}`,
];
}
function run(executable, args, cwd, timeout, successMarker) {
function run(executable, args, cwd, timeout, successMarker, games) {
return new Promise((resolve, reject) => {
const child = spawn(executable, args, {
cwd, shell: false, windowsHide: command !== 'play', stdio: ['ignore', 'pipe', 'pipe'],
cwd, shell: false, windowsHide: !['play', 'course'].includes(command), stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, ...(games ? { LUANTI_GAME_PATH: games, MINETEST_GAME_PATH: '' } : {}) },
});
let tail = '', timedOut = false, interrupted = false;
const pending = new Set([successMarker].flat().filter(Boolean));
let timedOut = false, interrupted = false;
const capture = stream => {
let tail = '';
stream.setEncoding('utf8');
stream.on('data', text => {
// Bound memory even if the engine emits excessive diagnostics.
tail = (tail + text).slice(-1024 * 1024);
for (const marker of pending) if (tail.includes(marker)) pending.delete(marker);
process.stdout.write(text);
});
};
@@ -99,60 +145,44 @@ function run(executable, args, cwd, timeout, successMarker) {
if (timedOut) reject(new Error(`Process exceeded ${timeout / 1000}s timeout.`));
else if (interrupted) reject(new Error('Interrupted; child process stopped.'));
else if (code !== 0) reject(new Error(`Process failed (exit ${code}, signal ${signal || 'none'}).`));
else if (successMarker && !tail.includes(successMarker)) reject(new Error('Engine exited without the required success marker.'));
else if (pending.size) reject(new Error('Engine exited without the required package-load/test success marker.'));
else resolve();
});
});
}
async function stageGame(engine) {
for (const required of ['game.conf', 'mods/hearth/init.lua', 'mods/hearth/model.lua', 'mods/hearth/runtime.lua', 'mods/hearth/content.lua']) {
if (!await exists(path.join(source, required))) throw new Error(`Game is incomplete: missing ${required}.`);
export async function writeWorld(world, config, location, extra = [], environment = 'production', gameId = 'last_hearth', env = process.env) {
const graphics = graphicsSettings(env);
const settings = environmentSettings(environment, [...location, ...extra]);
const graphicsKeys = new Set(graphics.map(line => line.split('=')[0].trim()));
if (settings.some(line => graphicsKeys.has(line.split('=')[0].trim()))) {
throw new Error('Graphics settings are launcher-managed; use HEARTH_VIEW_DISTANCE.');
}
const games = path.join(engine, 'games');
await fs.mkdir(games, { recursive: true });
const destination = path.join(games, 'last_hearth');
if (await exists(destination)) {
if ((await fs.lstat(destination)).isSymbolicLink() || !await exists(path.join(destination, marker))) {
throw new Error(`Refusing to overwrite an unmanaged game: ${destination}`);
}
}
const stage = await fs.mkdtemp(path.join(games, '.last-hearth-stage-'));
const backup = path.join(games, `.last-hearth-backup-${randomUUID()}`);
let backedUp = false;
try {
await copyTree(source, stage, excluded);
await generateAssets(stage, source);
await fs.writeFile(path.join(stage, marker), 'Managed copy; edit the repository, not this directory.\n');
if (await exists(destination)) { await fs.rename(destination, backup); backedUp = true; }
try { await fs.rename(stage, destination); }
catch (error) { if (backedUp) await fs.rename(backup, destination); throw error; }
if (backedUp) await fs.rm(backup, { recursive: true, force: true });
} finally {
await fs.rm(stage, { recursive: true, force: true });
}
}
async function writeWorld(world, config, location, extra = []) {
await fs.mkdir(world, { recursive: true });
await ensureDirectory(world);
const worldFile = path.join(world, 'world.mt');
if (!await exists(worldFile)) {
// Decide before writing world.mt. Saved mapgen parameters are engine-owned;
// even a legacy/orphan map_meta.txt opts out of creation-time overrides.
const worldExists = await exists(worldFile);
const newWorld = !worldExists && !await exists(path.join(world, 'map_meta.txt'));
if (!worldExists) {
await fs.writeFile(worldFile, [
'gameid = last_hearth', 'backend = sqlite3', 'player_backend = sqlite3',
`gameid = ${gameId}`, 'backend = sqlite3', 'player_backend = sqlite3',
'auth_backend = sqlite3', 'mod_storage_backend = sqlite3', 'load_mod_hearth = true', '',
].join('\n'), { flag: 'wx' });
}
await safePath(config);
await fs.writeFile(config, [
'enable_damage = true', 'enable_pvp = false', 'creative_mode = false',
'bind_address = 127.0.0.1', 'server_announce = false', 'enable_ipv6 = false',
'time_speed = 0', 'movement_speed_walk = 4', 'movement_speed_fast = 6',
'movement_speed_jump = 6.5', 'secure.enable_security = true',
'mg_name = v7', 'fixed_map_seed = 731104',
...location, ...extra, '',
].join('\n'), { mode: 0o600 });
...graphics,
...terrainSettings(newWorld),
...settings.filter(line => newWorld || !isTerrainSetting(line)), '',
].join('\n'), { flag: 'wx', mode: 0o600 });
}
async function engineTest(mode, runtime, engine, executable, location) {
async function engineTest(mode, runtime, games, executable, location, gameId, packageMarker, options = {}) {
const temporary = await fs.mkdtemp(path.join(runtime, `${mode}-`));
try {
const world = path.join(temporary, 'world');
@@ -178,11 +208,13 @@ async function engineTest(mode, runtime, engine, executable, location) {
await writeWorld(world, config, testLocation, [
'load_mod_hearth_smoke = true', `hearth_launcher_mode = ${mode}`,
`hearth_smoke_token = ${token}`, 'max_users = 0',
]);
...(Number.isSafeInteger(options.seed) && options.seed >= 0 ? [`fixed_map_seed = ${options.seed}`] : []),
...await fixtureSettings(options.fixture === false ? undefined : process.env.HEARTH_FIXTURE, 'staging'),
], 'staging', gameId);
await run(executable, [
'--server', '--world', world, '--gameid', 'last_hearth', '--config', config,
'--server', '--world', world, '--gameid', gameId, '--config', config,
'--port', String(randomInt(49152, 65536)), '--logfile', path.join(temporary, 'engine.log'),
], engine, TEST_TIMEOUT, `LAST_HEARTH_${mode.toUpperCase()}_PASS ${token}`);
], runtime, TEST_TIMEOUT, [packageMarker, `LAST_HEARTH_${mode.toUpperCase()}_PASS ${token}`], games);
} finally {
await fs.rm(temporary, { recursive: true, force: true });
}
@@ -191,13 +223,14 @@ async function engineTest(mode, runtime, engine, executable, location) {
async function standaloneTests(runtime) {
const candidates = process.env.HEARTH_LUA
? [process.env.HEARTH_LUA]
: ['luajit.exe', 'lua.exe', 'bin/luajit.exe', 'bin/lua.exe'].map(name => path.join(runtime, name));
: (process.platform === 'win32' ? ['luajit.exe', 'lua.exe', 'bin/luajit.exe', 'bin/lua.exe']
: ['luajit', 'lua', 'bin/luajit', 'bin/lua']).map(name => path.join(runtime, name));
let lua;
for (const candidate of candidates) {
if (!path.isAbsolute(candidate) || path.extname(candidate).toLowerCase() !== '.exe') {
throw new Error('HEARTH_LUA must be an absolute path to a local Lua executable (.exe).');
if (!path.isAbsolute(candidate)) {
throw new Error('HEARTH_LUA must be an absolute path to a local Lua executable.');
}
if (await exists(candidate)) { lua = candidate; break; }
if (await exists(candidate)) { lua = await safePath(candidate); break; }
}
if (!lua) throw new Error('Standalone Lua unavailable: engine checks passed, but unit tests were NOT run. Install Lua 5.1+/LuaJIT locally and set HEARTH_LUA to its absolute executable path.');
const specs = (await fs.readdir(path.join(source, 'tests'))).filter(name => name.endsWith('_spec.lua')).sort();
@@ -210,48 +243,118 @@ async function standaloneTests(runtime) {
}
}
async function main() {
if (!['check', 'smoke', 'play', 'test'].includes(command) || process.argv.length !== 3) {
throw new Error('Usage: node scripts/run.mjs <check|smoke|play|test>');
async function launchCourse(stage, runtime, games, packaged, gameId, executable, headless) {
// No original world, snapshot, restored copy, or inherited location/fixture.
// Every invocation gets a new stage/course, marker and UUID, removed on exit.
const { world, nonce, settings } = await prepareCourse(stage, { environment: headless ? 'staging' : 'local' });
const extra = [...settings];
if (headless) {
const probe = await safePath(path.join(source, 'tests/course_engine.lua'));
if (!await exists(probe)) throw new Error('test:course requires the main-owned tests/course_engine.lua native probe; no fake player or exported build API is assumed.');
const mod = path.join(world, 'worldmods/hearth_course_probe');
await fs.mkdir(mod, { recursive: true });
await fs.copyFile(probe, path.join(mod, 'init.lua'));
await fs.writeFile(path.join(mod, 'mod.conf'), 'name = hearth_course_probe\ndepends = hearth\n', { flag: 'wx' });
// Probe contract: native API/enrollment checks, request_shutdown on success,
// and LAST_HEARTH_COURSE_PASS <hearth_smoke_token>. Generation requires live
// /course consent; course.report is the only exported API, not course.build.
extra.push('load_mod_hearth_course_probe = true', 'hearth_launcher_mode = course',
`hearth_smoke_token = ${nonce}`, 'max_users = 0');
}
if (process.platform !== 'win32' || !process.env.LOCALAPPDATA || !path.isAbsolute(process.env.LOCALAPPDATA)) {
throw new Error('This launcher requires Windows and an absolute LOCALAPPDATA path.');
}
const location = coordinates();
const runtime = path.join(process.env.LOCALAPPDATA, 'last-hearth-runtime');
const engine = path.join(runtime, 'luanti-5.18.0-win64');
const executable = path.join(engine, 'bin', 'luanti.exe');
if (!await exists(executable)) throw new Error(`Official Luanti 5.18.0 runtime not found: ${executable}`);
const lockPath = path.join(runtime, 'launcher.lock');
let lock;
try { lock = await fs.open(lockPath, 'wx', 0o600); }
catch (error) {
if (error.code === 'EEXIST') throw new Error('Another launcher may be active. If it crashed, close its engine before removing LOCALAPPDATA/last-hearth-runtime/launcher.lock.');
throw error;
}
try {
await lock.writeFile(`${process.pid}\n`);
await stageGame(engine);
if (command === 'play') {
const world = path.join(runtime, 'worlds', 'last_hearth');
const config = path.join(runtime, 'last-hearth-play.conf');
await writeWorld(world, config, location);
await run(executable, [
'--go', '--world', world, '--gameid', 'last_hearth', '--config', config,
'--port', String(randomInt(49152, 65536)), '--logfile', path.join(runtime, 'last-hearth-play.log'),
], engine, PLAY_TIMEOUT);
} else if (command === 'test') {
await engineTest('check', runtime, engine, executable, location);
await engineTest('smoke', runtime, engine, executable, location);
if (process.env.HEARTH_LUA) await standaloneTests(runtime);
console.log('LAST_HEARTH_TEST_PASS: real-engine integration and isolated model/content suites completed.');
} else {
await engineTest(command, runtime, engine, executable, location);
}
} finally {
await lock.close();
await fs.unlink(lockPath);
const config = path.join(stage, 'course.conf');
await writeWorld(world, config, [], extra, headless ? 'staging' : 'local', gameId);
const packageMarker = await pinPackage(packaged, gameId, undefined, true);
if (!headless) {
console.log('Disposable summer course: use /course after arrival to generate and receive the kit. /course retry resets kit/needs/timer; /course report shows server step intervals and Lua callback wall duration, NOT FPS or CPU profiling. No automatic opponents. Original play world is not opened.');
}
await run(executable, [
headless ? '--server' : '--go', '--world', world, '--gameid', gameId, '--config', config,
'--port', String(randomInt(49152, 65536)), '--logfile', path.join(stage, 'course.log'),
], runtime, headless ? TEST_TIMEOUT : PLAY_TIMEOUT,
headless ? [packageMarker, `LAST_HEARTH_COURSE_PASS ${nonce}`] : packageMarker, games);
}
main().catch(error => { console.error(`Last Hearth: ${error.message}`); process.exitCode = 1; });
async function main() {
if (Number(process.versions.node.split('.')[0]) < 22) throw new Error('Node.js 22+ is required.');
const args = process.argv.slice(3);
if (command === 'package' && args.length <= 1) {
const destination = args.length ? absolute(args[0]) : path.join(source, 'dist/last_hearth');
if (!args.length) await ensureDirectory(path.dirname(destination));
await packageGame(destination);
console.log('LAST_HEARTH_PACKAGE_PASS: verified game directory and SHA256 manifest.');
return;
}
if (command === 'verify' && args.length === 1) {
await verifyPackage(absolute(args[0]));
console.log('LAST_HEARTH_PACKAGE_VERIFY_PASS');
return;
}
if (['backup', 'restore'].includes(command) && args.length === 3 && args[2] === '--offline') {
const options = { offline: true, runtime: runtimeHome() };
await (command === 'backup' ? backupWorld : restoreWorld)(absolute(args[0]), absolute(args[1]), options);
console.log(`LAST_HEARTH_${command.toUpperCase()}_PASS`);
return;
}
if (!['check', 'smoke', 'play', 'test', 'test:seeds', 'course', 'test:course'].includes(command) || args.length) {
throw new Error('Usage: node scripts/run.mjs <check|smoke|play|test|test:seeds|course|test:course|package [absolute-new-folder]|verify <absolute-package>|backup <absolute-world> <absolute-new-backup> --offline|restore <absolute-backup> <absolute-new-world> --offline>. OFFLINE: stop ALL external engines; lock checks cannot prove remote idleness. Backups contain private data: use a protected/encrypted volume.');
}
const course = ['course', 'test:course'].includes(command);
// Reject malformed graphics input before engine execution, staging or backups.
graphicsSettings();
const location = course || command === 'test:seeds' ? [] : coordinates();
const runtime = runtimeHome();
const executable = engineExecutable(process.env, process.platform, runtime);
// Fail before staging or touching worlds when the engine is absent/wrong.
await validateEngine(executable);
await withLauncherLock(runtime, async lease => {
// Never install into or overwrite the user's engine/game directories.
const stage = await fs.mkdtemp(path.join(runtime, 'session-'));
try {
const games = path.join(stage, 'games');
await fs.mkdir(games);
// Installed/share games precede LUANTI_GAME_PATH; never reuse their ID.
const gameId = `last_hearth_session_${randomUUID().replaceAll('-', '')}`;
const packaged = path.join(games, gameId);
await packageGame(packaged);
if (course) {
await launchCourse(stage, runtime, games, packaged, gameId, executable, command === 'test:course');
} else if (command === 'play') {
const fixture = await fixtureSettings(process.env.HEARTH_FIXTURE, 'local');
const world = path.join(runtime, 'worlds', 'last_hearth');
await embeddedOwner(world, runtime); // Refuse unmanaged content before touching the world.
if (await snapshotBeforePlay(runtime, lease)) {
console.log('LAST_HEARTH_PRELAUNCH_BACKUP_PASS: full snapshot retained outside the game package.');
}
const config = path.join(stage, 'play.conf');
await writeWorld(world, config, location, fixture, 'local');
await playGame(world, runtime, packaged, packageMarker => run(executable, [
'--go', '--world', world, '--gameid', gameId, '--config', config,
'--port', String(randomInt(49152, 65536)), '--logfile', path.join(stage, 'play.log'),
], runtime, PLAY_TIMEOUT, packageMarker, games));
} else if (command === 'test:seeds') {
const seeds = [731104, 19027, 2026, 987654321, 271828, 314159, 42, 8675309];
const packageMarker = await pinPackage(packaged, gameId);
await engineTest('check', runtime, games, executable, location, gameId, packageMarker);
for (const seed of seeds) {
await engineTest('seed', runtime, games, executable, location, gameId, packageMarker,
{seed, fixture: false});
}
console.log(`LAST_HEARTH_SEED_TEST_PASS: ${seeds.length} fixed seeds, required starting resources and safe arrival surfaces.`);
} else {
const packageMarker = await pinPackage(packaged, gameId);
if (command === 'test') {
await engineTest('check', runtime, games, executable, location, gameId, packageMarker);
await engineTest('smoke', runtime, games, executable, location, gameId, packageMarker);
if (process.env.HEARTH_LUA) await standaloneTests(runtime);
console.log('LAST_HEARTH_TEST_PASS: real-engine integration and isolated model/content suites completed.');
} else {
await engineTest(command, runtime, games, executable, location, gameId, packageMarker);
}
}
} finally { await fs.rm(stage, { recursive: true, force: true }); }
});
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
main().catch(error => { console.error(`Last Hearth: ${error.message}`); process.exitCode = 1; });
}
+29
View File
@@ -0,0 +1,29 @@
// Original v7 preset: creation-time inputs, never a saved-world migration.
// Keep in sync with game defaults; map_meta.txt belongs exclusively to Luanti.
const NEW_WORLD = Object.freeze([
'mg_name = v7', 'fixed_map_seed = 731104', 'water_level = 1',
'mapgen_limit = 4096',
'mg_flags = caves,nodungeons,light,decorations,biomes,ores',
'mgv7_spflags = mountains,ridges,nofloatlands,caverns',
'mgv7_mount_zero_level = 0',
'mgv7_np_terrain_base = 12, 45, (384, 384, 384), 82341, 5, 0.6, 2.0',
'mgv7_np_terrain_alt = 12, 28, (384, 384, 384), 5934, 5, 0.6, 2.0',
'mgv7_np_terrain_persist = 0.6, 0.1, (1200, 1200, 1200), 539, 3, 0.6, 2.0',
'mgv7_np_height_select = -8, 16, (320, 320, 320), 4213, 6, 0.7, 2.0',
'mgv7_np_filler_depth = 0, 1.2, (150, 150, 150), 261, 3, 0.7, 2.0',
'mgv7_np_mount_height = 250, 100, (700, 700, 700), 72449, 3, 0.6, 2.0',
'mgv7_np_mountain = -0.6, 1, (180, 280, 180), 5333, 5, 0.63, 2.0',
'mgv7_np_ridge_uwater = 0, 1, (700, 700, 700), 85039, 5, 0.6, 2.0',
'mgv7_np_ridge = 0, 1, (100, 100, 100), 6467, 4, 0.75, 2.0',
'mg_biome_np_heat = 50, 50, (256, 256, 256), 5349, 3, 0.5, 2.0',
'mg_biome_np_humidity = 50, 50, (256, 256, 256), 842, 3, 0.5, 2.0',
]);
export function terrainSettings(newWorld) {
if (typeof newWorld !== 'boolean') throw new TypeError('newWorld must be a boolean.');
return newWorld ? [...NEW_WORLD] : [];
}
export function isTerrainSetting(line) {
return /^\s*(?:mg\w*|fixed_map_seed|water_level|mapgen_limit)\s*=/.test(line);
}
+355
View File
@@ -0,0 +1,355 @@
// Explicit maintenance only: importing this module never uses the network or writes.
import * as fs from 'node:fs/promises';
import { constants } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { safePath } from './files.mjs';
const ROOT = fileURLToPath(new URL('../', import.meta.url));
const CATALOGUE = path.join(ROOT, 'mods/hearth/botany.lua');
const OUTPUT = path.join(ROOT, 'mods/hearth/wiki_articles.lua');
export const LIMITS = Object.freeze({ species: 80, batch: 10, concurrency: 2,
responseBytes: 2 * 1024 * 1024, catalogueBytes: 128 * 1024,
characters: 8000, requestMs: 15000, totalMs: 90000, redirects: 2 });
export const USER_AGENT = 'LastHearthSpeciesBundle/1.0 (offline educational game references; explicit manual maintenance)';
const ORIGIN = 'https://en.wikipedia.org';
const LICENSE = 'CC BY-SA 4.0';
const LICENSE_URL = 'https://creativecommons.org/licenses/by-sa/4.0/';
const NOTE_LICENSE = 'CC0 1.0';
const NOTE_LICENSE_URL = 'https://creativecommons.org/publicdomain/zero/1.0/';
const HEADER = '-- Generated by scripts/wiki-species.mjs; Wikipedia contributors, CC BY-SA 4.0.\n'
+ '-- Plaintext introduction excerpts, not full articles. See each revision/history and docs/species-references.md.\n';
const FIELDS = Object.freeze(['latin', 'title', 'url', 'pageid', 'revision', 'revision_url',
'history_url', 'revision_timestamp', 'retrieved', 'text', 'kind', 'excerpt', 'truncated',
'attribution', 'license', 'license_url', 'changes']);
function requireValue(ok, message) { if (!ok) throw new Error(message); }
function latinName(value) {
requireValue(typeof value === 'string' && value.length <= 100 &&
/^[A-Z][a-z]+ [a-z]+(?:-[a-z]+)*$/.test(value), 'Invalid Latin name (not a URL or query).');
return value;
}
function validateCatalogue(records) {
requireValue(Array.isArray(records) && records.length > 0 && records.length <= LIMITS.species, 'Catalogue scope must be 1..80.');
const ids = new Set(), names = new Set();
for (const r of records) {
requireValue(r && typeof r.id === 'string' && /^[a-z][a-z_]{0,39}$/.test(r.id) &&
!['constructor', 'prototype', '__proto__'].includes(r.id) && !ids.has(r.id), 'Invalid or duplicate species ID.');
latinName(r.latin);
requireValue(!names.has(r.latin), 'Duplicate Latin name.');
ids.add(r.id); names.add(r.latin);
}
return records;
}
// Parse only literal leading add(id,common,latin,...) rows. Never evaluate Lua.
export function parseCatalogue(source) {
requireValue(typeof source === 'string' && Buffer.byteLength(source) <= LIMITS.catalogueBytes, 'Catalogue too large.');
const rows = source.split(/\r?\n/).filter(line => /^\s*add\(/.test(line));
const records = rows.map(line => {
const m = /^\s*add\("([a-z_]+)","[^"\\\r\n]{1,100}","([^"\\\r\n]+)",/.exec(line);
requireValue(m, 'Unsupported catalogue literal.');
return { id: m[1], latin: m[2] };
});
requireValue(records.length === LIMITS.species, 'Expected exactly 80 botany catalogue rows.');
return validateCatalogue(records);
}
export function apiURL(names) {
requireValue(Array.isArray(names) && names.length > 0 && names.length <= LIMITS.batch, 'Invalid batch size.');
names.forEach(latinName);
const u = new URL('/w/api.php', ORIGIN);
u.search = new URLSearchParams({ action: 'query', format: 'json', formatversion: '2',
prop: 'extracts|info|revisions|pageprops', exintro: '1', explaintext: '1',
inprop: 'url', rvprop: 'ids|timestamp', redirects: '1', titles: names.join('|'), maxlag: '5' });
return u;
}
export function allowedURL(value) {
const u = new URL(value);
requireValue(u.origin === ORIGIN && u.protocol === 'https:' && !u.username && !u.password &&
!u.hash && !u.port, 'Only credential-free HTTPS en.wikipedia.org URLs are allowed.');
return u;
}
function allowedRequest(value, expected) {
const u = allowedURL(value);
requireValue(u.pathname === '/w/api.php' && u.search === expected.search, 'Unexpected API redirect or query.');
return u;
}
function abortable(promise, signal) {
if (signal.aborted) return Promise.reject(signal.reason);
return new Promise((resolve, reject) => {
const abort = () => reject(signal.reason);
signal.addEventListener('abort', abort, { once: true });
Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
});
}
async function boundedJSON(response, signal) {
requireValue(/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type') || ''), 'Expected JSON response.');
const length = response.headers.get('content-length');
requireValue(length === null || (/^\d+$/.test(length) && Number(length) <= LIMITS.responseBytes), 'Response exceeds byte limit.');
requireValue(response.body && typeof response.body.getReader === 'function', 'Missing response stream.');
const reader = response.body.getReader(), chunks = [];
let bytes = 0, done = false;
try {
while (true) {
const part = await abortable(reader.read(), signal);
if (part.done) { done = true; break; }
bytes += part.value.byteLength;
requireValue(bytes <= LIMITS.responseBytes, 'Response exceeds byte limit.');
chunks.push(part.value);
}
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)));
} finally {
if (!done) void reader.cancel().catch(() => {});
reader.releaseLock();
}
}
export async function fetchBatch(names, { fetchImpl = globalThis.fetch, signal: parent } = {}) {
const expected = apiURL(names);
const signal = AbortSignal.any([AbortSignal.timeout(LIMITS.requestMs), ...(parent ? [parent] : [])]);
let u = expected;
for (let hop = 0; hop <= LIMITS.redirects; hop++) {
allowedRequest(u, expected);
signal.throwIfAborted();
const pending = Promise.resolve(fetchImpl(u.href, { redirect: 'manual', credentials: 'omit',
referrerPolicy: 'no-referrer', signal, headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' } })).then(response => {
if (signal.aborted) {
void response.body?.cancel().catch(() => {});
signal.throwIfAborted();
}
return response;
});
const response = await abortable(pending, signal);
if (response.url) allowedRequest(response.url, expected);
if ([301, 302, 303, 307, 308].includes(response.status)) {
void response.body?.cancel().catch(() => {});
requireValue(hop < LIMITS.redirects && response.headers.get('location'), 'Too many or invalid redirects.');
u = allowedRequest(new URL(response.headers.get('location'), u).href, expected);
continue;
}
if (response.status !== 200) {
void response.body?.cancel().catch(() => {});
throw new Error(`Wikipedia HTTP ${response.status}; no cache written.`);
}
return boundedJSON(response, signal);
}
}
function positiveInteger(n, label) {
requireValue(Number.isSafeInteger(n) && n > 0, `Invalid ${label}.`);
return n;
}
function timestamp(s) {
requireValue(typeof s === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{3})?Z$/.test(s) &&
Number.isFinite(Date.parse(s)) && new Date(s).toISOString() === s.replace(/Z$/, s.includes('.') ? 'Z' : '.000Z'), 'Invalid timestamp.');
return s;
}
export function excerpt(text) {
requireValue(typeof text === 'string' && text.trim().length > 30 &&
!/[\x00-\x08\x0b-\x1f\x7f\u202a-\u202e\u2066-\u2069]/u.test(text) &&
!/<\/?[A-Za-z][^>]*>|\[\[|\]\]/u.test(text), 'Missing or non-plaintext introduction.');
text = text.trim();
const points = Array.from(text);
if (points.length <= LIMITS.characters) return { text, truncated: false };
const head = points.slice(0, LIMITS.characters).join('');
// Keep the copied prefix verbatim; prefer paragraph, sentence, then word boundary.
const paragraph = head.lastIndexOf('\n');
const sentences = [...head.matchAll(/[.!?](?=\s)/g)];
let end = paragraph >= head.length / 2 ? paragraph : (sentences.at(-1)?.index ?? -1) + 1;
if (end < head.length / 2) end = head.lastIndexOf(' ');
requireValue(end > 0, 'No safe excerpt boundary.');
return { text: head.slice(0, end).trimEnd(), truncated: true };
}
function articleTitle(s) {
requireValue(typeof s === 'string' && s.length > 0 && s.length <= 200 &&
!/[\x00-\x1f\x7f<>\[\]{}|#]/.test(s) && !s.includes(':'), 'Invalid article title.');
return s;
}
function wikiTitle(s) { return encodeURIComponent(s.replaceAll(' ', '_')); }
function verifiedArticleURL(value, title) {
const u = allowedURL(value);
requireValue(u.pathname.startsWith('/wiki/') && !u.search &&
decodeURIComponent(u.pathname.slice(6)).replaceAll('_', ' ') === title, 'Canonical title/URL mismatch.');
return u.href;
}
function missingEntry(r, title, url, retrieved) {
return { id: r.id, latin: r.latin, title, url,
history_url: `${ORIGIN}/w/index.php?title=${wikiTitle(title)}&action=history`, retrieved,
text: `No English Wikipedia species article was available for ${r.latin} when this bundle was retrieved. This is an original availability note, not a Wikipedia excerpt. No broader taxon or similarly named species has been substituted. The game catalogue is not evidence for real-world ecological, food or medicinal effects.`,
kind: 'original_notes', excerpt: false, truncated: false,
attribution: 'The Last Hearth contributors (original availability note)',
license: NOTE_LICENSE, license_url: NOTE_LICENSE_URL,
changes: 'Original availability note dedicated to CC0; no Wikipedia article text copied.' };
}
export function entriesFromResponse(records, data, retrieved) {
validateCatalogue(records); timestamp(retrieved);
requireValue(data && !data.error && !data.warnings && data.batchcomplete === true &&
Array.isArray(data.query?.pages) && data.query.pages.length <= records.length, 'Incomplete or invalid Wikipedia response.');
const q = data.query, aliases = new Map(), pages = new Map();
for (const rule of [...(q.normalized || []), ...(q.redirects || [])]) {
articleTitle(rule.from); articleTitle(rule.to);
requireValue(!aliases.has(rule.from) && !rule.tofragment, 'Ambiguous or fragment redirect.');
aliases.set(rule.from, rule.to);
}
requireValue(aliases.size <= records.length * 3, 'Too many title aliases.');
for (const page of q.pages) {
articleTitle(page.title);
requireValue(!pages.has(page.title), 'Duplicate article title.');
pages.set(page.title, page);
}
return records.map(r => {
let title = r.latin;
const seen = new Set();
while (aliases.has(title)) {
requireValue(!seen.has(title) && seen.size < 5, 'Cyclic or excessive title redirects.');
seen.add(title); title = aliases.get(title);
}
const p = pages.get(title);
requireValue(p && p.ns === 0 && !('invalid' in p) &&
!Object.hasOwn(p.pageprops || {}, 'disambiguation') && p.contentmodel === 'wikitext' &&
p.pagelanguage === 'en', `Unverified species article: ${r.latin}.`);
const url = verifiedArticleURL(p.canonicalurl, title);
if (p.missing === true) {
requireValue(!p.pageid && !p.revisions && title === r.latin, 'Ambiguous missing article.');
return missingEntry(r, title, url, retrieved);
}
requireValue(!('missing' in p), 'Unexpected missing marker.');
const pageid = positiveInteger(p.pageid, 'page ID');
requireValue(Array.isArray(p.revisions) && p.revisions.length === 1, 'Expected one source revision.');
const rev = p.revisions[0], revision = positiveInteger(rev.revid, 'revision');
requireValue(p.lastrevid === revision, 'Revision mismatch; retry maintenance later.');
timestamp(rev.timestamp);
requireValue(Date.parse(rev.timestamp) <= Date.parse(retrieved), 'Source revision is in the future.');
const copied = excerpt(p.extract);
return { id: r.id, latin: r.latin, title, url, pageid, revision,
revision_url: `${ORIGIN}/w/index.php?oldid=${revision}`,
history_url: `${ORIGIN}/w/index.php?title=${wikiTitle(title)}&action=history`,
revision_timestamp: rev.timestamp, retrieved, ...copied,
kind: 'wikipedia_intro', excerpt: true, attribution: `Wikipedia contributors, "${title}"`,
license: LICENSE, license_url: LICENSE_URL,
changes: copied.truncated ? 'Plaintext introduction; shortened at a text boundary. No translation.'
: 'Plaintext introduction only; article body omitted. No translation.' };
});
}
export async function fetchEntries(records, { fetchImpl = globalThis.fetch, now = () => new Date() } = {}) {
validateCatalogue(records);
const batches = [];
for (let i = 0; i < records.length; i += LIMITS.batch) batches.push(records.slice(i, i + LIMITS.batch));
const controller = new AbortController();
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(LIMITS.totalMs)]);
const results = new Array(batches.length);
let cursor = 0;
async function worker() {
while (!signal.aborted) {
const i = cursor++;
if (i >= batches.length) return;
const batch = batches[i];
const data = await fetchBatch(batch.map(r => r.latin), { fetchImpl, signal });
results[i] = entriesFromResponse(batch, data, now().toISOString());
}
signal.throwIfAborted();
}
try { await Promise.all(Array.from({ length: Math.min(LIMITS.concurrency, batches.length) }, worker)); }
catch (error) { controller.abort(error); throw error; }
return results.flat();
}
// Every UTF-8 byte is a fixed-width Lua decimal escape (Lua 5.1+). Article text
// cannot close a quote, inject a comment/long-string delimiter or execute code.
export function luaString(value) {
requireValue(typeof value === 'string', 'Expected string.');
return '"' + [...Buffer.from(value, 'utf8')].map(b => '\\' + String(b).padStart(3, '0')).join('') + '"';
}
export function serializeBundle(entries) {
validateCatalogue(entries);
return HEADER + 'return {\n' + entries.map(e => `\t[${luaString(e.id)}] = {\n` +
FIELDS.map(key => {
const value = e[key];
const absent = e.kind === 'original_notes' && ['pageid', 'revision', 'revision_url', 'revision_timestamp'].includes(key) && value === undefined;
requireValue(absent || (['pageid', 'revision'].includes(key) ? Number.isSafeInteger(value) && value > 0
: ['excerpt', 'truncated'].includes(key) ? typeof value === 'boolean' : typeof value === 'string'), `Invalid bundle field ${key}.`);
return `\t\t${key} = ${value === undefined ? 'nil' : typeof value === 'string' ? luaString(value) : value},\n`;
}).join('') + '\t},\n').join('') + '}\n';
}
// This narrow parser reads ONLY our generated data grammar, not general Lua.
export function parseBundle(source) {
requireValue(typeof source === 'string' && Buffer.byteLength(source) <= 16 * 1024 * 1024 &&
source.startsWith(HEADER + 'return {\n') && source.endsWith('}\n'), 'Invalid generated bundle.');
const decode = s => new TextDecoder('utf-8', { fatal: true }).decode(Buffer.from(
[...s.matchAll(/\\(\d{3})/g)].map(m => { const n = Number(m[1]); requireValue(n <= 255, 'Invalid Lua byte.'); return n; })));
const lines = source.slice((HEADER + 'return {\n').length, -2).split('\n');
lines.pop();
const entries = [];
let i = 0;
while (i < lines.length) {
const start = /^\t\["((?:\\\d{3})+)"\] = \{$/.exec(lines[i++]);
requireValue(start, 'Invalid Lua record.');
const entry = { id: decode(start[1]) };
for (const key of FIELDS) {
const m = new RegExp(`^\\t\\t${key} = ("(?:\\\\\\d{3})*"|true|false|nil|[1-9]\\d*),$`).exec(lines[i++]);
requireValue(m, 'Invalid Lua field.');
if (m[1] !== 'nil') entry[key] = m[1].startsWith('"') ? decode(m[1]) : m[1] === 'true' ? true : m[1] === 'false' ? false : Number(m[1]);
}
requireValue(lines[i++] === '\t},', 'Invalid Lua record end.');
entries.push(entry);
}
validateCatalogue(entries);
requireValue(serializeBundle(entries) === source, 'Noncanonical generated bundle.');
return entries;
}
async function readCatalogue() {
await safePath(CATALOGUE);
const stat = await fs.stat(CATALOGUE);
requireValue(stat.isFile() && stat.size <= LIMITS.catalogueBytes, 'Invalid catalogue file.');
return parseCatalogue(await fs.readFile(CATALOGUE, 'utf8'));
}
export async function main(args = process.argv.slice(2)) {
requireValue(args.length === 1 && ['--refresh', '--check', '--help'].includes(args[0]),
'Usage: node scripts/wiki-species.mjs --refresh | --check | --help (no URLs/output paths accepted)');
if (args[0] === '--help') {
console.log('--refresh: query all 80 species; replace the fixed Lua cache with verified intros or explicit missing-page notes after full validation.\n--check: verify fixed offline cache/catalogue without network.');
return;
}
const records = await readCatalogue();
if (args[0] === '--check') {
await safePath(OUTPUT);
requireValue((await fs.stat(OUTPUT)).size <= 16 * 1024 * 1024, 'Bundle too large.');
const entries = parseBundle(await fs.readFile(OUTPUT, 'utf8'));
requireValue(JSON.stringify(entries.map(({ id, latin }) => ({ id, latin }))) === JSON.stringify(records), 'Catalogue/cache mismatch.');
for (const e of entries) {
if (e.kind === 'original_notes') {
timestamp(e.retrieved); verifiedArticleURL(e.url, e.latin);
requireValue(serializeBundle([e]) === serializeBundle([missingEntry(e, e.latin, e.url, e.retrieved)]), 'Invalid original availability note.');
continue;
}
entriesFromResponse([{ id: e.id, latin: e.latin }], { batchcomplete: true, query: {
redirects: e.title === e.latin ? [] : [{ from: e.latin, to: e.title }],
pages: [{ ns: 0, pageid: e.pageid, title: e.title, canonicalurl: e.url, contentmodel: 'wikitext',
pagelanguage: 'en', lastrevid: e.revision, revisions: [{ revid: e.revision, timestamp: e.revision_timestamp }], extract: e.text }] } }, e.retrieved);
requireValue(e.kind === 'wikipedia_intro' && e.excerpt === true && e.license === LICENSE && e.license_url === LICENSE_URL &&
e.revision_url === `${ORIGIN}/w/index.php?oldid=${e.revision}` &&
e.history_url === `${ORIGIN}/w/index.php?title=${wikiTitle(e.title)}&action=history` &&
e.attribution === `Wikipedia contributors, "${e.title}"` && Array.from(e.text).length <= LIMITS.characters, 'Invalid cached attribution/excerpt.');
}
console.log(`Verified ${entries.length}/80 species: ${entries.filter(e => e.kind === 'wikipedia_intro').length} Wikipedia introductions, ${entries.filter(e => e.kind === 'original_notes').length} original availability notes; network requests: 0.`);
return;
}
const entries = await fetchEntries(records);
const source = serializeBundle(entries);
requireValue(parseBundle(source).length === LIMITS.species, 'Incomplete generated bundle.');
// Fixed output only; trusted local parents required, consistent with files.mjs.
// All network/validation work finishes BEFORE opening/truncating the old cache.
await safePath(OUTPUT);
const handle = await fs.open(OUTPUT, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | (constants.O_NOFOLLOW || 0), 0o644);
try { await handle.writeFile(source, 'utf8'); } finally { await handle.close(); }
console.log(`Cached ${entries.length}/80 species: ${entries.filter(e => e.kind === 'wikipedia_intro').length} Wikipedia introductions, ${entries.filter(e => e.kind === 'original_notes').length} original availability notes (${entries.filter(e => e.truncated).length} shortened); ${Math.ceil(records.length / LIMITS.batch)} API batches, concurrency <=2.`);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch(error => { console.error(error.message); process.exitCode = 1; });
}
+1525
View File
File diff suppressed because it is too large Load Diff