File size: 8,623 Bytes
cf2c6c4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7a1dea4
5ddfee6
 
 
 
2f9921b
 
 
 
 
 
 
 
 
cf2c6c4
 
 
 
 
 
 
 
 
 
2f9921b
 
 
 
 
 
 
 
cf2c6c4
 
 
 
2f9921b
 
 
 
cf2c6c4
 
 
2f9921b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cf2c6c4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2f9921b
cf2c6c4
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
/* 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)`);