CREATORJD commited on
Commit
f463dec
·
verified ·
1 Parent(s): 1abb55b

Publish MASSFRONT web v1.33.60 (part 2)

Browse files
assets/data/manifest.json CHANGED
@@ -91,8 +91,9 @@
91
  "src/daily.js",
92
  "src/account.js",
93
  "src/economy-net.js",
94
- "src/updater.js",
95
- "src/intro.js",
 
96
  "src/session.js",
97
  "src/faction-id.js",
98
  "src/glrecover.js",
 
91
  "src/daily.js",
92
  "src/account.js",
93
  "src/economy-net.js",
94
+ "src/updater.js",
95
+ "src/intro.js",
96
+ "src/launcher.js",
97
  "src/session.js",
98
  "src/faction-id.js",
99
  "src/glrecover.js",
assets/data/runtime-compatibility.json CHANGED
@@ -1,10 +1,10 @@
1
  {
2
  "schema": "massfront.runtime-compatibility",
3
  "schemaVersion": 1,
4
- "buildVersion": "1.33.55",
5
  "channel": "packaged-www",
6
- "manifestHash": "68edb621ff67d152b7ad6a3bf43120317b9973f166ffa13f7a81091fc5b3aab9",
7
- "balanceHash": "da4f1042e4ae8f1173dbdb51b2570cb539735aba701dd18f2721bb40b26fd72e",
8
  "algorithm": {
9
  "leaf": "sha256(raw-artifact-bytes)",
10
  "root": "sha256(domain\\0 + utf8ByteLength(path):path\\0decimalSize\\0leaf\\n for each ordered entry)",
@@ -16,23 +16,23 @@
16
  "manifestEntries": [
17
  {
18
  "path": "index.html",
19
- "size": 428689,
20
- "sha256": "0cc58c06ffa8de819e1c43bfb6872f177e2dc9ebbec7468193a4c7a6bfaf6ad3"
21
  },
22
  {
23
  "path": "boot.js",
24
- "size": 33378,
25
- "sha256": "99369a2124543b5bf5d9d4df0cb5f0092e3d835a3c603959ffeb9b323c799767"
26
  },
27
  {
28
  "path": "sw.js",
29
  "size": 6419,
30
- "sha256": "dc24d82f2e7be870cb1fbb9a2c4dbcac1926366f14431c820a710732ad2e64e4"
31
  },
32
  {
33
  "path": "src/styles/ui.css",
34
- "size": 342825,
35
- "sha256": "fefbdc6f2bad8362b8474a183ebdd68527a7840b71ea06386b80acb114f34677"
36
  },
37
  {
38
  "path": "assets/ui.css",
@@ -121,8 +121,8 @@
121
  },
122
  {
123
  "path": "src/engine/perf.js",
124
- "size": 8281,
125
- "sha256": "da348a05513b7ba75cd5c978a226faeaa7f870c9bf1cb117f9b8336332235bea"
126
  },
127
  {
128
  "path": "src/engine/noisegen.js",
@@ -371,8 +371,8 @@
371
  },
372
  {
373
  "path": "src/game/meta.js",
374
- "size": 126856,
375
- "sha256": "3c042a6d37171dc1121e28bb87b4639e60a779727900777da22e02cd27582e07"
376
  },
377
  {
378
  "path": "src/game/ai.js",
@@ -391,8 +391,8 @@
391
  },
392
  {
393
  "path": "src/ui/hud.js",
394
- "size": 178226,
395
- "sha256": "aaa80213df462fad27dcf1f3dc17bd96075ca16c1987cf5151656d3efca27388"
396
  },
397
  {
398
  "path": "src/ui/render3d.js",
@@ -441,8 +441,8 @@
441
  },
442
  {
443
  "path": "src/assetpack.js",
444
- "size": 18120,
445
- "sha256": "22ec799cfa0648e876c1a5f0ce9cad6696adbab49aa902d52ec7fbc26f5c1f97"
446
  },
447
  {
448
  "path": "src/hazards.js",
@@ -451,13 +451,13 @@
451
  },
452
  {
453
  "path": "src/game/statehash.js",
454
- "size": 11138,
455
- "sha256": "09b476476b4b4200b3d5a51338e105aa445ebfbef1d31049f83b517ba4cd79db"
456
  },
457
  {
458
  "path": "src/authportal.js",
459
- "size": 103419,
460
- "sha256": "81f7604547f65c318cad5ca27803ee93ed55797124c9a9facaf2a2ef84ed4bef"
461
  },
462
  {
463
  "path": "src/tutorial.js",
@@ -501,8 +501,8 @@
501
  },
502
  {
503
  "path": "src/socialui.js",
504
- "size": 81988,
505
- "sha256": "700d217b7ea999dcc5fc7915282619e092d65b4b29ca02b947f59ffcfb1c1096"
506
  },
507
  {
508
  "path": "src/game/matchconsumer.js",
@@ -526,13 +526,18 @@
526
  },
527
  {
528
  "path": "src/updater.js",
529
- "size": 102031,
530
- "sha256": "198874ef66f3c0fef1a8b86507ef7c411532a26910c3dbd73a8328dc4b0b3146"
531
  },
532
  {
533
  "path": "src/intro.js",
534
- "size": 207114,
535
- "sha256": "3661c3b5e29ec597e0021c7c769d3748a67b948fa09d76d4f9ae601976c291d5"
 
 
 
 
 
536
  },
537
  {
538
  "path": "src/session.js",
@@ -551,8 +556,8 @@
551
  },
552
  {
553
  "path": "src/main.js",
554
- "size": 181090,
555
- "sha256": "857428872384b8e03a4c9804ead7b8187240c3f141193e1bbda312e9bcbe2e2b"
556
  },
557
  {
558
  "path": "src/intel.js",
@@ -586,8 +591,8 @@
586
  },
587
  {
588
  "path": "src/ui/hudflow.js",
589
- "size": 37901,
590
- "sha256": "107fd65fca0af162142e1661401149388dff46ae236ab16d6e9f592bf98ce4b4"
591
  },
592
  {
593
  "path": "src/ui/hotslots.js",
@@ -698,8 +703,8 @@
698
  },
699
  {
700
  "path": "src/game/meta.js",
701
- "size": 126856,
702
- "sha256": "3c042a6d37171dc1121e28bb87b4639e60a779727900777da22e02cd27582e07"
703
  },
704
  {
705
  "path": "src/game/ai.js",
@@ -763,8 +768,8 @@
763
  },
764
  {
765
  "path": "src/main.js",
766
- "size": 181090,
767
- "sha256": "857428872384b8e03a4c9804ead7b8187240c3f141193e1bbda312e9bcbe2e2b"
768
  },
769
  {
770
  "path": "src/intel.js",
 
1
  {
2
  "schema": "massfront.runtime-compatibility",
3
  "schemaVersion": 1,
4
+ "buildVersion": "1.33.60",
5
  "channel": "packaged-www",
6
+ "manifestHash": "addd4716b37b8595da29a6becfc8add0a0fba3e2150dd86281e50dfdff8b1168",
7
+ "balanceHash": "4154ba8ba6f0fea81cc28e3af073d1949680d4a67a9cb696a9e5861c5fb129fe",
8
  "algorithm": {
9
  "leaf": "sha256(raw-artifact-bytes)",
10
  "root": "sha256(domain\\0 + utf8ByteLength(path):path\\0decimalSize\\0leaf\\n for each ordered entry)",
 
16
  "manifestEntries": [
17
  {
18
  "path": "index.html",
19
+ "size": 435619,
20
+ "sha256": "bf991b42fc3b9b472c85b215b8c862d43a73e07eeb5b9719bd7ea3cefc4dd032"
21
  },
22
  {
23
  "path": "boot.js",
24
+ "size": 46254,
25
+ "sha256": "14ec9d1c4d84304a9a768823e4958c80abf29383c070eff90925154d74bca573"
26
  },
27
  {
28
  "path": "sw.js",
29
  "size": 6419,
30
+ "sha256": "604a58aa0f607a303c37e68a4e93c52d333ca78fe403243a2c9603bdf385b14b"
31
  },
32
  {
33
  "path": "src/styles/ui.css",
34
+ "size": 372493,
35
+ "sha256": "39c2a056e6299458f38b7448a6a56cdc240e6c534ed52636758ec64e5c713d1b"
36
  },
37
  {
38
  "path": "assets/ui.css",
 
121
  },
122
  {
123
  "path": "src/engine/perf.js",
124
+ "size": 25202,
125
+ "sha256": "e74249688bef2ede6a885f62e0f51300fe5d42aa4a1929924249194e3e713b2a"
126
  },
127
  {
128
  "path": "src/engine/noisegen.js",
 
371
  },
372
  {
373
  "path": "src/game/meta.js",
374
+ "size": 127622,
375
+ "sha256": "5180c016ba355f55dc9230c9501e68b80c8b93b430a8fe6fede012752d77c332"
376
  },
377
  {
378
  "path": "src/game/ai.js",
 
391
  },
392
  {
393
  "path": "src/ui/hud.js",
394
+ "size": 183281,
395
+ "sha256": "9014df151281a0ba20b85d67b962e394e7902d6cda1e34105fe9373735dc0b87"
396
  },
397
  {
398
  "path": "src/ui/render3d.js",
 
441
  },
442
  {
443
  "path": "src/assetpack.js",
444
+ "size": 55805,
445
+ "sha256": "4d3f45ce562e56ee5137516be79bc6faa4fc3db75c483ef1c7ce0300d47363b6"
446
  },
447
  {
448
  "path": "src/hazards.js",
 
451
  },
452
  {
453
  "path": "src/game/statehash.js",
454
+ "size": 12260,
455
+ "sha256": "f2c22000002e6b125f8876deedac28a932448890663257614e1c873129a901d2"
456
  },
457
  {
458
  "path": "src/authportal.js",
459
+ "size": 109260,
460
+ "sha256": "893c17c4c6ae384b875660284bab99be28b796f24b34af4647d3931eda6b13c7"
461
  },
462
  {
463
  "path": "src/tutorial.js",
 
501
  },
502
  {
503
  "path": "src/socialui.js",
504
+ "size": 94822,
505
+ "sha256": "906face8283ca5cf2ba416c79e11e22ce61afcedf6d39eba75907e5de169bd55"
506
  },
507
  {
508
  "path": "src/game/matchconsumer.js",
 
526
  },
527
  {
528
  "path": "src/updater.js",
529
+ "size": 146225,
530
+ "sha256": "fc1024feb7d7bff0239e0c8792219d28c1b488ce1e1c263b75e8f7ae9d0cbcd8"
531
  },
532
  {
533
  "path": "src/intro.js",
534
+ "size": 207551,
535
+ "sha256": "d4ecb5df0a0353870f66697b9221872b2fbe3e6982f3f061275eb46d2caa5387"
536
+ },
537
+ {
538
+ "path": "src/launcher.js",
539
+ "size": 28822,
540
+ "sha256": "d8bd5b2ab2722aacf3576b886ea2da5305ebef9af9f15e8baa40cfe8e8b3850f"
541
  },
542
  {
543
  "path": "src/session.js",
 
556
  },
557
  {
558
  "path": "src/main.js",
559
+ "size": 184506,
560
+ "sha256": "18036eba44152f565ba9e37d32f0e87031f1d767451d1d9a70b0336edd7e1456"
561
  },
562
  {
563
  "path": "src/intel.js",
 
591
  },
592
  {
593
  "path": "src/ui/hudflow.js",
594
+ "size": 42564,
595
+ "sha256": "9496dd95562674ba8357536e68b2f76b398a959abcfc9e415749c9c569633f6a"
596
  },
597
  {
598
  "path": "src/ui/hotslots.js",
 
703
  },
704
  {
705
  "path": "src/game/meta.js",
706
+ "size": 127622,
707
+ "sha256": "5180c016ba355f55dc9230c9501e68b80c8b93b430a8fe6fede012752d77c332"
708
  },
709
  {
710
  "path": "src/game/ai.js",
 
768
  },
769
  {
770
  "path": "src/main.js",
771
+ "size": 184506,
772
+ "sha256": "18036eba44152f565ba9e37d32f0e87031f1d767451d1d9a70b0336edd7e1456"
773
  },
774
  {
775
  "path": "src/intel.js",
boot.js CHANGED
@@ -24,21 +24,26 @@
24
  './assets/data/theatreprofiles-stage10.js',
25
  './assets/data/interiortopology-stage10.js','./assets/data/orbitaltopology-stage10.js',
26
  './src/game/determinism.js',
27
- './assets/data/unitrows.js','./assets/data/unitsheet.js','./assets/data/itemart.js','./assets/data/planetart.js','./src/engine/gl.js','./src/engine/planetpreview.js','./src/engine/perf.js','./src/engine/noisegen.js','./src/engine/factionenergy.js','./src/engine/ordnancetrails.js','./src/engine/terragen.js','./src/terralab.js','./src/engine/mesh.js','./src/engine/billboard.js','./src/engine/macrofx.js','./src/engine/shieldfx.js','./src/engine/tacticons.js','./src/engine/gpufx.js','./src/engine/volfx.js','./src/engine/shockwave.js','./src/engine/vfxlayers.js','./src/engine/organicfx.js','./assets/basis/basis_transcoder.js','./src/engine/materials.js','./src/engine/materials-v2.js','./src/engine/terrain.js','./src/engine/models-world-data.js','./src/engine/models-world-loader.js','./src/engine/models.js','./assets/data/worldkit.js','./assets/data/locationgrammar.js','./assets/data/sitetemplates.js','./assets/data/sitetemplates-stage9.js','./assets/data/locationplans.js','./assets/data/battlefieldtopology-stage10.js','./src/engine/worldsites.js','./assets/data/meshes.js','./src/engine/models-legion.js','./src/engine/models-machine.js','./src/engine/models-infestation.js','./src/engine/models-civic.js','./src/engine/models-skyline.js','./src/engine/materials-world-v2.js','./src/engine/models-units-nova.js','./src/engine/models-units-legion.js','./src/engine/models-units-syndicate.js','./src/engine/models-units-brood.js','./src/engine/modkit.js','./src/engine/physics.js','./src/engine/cloudfx.js','./src/engine/cloudpostfx.js','./src/game/utilityjobs.js','./src/game/sim.js','./src/game/airwarfare.js','./src/game/economy.js','./src/game/commander.js','./src/game/meta.js','./src/game/ai.js','./src/ui/input.js','./src/ui/facticons.js','./src/ui/hud.js','./src/ui/render3d.js','./src/ui/orderfx.js','./src/airlift.js','./src/airlift-factions.js','./src/rumble.js','./src/factions.js','./src/factext.js','./src/offline.js','./src/audio.js','./src/assetpack.js','./src/hazards.js','./src/game/statehash.js','./src/authportal.js','./src/tutorial.js','./src/adboards.js','./src/storeui.js','./src/restree3d.js','./src/develop.js','./src/factiondoctrine.js','./src/endgame.js','./src/story.js','./src/socialui.js','./src/game/matchconsumer.js','./src/daily.js','./src/account.js','./src/economy-net.js','./src/updater.js','./src/intro.js','./src/session.js','./src/faction-id.js','./src/glrecover.js','./src/main.js','./src/intel.js','./src/repairbay.js','./src/galaxyui.js','./src/departure.js','./src/warprimer.js','./src/uistack.js','./src/ui/hudflow.js','./src/ui/hotslots.js','./src/galactic-operations.js','./src/onboarding.js','./src/career-faction-gate.js'
28
  ];
29
  /* Packaged scripts need a release key. WebViews and development browsers can
30
  otherwise reuse a stale source even after the installer or local preview
31
  has changed, which made new settings appear to be missing until cache was
32
  cleared manually. Patch bundles keep their content-addressed Blob URLs. */
33
- var PACKAGED_REV='1.33.55';
34
  /* Source-cache revision is deliberately independent from the user-facing
35
  release number. Local/hotfix rebuilds of the same release must not reuse
36
  an older gl.js merely because its ?v= version string is unchanged. */
37
  /* shell2 changes recovery delivery only. Keep the already-verified runtime
38
  source revision so a service-worker repair does not cold-fetch all 108
39
  scripts from the host at once. */
40
- var PACKAGED_SRC_REV=PACKAGED_REV+'-boot6';
41
  var DB='massfront-updates', STORE='bundles';
 
 
 
 
 
42
  var bootShield=null, bootShieldTimer=0, bootShieldWatchdog=0;
43
  var bootShieldEvents=['pointerdown','pointerup','touchstart','touchend','click'];
44
  /* Absolute ceiling on how long the shield/guard may live. The guard is only
@@ -107,12 +112,35 @@
107
  function bundleMeta(rec){
108
  if(!rec) return null;
109
  return {version:rec.version,channel:rec.channel||'stable',at:rec.at,
 
110
  notes:rec.notes||'',severity:rec.severity||'recommended',
111
- kind:rec.kind||'full',patchedFrom:rec.patchedFrom||''};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  }
113
  function sameBundle(a,b){
114
  if(!a||!b||String(a.version)!==String(b.version)||
115
- patchChannel(a)!==patchChannel(b)) return false;
116
  if(a.at!=null||b.at!=null) return String(a.at||'')===String(b.at||'');
117
  return true;
118
  }
@@ -145,19 +173,21 @@
145
  }
146
  function probationMatchesBundle(prob,bundle){
147
  if(!prob||!bundle||String(prob.version)!==String(bundle.version)||
148
- patchChannel(prob)!==patchChannel(bundle)) return false;
149
  return prob.pendingAt==null||String(prob.pendingAt)===String(bundle.at||'');
150
  }
151
  function probationOwnsMetaExact(prob,meta){
152
  return !!(prob&&meta&&prob.pendingAt!=null&&meta.at!=null&&
153
  String(prob.version)===String(meta.version)&&
154
  patchChannel(prob)===patchChannel(meta)&&
 
155
  String(prob.pendingAt)===String(meta.at));
156
  }
157
  function pendingMatchesFailure(pending,prob,bundle,version){
158
  if(!pending||String(pending.version)!==String(version)) return false;
159
  var owner=prob||bundle;
160
  if(owner&&patchChannel(pending)!==patchChannel(owner)) return false;
 
161
  var token=prob&&prob.pendingAt!=null?prob.pendingAt:
162
  (bundle&&bundle.at!=null?bundle.at:null);
163
  /* Old records without an attempt token cannot be distinguished from a
@@ -215,7 +245,58 @@
215
  function validBundle(b){
216
  if(!b||!b.files||!verNewer(b.version,PACKAGED_REV)) return false;
217
  var order=b.order&&b.order.length?b.order:MANIFEST;
218
- for(var i=0;i<order.length;i++) if(typeof b.files[order[i]]!=='string') return false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  return true;
220
  }
221
  function evictSuperseded(db){
@@ -353,6 +434,15 @@
353
  :(!meta||sameBundle(meta,value)));
354
  if(good){
355
  store.put(value,'active'); store.put(bundleMeta(value),'activeMeta');
 
 
 
 
 
 
 
 
 
356
  clearPreviousRecords(store);
357
  clearFinishedRollback(value);
358
  decision={kind:'recovered',bundle:value,failedVersion:version,
@@ -404,6 +494,8 @@
404
  return;
405
  }
406
  var claimed={version:bundle.version,channel:bundle.channel||prob.channel,
 
 
407
  pendingAt:bundle.at!=null?bundle.at:prob.pendingAt,
408
  at:prob.at||Date.now(),tries:(prob.tries|0)+1};
409
  store.put(claimed,'probation');
@@ -415,6 +507,8 @@
415
  /* A mismatched legacy/torn guard must never be used to validate a
416
  different active record. Replace it with an exact first claim. */
417
  store.put({version:bundle.version,channel:bundle.channel||'stable',
 
 
418
  pendingAt:bundle.at,at:Date.now(),tries:1},'probation');
419
  clearFinishedRollback(bundle);
420
  decision={kind:'run',bundle:bundle,claimed:true,repaired:true};
@@ -509,10 +603,158 @@
509
  injectScripts(function(s,i){ s.src=MANIFEST[i]+'?v='+PACKAGED_SRC_REV; },
510
  MANIFEST.length,rendererGateIndex(MANIFEST));
511
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
512
  function runBundle(b){
513
  /* Blob URLs rather than inline text: the browser keeps a real filename for
514
  each source, so a stack trace from a patched build is still readable. */
515
  var order=b.order&&b.order.length? b.order : MANIFEST;
 
 
 
 
516
  injectScripts(function(s,i){
517
  var path=order[i], src=b.files[path];
518
  if(src==null) s.src=path;
@@ -533,6 +775,9 @@
533
  window.__MASSFRONT_PATCHED=decision.bundle.version||'?';
534
  window.__MASSFRONT_PATCH_AT=decision.bundle.at;
535
  window.__MASSFRONT_PATCH_CHANNEL=decision.bundle.channel||'stable';
 
 
 
536
  window.__MASSFRONT_RECOVERED_PATCH=decision.failedVersion||'?';
537
  console.warn('boot: restored validated patch '+decision.bundle.version+
538
  ' after '+decision.failedVersion+' failed');
@@ -543,6 +788,9 @@
543
  window.__MASSFRONT_PATCHED=decision.bundle.version||'?';
544
  window.__MASSFRONT_PATCH_AT=decision.bundle.at;
545
  window.__MASSFRONT_PATCH_CHANNEL=decision.bundle.channel||'stable';
 
 
 
546
  runBundle(decision.bundle);
547
  return;
548
  }
@@ -560,12 +808,16 @@
560
  failed before it selected `active`. It has NOT proved the stored patch
561
  good, so it must not erase that patch's probation record. Leaving it in
562
  place means the next healthy launch retries under the normal rollback
563
- guard instead of running an unguarded payload. A recovered previous
564
- patch is similarly only a fallback for a different failed version. */
565
- if(!window.__MASSFRONT_PATCHED||window.__MASSFRONT_RECOVERED_PATCH) return;
 
566
  idb().then(function(db){
567
  return confirmPatch(db,{version:window.__MASSFRONT_PATCHED,
568
  channel:window.__MASSFRONT_PATCH_CHANNEL||'stable',
 
 
 
569
  at:window.__MASSFRONT_PATCH_AT});
570
  }).catch(function(){});
571
  };
@@ -583,7 +835,7 @@
583
  if(!secure) return;
584
  window.__mfPwaDiag={supported:true,registered:false,controlled:!!navigator.serviceWorker.controller,error:null};
585
  window.addEventListener('load',function(){
586
- navigator.serviceWorker.register('./sw.js?v=1.33.55-shell2',{scope:'./',updateViaCache:'none'})
587
  .then(function(reg){
588
  window.__mfPwaDiag.registered=true;
589
  window.__mfPwaDiag.scope=reg.scope;
 
24
  './assets/data/theatreprofiles-stage10.js',
25
  './assets/data/interiortopology-stage10.js','./assets/data/orbitaltopology-stage10.js',
26
  './src/game/determinism.js',
27
+ './assets/data/unitrows.js','./assets/data/unitsheet.js','./assets/data/itemart.js','./assets/data/planetart.js','./src/engine/gl.js','./src/engine/planetpreview.js','./src/engine/perf.js','./src/engine/noisegen.js','./src/engine/factionenergy.js','./src/engine/ordnancetrails.js','./src/engine/terragen.js','./src/terralab.js','./src/engine/mesh.js','./src/engine/billboard.js','./src/engine/macrofx.js','./src/engine/shieldfx.js','./src/engine/tacticons.js','./src/engine/gpufx.js','./src/engine/volfx.js','./src/engine/shockwave.js','./src/engine/vfxlayers.js','./src/engine/organicfx.js','./assets/basis/basis_transcoder.js','./src/engine/materials.js','./src/engine/materials-v2.js','./src/engine/terrain.js','./src/engine/models-world-data.js','./src/engine/models-world-loader.js','./src/engine/models.js','./assets/data/worldkit.js','./assets/data/locationgrammar.js','./assets/data/sitetemplates.js','./assets/data/sitetemplates-stage9.js','./assets/data/locationplans.js','./assets/data/battlefieldtopology-stage10.js','./src/engine/worldsites.js','./assets/data/meshes.js','./src/engine/models-legion.js','./src/engine/models-machine.js','./src/engine/models-infestation.js','./src/engine/models-civic.js','./src/engine/models-skyline.js','./src/engine/materials-world-v2.js','./src/engine/models-units-nova.js','./src/engine/models-units-legion.js','./src/engine/models-units-syndicate.js','./src/engine/models-units-brood.js','./src/engine/modkit.js','./src/engine/physics.js','./src/engine/cloudfx.js','./src/engine/cloudpostfx.js','./src/game/utilityjobs.js','./src/game/sim.js','./src/game/airwarfare.js','./src/game/economy.js','./src/game/commander.js','./src/game/meta.js','./src/game/ai.js','./src/ui/input.js','./src/ui/facticons.js','./src/ui/hud.js','./src/ui/render3d.js','./src/ui/orderfx.js','./src/airlift.js','./src/airlift-factions.js','./src/rumble.js','./src/factions.js','./src/factext.js','./src/offline.js','./src/audio.js','./src/assetpack.js','./src/hazards.js','./src/game/statehash.js','./src/authportal.js','./src/tutorial.js','./src/adboards.js','./src/storeui.js','./src/restree3d.js','./src/develop.js','./src/factiondoctrine.js','./src/endgame.js','./src/story.js','./src/socialui.js','./src/game/matchconsumer.js','./src/daily.js','./src/account.js','./src/economy-net.js','./src/updater.js','./src/intro.js','./src/launcher.js','./src/session.js','./src/faction-id.js','./src/glrecover.js','./src/main.js','./src/intel.js','./src/repairbay.js','./src/galaxyui.js','./src/departure.js','./src/warprimer.js','./src/uistack.js','./src/ui/hudflow.js','./src/ui/hotslots.js','./src/galactic-operations.js','./src/onboarding.js','./src/career-faction-gate.js'
28
  ];
29
  /* Packaged scripts need a release key. WebViews and development browsers can
30
  otherwise reuse a stale source even after the installer or local preview
31
  has changed, which made new settings appear to be missing until cache was
32
  cleared manually. Patch bundles keep their content-addressed Blob URLs. */
33
+ var PACKAGED_REV='1.33.60';
34
  /* Source-cache revision is deliberately independent from the user-facing
35
  release number. Local/hotfix rebuilds of the same release must not reuse
36
  an older gl.js merely because its ?v= version string is unchanged. */
37
  /* shell2 changes recovery delivery only. Keep the already-verified runtime
38
  source revision so a service-worker repair does not cold-fetch all 108
39
  scripts from the host at once. */
40
+ var PACKAGED_SRC_REV=PACKAGED_REV+'-boot8';
41
  var DB='massfront-updates', STORE='bundles';
42
+ /* Packaged boot capability flag. OTA source checks this before staging a
43
+ descriptor that references per-artifact records. Old APK-bound boot
44
+ loaders never set it and therefore continue receiving the legacy complete
45
+ in-record bundle they understand. */
46
+ window.__MF_ARTIFACT_BOOT_V1=true;
47
  var bootShield=null, bootShieldTimer=0, bootShieldWatchdog=0;
48
  var bootShieldEvents=['pointerdown','pointerup','touchstart','touchend','click'];
49
  /* Absolute ceiling on how long the shield/guard may live. The guard is only
 
112
  function bundleMeta(rec){
113
  if(!rec) return null;
114
  return {version:rec.version,channel:rec.channel||'stable',at:rec.at,
115
+ schema:rec.schema||1,
116
  notes:rec.notes||'',severity:rec.severity||'recommended',
117
+ kind:rec.kind||'full',patchedFrom:rec.patchedFrom||'',
118
+ manifestRoot:rec.manifestRoot||'',payloadRoot:rec.payloadRoot||'',
119
+ targetRoot:rec.targetRoot||'',sourcePayloadRoot:rec.sourcePayloadRoot||'',
120
+ fullRoot:rec.fullRoot||'',manifestKind:rec.manifestKind||'',
121
+ manifestCategory:rec.manifestCategory||'',
122
+ manifestPatchFrom:rec.manifestPatchFrom||'',
123
+ runtimeRoot:rec.runtimeRoot||'',
124
+ storage:rec.storage||''};
125
+ }
126
+ function sameRoots(a,b){
127
+ if(!a||!b) return false;
128
+ var ah=!!(a.manifestRoot||a.targetRoot||a.runtimeRoot),
129
+ bh=!!(b.manifestRoot||b.targetRoot||b.runtimeRoot);
130
+ if(!ah&&!bh) return true;
131
+ if(!(a.manifestRoot&&b.manifestRoot&&a.targetRoot&&b.targetRoot&&
132
+ String(a.manifestRoot).toLowerCase()===String(b.manifestRoot).toLowerCase()&&
133
+ String(a.targetRoot).toLowerCase()===String(b.targetRoot).toLowerCase())) return false;
134
+ /* Schema-1/2 records from before rooted identities remain compatible only
135
+ when both sides omit the runtime root. Once either side names one, both
136
+ must name the same ordered runtime or it cannot bless/replace the other. */
137
+ if(a.runtimeRoot||b.runtimeRoot) return !!(a.runtimeRoot&&b.runtimeRoot&&
138
+ String(a.runtimeRoot).toLowerCase()===String(b.runtimeRoot).toLowerCase());
139
+ return true;
140
  }
141
  function sameBundle(a,b){
142
  if(!a||!b||String(a.version)!==String(b.version)||
143
+ patchChannel(a)!==patchChannel(b)||!sameRoots(a,b)) return false;
144
  if(a.at!=null||b.at!=null) return String(a.at||'')===String(b.at||'');
145
  return true;
146
  }
 
173
  }
174
  function probationMatchesBundle(prob,bundle){
175
  if(!prob||!bundle||String(prob.version)!==String(bundle.version)||
176
+ patchChannel(prob)!==patchChannel(bundle)||!sameRoots(prob,bundle)) return false;
177
  return prob.pendingAt==null||String(prob.pendingAt)===String(bundle.at||'');
178
  }
179
  function probationOwnsMetaExact(prob,meta){
180
  return !!(prob&&meta&&prob.pendingAt!=null&&meta.at!=null&&
181
  String(prob.version)===String(meta.version)&&
182
  patchChannel(prob)===patchChannel(meta)&&
183
+ sameRoots(prob,meta)&&
184
  String(prob.pendingAt)===String(meta.at));
185
  }
186
  function pendingMatchesFailure(pending,prob,bundle,version){
187
  if(!pending||String(pending.version)!==String(version)) return false;
188
  var owner=prob||bundle;
189
  if(owner&&patchChannel(pending)!==patchChannel(owner)) return false;
190
+ if(owner&&!sameRoots(pending,owner)) return false;
191
  var token=prob&&prob.pendingAt!=null?prob.pendingAt:
192
  (bundle&&bundle.at!=null?bundle.at:null);
193
  /* Old records without an attempt token cannot be distinguished from a
 
245
  function validBundle(b){
246
  if(!b||!b.files||!verNewer(b.version,PACKAGED_REV)) return false;
247
  var order=b.order&&b.order.length?b.order:MANIFEST;
248
+ var schema=Number(b.schema||1);
249
+ if(schema>=3){
250
+ var hex=/^[0-9a-f]{64}$/;
251
+ if(!Number.isSafeInteger(schema)||
252
+ !hex.test(String(b.manifestRoot||''))||
253
+ !hex.test(String(b.payloadRoot||''))||
254
+ !hex.test(String(b.sourcePayloadRoot||''))||
255
+ !hex.test(String(b.fullRoot||''))||
256
+ !hex.test(String(b.targetRoot||''))||
257
+ !hex.test(String(b.runtimeRoot||''))||
258
+ String(b.targetRoot)!==String(b.fullRoot)||
259
+ (String(b.payloadRoot)!==String(b.sourcePayloadRoot)&&
260
+ String(b.payloadRoot)!==String(b.fullRoot))||
261
+ (b.manifestKind!=='full'&&b.manifestKind!=='patch')||
262
+ ['system','hotfix','content','overhaul'].indexOf(b.manifestCategory)<0||
263
+ (b.manifestKind==='patch'&&!/^\d+\.\d+\.\d+$/.test(String(b.manifestPatchFrom||'')))||
264
+ (b.manifestKind==='full'&&String(b.manifestPatchFrom||'')!=='')||
265
+ (b.channel!=='stable'&&b.channel!=='preview')||
266
+ (b.storage!=='artifact-v1'&&b.storage!=='bundle-v1')||
267
+ !Array.isArray(b.order)||!b.order.length||
268
+ !b.files||typeof b.files!=='object'||Array.isArray(b.files)) return false;
269
+ var own=Object.keys(b.files),seen={};
270
+ if(own.length!==b.order.length) return false;
271
+ for(var p=0;p<b.order.length;p++){
272
+ var path=b.order[p];
273
+ if(typeof path!=='string'||path.length>512||
274
+ !/^[A-Za-z0-9._/-]+$/.test(path)||path.charAt(0)==='/'||
275
+ path.slice(-1)==='/'||path.indexOf('//')>=0||
276
+ path.split('/').some(function(part){return !part||part==='.'||part==='..';})||
277
+ seen[path]||!Object.prototype.hasOwnProperty.call(b.files,path)) return false;
278
+ seen[path]=true;
279
+ }
280
+ for(var o=0;o<own.length;o++) if(!seen[own[o]]) return false;
281
+ }
282
+ for(var i=0;i<order.length;i++){
283
+ var file=b.files[order[i]];
284
+ if(typeof file==='string'){
285
+ if(schema>=3&&b.storage!=='bundle-v1') return false;
286
+ continue;
287
+ }
288
+ if(!file||typeof file!=='object'||typeof file.key!=='string'||!file.key||
289
+ typeof file.sha256!=='string'||!/^[0-9a-f]{64}$/i.test(file.sha256)||
290
+ !Number.isSafeInteger(file.size)||file.size<=0) return false;
291
+ if(schema>=3){
292
+ if(b.storage!=='artifact-v1') return false;
293
+ var suffix=':file:'+order[i];
294
+ if(file.key.slice(-suffix.length)!==suffix||
295
+ !/^transfer-v1:(stable|preview)-\d+\.\d+\.\d+-[0-9a-f]{64}-(payload|full)$/.test(
296
+ file.key.slice(0,-suffix.length)))
297
+ return false;
298
+ }
299
+ }
300
  return true;
301
  }
302
  function evictSuperseded(db){
 
434
  :(!meta||sameBundle(meta,value)));
435
  if(good){
436
  store.put(value,'active'); store.put(bundleMeta(value),'activeMeta');
437
+ /* Re-validate the rollback payload on this launch. Its metadata
438
+ was previously trusted, but descriptor records may since have
439
+ been evicted or corrupted. tries:1 makes the next launch fail
440
+ closed to packaged code unless this exact recovered runtime
441
+ reaches __bootOk now. */
442
+ store.put({version:value.version,channel:value.channel||'stable',
443
+ manifestRoot:value.manifestRoot||'',targetRoot:value.targetRoot||'',
444
+ runtimeRoot:value.runtimeRoot||'',
445
+ pendingAt:value.at,at:Date.now(),tries:1,recoveryOf:version},'probation');
446
  clearPreviousRecords(store);
447
  clearFinishedRollback(value);
448
  decision={kind:'recovered',bundle:value,failedVersion:version,
 
494
  return;
495
  }
496
  var claimed={version:bundle.version,channel:bundle.channel||prob.channel,
497
+ manifestRoot:bundle.manifestRoot||'',targetRoot:bundle.targetRoot||'',
498
+ runtimeRoot:bundle.runtimeRoot||'',
499
  pendingAt:bundle.at!=null?bundle.at:prob.pendingAt,
500
  at:prob.at||Date.now(),tries:(prob.tries|0)+1};
501
  store.put(claimed,'probation');
 
507
  /* A mismatched legacy/torn guard must never be used to validate a
508
  different active record. Replace it with an exact first claim. */
509
  store.put({version:bundle.version,channel:bundle.channel||'stable',
510
+ manifestRoot:bundle.manifestRoot||'',targetRoot:bundle.targetRoot||'',
511
+ runtimeRoot:bundle.runtimeRoot||'',
512
  pendingAt:bundle.at,at:Date.now(),tries:1},'probation');
513
  clearFinishedRollback(bundle);
514
  decision={kind:'run',bundle:bundle,claimed:true,repaired:true};
 
603
  injectScripts(function(s,i){ s.src=MANIFEST[i]+'?v='+PACKAGED_SRC_REV; },
604
  MANIFEST.length,rendererGateIndex(MANIFEST));
605
  }
606
+ function runArtifactBundle(b){
607
+ /* Descriptor bundles keep each verified source artifact in its own IDB
608
+ record. Load, re-hash, execute and release one file at a time so a large
609
+ source update does not deserialize a ~93 MB object or create 110 Blob
610
+ URLs simultaneously. Strict sequencing preserves the classic shared
611
+ global scope just like async=false script tags. */
612
+ var order=b.order&&b.order.length?b.order:MANIFEST;
613
+ var gate=rendererGateIndex(order),index=0,total=order.length,executed=0,stopped=false;
614
+ var runtimeRows=[];
615
+ bootProgress(0,total);
616
+ function rangeFailure(path,reason){
617
+ if(stopped) return;
618
+ stopped=true;
619
+ console.error('boot: failed',path,reason||'stored artifact unavailable');
620
+ clearBootShield();
621
+ try{if(typeof window.__MASSFRONT_CLEAR_INPUT_GUARD==='function')
622
+ window.__MASSFRONT_CLEAR_INPUT_GUARD();}catch(e){}
623
+ if(executed===0){
624
+ /* The verification pass failed before one downloaded script executed,
625
+ so a packaged session is safe. Keep probation in IndexedDB; clearing
626
+ only the window identity prevents that fallback frame from blessing
627
+ the broken patch through __bootOk. */
628
+ try{
629
+ delete window.__MASSFRONT_PATCHED; delete window.__MASSFRONT_PATCH_AT;
630
+ delete window.__MASSFRONT_PATCH_CHANNEL;
631
+ delete window.__MASSFRONT_PATCH_MANIFEST_ROOT;
632
+ delete window.__MASSFRONT_PATCH_TARGET_ROOT;
633
+ delete window.__MASSFRONT_PATCH_RUNTIME_ROOT;
634
+ }catch(e){}
635
+ runPackaged();
636
+ }else{
637
+ /* A record changed between preflight and execution. Never mix a partial
638
+ classic-script global scope with packaged code; restart so the armed
639
+ probation transaction performs normal recovery. */
640
+ console.warn('boot: stored artifact changed during execution; restarting for recovery');
641
+ try{ if(window.location&&typeof window.location.reload==='function') window.location.reload(); }
642
+ catch(e){}
643
+ }
644
+ }
645
+ function textDigest(text){
646
+ if(!window.crypto||!window.crypto.subtle||typeof TextEncoder==='undefined')
647
+ return Promise.reject(new Error('integrity checks unavailable'));
648
+ var bytes=new TextEncoder().encode(text);
649
+ return window.crypto.subtle.digest('SHA-256',bytes).then(function(value){
650
+ var a=new Uint8Array(value),hex='';
651
+ for(var i=0;i<a.length;i++) hex+=a[i].toString(16).padStart(2,'0');
652
+ return {size:bytes.byteLength,sha256:hex.toLowerCase()};
653
+ });
654
+ }
655
+ function digest(text,ref,path){
656
+ return textDigest(text).then(function(actual){
657
+ if(!Number.isSafeInteger(ref.size)||ref.size<=0||actual.size!==ref.size)
658
+ throw new Error('stored size mismatch');
659
+ if(actual.sha256!==String(ref.sha256).toLowerCase())
660
+ throw new Error('stored hash mismatch');
661
+ return text;
662
+ });
663
+ }
664
+ function append(text,path){
665
+ var blob=URL.createObjectURL(new Blob([text+'\n//# sourceURL='+path],{type:'text/javascript'}));
666
+ var script=document.createElement('script'); script.async=false; script.src=blob;
667
+ script.onload=function(){
668
+ URL.revokeObjectURL(blob);
669
+ var current=index++; executed++;
670
+ bootProgress(index,total);
671
+ if(current===gate&&window.__MF_GL_BOOT_FAILED===true){
672
+ console.warn('boot: renderer phase stopped after WebGL2 initialization failed');
673
+ clearBootShield();
674
+ try{if(typeof window.__MASSFRONT_CLEAR_INPUT_GUARD==='function')
675
+ window.__MASSFRONT_CLEAR_INPUT_GUARD();}catch(e){}
676
+ return;
677
+ }
678
+ next();
679
+ };
680
+ script.onerror=function(){ URL.revokeObjectURL(blob); rangeFailure(path,'script load error'); };
681
+ document.body.appendChild(script);
682
+ }
683
+ var dbPromise=idb();
684
+ function readArtifact(path,ref){
685
+ if(typeof ref==='string') return Promise.resolve(ref);
686
+ return dbPromise.then(function(db){
687
+ return new Promise(function(res,rej){
688
+ try{
689
+ var q=db.transaction(STORE,'readonly').objectStore(STORE).get(ref.key);
690
+ q.onsuccess=function(){ res(q.result); };
691
+ q.onerror=function(){ rej(q.error||new Error('artifact read failed')); };
692
+ }catch(e){ rej(e); }
693
+ });
694
+ }).then(function(rec){
695
+ if(!rec||typeof rec.text!=='string'||!Number.isSafeInteger(rec.size)||
696
+ rec.size<=0||rec.size!==ref.size||
697
+ String(rec.sha256).toLowerCase()!==String(ref.sha256).toLowerCase())
698
+ throw new Error('stored artifact metadata mismatch');
699
+ return digest(rec.text,ref,path);
700
+ });
701
+ }
702
+ function preflight(i){
703
+ if(stopped) return;
704
+ if(i>=total){
705
+ textDigest(runtimeRows.join('\n')).then(function(actual){
706
+ if(Number(b.schema||1)<3) return actual;
707
+ if(actual.sha256!==String(b.runtimeRoot||'').toLowerCase())
708
+ throw new Error('ordered runtime root mismatch');
709
+ var contract=[
710
+ 'schema='+b.schema,'channel='+b.channel,'version='+b.version,
711
+ 'kind='+b.manifestKind,'category='+b.manifestCategory,
712
+ 'patchFrom='+b.manifestPatchFrom,
713
+ 'payload='+b.sourcePayloadRoot,'full='+b.fullRoot,
714
+ 'runtime='+actual.sha256
715
+ ].join('\n');
716
+ return textDigest(contract).then(function(root){
717
+ if(root.sha256!==String(b.manifestRoot||'').toLowerCase())
718
+ throw new Error('manifest root contract mismatch');
719
+ return actual;
720
+ });
721
+ }).then(function(){
722
+ index=0;bootProgress(0,total);next();
723
+ }).catch(function(error){ rangeFailure('__runtime__',error&&error.message); });
724
+ return;
725
+ }
726
+ var path=order[i],ref=b.files[path];
727
+ if(typeof ref==='string'){
728
+ textDigest(ref).then(function(actual){
729
+ if(!Number.isSafeInteger(actual.size)||actual.size<=0)
730
+ throw new Error('stored size mismatch');
731
+ runtimeRows.push(path+'|'+actual.size+'|'+actual.sha256);
732
+ bootProgress(i+1,total);preflight(i+1);
733
+ }).catch(function(error){ rangeFailure(path,error&&error.message); });
734
+ return;
735
+ }
736
+ readArtifact(path,ref).then(function(){
737
+ runtimeRows.push(path+'|'+ref.size+'|'+String(ref.sha256).toLowerCase());
738
+ bootProgress(i+1,total);preflight(i+1);
739
+ }).catch(function(error){ rangeFailure(path,error&&error.message); });
740
+ }
741
+ function next(){
742
+ if(stopped||index>=total) return;
743
+ var path=order[index],ref=b.files[path];
744
+ if(typeof ref==='string'){ append(ref,path); return; }
745
+ readArtifact(path,ref).then(function(text){ append(text,path); })
746
+ .catch(function(error){ rangeFailure(path,error&&error.message); });
747
+ }
748
+ preflight(0);
749
+ }
750
  function runBundle(b){
751
  /* Blob URLs rather than inline text: the browser keeps a real filename for
752
  each source, so a stack trace from a patched build is still readable. */
753
  var order=b.order&&b.order.length? b.order : MANIFEST;
754
+ if(Number(b.schema||1)>=3&&b.runtimeRoot){ runArtifactBundle(b);return; }
755
+ for(var i=0;i<order.length;i++) if(typeof b.files[order[i]]!=='string'){
756
+ runArtifactBundle(b); return;
757
+ }
758
  injectScripts(function(s,i){
759
  var path=order[i], src=b.files[path];
760
  if(src==null) s.src=path;
 
775
  window.__MASSFRONT_PATCHED=decision.bundle.version||'?';
776
  window.__MASSFRONT_PATCH_AT=decision.bundle.at;
777
  window.__MASSFRONT_PATCH_CHANNEL=decision.bundle.channel||'stable';
778
+ window.__MASSFRONT_PATCH_MANIFEST_ROOT=decision.bundle.manifestRoot||'';
779
+ window.__MASSFRONT_PATCH_TARGET_ROOT=decision.bundle.targetRoot||'';
780
+ window.__MASSFRONT_PATCH_RUNTIME_ROOT=decision.bundle.runtimeRoot||'';
781
  window.__MASSFRONT_RECOVERED_PATCH=decision.failedVersion||'?';
782
  console.warn('boot: restored validated patch '+decision.bundle.version+
783
  ' after '+decision.failedVersion+' failed');
 
788
  window.__MASSFRONT_PATCHED=decision.bundle.version||'?';
789
  window.__MASSFRONT_PATCH_AT=decision.bundle.at;
790
  window.__MASSFRONT_PATCH_CHANNEL=decision.bundle.channel||'stable';
791
+ window.__MASSFRONT_PATCH_MANIFEST_ROOT=decision.bundle.manifestRoot||'';
792
+ window.__MASSFRONT_PATCH_TARGET_ROOT=decision.bundle.targetRoot||'';
793
+ window.__MASSFRONT_PATCH_RUNTIME_ROOT=decision.bundle.runtimeRoot||'';
794
  runBundle(decision.bundle);
795
  return;
796
  }
 
808
  failed before it selected `active`. It has NOT proved the stored patch
809
  good, so it must not erase that patch's probation record. Leaving it in
810
  place means the next healthy launch retries under the normal rollback
811
+ guard instead of running an unguarded payload. Recovered previous code
812
+ now owns a fresh probation guard and must confirm its own descriptor
813
+ records before becoming the new last-known-good runtime. */
814
+ if(!window.__MASSFRONT_PATCHED) return;
815
  idb().then(function(db){
816
  return confirmPatch(db,{version:window.__MASSFRONT_PATCHED,
817
  channel:window.__MASSFRONT_PATCH_CHANNEL||'stable',
818
+ manifestRoot:window.__MASSFRONT_PATCH_MANIFEST_ROOT||'',
819
+ targetRoot:window.__MASSFRONT_PATCH_TARGET_ROOT||'',
820
+ runtimeRoot:window.__MASSFRONT_PATCH_RUNTIME_ROOT||'',
821
  at:window.__MASSFRONT_PATCH_AT});
822
  }).catch(function(){});
823
  };
 
835
  if(!secure) return;
836
  window.__mfPwaDiag={supported:true,registered:false,controlled:!!navigator.serviceWorker.controller,error:null};
837
  window.addEventListener('load',function(){
838
+ navigator.serviceWorker.register('./sw.js?v=1.33.60-shell2',{scope:'./',updateViaCache:'none'})
839
  .then(function(reg){
840
  window.__mfPwaDiag.registered=true;
841
  window.__mfPwaDiag.scope=reg.scope;
index.html CHANGED
@@ -17,19 +17,19 @@
17
  <meta name="mobile-web-app-capable" content="yes">
18
  <meta name="theme-color" content="#060c16">
19
  <meta name="format-detection" content="telephone=no">
20
- <link rel="apple-touch-icon" href="./assets/icons/icon-180.png?v=1.33.55">
21
- <link rel="apple-touch-icon" sizes="152x152" href="./assets/icons/icon-152.png?v=1.33.55">
22
- <link rel="apple-touch-startup-image" href="./assets/icons/splash-2732.png?v=1.33.55">
23
- <link rel="icon" href="./assets/icons/icon-192.png?v=1.33.55">
24
- <link rel="manifest" href="./assets/app.webmanifest?v=1.33.55">
25
- <link rel="stylesheet" href="./src/styles/ui.css?v=1.33.55-hud">
26
- <link rel="stylesheet" href="./assets/ui.css?v=1.33.55-ui-safety">
27
- <link rel="stylesheet" href="./src/styles/auth.css?v=1.33.55">
28
- <link rel="stylesheet" href="./src/styles/tutorial.css?v=1.33.55">
29
- <link rel="stylesheet" href="./src/styles/ads.css?v=1.33.55">
30
- <link rel="stylesheet" href="./src/styles/store.css?v=1.33.55">
31
- <link rel="stylesheet" href="./src/styles/restree.css?v=1.33.55">
32
- <link rel="stylesheet" href="./src/styles/intro.css?v=1.33.55">
33
  <style>
34
  /* CRITICAL — inline so it holds on the FIRST painted frame even if the linked
35
  intro.css above is slow or fails. The launch reveal (src/intro.js, z-index 950)
@@ -73,14 +73,17 @@ body:not(.mfIntroDone) .overlay{display:none!important}
73
  <div id="goalBar" role="status" aria-live="polite"></div>
74
  </div>
75
 
76
- <!-- Was: a truncated profile name ("COMMANDE ▪ LV 1"), an XP bar and a health
77
- bar. The name was slice(0,8) of whatever the profile was called, so it read
78
- as a different word for most players, and the commander's health is already
79
- on the unit itself in the 3D view. Now: rank symbol + level + XP, pinned to
80
- the top-left corner. Still tappable to select and center the Commander. -->
81
  <div id="heroBar" role="button" tabindex="0" aria-label="Select and center the Commander">
82
- <div class="heroRow"><span id="heroRankEm">🎗</span><span id="heroLvlTxt">LV 1</span></div>
83
- <div id="xpOuter"><div id="xpFill"></div></div>
 
 
 
 
 
84
  </div>
85
 
86
  <div id="toast"></div>
@@ -105,11 +108,12 @@ body:not(.mfIntroDone) .overlay{display:none!important}
105
  </span>
106
  </div>
107
  </div>
108
- <div id="hudDeckTabs" aria-label="Command dock">
109
- <button class="hudDeckBtn on" data-deck="orders"><span>⌁</span>ORDERS</button>
110
- <button class="hudDeckBtn" data-deck="platoons"><span>Ⅳ</span>PLATOONS</button>
111
- <button class="hudDeckBtn" data-deck="abilities" disabled aria-disabled="true"><span>✦</span>ABILITIES</button>
112
- <button class="hudDeckBtn" data-deck="view"><span>◇</span>VIEW</button>
 
113
  </div>
114
  <div class="btnrow" id="camRow">
115
  <button class="cbtn camBtn" id="rotL"><span class="em">⟲</span><span>Rot</span></button>
@@ -156,7 +160,6 @@ body:not(.mfIntroDone) .overlay{display:none!important}
156
  <button class="cbtn" id="boxBtn"><span class="em">⬚</span><span>Select</span></button>
157
  <button class="cbtn" id="stopBtn"><span class="em">✋</span><span>Stop</span></button>
158
  <button class="cbtn" id="buildBtn"><span class="em">🏗</span><span>Build</span></button>
159
- <button class="cbtn" id="baseFindBtn"><span class="em">⌖</span><span>Base</span></button>
160
  </div>
161
  </div>
162
 
@@ -526,30 +529,129 @@ body:not(.mfIntroDone) .overlay{display:none!important}
526
  <!-- The version block used to sit permanently at the bottom of the menu. It is
527
  now a status dot in the header — grey unchecked, amber checking or update
528
  waiting, green current, red failed — and this is where the dot leads. -->
529
- <div class="overlay" id="updScr">
530
- <div class="subMenuHead">
531
- <h2>◈ GAME VERSION</h2>
532
- <span>Updates · release notes · rollback</span>
533
- </div>
534
- <div class="updScroll">
535
- <div id="updPanel" class="mini">
536
- <div class="updHead">
537
- <div>
538
- <div id="updTxt">GAME VERSION</div>
539
- <div id="updSub">v1.32.40</div>
 
 
 
 
 
 
540
  </div>
541
- <div class="updBtns">
542
- <button id="updCancel" style="display:none">✕</button>
543
- <button id="updBtn">CHECK</button>
 
 
 
 
544
  </div>
545
- </div>
546
- <div id="updBarO"><div id="updBarF"></div></div>
547
- <div id="updFeed" style="display:none"></div>
548
- <div id="updNotes" style="display:none"></div>
549
- <button id="updRoll">↺ Revert to the packaged build</button>
550
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
551
  </div>
552
- <div class="warFoot"><button class="mbtn alt" id="updBack">◀ &nbsp;BACK</button></div>
 
 
 
 
553
  </div>
554
 
555
  <div class="overlay" id="inboxScr">
@@ -707,6 +809,6 @@ body:not(.mfIntroDone) .overlay{display:none!important}
707
  <button class="mbtn alt" id="pauseSettings">⚙ &nbsp;SETTINGS</button>
708
  <button class="mbtn alt" id="quitBtn">✦ &nbsp;ABANDON MATCH</button>
709
  </div>
710
- <script src="./boot.js?v=1.33.55-boot7"></script>
711
  </body>
712
  </html>
 
17
  <meta name="mobile-web-app-capable" content="yes">
18
  <meta name="theme-color" content="#060c16">
19
  <meta name="format-detection" content="telephone=no">
20
+ <link rel="apple-touch-icon" href="./assets/icons/icon-180.png?v=1.33.60">
21
+ <link rel="apple-touch-icon" sizes="152x152" href="./assets/icons/icon-152.png?v=1.33.60">
22
+ <link rel="apple-touch-startup-image" href="./assets/icons/splash-2732.png?v=1.33.60">
23
+ <link rel="icon" href="./assets/icons/icon-192.png?v=1.33.60">
24
+ <link rel="manifest" href="./assets/app.webmanifest?v=1.33.60">
25
+ <link rel="stylesheet" href="./src/styles/ui.css?v=1.33.60-hud2">
26
+ <link rel="stylesheet" href="./assets/ui.css?v=1.33.60-ui-safety">
27
+ <link rel="stylesheet" href="./src/styles/auth.css?v=1.33.60">
28
+ <link rel="stylesheet" href="./src/styles/tutorial.css?v=1.33.60">
29
+ <link rel="stylesheet" href="./src/styles/ads.css?v=1.33.60">
30
+ <link rel="stylesheet" href="./src/styles/store.css?v=1.33.60">
31
+ <link rel="stylesheet" href="./src/styles/restree.css?v=1.33.60">
32
+ <link rel="stylesheet" href="./src/styles/intro.css?v=1.33.60">
33
  <style>
34
  /* CRITICAL — inline so it holds on the FIRST painted frame even if the linked
35
  intro.css above is slow or fails. The launch reveal (src/intro.js, z-index 950)
 
73
  <div id="goalBar" role="status" aria-live="polite"></div>
74
  </div>
75
 
76
+ <!-- The upper-left commander locator is both identity and navigation: it uses
77
+ the chosen commander's canonical portrait/callsign, exposes live HP/XP, and
78
+ a tap always selects and recentres that commander. -->
 
 
79
  <div id="heroBar" role="button" tabindex="0" aria-label="Select and center the Commander">
80
+ <span id="heroPortrait" aria-hidden="true"><img id="heroPortraitImg" alt=""><i id="heroPortraitFallback">C</i><b id="heroRankEm">🎗</b></span>
81
+ <span class="heroProfileBody">
82
+ <span class="heroProfileHead"><strong id="heroNameTxt">COMMANDER</strong><b id="heroLvlTxt">LV 1</b></span>
83
+ <span id="heroCallsignTxt">FIELD COMMAND</span>
84
+ <span class="heroVital"><small>HP</small><span class="heroBarOuter"><i id="heroHpFill"></i></span><b id="heroHpTxt">100%</b></span>
85
+ <span class="heroVital heroXp"><small>XP</small><span id="xpOuter"><i id="xpFill"></i></span><b id="heroXpTxt">0%</b></span>
86
+ </span>
87
  </div>
88
 
89
  <div id="toast"></div>
 
108
  </span>
109
  </div>
110
  </div>
111
+ <div id="hudDeckTabs" role="tablist" aria-label="Command dock">
112
+ <button class="hudDeckBtn on" role="tab" aria-selected="true" data-deck="orders"><span>⌁</span>ORDERS</button>
113
+ <button class="hudDeckBtn" role="tab" aria-selected="false" data-deck="platoons"><span>Ⅳ</span>PLATOONS</button>
114
+ <button class="hudDeckBtn" role="tab" aria-selected="false" data-deck="buildings"><span>⌂</span>BUILDINGS</button>
115
+ <button class="hudDeckBtn" role="tab" aria-selected="false" data-deck="abilities" disabled aria-disabled="true"><span>✦</span>ABILITIES</button>
116
+ <button class="hudDeckBtn" role="tab" aria-selected="false" data-deck="view"><span>◇</span>VIEW</button>
117
  </div>
118
  <div class="btnrow" id="camRow">
119
  <button class="cbtn camBtn" id="rotL"><span class="em">⟲</span><span>Rot</span></button>
 
160
  <button class="cbtn" id="boxBtn"><span class="em">⬚</span><span>Select</span></button>
161
  <button class="cbtn" id="stopBtn"><span class="em">✋</span><span>Stop</span></button>
162
  <button class="cbtn" id="buildBtn"><span class="em">🏗</span><span>Build</span></button>
 
163
  </div>
164
  </div>
165
 
 
529
  <!-- The version block used to sit permanently at the bottom of the menu. It is
530
  now a status dot in the header — grey unchecked, amber checking or update
531
  waiting, green current, red failed — and this is where the dot leads. -->
532
+ <!-- GAME LAUNCHER / VERSION CENTER
533
+ One surface owns both cold-launch readiness and the menu's version detail.
534
+ The updater keeps its established IDs and authority; src/launcher.js reads
535
+ explicit updater/auth events and never infers state from the labels below. -->
536
+ <div class="overlay mfLauncher" id="updScr" aria-label="MASSFRONT launcher and game version">
537
+ <div class="mfLauncherBackdrop" aria-hidden="true"><i></i><i></i><i></i></div>
538
+ <header class="mfLauncherTop">
539
+ <div class="mfLauncherTopBrand"><b>MASSFRONT</b><span>COMMAND NETWORK</span></div>
540
+ <div class="mfLauncherIdentity" id="mfLaunchIdentity" data-state="pending">
541
+ <i aria-hidden="true"></i><span id="mfLaunchIdentityText">IDENTITY LINK</span>
542
+ </div>
543
+ </header>
544
+ <div class="updScroll mfLauncherScroll" id="mfLauncherScroll">
545
+ <section class="mfLauncherHero" aria-labelledby="mfLaunchHeroTitle">
546
+ <div class="mfLauncherHeroArt" aria-hidden="true">
547
+ <img src="./assets/icons/splash-2732.png?v=1.33.60" alt="" decoding="async">
548
+ <span class="mfLauncherOrbit"></span><span class="mfLauncherSweep"></span>
549
  </div>
550
+ <div class="mfLauncherHeroCopy">
551
+ <span class="mfLauncherEyebrow">◈ MASSFRONT LAUNCH CONTROL</span>
552
+ <h2 id="mfLaunchHeroTitle">YOUR COMMAND AWAITS</h2>
553
+ <p id="mfLaunchHeroSummary">Verifying the installed battlefield and command link.</p>
554
+ <div class="mfLauncherHeroChips">
555
+ <span id="mfLaunchVersion">v—</span><span id="mfLaunchCategory">SYSTEM</span><span id="mfLaunchNetwork">CHECKING LINK</span>
556
+ </div>
557
  </div>
558
+ </section>
559
+
560
+ <section class="mfLauncherConsole" aria-labelledby="mfLaunchUpdateTitle">
561
+ <div class="mfLauncherSectionHead">
562
+ <div><span>01</span><h3 id="mfLaunchUpdateTitle">GAME READINESS</h3></div>
563
+ <b id="mfLaunchPhase">INITIALIZING</b>
564
+ </div>
565
+ <div id="updPanel" class="mini">
566
+ <div class="updHead">
567
+ <div>
568
+ <div id="updTxt">GAME VERSION</div>
569
+ <div id="updSub">Preparing local runtime</div>
570
+ </div>
571
+ <div class="updBtns">
572
+ <button id="updCancel" type="button" aria-label="Cancel update" style="display:none">✕</button>
573
+ <button id="updBtn" type="button">CHECK</button>
574
+ </div>
575
+ </div>
576
+ <div id="updBarO" role="progressbar" aria-label="Update progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><div id="updBarF"></div></div>
577
+ <div class="mfLauncherTelemetry" aria-live="polite">
578
+ <span><small>TRANSFER</small><b id="mfLaunchTransfer">—</b></span>
579
+ <span><small>SPEED</small><b id="mfLaunchSpeed">—</b></span>
580
+ <span><small>INTEGRITY</small><b id="mfLaunchIntegrity">LOCAL</b></span>
581
+ </div>
582
+ <div id="updFeed" style="display:none"></div>
583
+ <div id="updNotes" style="display:none"></div>
584
+ <button id="updRoll" type="button">↺ Revert to the packaged build</button>
585
+ </div>
586
+ </section>
587
+
588
+ <section class="mfLauncherRelease" aria-labelledby="mfLaunchReleaseTitle">
589
+ <div class="mfLauncherSectionHead">
590
+ <div><span>02</span><h3 id="mfLaunchReleaseTitle">LATEST RELEASE</h3></div>
591
+ <b id="mfLaunchReleaseVersion">v—</b>
592
+ </div>
593
+ <article class="mfLauncherReleaseCard">
594
+ <div class="mfLauncherReleaseBanner">
595
+ <span id="mfLaunchReleaseKind">SYSTEM</span>
596
+ <div><h4 id="mfLaunchReleaseName">COMMAND SYSTEM</h4><p id="mfLaunchReleaseSummary">Release information is loading.</p></div>
597
+ </div>
598
+ <div class="mfLauncherTabs" role="tablist" aria-label="Release note sections">
599
+ <button type="button" class="on" role="tab" aria-selected="true" data-launch-notes="features">FEATURES</button>
600
+ <button type="button" role="tab" aria-selected="false" data-launch-notes="fixes">BUG FIXES</button>
601
+ <button type="button" role="tab" aria-selected="false" data-launch-notes="upcoming">UPCOMING</button>
602
+ </div>
603
+ <ul id="mfLaunchNoteList" class="mfLauncherNoteList"><li>Preparing release briefing…</li></ul>
604
+ </article>
605
+ </section>
606
+
607
+ <section class="mfLauncherContent" aria-labelledby="mfLaunchContentTitle">
608
+ <div class="mfLauncherSectionHead">
609
+ <div><span>03</span><h3 id="mfLaunchContentTitle">OPTIONAL DOWNLOADS</h3></div>
610
+ <b>CORE PLAY UNAFFECTED</b>
611
+ </div>
612
+ <div class="mfLauncherPackRail">
613
+ <article class="mfLauncherPack" data-launch-pack="audio">
614
+ <img src="./assets/icons/icon-512.png?v=1.33.60" alt="MASSFRONT faction command insignia" loading="lazy" decoding="async">
615
+ <div><span>COMMAND AUDIO</span><h4>VOICES + MUSIC</h4><p>Faction radio, UGA KEEL guidance and the full battle score.</p><small id="mfLaunchPackAudioState">CHECKING</small></div>
616
+ <button id="mfLaunchPackAudio" type="button">MANAGE</button>
617
+ </article>
618
+ <article class="mfLauncherPack onlineOnly" data-launch-pack="galactic">
619
+ <img src="./assets/textures/planets/war-table/aelos-basecolor-v1.png?v=1.33.60" alt="Aelos planetary surface" loading="lazy" decoding="async">
620
+ <div><span>EXPANSION MODULE</span><h4>GALACTIC EXPLORATION</h4><p>Optional ship, orbital and planetary campaign content.</p><small id="mfLaunchPackGalacticState">OPTIONAL</small></div>
621
+ <button id="mfLaunchPackGalactic" type="button">INSTALL</button>
622
+ <i class="mfRequiresNet">☁ REQUIRES INTERNET</i>
623
+ </article>
624
+ </div>
625
+ <div id="mfLaunchPackProgress"></div>
626
+ </section>
627
+
628
+ <section class="mfLauncherHistory" aria-labelledby="mfLaunchHistoryTitle">
629
+ <div class="mfLauncherSectionHead">
630
+ <div><span>04</span><h3 id="mfLaunchHistoryTitle">VERSION HISTORY</h3></div>
631
+ <b>EVIDENCE-BACKED</b>
632
+ </div>
633
+ <div class="mfLauncherTabs mfLauncherHistoryTabs" role="tablist" aria-label="Version history source">
634
+ <button type="button" class="on" role="tab" aria-selected="true" data-launch-history="published">PUBLISHED</button>
635
+ <button type="button" role="tab" aria-selected="false" data-launch-history="device">THIS DEVICE</button>
636
+ </div>
637
+ <ol id="mfLaunchHistoryList" class="mfLauncherTimeline"><li><b>BUILDING CATALOG</b><span>Verified records are loading.</span></li></ol>
638
+ </section>
639
+
640
+ <details class="mfLauncherDetails" id="mfLaunchDetails">
641
+ <summary>TECHNICAL DETAILS <span>storage · channel · rollback</span></summary>
642
+ <div class="mfLauncherDetailGrid">
643
+ <span><small>INSTALLED</small><b id="mfLaunchTechInstalled">v—</b></span>
644
+ <span><small>CHANNEL</small><b id="mfLaunchTechChannel">STABLE</b></span>
645
+ <span><small>NETWORK</small><b id="mfLaunchTechNetwork">CHECKING</b></span>
646
+ <span><small>AVAILABLE STORAGE</small><b id="mfLaunchTechStorage">—</b></span>
647
+ </div>
648
+ </details>
649
  </div>
650
+ <footer class="warFoot mfLauncherFoot">
651
+ <button class="mbtn alt mfLauncherOfflineAction" id="mfLaunchOffline" type="button">PLAY OFFLINE</button>
652
+ <button class="accBtn mfLauncherPlay" id="mfLaunchPlay" type="button" data-front-focus disabled>VERIFYING GAME…</button>
653
+ <button class="mbtn alt" id="updBack" type="button">◀ &nbsp;BACK</button>
654
+ </footer>
655
  </div>
656
 
657
  <div class="overlay" id="inboxScr">
 
809
  <button class="mbtn alt" id="pauseSettings">⚙ &nbsp;SETTINGS</button>
810
  <button class="mbtn alt" id="quitBtn">✦ &nbsp;ABANDON MATCH</button>
811
  </div>
812
+ <script src="./boot.js?v=1.33.60-boot7"></script>
813
  </body>
814
  </html>
src/assetpack.js CHANGED
@@ -30,42 +30,186 @@
30
  size; a pack that has not changed costs one small JSON request.
31
  ============================================================================ */
32
 
33
- const PACK = { idx:null, have:{}, busy:false, got:0, total:0, state:'idle', err:'' };
34
- const PACK_DB = 'massfront-packs', PACK_STORE = 'files';
35
- const PACK_PREF = 'massfront_pack_pref'; // 'auto' | 'ask' | 'off'
36
-
37
- function packDb(){
38
- return new Promise((res, rej) => {
39
- const r = indexedDB.open(PACK_DB, 1);
40
- r.onupgradeneeded = () => { const d = r.result;
41
- if(!d.objectStoreNames.contains(PACK_STORE)) d.createObjectStore(PACK_STORE); };
42
- r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error);
43
- });
44
- }
45
- async function packGet(k){
46
- const db = await packDb();
47
- return new Promise((res, rej) => {
48
- const tx = db.transaction(PACK_STORE, 'readonly');
49
- const q = tx.objectStore(PACK_STORE).get(k);
50
- q.onsuccess = () => res(q.result); q.onerror = () => rej(q.error);
51
- });
52
- }
53
- async function packPut(k, v){
54
- const db = await packDb();
55
- return new Promise((res, rej) => {
56
- const tx = db.transaction(PACK_STORE, 'readwrite');
57
- tx.objectStore(PACK_STORE).put(v, k);
58
- tx.oncomplete = () => res(); tx.onerror = () => rej(tx.error);
59
- });
60
- }
61
- async function packKeys(){
62
- const db = await packDb();
63
- return new Promise((res, rej) => {
64
- const tx = db.transaction(PACK_STORE, 'readonly');
65
- const q = tx.objectStore(PACK_STORE).getAllKeys();
66
- q.onsuccess = () => res(q.result || []); q.onerror = () => rej(q.error);
67
- });
68
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
  /* The endpoint is the same one the updater resolves — one server, one setting,
71
  configured in exactly one place. */
@@ -80,79 +224,645 @@ function packBytes(n){
80
  return n < 1048576 ? (n / 1024).toFixed(0) + ' KB' : (n / 1048576).toFixed(1) + ' MB';
81
  }
82
 
83
- async function packLoadIndex(){
84
- if(typeof netAllowed==='function' && !netAllowed()) return null;
85
- const base = packEndpoint();
86
- if(!base) return null;
87
- try{
88
- const r = await fetch(base + '/packs.json?t=' + Date.now(), {cache:'no-store'});
89
- if(!r.ok) return null;
90
- const j = await r.json();
91
- PACK.idx = j && j.packs ? j.packs : null;
92
- return PACK.idx;
93
- }catch(e){ return null; }
94
- }
95
-
96
- /* What is missing, and how many bytes that is. Size is part of the key so a
97
- replaced file re-downloads without needing a version number. */
98
- async function packMissing(pack){
99
- if(!PACK.idx || !PACK.idx[pack]) return {files:[], bytes:0};
100
- const keys = new Set(await packKeys());
101
- const out = [];
102
- let bytes = 0;
103
- for(const f of PACK.idx[pack].files){
104
- const k = pack + '/' + f.name + ':' + f.size;
105
- if(!keys.has(k)){ out.push({...f, key:k}); bytes += f.size; }
106
- }
107
- return {files:out, bytes};
108
- }
109
-
110
- async function packDownload(pack, onProgress){
111
- if(typeof netAllowed==='function' && !netAllowed()){
112
- PACK.state='idle'; packRenderBar(); return false;
113
- }
114
- const base = packEndpoint();
115
- if(!base) return false;
116
- if(PACK.busy) return false; // second reader of the flag; see packStarting
117
- const miss = await packMissing(pack);
118
- if(!miss.files.length){ PACK.state = 'ready'; return true; }
119
- PACK.busy = true; PACK.state = 'downloading';
120
- PACK.total = miss.bytes; PACK.got = 0;
121
- for(const f of miss.files){
122
- try{
123
- const r = await fetch(base + '/pack/' + pack + '/' + encodeURIComponent(f.name));
124
- if(!r.ok) throw new Error('HTTP ' + r.status);
125
- const blob = await r.blob();
126
- await packPut(f.key, blob);
127
- PACK.got += f.size;
128
- if(onProgress) onProgress(PACK.got, PACK.total);
129
- packRenderBar();
130
- }catch(e){
131
- PACK.busy = false; PACK.state = 'error';
132
- PACK.err = 'Download failed — the game runs without it';
133
- packRenderBar();
134
- return false;
135
- }
136
- }
137
- PACK.busy = false; PACK.state = 'ready';
138
- packRenderBar();
139
- return true;
140
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
 
142
  /* Hand back a playable URL for a pack file, or null if it is not stored. The
143
  object URL is cached per file — creating a new one on every play would leak a
144
  blob reference for the lifetime of the document. */
145
  const packURLs = {};
146
- async function packURL(pack, name){
147
- const k = pack + '/' + name;
148
- if(packURLs[k]) return packURLs[k];
149
- if(!PACK.idx || !PACK.idx[pack]) return null;
150
- const meta = PACK.idx[pack].files.find(f => f.name === name);
151
- if(!meta) return null;
152
- const blob = await packGet(k + ':' + meta.size);
153
- if(!blob) return null;
154
- return (packURLs[k] = URL.createObjectURL(blob));
155
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
 
157
  /* ---- UI -------------------------------------------------------------------
158
  A single line on the start screen, in the same register as the updater panel:
@@ -248,15 +958,19 @@ async function packStartInner(manual){
248
  if(!want.length){ PACK.state = 'idle'; packRenderBar(); return; }
249
  let bytes = 0;
250
  const need = [];
251
- for(const p of want){
252
- const m = await packMissing(p);
253
- if(m.files.length){ need.push(p); bytes += m.bytes; }
254
- }
255
- PACK.total = bytes;
256
- if(!need.length){
257
- PACK.state = 'ready'; packRenderBar();
258
- if(typeof audAttachPack === 'function') audAttachPack();
259
- return;
 
 
 
 
260
  }
261
  let pref = 'ask';
262
  try{ pref = localStorage.getItem(PACK_PREF) || 'ask'; }catch(e){}
@@ -274,8 +988,10 @@ async function packStartInner(manual){
274
  IndexedDB and never hear a note of it, permanently, with no control in
275
  the UI that would fix it. Re-derived from storage rather than from the
276
  download results so a pack completed on an EARLIER launch also counts. */
277
- let haveOne = false;
278
- for(const p of want) if(!(await packMissing(p)).files.length){ haveOne = true; break; }
 
 
279
  if(haveOne && typeof audAttachPack === 'function') audAttachPack();
280
  /* A later pack succeeding must not bury an earlier one's failure: PACK.state
281
  is what the panel reads, and 'ready' hides the panel outright — so voice
@@ -296,25 +1012,30 @@ function initAssetPacks(){
296
 
297
  /* Galactic Exploration ships in player APK/www from the signed runtime pack.
298
  Older installs (notably 1.33.51) omitted it. When the HEAD probe misses, this
299
- fetches the remote stub, then the signed manifest, then each file into a
300
- dedicated IndexedDB — same blob-keyed pattern as audio packs. A blob: URL for
301
- index.html was considered for navigation and rejected: the Galactic entry
 
302
  ticket lives in this document's sessionStorage, and a blob: document is a
303
  different origin, so the ticket and the return bridge would vanish. Cache
304
  here; mfOpenExploration keeps the War Room fallback. Opening still needs the
305
  files on the same origin (packaged www), which is why player packs include
306
  the module instead of relying on this download alone. */
307
- const EXP_PACK_DB='massfront-exploration-pack', EXP_PACK_STORE='files';
308
- const EXP_PACK_STUB='assets/data/exploration-pack-remote.json';
309
- let expPackBusy=false;
310
-
311
- function expPackDb(){
312
- return new Promise((res, rej) => {
313
- const r = indexedDB.open(EXP_PACK_DB, 1);
314
- r.onupgradeneeded = () => { const d = r.result;
315
- if(!d.objectStoreNames.contains(EXP_PACK_STORE)) d.createObjectStore(EXP_PACK_STORE); };
316
- r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error);
317
- });
 
 
 
 
318
  }
319
  async function expPackGet(k){
320
  const db = await expPackDb();
@@ -323,14 +1044,6 @@ async function expPackGet(k){
323
  q.onsuccess = () => res(q.result); q.onerror = () => rej(q.error);
324
  });
325
  }
326
- async function expPackPut(k, v){
327
- const db = await expPackDb();
328
- return new Promise((res, rej) => {
329
- const tx = db.transaction(EXP_PACK_STORE, 'readwrite');
330
- tx.objectStore(EXP_PACK_STORE).put(v, k);
331
- tx.oncomplete = () => res(); tx.onerror = () => rej(tx.error);
332
- });
333
- }
334
  function expPackJoin(base, rel){
335
  const root=String(base||'').replace(/\/?$/,'/');
336
  const path=String(rel||'').replace(/^\/+/,'');
@@ -351,38 +1064,69 @@ async function mfExplorationRemoteSpec(){
351
  const q=String(stub&&stub.downloadQuery||'');
352
  return {base,manifest,downloadQuery:q};
353
  }
354
- async function mfInstallExplorationPack(){
355
- if(expPackBusy) return {ok:false,reason:'busy'};
356
- if(typeof indexedDB==='undefined') return {ok:false,reason:'no-idb'};
357
- if(typeof netAllowed==='function'&&!netAllowed()) return {ok:false,reason:'offline'};
358
- expPackBusy=true;
359
- if(typeof toast==='function') toast('Galactic pack downloading...');
360
- try{
361
- const spec=await mfExplorationRemoteSpec();
 
 
 
 
 
 
 
 
362
  if(!spec.manifest&&!spec.base) return {ok:false,reason:'no-endpoint'};
363
  let manUrl=spec.manifest||expPackJoin(spec.base,'exploration-content-manifest-v1.json');
364
  if(spec.downloadQuery&&manUrl.indexOf('?')<0) manUrl+=spec.downloadQuery;
365
- const mr=await fetch(manUrl,{cache:'no-store'});
366
- if(!mr.ok) return {ok:false,reason:'manifest'};
367
- const man=await mr.json();
368
- if(!man||man.kind!=='ExplorationContentManifestV1'||!Array.isArray(man.files)||!man.files.length)
369
- return {ok:false,reason:'manifest'};
370
- const q=spec.downloadQuery||'';
371
- for(const entry of man.files){
372
- const rel=String(entry.path||'').replace(/\\/g,'/');
373
- if(!rel||rel.startsWith('/')||rel.includes('..')) return {ok:false,reason:'path'};
374
- const have=await expPackGet(rel);
375
- if(have&&typeof have.size==='number'&&have.size===entry.bytes) continue;
376
- const fileUrl=expPackJoin(spec.base,rel);
377
- const url=fileUrl+(q&&fileUrl.indexOf('?')<0?q:'');
378
- const fr=await fetch(url,{cache:'no-store'});
379
- if(!fr.ok) return {ok:false,reason:'file'};
380
- const blob=await fr.blob();
381
- await expPackPut(rel, blob);
382
- }
383
- return {ok:true,cached:true,openUrl:null};
384
- }catch(e){
385
- return {ok:false,reason:'fetch'};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
386
  }finally{
387
  expPackBusy=false;
388
  }
 
30
  size; a pack that has not changed costs one small JSON request.
31
  ============================================================================ */
32
 
33
+ const PACK = {
34
+ /* idx/rawIndex describe what the server currently offers. activeIdx/rawActive
35
+ describe the last fully verified set the game is allowed to mount. Keeping
36
+ those identities separate is what lets a player keep using v1 while a v2
37
+ download is declined, interrupted, corrupt or too large for free storage. */
38
+ idx:null, rawIndex:null, activeIdx:Object.create(null), rawActive:{version:2,packs:{}},
39
+ have:{}, busy:false, got:0, total:0, state:'idle', err:'', storage:null
40
+ };
41
+ const PACK_DB = 'massfront-packs', PACK_STORE = 'files';
42
+ const PACK_CHUNK_STORE = 'chunks', PACK_META_STORE = 'meta', PACK_DB_VERSION = 2;
43
+ const PACK_PREF = 'massfront_pack_pref'; // 'auto' | 'ask' | 'off'
44
+ /* Two MiB keeps a killed phone from repeating a large transfer, but does not
45
+ turn IndexedDB into tens of thousands of tiny transactions. Publishers may
46
+ choose smaller chunks; anything larger than eight MiB is rejected so a bad
47
+ manifest cannot silently defeat the bounded-memory contract. A single file
48
+ is capped too: very large products must be sectioned into independent files
49
+ and packs instead of recreating the old monolithic download problem. */
50
+ const PACK_DEFAULT_CHUNK_BYTES = 2 * 1024 * 1024;
51
+ const PACK_MAX_CHUNK_BYTES = 8 * 1024 * 1024;
52
+ const PACK_MAX_FILE_BYTES = 256 * 1024 * 1024;
53
+ const PACK_MAX_PACKS = 512, PACK_MAX_FILES = 4096, PACK_MAX_CHUNKS_PER_FILE = 4096;
54
+ const PACK_STORAGE_MARGIN_BYTES = 16 * 1024 * 1024;
55
+
56
+ const PACK_SHA256_K = new Uint32Array([
57
+ 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
58
+ 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
59
+ 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
60
+ 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
61
+ 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
62
+ 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
63
+ 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
64
+ 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
65
+ ]);
66
+
67
+ /* WebCrypto has no streaming digest. Reading a 200+ MiB GLB into one
68
+ ArrayBuffer merely to verify it would negate chunked delivery, so this small
69
+ incremental SHA-256 keeps verification bounded to one chunk at a time. */
70
+ class PackSha256State{
71
+ constructor(){
72
+ this.h = new Uint32Array([0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]);
73
+ this.w = new Uint32Array(64);
74
+ this.buf = new Uint8Array(64);
75
+ this.bufLen = 0;
76
+ this.bytes = 0;
77
+ }
78
+ block(data, off){
79
+ const w=this.w;
80
+ for(let i=0;i<16;i++){
81
+ const p=off+i*4;
82
+ w[i]=((data[p]<<24)|(data[p+1]<<16)|(data[p+2]<<8)|data[p+3])>>>0;
83
+ }
84
+ for(let i=16;i<64;i++){
85
+ const x=w[i-15],y=w[i-2];
86
+ const s0=((x>>>7)|(x<<25))^((x>>>18)|(x<<14))^(x>>>3);
87
+ const s1=((y>>>17)|(y<<15))^((y>>>19)|(y<<13))^(y>>>10);
88
+ w[i]=(w[i-16]+s0+w[i-7]+s1)>>>0;
89
+ }
90
+ let a=this.h[0],b=this.h[1],c=this.h[2],d=this.h[3];
91
+ let e=this.h[4],f=this.h[5],g=this.h[6],h=this.h[7];
92
+ for(let i=0;i<64;i++){
93
+ const s1=((e>>>6)|(e<<26))^((e>>>11)|(e<<21))^((e>>>25)|(e<<7));
94
+ const ch=(e&f)^(~e&g);
95
+ const t1=(h+s1+ch+PACK_SHA256_K[i]+w[i])>>>0;
96
+ const s0=((a>>>2)|(a<<30))^((a>>>13)|(a<<19))^((a>>>22)|(a<<10));
97
+ const maj=(a&b)^(a&c)^(b&c);
98
+ const t2=(s0+maj)>>>0;
99
+ h=g;g=f;f=e;e=(d+t1)>>>0;d=c;c=b;b=a;a=(t1+t2)>>>0;
100
+ }
101
+ this.h[0]=(this.h[0]+a)>>>0;this.h[1]=(this.h[1]+b)>>>0;
102
+ this.h[2]=(this.h[2]+c)>>>0;this.h[3]=(this.h[3]+d)>>>0;
103
+ this.h[4]=(this.h[4]+e)>>>0;this.h[5]=(this.h[5]+f)>>>0;
104
+ this.h[6]=(this.h[6]+g)>>>0;this.h[7]=(this.h[7]+h)>>>0;
105
+ }
106
+ update(input){
107
+ const data=input instanceof Uint8Array?input:new Uint8Array(input);
108
+ this.bytes+=data.length;
109
+ let off=0;
110
+ if(this.bufLen){
111
+ const n=Math.min(64-this.bufLen,data.length);
112
+ this.buf.set(data.subarray(0,n),this.bufLen);this.bufLen+=n;off=n;
113
+ if(this.bufLen===64){this.block(this.buf,0);this.bufLen=0;}
114
+ }
115
+ while(off+64<=data.length){this.block(data,off);off+=64;}
116
+ if(off<data.length){this.buf.set(data.subarray(off),0);this.bufLen=data.length-off;}
117
+ return this;
118
+ }
119
+ hex(){
120
+ const bitHi=Math.floor(this.bytes/0x20000000),bitLo=(this.bytes*8)>>>0;
121
+ this.buf[this.bufLen++]=0x80;
122
+ if(this.bufLen>56){this.buf.fill(0,this.bufLen);this.block(this.buf,0);this.bufLen=0;}
123
+ this.buf.fill(0,this.bufLen,56);
124
+ this.buf[56]=(bitHi>>>24)&255;this.buf[57]=(bitHi>>>16)&255;
125
+ this.buf[58]=(bitHi>>>8)&255;this.buf[59]=bitHi&255;
126
+ this.buf[60]=(bitLo>>>24)&255;this.buf[61]=(bitLo>>>16)&255;
127
+ this.buf[62]=(bitLo>>>8)&255;this.buf[63]=bitLo&255;
128
+ this.block(this.buf,0);
129
+ return [...this.h].map(n=>n.toString(16).padStart(8,'0')).join('');
130
+ }
131
+ }
132
+
133
+ async function packHashBlob(blob, sliceBytes=PACK_DEFAULT_CHUNK_BYTES){
134
+ const hash=new PackSha256State();
135
+ for(let off=0;off<blob.size;off+=sliceBytes)
136
+ hash.update(new Uint8Array(await blob.slice(off,Math.min(blob.size,off+sliceBytes)).arrayBuffer()));
137
+ return hash.hex();
138
+ }
139
+
140
+ let packDbPromise=null;
141
+ function packDb(){
142
+ if(packDbPromise) return packDbPromise;
143
+ packDbPromise=new Promise((res, rej) => {
144
+ const r = indexedDB.open(PACK_DB, PACK_DB_VERSION);
145
+ r.onupgradeneeded = () => { const d = r.result;
146
+ if(!d.objectStoreNames.contains(PACK_STORE)) d.createObjectStore(PACK_STORE);
147
+ if(!d.objectStoreNames.contains(PACK_CHUNK_STORE)) d.createObjectStore(PACK_CHUNK_STORE);
148
+ if(!d.objectStoreNames.contains(PACK_META_STORE)) d.createObjectStore(PACK_META_STORE); };
149
+ r.onsuccess = () => {
150
+ r.result.onversionchange=()=>{r.result.close();packDbPromise=null;};
151
+ res(r.result);
152
+ };
153
+ r.onerror = () => {packDbPromise=null;rej(r.error);};
154
+ r.onblocked=()=>{packDbPromise=null;rej(new Error('Asset-pack storage upgrade is blocked by another tab'));};
155
+ });
156
+ return packDbPromise;
157
+ }
158
+ async function packStoreGet(store,k){
159
+ const db = await packDb();
160
+ return new Promise((res, rej) => {
161
+ const tx = db.transaction(store, 'readonly');
162
+ const q = tx.objectStore(store).get(k);
163
+ q.onsuccess = () => res(q.result); q.onerror = () => rej(q.error);
164
+ });
165
+ }
166
+ async function packStorePut(store,k,v){
167
+ const db = await packDb();
168
+ return new Promise((res, rej) => {
169
+ const tx = db.transaction(store, 'readwrite');
170
+ tx.objectStore(store).put(v, k);
171
+ tx.oncomplete = () => res(); tx.onerror = () => rej(tx.error);tx.onabort=()=>rej(tx.error);
172
+ });
173
+ }
174
+ async function packStoreDelete(store,k){
175
+ const db=await packDb();
176
+ return new Promise((res,rej)=>{
177
+ const tx=db.transaction(store,'readwrite');
178
+ tx.objectStore(store).delete(k);
179
+ tx.oncomplete=()=>res();tx.onerror=()=>rej(tx.error);tx.onabort=()=>rej(tx.error);
180
+ });
181
+ }
182
+ async function packKeys(store=PACK_STORE){
183
+ const db = await packDb();
184
+ return new Promise((res, rej) => {
185
+ const tx = db.transaction(store, 'readonly');
186
+ const q = tx.objectStore(store).getAllKeys();
187
+ q.onsuccess = () => res(q.result || []); q.onerror = () => rej(q.error);
188
+ });
189
+ }
190
+ async function packDeletePrefix(store,prefix){
191
+ const keys=(await packKeys(store)).filter(k=>String(k).startsWith(prefix));
192
+ if(!keys.length) return 0;
193
+ const db=await packDb();
194
+ await new Promise((res,rej)=>{
195
+ const tx=db.transaction(store,'readwrite'),target=tx.objectStore(store);
196
+ for(const k of keys) target.delete(k);
197
+ tx.oncomplete=()=>res();tx.onerror=()=>rej(tx.error);tx.onabort=()=>rej(tx.error);
198
+ });
199
+ return keys.length;
200
+ }
201
+ async function packDeleteKeys(store,keys){
202
+ if(!keys.length) return 0;
203
+ const db=await packDb();
204
+ await new Promise((res,rej)=>{
205
+ const tx=db.transaction(store,'readwrite'),target=tx.objectStore(store);
206
+ for(const key of keys) target.delete(key);
207
+ tx.oncomplete=()=>res();tx.onerror=()=>rej(tx.error);tx.onabort=()=>rej(tx.error);
208
+ });
209
+ return keys.length;
210
+ }
211
+ const packGet=k=>packStoreGet(PACK_STORE,k);
212
+ const packPut=(k,v)=>packStorePut(PACK_STORE,k,v);
213
 
214
  /* The endpoint is the same one the updater resolves — one server, one setting,
215
  configured in exactly one place. */
 
224
  return n < 1048576 ? (n / 1024).toFixed(0) + ' KB' : (n / 1048576).toFixed(1) + ' MB';
225
  }
226
 
227
+ const PACK_INDEX_META_KEY='index:active';
228
+ const PACK_OFFER_META_KEY='index:offered';
229
+ const PACK_ACTIVE_ROLE='active-v2';
230
+ function packIndexRecord(saved){return saved&&saved.index?saved.index:saved;}
231
+ async function packPersistOffer(raw){
232
+ await packStorePut(PACK_META_STORE,PACK_OFFER_META_KEY,{role:'offered-v2',index:raw,savedAt:Date.now()});
233
+ }
234
+ async function packPersistActive(raw){
235
+ const normalized=packNormalizeIndex(raw);
236
+ /* One IndexedDB record is the activation pointer. Payload Blobs may arrive
237
+ over many launches, but the game sees all of the new dependency set or
238
+ none of it because this transaction completes before the in-memory pointer
239
+ moves. */
240
+ await packStorePut(PACK_META_STORE,PACK_INDEX_META_KEY,{
241
+ role:PACK_ACTIVE_ROLE,index:raw,savedAt:Date.now()
242
+ });
243
+ PACK.rawActive=raw;PACK.activeIdx=normalized;
244
+ return normalized;
245
+ }
246
+ function packPackIdentity(pack){
247
+ if(!pack) return '';
248
+ return JSON.stringify({
249
+ format:pack.format,dependencies:pack.dependencies,
250
+ files:pack.files.map(file=>({
251
+ name:file.name,size:file.size,sha256:file.sha256,
252
+ chunks:file.chunks.map(chunk=>[chunk.offset,chunk.size,chunk.sha256])
253
+ }))
254
+ });
255
+ }
256
+ async function packOwnFilesReady(index,id){
257
+ const pack=index&&index[id];
258
+ if(!pack) return false;
259
+ for(const file of pack.files) if(!(await packFileReady(id,file))) return false;
260
+ return true;
261
+ }
262
+ async function packRecoverLegacyActive(raw,index){
263
+ const packs={},accepted=new Set(),order=[];
264
+ const seen=new Set();
265
+ function add(id){if(seen.has(id)) return;seen.add(id);for(const dep of index[id].dependencies)add(dep);order.push(id);}
266
+ for(const id of Object.keys(index)) add(id);
267
+ for(const id of order){
268
+ if(index[id].dependencies.some(dep=>!accepted.has(dep))) continue;
269
+ if(await packOwnFilesReady(index,id)){packs[id]=raw.packs[id];accepted.add(id);}
270
+ }
271
+ return {version:Number(raw.version)||2,packs};
272
+ }
273
+ async function packLoadCachedIndex(){
274
+ try{
275
+ const offeredSaved=await packStoreGet(PACK_META_STORE,PACK_OFFER_META_KEY);
276
+ const activeSaved=await packStoreGet(PACK_META_STORE,PACK_INDEX_META_KEY);
277
+ const taggedActive=!!(activeSaved&&activeSaved.role===PACK_ACTIVE_ROLE);
278
+ const legacyRaw=!taggedActive?packIndexRecord(activeSaved):null;
279
+ let offeredRaw=packIndexRecord(offeredSaved)||legacyRaw;
280
+ let activeRaw=taggedActive?packIndexRecord(activeSaved):null;
281
+ if(!offeredRaw&&activeRaw) offeredRaw=activeRaw;
282
+ if(!offeredRaw) return null;
283
+ const offered=packNormalizeIndex(offeredRaw);
284
+ if(!activeRaw){
285
+ /* v1 stored a server offer under the misleading `index:active` key.
286
+ Promote only entries whose final Blobs already verify; a merely cached
287
+ manifest must never become executable authority during migration. */
288
+ activeRaw=await packRecoverLegacyActive(offeredRaw,offered);
289
+ try{await packPersistOffer(offeredRaw);}catch(e){}
290
+ await packPersistActive(activeRaw);
291
+ }else{
292
+ PACK.rawActive=activeRaw;PACK.activeIdx=packNormalizeIndex(activeRaw);
293
+ }
294
+ PACK.idx=offered;PACK.rawIndex=offeredRaw;
295
+ return PACK.idx;
296
+ }catch(e){return null;}
297
+ }
298
+ async function packAdoptInstalledOffers(){
299
+ if(!PACK.idx||!PACK.rawIndex) return false;
300
+ const raw={...PACK.rawActive,packs:{...(PACK.rawActive&&PACK.rawActive.packs||{})}};
301
+ const activeNext={...PACK.activeIdx};
302
+ let changed=false;
303
+ const order=[],seen=new Set();
304
+ function add(id){if(seen.has(id))return;seen.add(id);for(const dep of PACK.idx[id].dependencies)add(dep);order.push(id);}
305
+ for(const id of Object.keys(PACK.idx)) add(id);
306
+ for(const id of order){
307
+ if(activeNext[id]) continue;
308
+ let depsReady=true;
309
+ for(const dep of PACK.idx[id].dependencies){
310
+ const active=activeNext[dep];
311
+ if(!active||packPackIdentity(active)!==packPackIdentity(PACK.idx[dep])){depsReady=false;break;}
312
+ }
313
+ if(!depsReady||!(await packOwnFilesReady(PACK.idx,id))) continue;
314
+ raw.packs[id]=PACK.rawIndex.packs[id];
315
+ /* Make this entry visible to later nodes in the same topological pass, but
316
+ do not move the live pointer before the IDB activation record commits. */
317
+ activeNext[id]=PACK.idx[id];
318
+ changed=true;
319
+ }
320
+ if(changed) await packPersistActive(raw);
321
+ return changed;
322
+ }
323
+ async function packLoadIndex(){
324
+ if(PACK.busy&&PACK.idx) return PACK.idx;
325
+ const cached=await packLoadCachedIndex();
326
+ if(typeof netAllowed==='function' && !netAllowed()) return cached;
327
+ const base = packEndpoint();
328
+ if(!base) return cached;
329
+ try{
330
+ const r = await fetch(base + '/packs.json?t=' + Date.now(), {cache:'no-store'});
331
+ if(!r.ok) return cached;
332
+ const j = await r.json();
333
+ let merged=j;
334
+ try{
335
+ const prior=PACK.rawIndex&&PACK.rawIndex.packs?PACK.rawIndex.packs:{};
336
+ const local=Object.fromEntries(Object.entries(prior).filter(([,pack])=>pack&&pack.localRegistration));
337
+ merged={...j,packs:{...local,...j.packs}}; // an official endpoint entry supersedes a local registration.
338
+ }catch(e){}
339
+ PACK.idx = packNormalizeIndex(merged);PACK.rawIndex=merged;
340
+ /* The offer is durable for interrupted downloads, but it is not the mount
341
+ pointer. packPersistActive() is the sole authority transition. */
342
+ try{await packPersistOffer(merged);}catch(e){}
343
+ try{await packAdoptInstalledOffers();}catch(e){}
344
+ return PACK.idx;
345
+ }catch(e){
346
+ if(cached) return cached;
347
+ PACK.err='Optional content manifest is invalid';return null;
348
+ }
349
+ }
350
+
351
+ function packValidId(id){return /^[a-z0-9][a-z0-9._-]{0,63}$/i.test(String(id||''));}
352
+ function packValidPath(name){
353
+ const p=String(name||'').replace(/\\/g,'/');
354
+ return p&&!p.startsWith('/')&&!p.split('/').some(s=>!s||s==='.'||s==='..')?p:'';
355
+ }
356
+ function packHash(value){
357
+ const h=String(value||'').toLowerCase();
358
+ return /^[a-f0-9]{64}$/.test(h)?h:'';
359
+ }
360
+ function packNormalizeIndex(index){
361
+ if(!index||typeof index!=='object'||!index.packs||typeof index.packs!=='object') throw new Error('packs');
362
+ if(Object.keys(index.packs).length>PACK_MAX_PACKS) throw new Error('packs');
363
+ const out=Object.create(null);
364
+ for(const [id,raw] of Object.entries(index.packs)){
365
+ if(!packValidId(id)||!raw||!Array.isArray(raw.files)||raw.files.length>PACK_MAX_FILES) throw new Error('pack');
366
+ const format=Math.max(1,Number(raw.format||raw.manifestVersion||1)|0);
367
+ const chunkSize=Number(raw.chunkSize||PACK_DEFAULT_CHUNK_BYTES);
368
+ if(!Number.isSafeInteger(chunkSize)||chunkSize<1||chunkSize>PACK_MAX_CHUNK_BYTES) throw new Error('chunk-size');
369
+ const dependencies=Array.isArray(raw.dependencies)?raw.dependencies.map(String):[];
370
+ if(new Set(dependencies).size!==dependencies.length||dependencies.some(dep=>!packValidId(dep))) throw new Error('dependencies');
371
+ const seen=new Set(),files=[];
372
+ let bytes=0;
373
+ for(const source of raw.files){
374
+ const name=packValidPath(source&&source.name);
375
+ const size=Number(source&&source.size);
376
+ const sha256=packHash(source&&source.sha256);
377
+ if(!name||seen.has(name)||!Number.isSafeInteger(size)||size<1||size>PACK_MAX_FILE_BYTES) throw new Error('file');
378
+ if(format>=2&&!sha256) throw new Error('whole-hash');
379
+ seen.add(name);bytes+=size;
380
+ let chunks=[];
381
+ if(Array.isArray(source.chunks)){
382
+ if(source.chunks.length>PACK_MAX_CHUNKS_PER_FILE) throw new Error('chunks');
383
+ let offset=0;
384
+ chunks=source.chunks.map((chunk,index)=>{
385
+ const at=Number(chunk&&chunk.offset),n=Number(chunk&&chunk.size),hash=packHash(chunk&&chunk.sha256);
386
+ if(!Number.isSafeInteger(at)||at!==offset||!Number.isSafeInteger(n)||n<1||n>PACK_MAX_CHUNK_BYTES||at+n>size||!hash)
387
+ throw new Error('chunk');
388
+ offset+=n;
389
+ return {index,offset:at,size:n,sha256:hash};
390
+ });
391
+ if(offset!==size) throw new Error('chunk-coverage');
392
+ }else{
393
+ if(format>=2) throw new Error('chunks');
394
+ if(Math.ceil(size/chunkSize)>PACK_MAX_CHUNKS_PER_FILE) throw new Error('chunks');
395
+ for(let offset=0,index=0;offset<size;offset+=chunkSize,index++)
396
+ chunks.push({index,offset,size:Math.min(chunkSize,size-offset),sha256:''});
397
+ }
398
+ files.push({...source,name,size,sha256,chunks});
399
+ }
400
+ if(raw.bytes!==undefined&&Number(raw.bytes)!==bytes) throw new Error('pack-bytes');
401
+ out[id]={...raw,id,format,chunkSize,bytes,files,dependencies};
402
+ }
403
+ /* Reject missing edges and cycles at the trust boundary. Install order can
404
+ then be a small deterministic DFS, and a malformed remote manifest can
405
+ never recurse forever or silently omit a prerequisite. */
406
+ const visiting=new Set(),visited=new Set();
407
+ function visit(id){
408
+ if(visited.has(id)) return;
409
+ if(visiting.has(id)) throw new Error('dependency-cycle');
410
+ visiting.add(id);
411
+ for(const dep of out[id].dependencies){if(!out[dep]) throw new Error('dependency-missing');visit(dep);}
412
+ visiting.delete(id);visited.add(id);
413
+ }
414
+ for(const id of Object.keys(out)) visit(id);
415
+ return out;
416
+ }
417
+ async function packRegisterPack(id,raw){
418
+ if(!packValidId(id)||!raw) throw new Error('pack');
419
+ const base=PACK.rawIndex&&PACK.rawIndex.packs?PACK.rawIndex:{version:2,packs:{}};
420
+ const merged={...base,packs:{...base.packs,[id]:{...raw,localRegistration:true}}};
421
+ const normalized=packNormalizeIndex(merged);
422
+ PACK.rawIndex=merged;PACK.idx=normalized;
423
+ await packPersistOffer(merged);
424
+ return normalized[id];
425
+ }
426
+ function packLegacyFileKey(pack,file){return pack+'/'+file.name+':'+file.size;}
427
+ function packFileKey(pack,file){
428
+ const legacy=packLegacyFileKey(pack,file);
429
+ return file.sha256?legacy+':sha256:'+file.sha256:legacy;
430
+ }
431
+ function packMetaKey(pack,file,storageKey=packFileKey(pack,file)){return 'file:'+storageKey;}
432
+ function packChunkPrefix(pack,file){return pack+'/'+file.name+':'+file.size+':'+(file.sha256||'legacy')+':chunk:';}
433
+ function packChunkKey(pack,file,chunk){return packChunkPrefix(pack,file)+chunk.index;}
434
+ function packStoredBlob(value){return value instanceof Blob?value:(value&&value.blob instanceof Blob?value.blob:null);}
435
+ async function packStoredFinal(pack,file){
436
+ const key=packFileKey(pack,file);
437
+ let blob=packStoredBlob(await packGet(key));
438
+ if(blob) return {key,blob};
439
+ const legacyKey=packLegacyFileKey(pack,file);
440
+ if(legacyKey!==key){
441
+ blob=packStoredBlob(await packGet(legacyKey));
442
+ if(blob) return {key:legacyKey,blob};
443
+ }
444
+ return {key,blob:null};
445
+ }
446
+ async function packFileReady(pack,file,{forceHash=false}={}){
447
+ const stored=await packStoredFinal(pack,file),blob=stored.blob;
448
+ if(!blob||blob.size!==file.size) return false;
449
+ if(!file.sha256) return true; // v1 manifests and their legacy Blobs remain playable.
450
+ const metaKey=packMetaKey(pack,file,stored.key);
451
+ const meta=await packStoreGet(PACK_META_STORE,metaKey);
452
+ if(!forceHash&&meta&&meta.sha256===file.sha256&&meta.size===file.size) return true;
453
+ const actual=await packHashBlob(blob,file.chunkSize||PACK_DEFAULT_CHUNK_BYTES);
454
+ if(actual!==file.sha256) return false;
455
+ await packStorePut(PACK_META_STORE,metaKey,{sha256:actual,size:file.size,verifiedAt:Date.now()});
456
+ return true;
457
+ }
458
+ async function packStoredChunk(pack,file,chunk){
459
+ const value=await packStoreGet(PACK_CHUNK_STORE,packChunkKey(pack,file,chunk));
460
+ const blob=packStoredBlob(value);
461
+ if(!blob||blob.size!==chunk.size) return null;
462
+ if(chunk.sha256){
463
+ /* IDB is durable, not an integrity oracle. A killed or externally damaged
464
+ record can retain its old metadata, so always re-hash persisted chunks
465
+ before resuming instead of trusting value.sha256. */
466
+ if(await packHashBlob(blob,chunk.size)!==chunk.sha256) return null;
467
+ }
468
+ return blob;
469
+ }
470
+ async function packResumeState(pack,file){
471
+ const present=new Map();let bytes=0;
472
+ for(const chunk of file.chunks){
473
+ const blob=await packStoredChunk(pack,file,chunk);
474
+ if(blob){present.set(chunk.index,blob);bytes+=chunk.size;}
475
+ }
476
+ return {present,bytes,remaining:file.size-bytes};
477
+ }
478
+
479
+ /* What is missing, including already-persisted partial chunks. Hashed finals
480
+ use content identities so a same-size replacement cannot overwrite the live
481
+ version before activation; packStoredFinal keeps the v1 name+size key as a
482
+ read-compatible fallback for existing voice/music installs. */
483
+ async function packMissing(pack,options={},index=PACK.idx){
484
+ if(!index || !index[pack]) return {files:[], bytes:0};
485
+ const out = [];
486
+ let bytes = 0,storedBytes=0;
487
+ for(const f of index[pack].files){
488
+ if(await packFileReady(pack,f,{forceHash:!!options.forceHash})){storedBytes+=f.size;continue;}
489
+ const resume=await packResumeState(pack,f);
490
+ out.push({...f,key:packFileKey(pack,f),remainingBytes:resume.remaining});
491
+ bytes+=resume.remaining;storedBytes+=resume.bytes;
492
+ }
493
+ return {files:out,bytes,storedBytes,totalBytes:index[pack].bytes};
494
+ }
495
+
496
+ function packFail(code,message){const e=new Error(message||code);e.code=code;throw e;}
497
+ function packFileUrl(base,pack,file,meta){
498
+ meta=meta||(PACK.idx&&PACK.idx[pack]);
499
+ if(meta&&meta.baseUrl){
500
+ const root=String(meta.baseUrl).replace(/\/?$/,'/');
501
+ const url=root+file.name.split('/').map(encodeURIComponent).join('/');
502
+ const query=String(meta.downloadQuery||'');
503
+ return url+(query&&url.indexOf('?')<0?(query.startsWith('?')?query:'?'+query):'');
504
+ }
505
+ return String(base||'').replace(/\/$/,'')+'/pack/'+encodeURIComponent(pack)+'/'
506
+ +file.name.split('/').map(encodeURIComponent).join('/');
507
+ }
508
+ async function packStoragePreflight(pack,missing){
509
+ const largest=missing.files.reduce((n,f)=>Math.max(n,f.size),0);
510
+ const margin=missing.bytes?Math.max(PACK_STORAGE_MARGIN_BYTES,Math.ceil(missing.bytes*.08)):0;
511
+ const required=missing.bytes+largest+margin;
512
+ const storage=typeof navigator!=='undefined'&&navigator.storage;
513
+ if(!storage||typeof storage.estimate!=='function')
514
+ return {ok:true,supported:false,pack,required,available:null,quota:null,usage:null};
515
+ try{
516
+ const estimate=await storage.estimate();
517
+ const quota=Number(estimate.quota),usage=Number(estimate.usage||0);
518
+ const available=Number.isFinite(quota)?Math.max(0,quota-usage):null;
519
+ return {ok:available===null||available>=required,supported:true,pack,required,available,quota,usage};
520
+ }catch(e){return {ok:true,supported:false,pack,required,available:null,quota:null,usage:null};}
521
+ }
522
+ async function packClearChunks(pack,file){await packDeletePrefix(PACK_CHUNK_STORE,packChunkPrefix(pack,file));}
523
+ function packDropURL(pack,name){
524
+ const key=pack+'/'+name,entry=packURLs[key],url=entry&&entry.url||entry;
525
+ if(url&&typeof URL.revokeObjectURL==='function') try{URL.revokeObjectURL(url);}catch(e){}
526
+ delete packURLs[key];
527
+ }
528
+ async function packCommitFullResponse(pack,file,response,onBytes,resume){
529
+ /* A 200 response means the origin ignored Range. Consume its body as a
530
+ stream and commit only complete manifest chunks; response.blob() would
531
+ recreate the exact monolithic allocation this subsystem exists to avoid.
532
+ Completed chunks survive a short response or killed tab and are reused on
533
+ the next attempt. Browsers without response streaming fail closed here;
534
+ the origin must honor Range for them. */
535
+ const reader=response.body&&typeof response.body.getReader==='function'?response.body.getReader():null;
536
+ if(!reader) packFail('range-stream','Range was ignored and bounded response streaming is unavailable');
537
+ const whole=new PackSha256State();
538
+ let total=0,index=0,chunk=file.chunks[0],filled=0;
539
+ let buffer=chunk?new Uint8Array(chunk.size):null;
540
+ try{
541
+ while(true){
542
+ const read=await reader.read();
543
+ if(read.done) break;
544
+ const data=read.value instanceof Uint8Array?read.value:new Uint8Array(read.value||0);
545
+ if(total+data.length>file.size) packFail('range-ignored-size','Ignored Range response exceeds manifest size');
546
+ whole.update(data);total+=data.length;
547
+ let at=0;
548
+ while(at<data.length){
549
+ if(!chunk) packFail('range-ignored-size','Ignored Range response exceeds manifest chunks');
550
+ const n=Math.min(chunk.size-filled,data.length-at);
551
+ buffer.set(data.subarray(at,at+n),filled);filled+=n;at+=n;
552
+ if(filled===chunk.size){
553
+ const actual=new PackSha256State().update(buffer).hex();
554
+ if(chunk.sha256&&actual!==chunk.sha256) packFail('chunk-hash','Full response chunk hash failed');
555
+ const reusable=!!chunk.sha256&&resume.present.has(chunk.index);
556
+ if(!reusable){
557
+ await packStorePut(PACK_CHUNK_STORE,packChunkKey(pack,file,chunk),{
558
+ blob:new Blob([buffer],{type:file.type||'application/octet-stream'}),
559
+ size:chunk.size,sha256:chunk.sha256||actual
560
+ });
561
+ if(onBytes&&!resume.present.has(chunk.index)) onBytes(chunk.size);
562
+ }
563
+ chunk=file.chunks[++index];filled=0;
564
+ buffer=chunk?new Uint8Array(chunk.size):null;
565
+ }
566
+ }
567
+ }
568
+ }catch(e){try{await reader.cancel();}catch(ignore){}throw e;}
569
+ if(total!==file.size||index!==file.chunks.length||filled)
570
+ packFail('range-ignored-size','Ignored Range response ended before manifest size');
571
+ const actual=whole.hex();
572
+ if(file.sha256&&actual!==file.sha256){
573
+ /* Synthetic legacy chunks carry no individual hashes. Keeping them after
574
+ a whole-file failure poisons every retry because size alone would make
575
+ the bad records look resumable. Clear the identity as one unit. */
576
+ await packClearChunks(pack,file);
577
+ packFail('file-hash','Whole-file hash failed');
578
+ }
579
+ await packAssembleFile(pack,file);
580
+ }
581
+ async function packFetchChunk(base,pack,file,chunk,packMeta){
582
+ const end=chunk.offset+chunk.size-1;
583
+ const response=await fetch(packFileUrl(base,pack,file,packMeta),{
584
+ cache:'no-store',headers:{Range:'bytes='+chunk.offset+'-'+end}
585
+ });
586
+ if(!response.ok) packFail('http','HTTP '+response.status);
587
+ if(response.status===200){
588
+ /* A CDN may legally ignore Range. Treat that as one explicit full-file
589
+ fallback, never as the requested chunk, or each loop iteration would
590
+ download and append the entire object again. */
591
+ return {fullResponse:response};
592
+ }
593
+ if(response.status!==206) packFail('range-status','Unexpected Range response');
594
+ const blob=await response.blob();
595
+ const contentRange=response.headers&&response.headers.get&&response.headers.get('content-range');
596
+ const match=/^bytes\s+(\d+)-(\d+)\/(\d+)$/i.exec(String(contentRange||''));
597
+ if(!match||Number(match[1])!==chunk.offset||Number(match[2])!==end||Number(match[3])!==file.size)
598
+ packFail('content-range','Content-Range does not match request');
599
+ if(blob.size!==chunk.size) packFail('chunk-size','Chunk size does not match manifest');
600
+ const hash=await packHashBlob(blob,chunk.size);
601
+ if(chunk.sha256&&hash!==chunk.sha256) packFail('chunk-hash','Chunk hash failed');
602
+ return {chunk:blob,sha256:hash};
603
+ }
604
+ async function packAssembleFile(pack,file){
605
+ const parts=[],hash=new PackSha256State();let size=0;
606
+ for(const chunk of file.chunks){
607
+ const blob=await packStoredChunk(pack,file,chunk);
608
+ if(!blob) packFail('chunk-missing','Verified chunk disappeared before assembly');
609
+ const bytes=new Uint8Array(await blob.arrayBuffer());
610
+ hash.update(bytes);parts.push(blob);size+=blob.size;
611
+ }
612
+ if(size!==file.size) packFail('size','Assembled size does not match manifest');
613
+ const actual=hash.hex();
614
+ if(file.sha256&&actual!==file.sha256){
615
+ await packClearChunks(pack,file);
616
+ packFail('file-hash','Whole-file hash failed');
617
+ }
618
+ const blob=new Blob(parts,{type:file.type||'application/octet-stream'});
619
+ packDropURL(pack,file.name);
620
+ await packPut(packFileKey(pack,file),blob);
621
+ if(file.sha256) await packStorePut(PACK_META_STORE,packMetaKey(pack,file),{sha256:actual,size:file.size,verifiedAt:Date.now()});
622
+ await packClearChunks(pack,file);
623
+ }
624
+ async function packDownloadFile(base,pack,file,onBytes,packMeta){
625
+ /* Keep the prior final Blob until the replacement has passed every chunk and
626
+ whole-file hash. packPut() is the atomic identity swap; deleting first made
627
+ an interrupted Repair destroy the last-known-good offline copy. */
628
+ const resume=await packResumeState(pack,file);
629
+ for(const chunk of file.chunks){
630
+ if(resume.present.has(chunk.index)) continue;
631
+ const received=await packFetchChunk(base,pack,file,chunk,packMeta);
632
+ if(received.fullResponse){
633
+ await packCommitFullResponse(pack,file,received.fullResponse,onBytes,resume);
634
+ return;
635
+ }
636
+ await packStorePut(PACK_CHUNK_STORE,packChunkKey(pack,file,chunk),{
637
+ blob:received.chunk,size:chunk.size,sha256:chunk.sha256||received.sha256
638
+ });
639
+ if(onBytes) onBytes(chunk.size);
640
+ }
641
+ await packAssembleFile(pack,file);
642
+ }
643
+ function packDependencyOrder(pack,index=PACK.idx){
644
+ const order=[],seen=new Set();
645
+ function add(id){if(seen.has(id)) return;seen.add(id);for(const dep of index[id].dependencies) add(dep);order.push(id);}
646
+ add(pack);return order;
647
+ }
648
+ async function packPlan(pack,{forceHash=false,index=PACK.idx}={}){
649
+ const order=packDependencyOrder(pack,index),byPack=[],files=[];
650
+ let bytes=0,storedBytes=0;
651
+ for(const id of order){
652
+ const missing=await packMissing(id,{forceHash},index);
653
+ byPack.push({pack:id,meta:index[id],missing});bytes+=missing.bytes;storedBytes+=missing.storedBytes;
654
+ for(const file of missing.files) files.push(file);
655
+ }
656
+ return {order,byPack,files,bytes,storedBytes};
657
+ }
658
+ async function packGcPromotedPack(pack){
659
+ const active=PACK.activeIdx&&PACK.activeIdx[pack];
660
+ if(!active) return {files:0,chunks:0,metadata:0};
661
+ const filePrefix=pack+'/',metaPrefix='file:'+filePrefix;
662
+ const keepFiles=new Set(),keepMeta=new Set();
663
+ for(const file of active.files){
664
+ const stored=await packStoredFinal(pack,file);
665
+ if(stored.blob){keepFiles.add(stored.key);keepMeta.add(packMetaKey(pack,file,stored.key));}
666
+ }
667
+ const staleFiles=(await packKeys(PACK_STORE)).filter(key=>{
668
+ const value=String(key);return value.startsWith(filePrefix)&&!keepFiles.has(value);
669
+ });
670
+ const staleMeta=(await packKeys(PACK_META_STORE)).filter(key=>{
671
+ const value=String(key);return value.startsWith(metaPrefix)&&!keepMeta.has(value);
672
+ });
673
+ /* Promotion proves every active file has a verified final Blob. No chunk is
674
+ still needed by that identity, and any older hash/size identity is now
675
+ unreachable. Clearing only this pack namespace preserves dependencies and
676
+ every independently installed optional pack. */
677
+ const staleChunks=(await packKeys(PACK_CHUNK_STORE)).filter(key=>String(key).startsWith(filePrefix));
678
+ return {
679
+ files:await packDeleteKeys(PACK_STORE,staleFiles),
680
+ chunks:await packDeleteKeys(PACK_CHUNK_STORE,staleChunks),
681
+ metadata:await packDeleteKeys(PACK_META_STORE,staleMeta)
682
+ };
683
+ }
684
+ async function packPromotePlan(plan,index,rawIndex){
685
+ for(const id of plan.order){
686
+ const remaining=await packMissing(id,{},index);
687
+ if(remaining.files.length) packFail('verify','Pack cannot activate before every file verifies');
688
+ }
689
+ const packs={...(PACK.rawActive&&PACK.rawActive.packs||{})};
690
+ for(const id of plan.order){
691
+ if(!rawIndex||!rawIndex.packs||!rawIndex.packs[id]) packFail('manifest','Pack offer changed before activation');
692
+ packs[id]=rawIndex.packs[id];
693
+ }
694
+ const next={...(PACK.rawActive||{}),version:Number(rawIndex&&rawIndex.version)||2,packs};
695
+ await packPersistActive(next);
696
+ const gc={};
697
+ for(const id of plan.order){
698
+ try{gc[id]=await packGcPromotedPack(id);}
699
+ catch(e){gc[id]={error:e&&e.message||String(e)};}
700
+ }
701
+ return gc;
702
+ }
703
+ function packJournalKey(pack){return 'journal:'+pack;}
704
+ async function packInstallPack(pack,options={}){
705
+ if(!packValidId(pack)) return {ok:false,reason:'pack-id'};
706
+ if(PACK.busy) return {ok:false,reason:'busy'};
707
+ PACK.busy=true;PACK.state='downloading';PACK.err='';
708
+ try{
709
+ /* Own the operation before the first await. Otherwise two callers that
710
+ both need packs.json can pass the busy check and begin parallel writes. */
711
+ const idx=PACK.idx||await packLoadIndex();
712
+ if(!idx||!idx[pack]){PACK.state='error';PACK.err='Optional content manifest is unavailable';return {ok:false,reason:'manifest'};}
713
+ const offerIndex=idx,offerRaw=PACK.rawIndex;
714
+ const plan=await packPlan(pack,{forceHash:!!options.repair,index:offerIndex});
715
+ if(!plan.files.length){
716
+ const gc=await packPromotePlan(plan,offerIndex,offerRaw);
717
+ PACK.state='ready';return {ok:true,pack,packs:plan.order,installed:true,downloaded:0,gc};
718
+ }
719
+ if(typeof netAllowed==='function'&&!netAllowed()){
720
+ PACK.state='error';PACK.err='Optional content is unavailable while offline';
721
+ return {ok:false,reason:'offline'};
722
+ }
723
+ const storage=await packStoragePreflight(pack,plan);PACK.storage=storage;
724
+ if(!storage.ok){
725
+ PACK.state='error';PACK.err='Not enough storage for this optional pack';
726
+ return {ok:false,reason:'storage',storage};
727
+ }
728
+ const base=packEndpoint();
729
+ if(!base&&plan.byPack.some(item=>item.missing.files.length&&!offerIndex[item.pack].baseUrl)){
730
+ PACK.state='error';PACK.err='Optional content server is unavailable';return {ok:false,reason:'endpoint'};
731
+ }
732
+ PACK.total=plan.bytes;PACK.got=0;
733
+ await packStorePut(PACK_META_STORE,packJournalKey(pack),{
734
+ state:'installing',pack,dependencies:plan.order,bytes:plan.bytes,startedAt:Date.now()
735
+ });
736
+ for(const item of plan.byPack){
737
+ for(const file of item.missing.files){
738
+ await packDownloadFile(base,item.pack,file,n=>{
739
+ PACK.got+=n;
740
+ if(options.onProgress) options.onProgress(PACK.got,PACK.total,{pack:item.pack,file:file.name});
741
+ if(options.ui) packRenderBar();
742
+ },item.meta);
743
+ await packStorePut(PACK_META_STORE,packJournalKey(pack),{
744
+ state:'installing',pack,dependencies:plan.order,bytes:plan.bytes,downloaded:PACK.got,
745
+ completedPack:item.pack,completedFile:file.name,updatedAt:Date.now()
746
+ });
747
+ }
748
+ if((await packMissing(item.pack,{},offerIndex)).files.length) packFail('verify','Pack remained incomplete after install');
749
+ }
750
+ const gc=await packPromotePlan(plan,offerIndex,offerRaw);
751
+ await packStorePut(PACK_META_STORE,packJournalKey(pack),{
752
+ state:'ready',pack,dependencies:plan.order,bytes:plan.bytes,completedAt:Date.now()
753
+ });
754
+ PACK.state='ready';
755
+ return {ok:true,pack,packs:plan.order,installed:true,downloaded:plan.bytes,storage,gc};
756
+ }catch(e){
757
+ PACK.state='error';PACK.err='Download failed — the game runs without it';
758
+ try{await packStorePut(PACK_META_STORE,packJournalKey(pack),{
759
+ state:'failed',pack,reason:e&&e.code||'download',updatedAt:Date.now()
760
+ });}catch(ignore){}
761
+ return {ok:false,reason:e&&e.code||'download',message:e&&e.message||String(e)};
762
+ }finally{
763
+ PACK.busy=false;
764
+ if(options.ui) packRenderBar();
765
+ }
766
+ }
767
+ async function packDownload(pack,onProgress){
768
+ const result=await packInstallPack(pack,{onProgress,ui:true});
769
+ return !!result.ok;
770
+ }
771
 
772
  /* Hand back a playable URL for a pack file, or null if it is not stored. The
773
  object URL is cached per file — creating a new one on every play would leak a
774
  blob reference for the lifetime of the document. */
775
  const packURLs = {};
776
+ async function packURL(pack, name){
777
+ const k = pack + '/' + name;
778
+ if(!PACK.idx) await packLoadIndex();
779
+ const active=PACK.activeIdx&&PACK.activeIdx[pack];
780
+ if(!active) return null;
781
+ const meta = active.files.find(f => f.name === name);
782
+ if(!meta) return null;
783
+ const identity=packFileKey(pack,meta)+':'+(meta.sha256||'legacy');
784
+ if(packURLs[k]&&packURLs[k].identity===identity) return packURLs[k].url;
785
+ if(packURLs[k]) packDropURL(pack,name);
786
+ if(!(await packFileReady(pack,meta))) return null;
787
+ const blob = (await packStoredFinal(pack,meta)).blob;
788
+ if(!blob) return null;
789
+ const url=URL.createObjectURL(blob);
790
+ packURLs[k]={identity,url};return url;
791
+ }
792
+
793
+ async function packList(){
794
+ const idx=PACK.idx||await packLoadIndex();
795
+ const ids=new Set([...Object.keys(PACK.activeIdx||{}),...Object.keys(idx||{})]);
796
+ return [...ids].map(id=>{
797
+ const offer=idx&&idx[id],active=PACK.activeIdx&&PACK.activeIdx[id],pack=offer||active;
798
+ return {
799
+ id,label:String(pack.label||id),bytes:pack.bytes,files:pack.files.length,
800
+ installed:!!active,updateAvailable:!!(active&&offer&&packPackIdentity(active)!==packPackIdentity(offer)),
801
+ dependencies:Array.isArray(pack.dependencies)?[...pack.dependencies]:[]
802
+ };
803
+ });
804
+ }
805
+ async function packStatus(pack,options={}){
806
+ const idx=PACK.idx||await packLoadIndex();
807
+ if(!idx||!idx[pack]) return {ok:false,reason:'manifest',pack};
808
+ const plan=await packPlan(pack,{forceHash:!!options.verify,index:idx});
809
+ const missing=plan.byPack.find(item=>item.pack===pack).missing;
810
+ const active=PACK.activeIdx&&PACK.activeIdx[pack];
811
+ const activePlan=active?await packPlan(pack,{forceHash:!!options.verify,index:PACK.activeIdx}):null;
812
+ const installed=!!(activePlan&&!activePlan.files.length);
813
+ return {
814
+ ok:true,pack,installed,partial:plan.storedBytes>0&&plan.files.length>0,
815
+ updateAvailable:!!(active&&packPackIdentity(active)!==packPackIdentity(idx[pack])),
816
+ bytes:idx[pack].bytes,storedBytes:missing.storedBytes,remainingBytes:missing.bytes,
817
+ missingFiles:missing.files.map(f=>f.name),dependencies:plan.order.filter(id=>id!==pack),
818
+ missingDependencies:plan.byPack.filter(item=>item.pack!==pack&&item.missing.files.length).map(item=>item.pack)
819
+ };
820
+ }
821
+ async function packPreflight(pack){
822
+ const idx=PACK.idx||await packLoadIndex();
823
+ if(!idx||!idx[pack]) return {ok:false,reason:'manifest',pack};
824
+ return packStoragePreflight(pack,await packPlan(pack));
825
+ }
826
+ async function packRemove(pack,options={}){
827
+ if(!packValidId(pack)) return {ok:false,reason:'pack-id'};
828
+ if(PACK.busy) return {ok:false,reason:'busy'};
829
+ PACK.busy=true;
830
+ try{
831
+ const idx=PACK.idx||await packLoadIndex();
832
+ const active=PACK.activeIdx||Object.create(null),dependents=[];
833
+ if(active[pack]){
834
+ for(const id of Object.keys(active)){
835
+ if(id!==pack&&packDependencyOrder(id,active).includes(pack)) dependents.push(id);
836
+ }
837
+ if(dependents.length&&!options.force) return {ok:false,reason:'dependency',pack,dependents};
838
+ const deactivate=new Set([pack,...(options.force?dependents:[])]);
839
+ const packs=Object.fromEntries(Object.entries(PACK.rawActive.packs||{}).filter(([id])=>!deactivate.has(id)));
840
+ await packPersistActive({...PACK.rawActive,packs});
841
+ }
842
+ const files=await packDeletePrefix(PACK_STORE,pack+'/');
843
+ const chunks=await packDeletePrefix(PACK_CHUNK_STORE,pack+'/');
844
+ const metadata=await packDeletePrefix(PACK_META_STORE,'file:'+pack+'/');
845
+ await packStoreDelete(PACK_META_STORE,packJournalKey(pack));
846
+ for(const key of Object.keys(packURLs)) if(key.startsWith(pack+'/')) packDropURL(pack,key.slice(pack.length+1));
847
+ return {ok:true,pack,removed:true,files,chunks,metadata};
848
+ }catch(e){return {ok:false,reason:'storage',message:e&&e.message||String(e)};}
849
+ finally{PACK.busy=false;}
850
+ }
851
+
852
+ /* Public, pack-ID-neutral control surface. Audio keeps using packURL() and the
853
+ legacy start-screen prompt; launcher/settings code can install, verify,
854
+ repair or remove any manifest pack without learning its IndexedDB layout. */
855
+ const MASSFRONT_ASSET_PACKS=Object.freeze({
856
+ loadIndex:packLoadIndex,
857
+ list:packList,
858
+ status:packStatus,
859
+ preflight:packPreflight,
860
+ install:(pack,options)=>packInstallPack(pack,options||{}),
861
+ repair:(pack,options)=>packInstallPack(pack,{...(options||{}),repair:true}),
862
+ remove:(pack,options)=>packRemove(pack,options||{}),
863
+ url:packURL
864
+ });
865
+ if(typeof window!=='undefined') window.MASSFRONT_ASSET_PACKS=MASSFRONT_ASSET_PACKS;
866
 
867
  /* ---- UI -------------------------------------------------------------------
868
  A single line on the start screen, in the same register as the updater panel:
 
958
  if(!want.length){ PACK.state = 'idle'; packRenderBar(); return; }
959
  let bytes = 0;
960
  const need = [];
961
+ for(const p of want){
962
+ const m = await packMissing(p);
963
+ if(m.files.length){ need.push(p); bytes += m.bytes; }
964
+ }
965
+ let activeOne=false;
966
+ for(const p of want){
967
+ if(PACK.activeIdx[p]&&!(await packPlan(p,{index:PACK.activeIdx})).files.length){activeOne=true;break;}
968
+ }
969
+ if(activeOne&&typeof audAttachPack==='function') audAttachPack();
970
+ PACK.total = bytes;
971
+ if(!need.length){
972
+ PACK.state = 'ready'; packRenderBar();
973
+ return;
974
  }
975
  let pref = 'ask';
976
  try{ pref = localStorage.getItem(PACK_PREF) || 'ask'; }catch(e){}
 
988
  IndexedDB and never hear a note of it, permanently, with no control in
989
  the UI that would fix it. Re-derived from storage rather than from the
990
  download results so a pack completed on an EARLIER launch also counts. */
991
+ let haveOne = false;
992
+ for(const p of want){
993
+ if(PACK.activeIdx[p]&&!(await packPlan(p,{index:PACK.activeIdx})).files.length){haveOne=true;break;}
994
+ }
995
  if(haveOne && typeof audAttachPack === 'function') audAttachPack();
996
  /* A later pack succeeding must not bury an earlier one's failure: PACK.state
997
  is what the panel reads, and 'ready' hides the panel outright — so voice
 
1012
 
1013
  /* Galactic Exploration ships in player APK/www from the signed runtime pack.
1014
  Older installs (notably 1.33.51) omitted it. When the HEAD probe misses, this
1015
+ fetches the remote stub and signed manifest, then registers that manifest
1016
+ with the same bounded pack engine used by audio. The former dedicated DB is
1017
+ read only for verified migration. A blob: URL for index.html was considered
1018
+ for navigation and rejected: the Galactic entry
1019
  ticket lives in this document's sessionStorage, and a blob: document is a
1020
  different origin, so the ticket and the return bridge would vanish. Cache
1021
  here; mfOpenExploration keeps the War Room fallback. Opening still needs the
1022
  files on the same origin (packaged www), which is why player packs include
1023
  the module instead of relying on this download alone. */
1024
+ const EXP_PACK_DB='massfront-exploration-pack', EXP_PACK_STORE='files';
1025
+ const EXP_PACK_ID='galactic-exploration';
1026
+ const EXP_PACK_STUB='assets/data/exploration-pack-remote.json';
1027
+ let expPackBusy=false,expPackDbPromise=null;
1028
+
1029
+ function expPackDb(){
1030
+ if(expPackDbPromise) return expPackDbPromise;
1031
+ expPackDbPromise=new Promise((res, rej) => {
1032
+ const r = indexedDB.open(EXP_PACK_DB, 1);
1033
+ r.onupgradeneeded = () => { const d = r.result;
1034
+ if(!d.objectStoreNames.contains(EXP_PACK_STORE)) d.createObjectStore(EXP_PACK_STORE); };
1035
+ r.onsuccess = () => {r.result.onversionchange=()=>{r.result.close();expPackDbPromise=null;};res(r.result);};
1036
+ r.onerror = () => {expPackDbPromise=null;rej(r.error);};
1037
+ });
1038
+ return expPackDbPromise;
1039
  }
1040
  async function expPackGet(k){
1041
  const db = await expPackDb();
 
1044
  q.onsuccess = () => res(q.result); q.onerror = () => rej(q.error);
1045
  });
1046
  }
 
 
 
 
 
 
 
 
1047
  function expPackJoin(base, rel){
1048
  const root=String(base||'').replace(/\/?$/,'/');
1049
  const path=String(rel||'').replace(/^\/+/,'');
 
1064
  const q=String(stub&&stub.downloadQuery||'');
1065
  return {base,manifest,downloadQuery:q};
1066
  }
1067
+ async function mfInstallExplorationPack(){
1068
+ if(expPackBusy) return {ok:false,reason:'busy'};
1069
+ if(typeof indexedDB==='undefined') return {ok:false,reason:'no-idb'};
1070
+ expPackBusy=true;
1071
+ if(typeof toast==='function') toast('Galactic pack downloading...');
1072
+ try{
1073
+ /* A completed generic pack remains discoverable from the cached manifest
1074
+ after an offline restart. Do not require the remote special manifest in
1075
+ that case. */
1076
+ const cached=PACK.idx||await packLoadCachedIndex();
1077
+ if(cached&&cached[EXP_PACK_ID]){
1078
+ const ready=await packStatus(EXP_PACK_ID);
1079
+ if(ready.installed) return {ok:true,cached:true,openUrl:null,pack:EXP_PACK_ID};
1080
+ }
1081
+ if(typeof netAllowed==='function'&&!netAllowed()) return {ok:false,reason:'offline'};
1082
+ const spec=await mfExplorationRemoteSpec();
1083
  if(!spec.manifest&&!spec.base) return {ok:false,reason:'no-endpoint'};
1084
  let manUrl=spec.manifest||expPackJoin(spec.base,'exploration-content-manifest-v1.json');
1085
  if(spec.downloadQuery&&manUrl.indexOf('?')<0) manUrl+=spec.downloadQuery;
1086
+ const mr=await fetch(manUrl,{cache:'no-store'});
1087
+ if(!mr.ok) return {ok:false,reason:'manifest'};
1088
+ const man=await mr.json();
1089
+ if(!man||man.kind!=='ExplorationContentManifestV1'||!Array.isArray(man.files)||!man.files.length)
1090
+ return {ok:false,reason:'manifest'};
1091
+ const files=[];
1092
+ for(const entry of man.files){
1093
+ const rel=String(entry.path||'').replace(/\\/g,'/');
1094
+ if(!rel||rel.startsWith('/')||rel.includes('..')) return {ok:false,reason:'path'};
1095
+ const match=/^sha256-([a-f0-9]{64})$/i.exec(String(entry.hash||''));
1096
+ if(!match) return {ok:false,reason:'hash'};
1097
+ files.push({name:rel,size:Number(entry.bytes),sha256:match[1].toLowerCase()});
1098
+ }
1099
+ const bytes=files.reduce((n,file)=>n+file.size,0);
1100
+ if(!Number.isSafeInteger(bytes)||Number(man.totalBytes)!==bytes) return {ok:false,reason:'size'};
1101
+ if(!PACK.idx) await packLoadIndex();
1102
+ const registered=await packRegisterPack(EXP_PACK_ID,{
1103
+ format:1,label:'Galactic Exploration',bytes,chunkSize:PACK_DEFAULT_CHUNK_BYTES,
1104
+ baseUrl:spec.base,downloadQuery:spec.downloadQuery||'',files
1105
+ });
1106
+ const migrationPlan=await packPlan(EXP_PACK_ID);
1107
+ const migrationStorage=await packStoragePreflight(EXP_PACK_ID,migrationPlan);
1108
+ if(!migrationStorage.ok) return {ok:false,reason:'storage',storage:migrationStorage};
1109
+
1110
+ /* 1.33.51 wrote whole, size-only Blobs into a separate database. Import
1111
+ only those that pass the signed manifest's whole-file SHA-256; the old
1112
+ store is otherwise read-only and the generic engine owns all new data,
1113
+ repair, removal, quota checks, Range chunks and offline mounting. */
1114
+ for(const file of registered.files){
1115
+ if(await packFileReady(EXP_PACK_ID,file)) continue;
1116
+ let legacy=null;
1117
+ try{legacy=packStoredBlob(await expPackGet(file.name));}catch(e){}
1118
+ if(!legacy||legacy.size!==file.size) continue;
1119
+ const actual=await packHashBlob(legacy,file.chunkSize);
1120
+ if(actual!==file.sha256) continue;
1121
+ await packPut(packFileKey(EXP_PACK_ID,file),legacy);
1122
+ await packStorePut(PACK_META_STORE,packMetaKey(EXP_PACK_ID,file),{
1123
+ sha256:actual,size:file.size,verifiedAt:Date.now(),migratedFrom:EXP_PACK_DB
1124
+ });
1125
+ }
1126
+ const result=await packInstallPack(EXP_PACK_ID);
1127
+ return {...result,cached:!!result.ok,openUrl:null};
1128
+ }catch(e){
1129
+ return {ok:false,reason:e&&e.code||'fetch',message:e&&e.message||String(e)};
1130
  }finally{
1131
  expPackBusy=false;
1132
  }
src/authportal.js CHANGED
@@ -74,6 +74,45 @@ let AP_CONFIRM_FOCUS_TOKEN = 0;
74
  let AP_SYNC_KIND = 'idle'; // idle | busy | success | error
75
  let AP_SYNC_MESSAGE = '';
76
  let AP_SESSION_EPOCH = 0; // invalidates authenticated work across account switches
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
 
78
  const AP_SESSION_KEY = 'massfront_authp_session_v1';
79
  const AP_URL_KEY = 'massfront_authp_url';
@@ -142,6 +181,7 @@ function apSaveSession(){
142
  }catch(e){}
143
  }
144
  function apLoadSession(){
 
145
  try{
146
  const s = localStorage.getItem(AP_SESSION_KEY);
147
  if (s){
@@ -173,6 +213,7 @@ function apLoadSession(){
173
  function apClearSession(){
174
  AP_SESSION_EPOCH++;
175
  AP_SESSION = null;
 
176
  apSocialResetCapabilities();
177
  AP_SYNC_KIND = 'idle'; AP_SYNC_MESSAGE = '';
178
  apSaveSession();
@@ -182,6 +223,7 @@ function apClearSession(){
182
  left with no launch prompt at all. Signing out is exactly the moment the
183
  gate should ask again. */
184
  try{ localStorage.removeItem(AP_GATE_KEY); }catch(e){}
 
185
  if(typeof renderAccount==='function')renderAccount();
186
  }
187
 
@@ -304,11 +346,14 @@ function apSetSessionFrom(data, email){
304
  paths into the same field disagreed. */
305
  ageOk: (u && 'ageOk' in u) ? !!u.ageOk : null,
306
  expiresAt: data.expiresAt || 0 };
 
 
 
307
  AP_SYNC_KIND = 'idle';
308
  AP_SYNC_MESSAGE = '';
309
  apSaveSession();
310
  if(typeof renderAccount==='function')renderAccount();
311
- apGateSatisfied();
312
  apGreet();
313
  }
314
  /* "Welcome Commander <name>" — shown once per sign-in, on the menu the player
@@ -342,6 +387,29 @@ async function apDeleteAccount(){
342
  async function apLogin(email, password){
343
  const data = await apRequest('POST', '/login', { email, password }, false);
344
  apSetSessionFrom(data, email);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  }
346
  async function apLogout(){
347
  try{ await apRequest('POST', '/logout', undefined, true); }catch(e){ /* best-effort — clear locally regardless */ }
@@ -354,6 +422,8 @@ async function apLogout(){
354
  async function apVerifySession(){
355
  if (!AP_SESSION) return;
356
  const token=AP_SESSION.token, epoch=AP_SESSION_EPOCH;
 
 
357
  try{
358
  const data = await apRequest('GET', '/me', undefined, true);
359
  if(!AP_SESSION||AP_SESSION_EPOCH!==epoch||AP_SESSION.token!==token) return;
@@ -361,7 +431,9 @@ async function apVerifySession(){
361
  if (data.user && 'username' in data.user) AP_SESSION.username = data.user.username;
362
  if (data.user && 'ageOk' in data.user) AP_SESSION.ageOk = !!data.user.ageOk;
363
  AP_SESSION.offline = false;
 
364
  apSaveSession();
 
365
  }catch(e){
366
  if(e.kind==='stale_session') return;
367
  if (e.status === 401){
@@ -369,7 +441,12 @@ async function apVerifySession(){
369
  synchronous sign-in callback may already have installed a new one. */
370
  if(AP_SESSION&&AP_SESSION_EPOCH===epoch&&AP_SESSION.token===token) apClearSession();
371
  apToast('Your session expired — sign in again.');
372
- }else if (AP_SESSION&&AP_SESSION_EPOCH===epoch&&AP_SESSION.token===token) AP_SESSION.offline = true;
 
 
 
 
 
373
  }
374
  if(typeof renderAccount==='function')renderAccount();
375
  apRender();
@@ -904,6 +981,7 @@ async function apDoClaimUsername(){
904
  try{
905
  const d = await apRequest('POST', '/username', { username: v }, true);
906
  if (AP_SESSION) { AP_SESSION.username = d.username || v; apSaveSession(); }
 
907
  sfx('level'); apToast('✓ Username set to ' + (d.username || v));
908
  }catch(e){
909
  sfx('alarm');
@@ -1104,14 +1182,17 @@ function apClose(){
1104
  if (!ov) return;
1105
  /* Dismissing the gate by ✕ counts as answering it — otherwise the same modal
1106
  returns on the next launch and reads as a bug. */
 
1107
  if (typeof AP_GATE_OPEN !== 'undefined' && AP_GATE_OPEN){
1108
  AP_GATE_OPEN = false;
 
1109
  try{ localStorage.setItem(AP_GATE_KEY, '1'); }catch(e){}
1110
  }
1111
  if (typeof apGateFoot === 'function') apGateFoot(false);
1112
  ov.style.display = 'none';
1113
  document.removeEventListener('keydown', apKeyHandler, true);
1114
  if (AP_LAST_FOCUS && AP_LAST_FOCUS.focus) AP_LAST_FOCUS.focus();
 
1115
  }
1116
 
1117
  /* ---- build the modal DOM once (createElement/innerHTML, appended to body) --------- */
@@ -1225,9 +1306,14 @@ let AP_GATE_OPEN = false;
1225
  function apGateSeen(){
1226
  try{ return localStorage.getItem(AP_GATE_KEY) === '1'; }catch(e){ return true; }
1227
  }
1228
- function apGateSatisfied(){
1229
  try{ localStorage.setItem(AP_GATE_KEY, '1'); }catch(e){}
1230
  if (AP_GATE_OPEN){ AP_GATE_OPEN = false; apClose(); }
 
 
 
 
 
1231
  }
1232
  function apGateFoot(on){
1233
  const box = document.querySelector('#apOverlay .apBox');
@@ -1242,22 +1328,32 @@ function apGateFoot(on){
1242
  box.appendChild(f);
1243
  apTapBind(document.getElementById('apOfflineBtn'), () => {
1244
  if (typeof sfx === 'function'){ try{ sfx('ui'); }catch(e){} }
1245
- apGateSatisfied();
1246
  });
1247
  }
1248
  /* Called by intro.js the moment the launch title hands off to the front end. */
1249
  function mfAuthGate(){
1250
  try{
1251
- if (AP_SESSION) return; // already signed in — nothing to ask
1252
- if (apGateSeen()) return; // asked once; never nag again
1253
- if (!document.getElementById('apOverlay')) return;
 
 
 
 
 
1254
  AP_GATE_OPEN = true;
 
1255
  apGateFoot(true);
1256
  /* On the gate this modal is a welcome, not a settings panel. */
1257
  const t = document.getElementById('apTitleTx');
1258
  if (t) t.textContent = '\u2b21 SIGN IN OR REGISTER';
1259
  apOpen(null);
1260
- }catch(e){ AP_GATE_OPEN = false; }
 
 
 
 
1261
  }
1262
 
1263
  /* ---- boot -------------------------------------------------------------------------- */
@@ -1265,6 +1361,7 @@ function initAuthPortal(){
1265
  apBuildUI();
1266
  apInjectMenuButton();
1267
  apLoadSession();
 
1268
  apResolveEndpoint().then(() => {
1269
  apRender();
1270
  if (AP_SESSION) apVerifySession();
@@ -1274,7 +1371,7 @@ function initAuthPortal(){
1274
  /* ============================================================================
1275
  SOCIAL CLIENT — thin, NON-THROWING wrappers over apRequest
1276
  ============================================================================
1277
- The verification-first server exposes:
1278
 
1279
  GET /social/friends -> {friends:[…]} (server pre-filters blocks)
1280
  GET /social/requests -> {requests:[…]}
@@ -1286,10 +1383,9 @@ function initAuthPortal(){
1286
  POST /social/online/heartbeat aggregate count only
1287
  GET /social/online aggregate count only
1288
 
1289
- and refuses an account that may not use social at all with 403 plus one of
1290
- three codes: 'unverified', 'age_restricted', 'social_banned'. apRequest
1291
- already lifts {error,message} off a non-2xx body onto e.kind/e.message, so
1292
- those three arrive here intact.
1293
 
1294
  Everything below RESOLVES. Not one of these functions rejects, because the
1295
  caller is a mailbox section that repaints mid-match: an unhandled rejection
@@ -1354,11 +1450,14 @@ function apSocialFail(e){
1354
  return { ok:false, code:'signed_out',
1355
  message:'Your session expired — sign in again to use friends.' };
1356
  if (kind === 'unverified')
1357
- return { ok:false, code:'unverified',
1358
- message: raw || 'Verify your email address before using friends.' };
1359
  if (kind === 'age_restricted')
1360
  return { ok:false, code:'age_restricted',
1361
  message: raw || 'Friends are not available on this account.' };
 
 
 
1362
  if (kind === 'social_banned')
1363
  return { ok:false, code:'social_banned',
1364
  message: raw || 'Friends have been disabled on this account.' };
@@ -1380,7 +1479,36 @@ function apSocialUsername(raw){
1380
  }
1381
  return {ok:true,value};
1382
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1383
  async function apSocialOnce(key,work){
 
 
1384
  const scoped=(AP_SESSION?AP_SESSION_EPOCH:'guest')+':'+key;
1385
  if(AP_SOCIAL_PENDING.has(scoped)){
1386
  AP_SOCIAL_PROBE.coalesced++;
@@ -1513,6 +1641,7 @@ async function socialReport(u,reason,context){
1513
  bound to AP_SESSION_EPOCH, so it cannot carry across sign-out/sign-in. */
1514
  async function socialHandshake(force){
1515
  if(!mfSocialSignedIn()) return {ok:false,code:'signed_out',message:'Sign in to check social capabilities.'};
 
1516
  if(!force&&AP_SOCIAL_CAPS.handshake&&AP_SOCIAL_CAPS.sessionEpoch===AP_SESSION_EPOCH&&
1517
  Date.now()-AP_SOCIAL_CAPS.checkedAt<AP_SOCIAL_CAP_TTL_MS)
1518
  return {ok:true,capabilities:mfSocialCapabilities()};
@@ -1885,24 +2014,15 @@ async function socialMatchCredential(match,rules,handoff){
1885
  Accepts either a wrapper result or a bare code string. */
1886
  function mfSocialGate(x){
1887
  const code = (x && typeof x === 'object') ? (x.code || x.kind || '') : String(x || '');
1888
- if (code === 'unverified' || code === 'email_unverified') return 'unverified';
1889
  if (code === 'age_restricted' || code === 'age') return 'age';
1890
  if (code === 'social_banned' || code === 'banned') return 'banned';
 
1891
  return 'ok';
1892
  }
1893
- /* The nudge that goes with each gate. Only ONE of the three is actionable —
1894
- an unverified email is fixed by the player, from the account portal, which
1895
- is why this lives in the file that owns the portal rather than in the
1896
- mailbox. Age and ban are stated plainly and offer no button, because
1897
- offering a button that cannot help is worse than offering none. */
1898
  function mfSocialNudge(gate){
1899
  const g = mfSocialGate(gate);
1900
- if (g === 'unverified')
1901
- return { gate:g,
1902
- text:'Verify your email address to send and accept friend requests. '+
1903
- 'Everything else in the game keeps working.',
1904
- cta:'OPEN ACCOUNT',
1905
- act:(trigger)=>{ try{ if (typeof apOpen === 'function') apOpen(trigger || null); }catch(e){} } };
1906
  if (g === 'age')
1907
  return { gate:g, text:'Friends are not available on this account.', cta:'', act:null };
1908
  if (g === 'banned')
@@ -1963,6 +2083,7 @@ if(typeof window!=='undefined') window.MFSocial={
1963
  lobbyInvites:socialLobbyInvites,respondLobbyInvite:socialLobbyInviteRespond,
1964
  verifyLobbyCompatibility:socialLobbyCompatibility,launchLobby:socialLobbyLaunch,
1965
  claimMatchCredential:socialMatchCredential,rulesHash:apSocialRulesHash,matchSocketUrl:socialMatchSocketUrl,
1966
- signedIn:mfSocialSignedIn,capabilities:mfSocialCapabilities,probe:mfSocialProbe
 
1967
  };
1968
  /* ---- end social client ---- */
 
74
  let AP_SYNC_KIND = 'idle'; // idle | busy | success | error
75
  let AP_SYNC_MESSAGE = '';
76
  let AP_SESSION_EPOCH = 0; // invalidates authenticated work across account switches
77
+ let AP_SESSION_VERIFIED = false; // cached tokens stay pending until /me confirms this process
78
+
79
+ /* The launcher consumes identity as state, never by inspecting portal text or
80
+ account fields. Keep this payload deliberately small: identity readiness is
81
+ useful to every front-end surface, while an email, username or bearer token
82
+ in a broadcast event would be needless exposure to every script on page. */
83
+ let AP_IDENTITY_REVISION = 0;
84
+ let AP_IDENTITY_STATE = Object.freeze({
85
+ state:'pending', signedIn:false, verified:false, source:'boot', revision:0
86
+ });
87
+ function mfIdentitySnapshot(){
88
+ return {
89
+ state:AP_IDENTITY_STATE.state,
90
+ signedIn:AP_IDENTITY_STATE.signedIn,
91
+ verified:AP_IDENTITY_STATE.verified,
92
+ source:AP_IDENTITY_STATE.source,
93
+ revision:AP_IDENTITY_STATE.revision
94
+ };
95
+ }
96
+ function apIdentitySet(state, source){
97
+ const cleanState=state==='connected'||state==='offline'?state:'pending';
98
+ const cleanSource=String(source||'unknown').replace(/[^a-z0-9_-]/gi,'').slice(0,40)||'unknown';
99
+ AP_IDENTITY_STATE=Object.freeze({
100
+ state:cleanState,
101
+ signedIn:!!(AP_SESSION&&AP_SESSION.token),
102
+ verified:cleanState==='connected'&&!!AP_SESSION_VERIFIED,
103
+ source:cleanSource,
104
+ revision:++AP_IDENTITY_REVISION
105
+ });
106
+ const detail=mfIdentitySnapshot();
107
+ try{
108
+ if(typeof window!=='undefined'&&typeof window.dispatchEvent==='function'&&typeof CustomEvent==='function')
109
+ window.dispatchEvent(new CustomEvent('massfront:identity-state',{detail}));
110
+ }catch(e){}
111
+ return detail;
112
+ }
113
+ if(typeof window!=='undefined') window.MFIdentity=Object.freeze({
114
+ event:'massfront:identity-state', snapshot:mfIdentitySnapshot
115
+ });
116
 
117
  const AP_SESSION_KEY = 'massfront_authp_session_v1';
118
  const AP_URL_KEY = 'massfront_authp_url';
 
181
  }catch(e){}
182
  }
183
  function apLoadSession(){
184
+ AP_SESSION_VERIFIED = false;
185
  try{
186
  const s = localStorage.getItem(AP_SESSION_KEY);
187
  if (s){
 
213
  function apClearSession(){
214
  AP_SESSION_EPOCH++;
215
  AP_SESSION = null;
216
+ AP_SESSION_VERIFIED = false;
217
  apSocialResetCapabilities();
218
  AP_SYNC_KIND = 'idle'; AP_SYNC_MESSAGE = '';
219
  apSaveSession();
 
223
  left with no launch prompt at all. Signing out is exactly the moment the
224
  gate should ask again. */
225
  try{ localStorage.removeItem(AP_GATE_KEY); }catch(e){}
226
+ apIdentitySet('pending','signed-out');
227
  if(typeof renderAccount==='function')renderAccount();
228
  }
229
 
 
346
  paths into the same field disagreed. */
347
  ageOk: (u && 'ageOk' in u) ? !!u.ageOk : null,
348
  expiresAt: data.expiresAt || 0 };
349
+ /* register/login just returned this token from the account server, so this
350
+ is the one path that may become connected without a separate /me call. */
351
+ AP_SESSION_VERIFIED = true;
352
  AP_SYNC_KIND = 'idle';
353
  AP_SYNC_MESSAGE = '';
354
  apSaveSession();
355
  if(typeof renderAccount==='function')renderAccount();
356
+ apGateSatisfied('connected');
357
  apGreet();
358
  }
359
  /* "Welcome Commander <name>" — shown once per sign-in, on the menu the player
 
387
  async function apLogin(email, password){
388
  const data = await apRequest('POST', '/login', { email, password }, false);
389
  apSetSessionFrom(data, email);
390
+ /* Backward-compatible identity hydration: older deployed Workers returned a
391
+ valid login token but omitted the account's persisted username. /me has
392
+ always returned the canonical row, so query it before the sign-in flow
393
+ completes. A transient second-request failure does not undo a successful
394
+ login; boot/session verification will retry it later. */
395
+ const token=AP_SESSION&&AP_SESSION.token,epoch=AP_SESSION_EPOCH;
396
+ try{
397
+ const me=await apRequest('GET','/me',undefined,true);
398
+ if(AP_SESSION&&AP_SESSION_EPOCH===epoch&&AP_SESSION.token===token&&me&&me.user){
399
+ if('username' in me.user){
400
+ const value=String(me.user.username==null?'':me.user.username).trim();
401
+ AP_SESSION.username=/^[a-z0-9_]{3,16}$/i.test(value)?value:null;
402
+ }
403
+ if('ageOk' in me.user)AP_SESSION.ageOk=!!me.user.ageOk;
404
+ AP_SESSION.email=String(me.user.email||AP_SESSION.email).trim().toLowerCase();
405
+ apSaveSession();
406
+ }
407
+ }catch(e){
408
+ /* apRequest already clears an actually invalid 401 session. Network and
409
+ old-server failures leave the authenticated session available, and the
410
+ next /me verification retries without asking the player to sign in. */
411
+ if(e&&e.status===401)throw e;
412
+ }
413
  }
414
  async function apLogout(){
415
  try{ await apRequest('POST', '/logout', undefined, true); }catch(e){ /* best-effort — clear locally regardless */ }
 
422
  async function apVerifySession(){
423
  if (!AP_SESSION) return;
424
  const token=AP_SESSION.token, epoch=AP_SESSION_EPOCH;
425
+ AP_SESSION_VERIFIED = false;
426
+ apIdentitySet('pending','session-verification');
427
  try{
428
  const data = await apRequest('GET', '/me', undefined, true);
429
  if(!AP_SESSION||AP_SESSION_EPOCH!==epoch||AP_SESSION.token!==token) return;
 
431
  if (data.user && 'username' in data.user) AP_SESSION.username = data.user.username;
432
  if (data.user && 'ageOk' in data.user) AP_SESSION.ageOk = !!data.user.ageOk;
433
  AP_SESSION.offline = false;
434
+ AP_SESSION_VERIFIED = true;
435
  apSaveSession();
436
+ apIdentitySet('connected','session-verified');
437
  }catch(e){
438
  if(e.kind==='stale_session') return;
439
  if (e.status === 401){
 
441
  synchronous sign-in callback may already have installed a new one. */
442
  if(AP_SESSION&&AP_SESSION_EPOCH===epoch&&AP_SESSION.token===token) apClearSession();
443
  apToast('Your session expired — sign in again.');
444
+ }else if (AP_SESSION&&AP_SESSION_EPOCH===epoch&&AP_SESSION.token===token){
445
+ AP_SESSION.offline = true;
446
+ AP_SESSION_VERIFIED = false;
447
+ apSaveSession();
448
+ apIdentitySet('offline','session-unverified');
449
+ }
450
  }
451
  if(typeof renderAccount==='function')renderAccount();
452
  apRender();
 
981
  try{
982
  const d = await apRequest('POST', '/username', { username: v }, true);
983
  if (AP_SESSION) { AP_SESSION.username = d.username || v; apSaveSession(); }
984
+ apSocialResetCapabilities();
985
  sfx('level'); apToast('✓ Username set to ' + (d.username || v));
986
  }catch(e){
987
  sfx('alarm');
 
1182
  if (!ov) return;
1183
  /* Dismissing the gate by ✕ counts as answering it — otherwise the same modal
1184
  returns on the next launch and reads as a bug. */
1185
+ let gateDismissed=false;
1186
  if (typeof AP_GATE_OPEN !== 'undefined' && AP_GATE_OPEN){
1187
  AP_GATE_OPEN = false;
1188
+ gateDismissed=true;
1189
  try{ localStorage.setItem(AP_GATE_KEY, '1'); }catch(e){}
1190
  }
1191
  if (typeof apGateFoot === 'function') apGateFoot(false);
1192
  ov.style.display = 'none';
1193
  document.removeEventListener('keydown', apKeyHandler, true);
1194
  if (AP_LAST_FOCUS && AP_LAST_FOCUS.focus) AP_LAST_FOCUS.focus();
1195
+ if(gateDismissed) apIdentitySet('offline','gate-dismissed');
1196
  }
1197
 
1198
  /* ---- build the modal DOM once (createElement/innerHTML, appended to body) --------- */
 
1306
  function apGateSeen(){
1307
  try{ return localStorage.getItem(AP_GATE_KEY) === '1'; }catch(e){ return true; }
1308
  }
1309
+ function apGateSatisfied(choice){
1310
  try{ localStorage.setItem(AP_GATE_KEY, '1'); }catch(e){}
1311
  if (AP_GATE_OPEN){ AP_GATE_OPEN = false; apClose(); }
1312
+ if(choice==='offline') return apIdentitySet('offline','offline-choice');
1313
+ if(choice==='connected'&&AP_SESSION_VERIFIED) return apIdentitySet('connected','session-server');
1314
+ if(AP_SESSION&&AP_SESSION_VERIFIED) return apIdentitySet('connected','session-server');
1315
+ if(AP_SESSION) return apIdentitySet('pending','session-verification');
1316
+ return apIdentitySet('offline','offline-choice');
1317
  }
1318
  function apGateFoot(on){
1319
  const box = document.querySelector('#apOverlay .apBox');
 
1328
  box.appendChild(f);
1329
  apTapBind(document.getElementById('apOfflineBtn'), () => {
1330
  if (typeof sfx === 'function'){ try{ sfx('ui'); }catch(e){} }
1331
+ apGateSatisfied('offline');
1332
  });
1333
  }
1334
  /* Called by intro.js the moment the launch title hands off to the front end. */
1335
  function mfAuthGate(){
1336
  try{
1337
+ /* A cached token is not connected merely because it survived a restart.
1338
+ apVerifySession will advance pending to connected or offline. */
1339
+ if (AP_SESSION){
1340
+ if(AP_SESSION_VERIFIED) return apIdentitySet('connected','session-verified');
1341
+ return apIdentitySet('pending','session-verification');
1342
+ }
1343
+ if (apGateSeen()) return apIdentitySet('offline','remembered-offline');
1344
+ if (!document.getElementById('apOverlay')) return apIdentitySet('pending','gate-unavailable');
1345
  AP_GATE_OPEN = true;
1346
+ apIdentitySet('pending','awaiting-choice');
1347
  apGateFoot(true);
1348
  /* On the gate this modal is a welcome, not a settings panel. */
1349
  const t = document.getElementById('apTitleTx');
1350
  if (t) t.textContent = '\u2b21 SIGN IN OR REGISTER';
1351
  apOpen(null);
1352
+ return mfIdentitySnapshot();
1353
+ }catch(e){
1354
+ AP_GATE_OPEN = false;
1355
+ return apIdentitySet('pending','gate-error');
1356
+ }
1357
  }
1358
 
1359
  /* ---- boot -------------------------------------------------------------------------- */
 
1361
  apBuildUI();
1362
  apInjectMenuButton();
1363
  apLoadSession();
1364
+ if(AP_SESSION) apIdentitySet('pending','session-verification');
1365
  apResolveEndpoint().then(() => {
1366
  apRender();
1367
  if (AP_SESSION) apVerifySession();
 
1371
  /* ============================================================================
1372
  SOCIAL CLIENT — thin, NON-THROWING wrappers over apRequest
1373
  ============================================================================
1374
+ The authenticated Social server exposes:
1375
 
1376
  GET /social/friends -> {friends:[…]} (server pre-filters blocks)
1377
  GET /social/requests -> {requests:[…]}
 
1383
  POST /social/online/heartbeat aggregate count only
1384
  GET /social/online aggregate count only
1385
 
1386
+ and refuses an account that may not use social with age, username,
1387
+ moderation or ban codes. E-mail verification is optional account metadata
1388
+ and is permanently outside Social and multiplayer authorization.
 
1389
 
1390
  Everything below RESOLVES. Not one of these functions rejects, because the
1391
  caller is a mailbox section that repaints mid-match: an unhandled rejection
 
1450
  return { ok:false, code:'signed_out',
1451
  message:'Your session expired — sign in again to use friends.' };
1452
  if (kind === 'unverified')
1453
+ return { ok:false, code:'service_policy',
1454
+ message:'The Social service is using an obsolete access policy — retry after it updates.' };
1455
  if (kind === 'age_restricted')
1456
  return { ok:false, code:'age_restricted',
1457
  message: raw || 'Friends are not available on this account.' };
1458
+ if (kind === 'username_required')
1459
+ return { ok:false, code:'username_required',
1460
+ message: raw || 'Choose your commander username before using Social Command.' };
1461
  if (kind === 'social_banned')
1462
  return { ok:false, code:'social_banned',
1463
  message: raw || 'Friends have been disabled on this account.' };
 
1479
  }
1480
  return {ok:true,value};
1481
  }
1482
+ function mfSocialIdentity(){
1483
+ let value='';
1484
+ try{value=String(AP_SESSION&&AP_SESSION.username||'').trim();}catch(e){}
1485
+ return {username:AP_SOCIAL_USERNAME_RE.test(value)?value:'',hasUsername:AP_SOCIAL_USERNAME_RE.test(value)};
1486
+ }
1487
+ function apSocialUsernameGate(){
1488
+ if(!mfSocialSignedIn())return null;
1489
+ return mfSocialIdentity().hasUsername?null:{ok:false,code:'username_required',
1490
+ message:'Choose your commander username before using Social Command.'};
1491
+ }
1492
+ /* The Social chooser writes the same canonical account field as Account. It
1493
+ does not create a second display name, and it never substitutes a local
1494
+ profile label for server identity. */
1495
+ async function socialClaimUsername(raw){
1496
+ const uv=apSocialUsername(raw);if(!uv.ok)return uv.result;
1497
+ if(!mfSocialSignedIn())return {ok:false,code:'signed_out',message:'Sign in before choosing a commander username.'};
1498
+ try{
1499
+ const d=await apRequest('POST','/username',{username:uv.value},true);
1500
+ const value=String(d&&d.username||uv.value).trim();
1501
+ if(!AP_SOCIAL_USERNAME_RE.test(value))return {ok:false,code:'bad_response',message:'The account server returned an invalid username.'};
1502
+ if(AP_SESSION){AP_SESSION.username=value;apSaveSession();}
1503
+ apSocialResetCapabilities();
1504
+ if(typeof renderAccount==='function')try{renderAccount();}catch(e){}
1505
+ if(typeof renderMetaHead==='function')try{renderMetaHead();}catch(e){}
1506
+ return {ok:true,username:value};
1507
+ }catch(e){return apSocialFail(e);}
1508
+ }
1509
  async function apSocialOnce(key,work){
1510
+ const usernameGate=apSocialUsernameGate();
1511
+ if(usernameGate)return usernameGate;
1512
  const scoped=(AP_SESSION?AP_SESSION_EPOCH:'guest')+':'+key;
1513
  if(AP_SOCIAL_PENDING.has(scoped)){
1514
  AP_SOCIAL_PROBE.coalesced++;
 
1641
  bound to AP_SESSION_EPOCH, so it cannot carry across sign-out/sign-in. */
1642
  async function socialHandshake(force){
1643
  if(!mfSocialSignedIn()) return {ok:false,code:'signed_out',message:'Sign in to check social capabilities.'};
1644
+ const usernameGate=apSocialUsernameGate();if(usernameGate)return usernameGate;
1645
  if(!force&&AP_SOCIAL_CAPS.handshake&&AP_SOCIAL_CAPS.sessionEpoch===AP_SESSION_EPOCH&&
1646
  Date.now()-AP_SOCIAL_CAPS.checkedAt<AP_SOCIAL_CAP_TTL_MS)
1647
  return {ok:true,capabilities:mfSocialCapabilities()};
 
2014
  Accepts either a wrapper result or a bare code string. */
2015
  function mfSocialGate(x){
2016
  const code = (x && typeof x === 'object') ? (x.code || x.kind || '') : String(x || '');
 
2017
  if (code === 'age_restricted' || code === 'age') return 'age';
2018
  if (code === 'social_banned' || code === 'banned') return 'banned';
2019
+ if (code === 'username_required' || code === 'username') return 'username';
2020
  return 'ok';
2021
  }
2022
+ /* Age and ban are stated plainly and offer no button because account e-mail
2023
+ verification is not, and must never become, a Social access action. */
 
 
 
2024
  function mfSocialNudge(gate){
2025
  const g = mfSocialGate(gate);
 
 
 
 
 
 
2026
  if (g === 'age')
2027
  return { gate:g, text:'Friends are not available on this account.', cta:'', act:null };
2028
  if (g === 'banned')
 
2083
  lobbyInvites:socialLobbyInvites,respondLobbyInvite:socialLobbyInviteRespond,
2084
  verifyLobbyCompatibility:socialLobbyCompatibility,launchLobby:socialLobbyLaunch,
2085
  claimMatchCredential:socialMatchCredential,rulesHash:apSocialRulesHash,matchSocketUrl:socialMatchSocketUrl,
2086
+ signedIn:mfSocialSignedIn,identity:mfSocialIdentity,claimUsername:socialClaimUsername,
2087
+ capabilities:mfSocialCapabilities,probe:mfSocialProbe
2088
  };
2089
  /* ---- end social client ---- */