/* 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 '); 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(/]*>([\s\S]*?)<\/body>/i); if(!bodyMatch) throw new Error('index.html has no body'); const shellBody=bodyMatch[1].replace(/\s*<\/script>\s*$/i,''); const stylePaths=Array.from(html.matchAll(/]*>/gi),m=>m[1].split('?')[0].replace(/^\.\//,'')); const shell={version,title:(html.match(/([\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)`);