Spaces:
Running
Running
| /* 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)`); | |