massfront-playtest / tools /bundle-update.mjs
CREATORJD's picture
v1.33.27 — planet terrain themes, full-map fog, outer-range normals, crater flooding, balance
2f9921b verified
Raw
History Blame Contribute Delete
8.62 kB
/* Build the ordered OTA payload. It carries the current HTML/CSS shell before
the classic scripts so an older installed package is never asked to run new
controllers against stale menu markup. One payload also keeps the channel
atomic: shell and behavior can only arrive together. */
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root=join(dirname(fileURLToPath(import.meta.url)),'..');
const version=process.argv[2];
if(!/^\d+\.\d+\.\d+$/.test(version||'')) throw new Error('usage: node tools/bundle-update.mjs <x.y.z>');
const order=JSON.parse(readFileSync(join(root,'assets/data/manifest.json'),'utf8')).order;
/* OTA patches replace the shell/CSS/classic scripts, but an older native
package cannot resolve binary art added after it shipped. Keep the normal
source and new APK lean by using file URLs there, while embedding only the
post-1.31 art in the atomic OTA payload. This makes an in-place update from
the existing mobile build visually complete and remains available offline
after the patch has downloaded. */
const otaBinaryAssets=[
'assets/terrain/ground-cracked.webp',
'assets/terrain/ground-soil.webp',
'assets/terrain/pave-panels.webp',
'assets/terrain/ground-grass.webp',
'assets/terrain/ground-cracked-nrm.webp',
'assets/terrain/ground-soil-nrm.webp',
'assets/terrain/pave-panels-nrm.webp',
'assets/terrain/ground-grass-nrm.webp',
'assets/terrain/arctic-ground.webp',
'assets/terrain/arctic-soil.webp',
'assets/terrain/arctic-grass.webp',
'assets/terrain/ashland-ground.webp',
'assets/terrain/ashland-soil.webp',
'assets/terrain/ashland-grass.webp',
'assets/terrain/vespera-ground.webp',
'assets/terrain/vespera-soil.webp',
'assets/terrain/vespera-grass.webp',
'assets/brand/massfront-title-command-conquer-overwhelm-v1.png',
'assets/modifiers/modifier-art-atlas-v1.png',
'assets/factions/cinematic/terran-frontline-command-v1.png',
'assets/factions/cinematic/crimson-dominion-v1.png',
'assets/factions/cinematic/syndicate-coalition-v1.png',
'assets/factions/cinematic/brood-swarm-v1.png'
].map(path=>({
path,
uri:'data:image/'+(path.endsWith('.webp')?'webp':'png')+';base64,'+readFileSync(join(root,path)).toString('base64')
}));
/* WHICH ASSETS ACTUALLY GOT EMBEDDED. Inlining is literal text substitution:
a source must contain the whole path as one string. When src/engine/gl.js
was refactored to build terrain paths with './assets/terrain/'+key+'.webp',
the joined string existed in no source, every terrain texture silently
failed to inline, and the release shipped 1.9 MB SMALLER with no ground art
and no error. Track every hit and fail the build on a miss — a payload that
quietly loses its textures must never reach a device again. */
const otaEmbedHits=new Map(otaBinaryAssets.map(a=>[a.path,0]));
const inlineOtaBinaryRefs=text=>{
let out=text;
for(const asset of otaBinaryAssets){
const refs=['./'+asset.path,'../../'+asset.path,asset.path];
for(const ref of refs){
const parts=out.split(ref);
if(parts.length>1){ otaEmbedHits.set(asset.path,otaEmbedHits.get(asset.path)+parts.length-1); out=parts.join(asset.uri); }
}
}
return out;
};
/* Assets whose only consumer was removed. They stay listed so the art is not
lost track of, but they are referenced by no source and embedding them would
add pure dead weight to every device's download. Verified 2026-08-11: no
occurrence of these filenames, or of their directories, anywhere in src/. */
const otaEmbedOrphans=new Set([
'assets/brand/massfront-title-command-conquer-overwhelm-v1.png',
'assets/modifiers/modifier-art-atlas-v1.png',
'assets/factions/cinematic/terran-frontline-command-v1.png',
'assets/factions/cinematic/crimson-dominion-v1.png',
'assets/factions/cinematic/syndicate-coalition-v1.png',
'assets/factions/cinematic/brood-swarm-v1.png'
]);
const assertOtaEmbedded=()=>{
const missed=[...otaEmbedHits.entries()].filter(([,n])=>n===0).map(([p])=>p)
.filter(p=>!otaEmbedOrphans.has(p));
const skipped=[...otaEmbedHits.entries()].filter(([p,n])=>n===0&&otaEmbedOrphans.has(p));
if(skipped.length) console.log(' (skipped '+skipped.length+' unreferenced asset(s) — not embedded, not an error)');
if(missed.length){
throw new Error('OTA EMBED FAILURE — '+missed.length+' asset(s) listed in otaBinaryAssets were never referenced by a literal path in any source, so the payload would ship without them:\n '+missed.join('\n ')+
'\nFix: reference each file as ONE whole string literal (e.g. \'./assets/terrain/x.webp\'), not by concatenation.');
}
};
const html=readFileSync(join(root,'index.html'),'utf8');
const bodyMatch=html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
if(!bodyMatch) throw new Error('index.html has no body');
const shellBody=bodyMatch[1].replace(/\s*<script\s+src=["']\.\/boot\.js["']><\/script>\s*$/i,'');
const stylePaths=Array.from(html.matchAll(/<link\s+rel=["']stylesheet["']\s+href=["']([^"']+)["'][^>]*>/gi),m=>m[1].split('?')[0].replace(/^\.\//,''));
const shell={version,title:(html.match(/<title>([\s\S]*?)<\/title>/i)||[])[1]||'MASSFRONT',body:shellBody,
styles:stylePaths.map(path=>({path,css:inlineOtaBinaryRefs(readFileSync(join(root,path),'utf8'))}))};
const prelude=`(function(){
var shell=${JSON.stringify(shell)};
/* A prior patch that faulted before its first frame must not leave its
document-level capture listeners behind for the next launch/update. */
try{if(typeof window.__MASSFRONT_CLEAR_INPUT_GUARD==='function')window.__MASSFRONT_CLEAR_INPUT_GUARD();}catch(e){}
document.querySelectorAll('[data-mf-input-shield]').forEach(function(n){n.remove();});
/* The install pointer began in the old document. Consume its release and
synthetic click in this new one, otherwise the Account button can inherit
the install tap as soon as authportal.js injects it. */
var guardActive=true, guardReleaseTimer=0, guardWatchdog=0;
var guardEvents=['pointerdown','pointerup','touchend','click'];
var guardOptions={capture:true,passive:false}, shield=null;
window.__MASSFRONT_INPUT_GUARD_UNTIL=Number.MAX_SAFE_INTEGER;
var clearGuard=function(){
if(!guardActive)return;
guardActive=false; window.__MASSFRONT_INPUT_GUARD_UNTIL=0;
if(guardWatchdog)clearTimeout(guardWatchdog);
guardEvents.forEach(function(name){document.removeEventListener(name,blockGuard,true);});
document.querySelectorAll('[data-mf-input-shield]').forEach(function(n){n.remove();});
};
var blockGuard=function(e){
if(!guardActive)return;
e.preventDefault(); e.stopImmediatePropagation();
};
/* Parsing a multi-megabyte patch can outlast a fixed timer on a slower phone.
The game releases the shield only after its first real frame, plus one
short click-quiet period. */
window.__MASSFRONT_RELEASE_INPUT_GUARD=function(){
if(!guardReleaseTimer)guardReleaseTimer=setTimeout(clearGuard,450);
};
window.__MASSFRONT_CLEAR_INPUT_GUARD=clearGuard;
guardEvents.forEach(function(name){document.addEventListener(name,blockGuard,guardOptions);});
/* Fail open even if a renderer or optional module crashes before confirmBoot.
A broken feature may show an error, but it must never brick every control. */
guardWatchdog=setTimeout(clearGuard,5000);
document.querySelectorAll('link[rel="stylesheet"],style[data-mf-shell-style]').forEach(function(n){n.remove();});
shell.styles.forEach(function(file){
var s=document.createElement('style'); s.setAttribute('data-mf-shell-style',file.path);
s.textContent=file.css; document.head.appendChild(s);
});
document.body.innerHTML=shell.body; document.title=shell.title;
shield=document.createElement('div'); shield.setAttribute('aria-hidden','true');
shield.setAttribute('data-mf-input-shield','');
shield.style.cssText='position:fixed;inset:0;z-index:2147483647;background:transparent;pointer-events:auto;touch-action:none';
document.body.appendChild(shield);
window.__MASSFRONT_SHELL=shell.version;
})();\n`;
const sources=order.map(path=>inlineOtaBinaryRefs(readFileSync(join(root,path),'utf8'))+'\n//# sourceURL='+path).join('\n;\n');
const body=prelude+sources;
assertOtaEmbedded(); // no silent texture loss, ever again
new Function(body);
const out=join(root,'releases',`MASSFRONT-v${version}-update.js`);
mkdirSync(dirname(out),{recursive:true});
writeFileSync(out,body);
console.log(`${order.length} sources -> ${out} (${(Buffer.byteLength(body)/1048576).toFixed(2)} MB)`);