CREATORJD commited on
Commit
dd94c65
Β·
verified Β·
1 Parent(s): feb9f49

MASSFRONT 1.33.76 exact packed browser release (part 5)

Browse files
src/galactic-operations.js CHANGED
@@ -332,7 +332,13 @@
332
  return typeof PROFILES!=='undefined'&&PROFILES&&text(PROFILES.active)?PROFILES.active:'';
333
  }
334
  function currentFlagOn(){
335
- return !!(typeof META!=='undefined'&&META&&META.settings&&META.settings.experimentalExploration===true);
 
 
 
 
 
 
336
  }
337
  function stripBridgeQuery(){
338
  if(typeof history==='undefined'||typeof history.replaceState!=='function')return;
@@ -456,10 +462,8 @@
456
  the authored abandon action can refund the deployment. A malformed nonce
457
  is never reflected into a URL. */
458
  setTimeout(()=>{
459
- const target=NONCE_RE.test(bridge.nonce)
460
- ?'./modules/space_exploration/index.html?groundRejected='+encodeURIComponent(bridge.nonce)
461
- :'./modules/space_exploration/index.html';
462
- try{location.href=target;}
463
  catch(e){bridge.status='return-error';bridge.reason='REJECTION_RETURN_FAILED';}
464
  },900);
465
  }
@@ -712,8 +716,8 @@
712
  function returnRejectedToNexus(){
713
  if(bridge.returning)return false;
714
  bridge.returning=true;bridge.status='returning-rejected';
715
- const target='./modules/space_exploration/index.html?groundRejected='+encodeURIComponent(bridge.nonce);
716
- try{location.href=target;return true;}
717
  catch(e){bridge.returning=false;bridge.status='return-error';bridge.reason='RETURN_NAVIGATION_FAILED';return false;}
718
  }
719
  function returnToNexus(){
@@ -723,14 +727,14 @@
723
  return false;
724
  }
725
  bridge.returning=true;bridge.status='returning';
726
- const target='./modules/space_exploration/index.html?groundResult='+encodeURIComponent(bridge.nonce);
727
- try{location.href=target;return true;}
728
  catch(e){bridge.returning=false;bridge.status='return-error';bridge.reason='RETURN_NAVIGATION_FAILED';if(typeof toast==='function')toast('NEXUS-VII return route could not be opened');return false;}
729
  }
730
  function returnExistingReportToNexus(record){
731
  bridge.active=false;bridge.report=clone(record);bridge.returning=true;bridge.status='returning-existing';bridge.reason='';
732
- const target='./modules/space_exploration/index.html?groundResult='+encodeURIComponent(bridge.nonce);
733
- try{location.href=target;return true;}
734
  catch(e){bridge.returning=false;bridge.status='return-error';bridge.reason='RETURN_NAVIGATION_FAILED';return false;}
735
  }
736
 
@@ -837,7 +841,9 @@
837
  };
838
  }
839
 
 
840
  const api={validateEntryTicket,validateRouteRequest,validateRequest,validateRequestMirror,validateTacticalReport,validateResultMirror,
 
841
  validateDeploymentContract,describeOperationEffects,checksum:envelopeChecksum};
842
  Object.defineProperties(api,{
843
  active:{enumerable:true,get:()=>bridge.active},
@@ -866,7 +872,13 @@
866
  if(bridge.menuRouteActive&&(id==='startScreen'||id==='warScr')){
867
  bridge.menuRouteActive=false;
868
  if(currentFlagOn()&&typeof mfOpenExploration==='function'){
869
- Promise.resolve(mfOpenExploration('campaign_hub')).then(opened=>{
 
 
 
 
 
 
870
  if(!opened)baseShowFrontScreen.call(this,id);
871
  });
872
  return true;
 
332
  return typeof PROFILES!=='undefined'&&PROFILES&&text(PROFILES.active)?PROFILES.active:'';
333
  }
334
  function currentFlagOn(){
335
+ return window.__MF_BUILD_HAS_GALACTIC_EXPLORATION===true
336
+ ||window.__MF_OTA_HAS_GALACTIC_DELIVERY===true
337
+ ||!!(typeof META!=='undefined'&&META&&META.settings&&META.settings.experimentalExploration===true);
338
+ }
339
+ function explorationReturnTarget(query=''){
340
+ const target='./modules/space_exploration/index.html'+query;
341
+ return typeof mfContentExplorationReturnUrl==='function'?mfContentExplorationReturnUrl(target):target;
342
  }
343
  function stripBridgeQuery(){
344
  if(typeof history==='undefined'||typeof history.replaceState!=='function')return;
 
462
  the authored abandon action can refund the deployment. A malformed nonce
463
  is never reflected into a URL. */
464
  setTimeout(()=>{
465
+ const target=explorationReturnTarget(NONCE_RE.test(bridge.nonce)?'?groundRejected='+encodeURIComponent(bridge.nonce):'');
466
+ try{if(!target)throw new Error('Content return unavailable');location.href=target;}
 
 
467
  catch(e){bridge.status='return-error';bridge.reason='REJECTION_RETURN_FAILED';}
468
  },900);
469
  }
 
716
  function returnRejectedToNexus(){
717
  if(bridge.returning)return false;
718
  bridge.returning=true;bridge.status='returning-rejected';
719
+ const target=explorationReturnTarget('?groundRejected='+encodeURIComponent(bridge.nonce));
720
+ try{if(!target)throw new Error('Content return unavailable');location.href=target;return true;}
721
  catch(e){bridge.returning=false;bridge.status='return-error';bridge.reason='RETURN_NAVIGATION_FAILED';return false;}
722
  }
723
  function returnToNexus(){
 
727
  return false;
728
  }
729
  bridge.returning=true;bridge.status='returning';
730
+ const target=explorationReturnTarget('?groundResult='+encodeURIComponent(bridge.nonce));
731
+ try{if(!target)throw new Error('Content return unavailable');location.href=target;return true;}
732
  catch(e){bridge.returning=false;bridge.status='return-error';bridge.reason='RETURN_NAVIGATION_FAILED';if(typeof toast==='function')toast('NEXUS-VII return route could not be opened');return false;}
733
  }
734
  function returnExistingReportToNexus(record){
735
  bridge.active=false;bridge.report=clone(record);bridge.returning=true;bridge.status='returning-existing';bridge.reason='';
736
+ const target=explorationReturnTarget('?groundResult='+encodeURIComponent(bridge.nonce));
737
+ try{if(!target)throw new Error('Content return unavailable');location.href=target;return true;}
738
  catch(e){bridge.returning=false;bridge.status='return-error';bridge.reason='RETURN_NAVIGATION_FAILED';return false;}
739
  }
740
 
 
841
  };
842
  }
843
 
844
+ let menuReturnContext=null;
845
  const api={validateEntryTicket,validateRouteRequest,validateRequest,validateRequestMirror,validateTacticalReport,validateResultMirror,
846
+ isMenuReturnContext:value=>value!==null&&value===menuReturnContext&&bridge.status==='menu-route',
847
  validateDeploymentContract,describeOperationEffects,checksum:envelopeChecksum};
848
  Object.defineProperties(api,{
849
  active:{enumerable:true,get:()=>bridge.active},
 
872
  if(bridge.menuRouteActive&&(id==='startScreen'||id==='warScr')){
873
  bridge.menuRouteActive=false;
874
  if(currentFlagOn()&&typeof mfOpenExploration==='function'){
875
+ // Only this synchronous call can authorize returning an existing
876
+ // neutral career. It grants no faction, personnel, or mission access.
877
+ menuReturnContext=Object.freeze({kind:'validated-menu-return'});
878
+ let opening;
879
+ try{opening=mfOpenExploration('campaign_hub',{menuReturn:menuReturnContext});}
880
+ finally{menuReturnContext=null;}
881
+ Promise.resolve(opening).then(opened=>{
882
  if(!opened)baseShowFrontScreen.call(this,id);
883
  });
884
  return true;
src/game/matchconsumer.js CHANGED
@@ -17,12 +17,14 @@
17
  const MC_INT=(v,min,max)=>Number.isSafeInteger(v)&&v>=min&&v<=max;
18
  const MC_KEYS=(v,keys)=>!!v&&typeof v==='object'&&!Array.isArray(v)&&
19
  Object.keys(v).length===keys.length&&Object.keys(v).every(k=>keys.includes(k));
20
- const MC_TYPES=Object.freeze(['move','stop','hold','attack','guard','build','produce','research','commander','repair','recycle','upgrade']);
21
  const MC_UNIT_COMMAND_MAX=64,MC_BATCH_COMMAND_MAX=8,MC_COMMAND_BYTES_MAX=2048,
22
  MC_LOGICAL_UNIT_MAX=MC_UNIT_COMMAND_MAX*MC_BATCH_COMMAND_MAX,
23
  MC_FORM_IDS=Object.freeze(['spread','line','wedge','box','column','arc']);
24
  let mcRules=null,mcWelcome=null,mcStart=null,mcLaunchStarted=false,mcSubmitFailure='',mcApplyFailure='';
25
  let mcUpgradePendingTick=-1,mcUpgradePendingSeq=-1;
 
 
26
 
27
  function mcLobbyRules(){
28
  const live=window.MFSocialUI&&MFSocialUI.state&&MFSocialUI.state.lobby,
@@ -40,12 +42,14 @@
40
  packet and the pending service receipt before replay could reconcile it. */
41
  if(mcWelcome.resumed!==true){
42
  mcUpgradePendingTick=mcUpgradePendingSeq=-1;
 
 
43
  mcLastCommittedTick=Number.isSafeInteger(mcWelcome.tick)?mcWelcome.tick:0;mcAppliedTicks.clear();
44
  if(mcQueuedTick){const Q=mcQueuedTick;mcQueuedTick=null;Q.resolve(false);}
45
  }
46
  }
47
  if(e&&e.type==='massfront-match:start'){
48
- mcStart=e.detail||null;mcLastCommittedTick=0;mcAppliedTicks.clear();
49
  if(typeof queueMicrotask==='function')queueMicrotask(mcBootstrapMatch);else setTimeout(mcBootstrapMatch,0);
50
  }
51
  }
@@ -214,6 +218,34 @@
214
  if(typeof populationCanSpawn==='function'&&!populationCanSpawn(command.unit,authority.team,authority.slot))return null;
215
  return {type:'produce',building:ref.id,unit:command.unit,count:command.count};
216
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  if(command.type==='research'){
218
  if(!MC_KEYS(command,['type','building','study'])||typeof command.study!=='string')return null;
219
  const ref=mcBuildingRef(command.building,authority,['techlab']),idx=typeof RESEARCH!=='undefined'?
@@ -284,6 +316,8 @@
284
  for(const i of P.indices){if(upgrades.has(i)||services.has(i))return false;upgrades.add(i);}
285
  const key=P.authority.team+':'+P.authority.slot,E=escrow.get(key)||{m:0,e:0,a:P.authority};
286
  E.m+=P.costM;E.e+=P.costE;escrow.set(key,E);
 
 
287
  }else if(P.type==='produce'){
288
  if(services.has(P.building))return false;
289
  const n=(prod.get(P.building)||0)+P.count,B=blds[P.building];prod.set(P.building,n);
@@ -425,8 +459,30 @@
425
  return;
426
  }
427
  if(plan.type==='produce'){
428
- const B=blds[plan.building];for(let n=0;n<plan.count;n++)B.queue.push(plan.unit);return;
429
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
  if(plan.type==='research'){
431
  const B=blds[plan.building],R=RESEARCH[plan.study];B.res=plan.study;B.resT=Math.min(R.t-.01,researchResumeTime(R.id));return;
432
  }
@@ -499,6 +555,11 @@
499
  }
500
  function mcClearUpgradeAt(tick){
501
  if(mcUpgradePendingTick>=0&&tick>=mcUpgradePendingTick)mcUpgradePendingTick=mcUpgradePendingSeq=-1;
 
 
 
 
 
502
  }
503
  function mcDuplicateTick(packet,signature){return mcAppliedTicks.has(packet.tick)&&mcAppliedTicks.get(packet.tick)===signature;}
504
  async function mcApplyTick(packet){
@@ -556,6 +617,11 @@
556
  accepted one remains pending until its authoritative target tick commits. */
557
  if(mcUpgradePendingSeq>=0&&(mcUpgradePendingSeq>lastSeq||mcUpgradePendingTick<=mcLastCommittedTick))
558
  mcUpgradePendingTick=mcUpgradePendingSeq=-1;
 
 
 
 
 
559
  return true;
560
  }
561
  function mcRuntimeActive(){
@@ -682,6 +748,33 @@
682
  if(receipt){mcUpgradePendingTick=receipt.targetTick;mcUpgradePendingSeq=receipt.seq;}
683
  return receipt;
684
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
685
  function mcUnitRefs(indices){return indices.map(i=>({id:i,generation:ugen[i]}));}
686
  function mcSelected(){const out=[];for(let i=0;i<unitHigh;i++)if(ualive[i]&&usel[i])out.push(i);return out;}
687
  function mcWrap(name,make){
@@ -744,6 +837,8 @@
744
  lastAppliedTick:()=>mcLastCommittedTick,resumeState:mcResumeState,
745
  submit:mcSubmit,takeover:mcTakeover,
746
  submitRepair:mcSubmitRepair,submitRecycle:mcSubmitRecycle,submitUpgrade:mcSubmitUpgrade,
 
 
747
  upgradePending:()=>mcSessionLockstep()&&mcUpgradePendingTick>=0,buildingRef:mcBuildingHandle,
748
  lastFailure:()=>mcApplyFailure||mcSubmitFailure,
749
  bootstrap:()=>Object.freeze({localSeat:mcWelcome&&mcWelcome.seat||0,seats:mcStart&&Array.isArray(mcStart.seats)?mcStart.seats.slice():[],rules:mcLobbyRules()}),seatAuthority:seat=>{
 
17
  const MC_INT=(v,min,max)=>Number.isSafeInteger(v)&&v>=min&&v<=max;
18
  const MC_KEYS=(v,keys)=>!!v&&typeof v==='object'&&!Array.isArray(v)&&
19
  Object.keys(v).length===keys.length&&Object.keys(v).every(k=>keys.includes(k));
20
+ const MC_TYPES=Object.freeze(['move','stop','hold','attack','guard','build','produce','research','commander','repair','recycle','upgrade','repeat','rally','cancel_production']);
21
  const MC_UNIT_COMMAND_MAX=64,MC_BATCH_COMMAND_MAX=8,MC_COMMAND_BYTES_MAX=2048,
22
  MC_LOGICAL_UNIT_MAX=MC_UNIT_COMMAND_MAX*MC_BATCH_COMMAND_MAX,
23
  MC_FORM_IDS=Object.freeze(['spread','line','wedge','box','column','arc']);
24
  let mcRules=null,mcWelcome=null,mcStart=null,mcLaunchStarted=false,mcSubmitFailure='',mcApplyFailure='';
25
  let mcUpgradePendingTick=-1,mcUpgradePendingSeq=-1;
26
+ const mcRepeatPending=new Map();
27
+ const mcCancelPending=new Map();
28
 
29
  function mcLobbyRules(){
30
  const live=window.MFSocialUI&&MFSocialUI.state&&MFSocialUI.state.lobby,
 
42
  packet and the pending service receipt before replay could reconcile it. */
43
  if(mcWelcome.resumed!==true){
44
  mcUpgradePendingTick=mcUpgradePendingSeq=-1;
45
+ mcRepeatPending.clear();
46
+ mcCancelPending.clear();
47
  mcLastCommittedTick=Number.isSafeInteger(mcWelcome.tick)?mcWelcome.tick:0;mcAppliedTicks.clear();
48
  if(mcQueuedTick){const Q=mcQueuedTick;mcQueuedTick=null;Q.resolve(false);}
49
  }
50
  }
51
  if(e&&e.type==='massfront-match:start'){
52
+ mcStart=e.detail||null;mcLastCommittedTick=0;mcAppliedTicks.clear();mcRepeatPending.clear();mcCancelPending.clear();
53
  if(typeof queueMicrotask==='function')queueMicrotask(mcBootstrapMatch);else setTimeout(mcBootstrapMatch,0);
54
  }
55
  }
 
218
  if(typeof populationCanSpawn==='function'&&!populationCanSpawn(command.unit,authority.team,authority.slot))return null;
219
  return {type:'produce',building:ref.id,unit:command.unit,count:command.count};
220
  }
221
+ if(command.type==='repeat'||command.type==='rally'){
222
+ const repeat=command.type==='repeat';
223
+ if(!MC_KEYS(command,repeat?['type','building','active']:['type','building','x','y'])||
224
+ repeat&&typeof command.active!=='boolean'||!repeat&&!(point=mcPoint(command)))return null;
225
+ const ref=mcBuildingRef(command.building,authority,['fac','tgate','harbor','airfield']);
226
+ if(!ref||!Array.isArray(ref.B.queue)||!(ref.B.prog>=1)||typeof commanderSlotForBuilding!=='function')return null;
227
+ if(repeat)return {type:'repeat',building:ref.id,active:command.active};
228
+ /* Transport carries map coordinates, not a client-resolved path. All seats
229
+ clamp the same marker; production later projects it for each chassis. */
230
+ point=point.map(v=>Math.max(20,Math.min(MAP-20,v)));
231
+ if(typeof battlefieldClampPoint==='function')point=battlefieldClampPoint(point[0],point[1],24);
232
+ if(!point||point.length!==2||!point.every(v=>Number.isFinite(v)&&v>=0&&v<=MAP))return null;
233
+ return {type:'rally',building:ref.id,point};
234
+ }
235
+ if(command.type==='cancel_production'){
236
+ const cap=typeof MF_PRODUCTION_QUEUE_CAP==='number'?MF_PRODUCTION_QUEUE_CAP:30;
237
+ if(!MC_KEYS(command,['type','building','start','unit','revision','queue'])||
238
+ !MC_INT(command.revision,0,1e9)||!Array.isArray(command.queue)||!command.queue.length||command.queue.length>cap||
239
+ !command.queue.every(t=>MC_INT(t,0,TYPES.length-1))||!MC_INT(command.start,0,command.queue.length-1)||
240
+ command.unit!==command.queue[command.start])return null;
241
+ const ref=mcBuildingRef(command.building,authority,['fac','tgate','harbor','airfield']);
242
+ if(!ref||!Array.isArray(ref.B.queue)||!(ref.B.prog>=1)||typeof commanderSlotForBuilding!=='function'||typeof credit!=='function')return null;
243
+ let last=command.start;while(last+1<command.queue.length&&command.queue[last+1]===command.unit)last++;
244
+ const T=TYPES[command.unit];if(!T||!Number.isFinite(T.bt)||T.bt<=0)return null;
245
+ const cost=typeof factionDoctrineUnitCost==='function'?factionDoctrineUnitCost(T,authority.team):{m:T.cm,e:T.ce};
246
+ if(!cost||!Number.isFinite(cost.m)||cost.m<0||!Number.isFinite(cost.e)||cost.e<0)return null;
247
+ return {type:'cancel_production',building:ref.id,last,revision:command.revision,queue:command.queue.slice(),cost,authority};
248
+ }
249
  if(command.type==='research'){
250
  if(!MC_KEYS(command,['type','building','study'])||typeof command.study!=='string')return null;
251
  const ref=mcBuildingRef(command.building,authority,['techlab']),idx=typeof RESEARCH!=='undefined'?
 
316
  for(const i of P.indices){if(upgrades.has(i)||services.has(i))return false;upgrades.add(i);}
317
  const key=P.authority.team+':'+P.authority.slot,E=escrow.get(key)||{m:0,e:0,a:P.authority};
318
  E.m+=P.costM;E.e+=P.costE;escrow.set(key,E);
319
+ }else if(P.type==='repeat'||P.type==='rally'||P.type==='cancel_production'){
320
+ if(services.has(P.building))return false;
321
  }else if(P.type==='produce'){
322
  if(services.has(P.building))return false;
323
  const n=(prod.get(P.building)||0)+P.count,B=blds[P.building];prod.set(P.building,n);
 
459
  return;
460
  }
461
  if(plan.type==='produce'){
462
+ const B=blds[plan.building];for(let n=0;n<plan.count;n++)B.queue.push(plan.unit);
463
+ B.queueRevision=(B.queueRevision||0)+1;return;
464
+ }
465
+ if(plan.type==='cancel_production'){
466
+ const B=blds[plan.building];
467
+ /* A repeat completion can replace [unit] with an identical [unit]. The
468
+ revision, not just the visible stack, identifies the work being cancelled.
469
+ Stale clicks and duplicate proposals consume no-op instead of freezing
470
+ every peer or cancelling the next chassis/refunding it a second time. */
471
+ if((B.queueRevision||0)!==plan.revision||B.queue.length!==plan.queue.length||
472
+ !B.queue.every((t,i)=>t===plan.queue[i])){
473
+ const local=mcLocalAuthority();
474
+ if(local&&local.seat===plan.authority.seat&&typeof toast==='function')toast('Queue changed β€” cancel not applied; select the current stack again');
475
+ return;
476
+ }
477
+ if(plan.last===0){
478
+ const T=TYPES[B.queue[0]],frac=Math.min(1,Math.max(0,B.prodT||0)/T.bt);
479
+ if(frac>0)credit(plan.authority.team,plan.cost.m*frac,plan.cost.e*frac,plan.authority.slot);
480
+ B.queue.shift();B.prodT=0;
481
+ }else B.queue.splice(plan.last,1);
482
+ B.queueRevision=(B.queueRevision||0)+1;return;
483
+ }
484
+ if(plan.type==='repeat'){blds[plan.building].repeat=plan.active;return;}
485
+ if(plan.type==='rally'){blds[plan.building].rally={x:plan.point[0],y:plan.point[1]};return;}
486
  if(plan.type==='research'){
487
  const B=blds[plan.building],R=RESEARCH[plan.study];B.res=plan.study;B.resT=Math.min(R.t-.01,researchResumeTime(R.id));return;
488
  }
 
555
  }
556
  function mcClearUpgradeAt(tick){
557
  if(mcUpgradePendingTick>=0&&tick>=mcUpgradePendingTick)mcUpgradePendingTick=mcUpgradePendingSeq=-1;
558
+ for(const [id,rows] of mcRepeatPending){
559
+ const pending=rows.filter(r=>r.tick>tick);
560
+ if(pending.length)mcRepeatPending.set(id,pending);else mcRepeatPending.delete(id);
561
+ }
562
+ for(const [id,r] of mcCancelPending)if(r.tick<=tick)mcCancelPending.delete(id);
563
  }
564
  function mcDuplicateTick(packet,signature){return mcAppliedTicks.has(packet.tick)&&mcAppliedTicks.get(packet.tick)===signature;}
565
  async function mcApplyTick(packet){
 
617
  accepted one remains pending until its authoritative target tick commits. */
618
  if(mcUpgradePendingSeq>=0&&(mcUpgradePendingSeq>lastSeq||mcUpgradePendingTick<=mcLastCommittedTick))
619
  mcUpgradePendingTick=mcUpgradePendingSeq=-1;
620
+ for(const [id,rows] of mcRepeatPending){
621
+ const pending=rows.filter(r=>r.seq<=lastSeq&&r.tick>mcLastCommittedTick);
622
+ if(pending.length)mcRepeatPending.set(id,pending);else mcRepeatPending.delete(id);
623
+ }
624
+ for(const [id,r] of mcCancelPending)if(r.seq>lastSeq||r.tick<=mcLastCommittedTick)mcCancelPending.delete(id);
625
  return true;
626
  }
627
  function mcRuntimeActive(){
 
748
  if(receipt){mcUpgradePendingTick=receipt.targetTick;mcUpgradePendingSeq=receipt.seq;}
749
  return receipt;
750
  }
751
+ function mcRepeatIntent(target){
752
+ const ref=mcBuildingHandle(target),B=ref&&blds[ref.id],rows=ref&&mcSessionLockstep()&&mcRepeatPending.get(ref.id),
753
+ last=rows&&rows[rows.length-1],pending=!!(last&&last.type===ref.type);
754
+ return {active:pending?last.active:!!(B&&B.repeat),pending};
755
+ }
756
+ function mcSubmitRepeat(target,active,delay){
757
+ const ref=mcBuildingHandle(target),receipt=mcSubmit({type:'repeat',building:ref,active},delay);
758
+ if(receipt){
759
+ /* Keep intent outside simulation state. Rapid ON then OFF before a tick
760
+ must transmit two explicit states, not two toggles of stale B.repeat. */
761
+ const rows=mcRepeatPending.get(ref.id)||[];
762
+ rows.push({type:ref.type,active,tick:receipt.targetTick,seq:receipt.seq});mcRepeatPending.set(ref.id,rows);
763
+ }
764
+ return receipt;
765
+ }
766
+ function mcSubmitRally(target,x,y,delay){
767
+ return mcSubmit({type:'rally',building:mcBuildingHandle(target),x:Math.round(x),y:Math.round(y)},delay);
768
+ }
769
+ function mcSubmitCancelProduction(target,start,snapshot,delay){
770
+ const ref=mcBuildingHandle(target),B=ref&&blds[ref.id];
771
+ if(ref&&mcCancelPending.has(ref.id)){mcSubmitFailure='Waiting for the previous cancellation';return null;}
772
+ const queue=snapshot?snapshot.queue:B&&B.queue,revision=snapshot?snapshot.revision:B&&B.queueRevision||0,
773
+ receipt=mcSubmit({type:'cancel_production',building:ref,start,unit:Array.isArray(queue)?queue[start]:null,
774
+ revision,queue:Array.isArray(queue)?queue.slice():null},delay);
775
+ if(receipt)mcCancelPending.set(ref.id,{tick:receipt.targetTick,seq:receipt.seq});
776
+ return receipt;
777
+ }
778
  function mcUnitRefs(indices){return indices.map(i=>({id:i,generation:ugen[i]}));}
779
  function mcSelected(){const out=[];for(let i=0;i<unitHigh;i++)if(ualive[i]&&usel[i])out.push(i);return out;}
780
  function mcWrap(name,make){
 
837
  lastAppliedTick:()=>mcLastCommittedTick,resumeState:mcResumeState,
838
  submit:mcSubmit,takeover:mcTakeover,
839
  submitRepair:mcSubmitRepair,submitRecycle:mcSubmitRecycle,submitUpgrade:mcSubmitUpgrade,
840
+ submitRepeat:mcSubmitRepeat,repeatIntent:mcRepeatIntent,submitRally:mcSubmitRally,
841
+ submitCancelProduction:mcSubmitCancelProduction,
842
  upgradePending:()=>mcSessionLockstep()&&mcUpgradePendingTick>=0,buildingRef:mcBuildingHandle,
843
  lastFailure:()=>mcApplyFailure||mcSubmitFailure,
844
  bootstrap:()=>Object.freeze({localSeat:mcWelcome&&mcWelcome.seat||0,seats:mcStart&&Array.isArray(mcStart.seats)?mcStart.seats.slice():[],rules:mcLobbyRules()}),seatAuthority:seat=>{
src/game/meta.js CHANGED
@@ -1711,13 +1711,84 @@ function mfBindTap(el,fn){
1711
  pointer timing while adding one guarded keyboard path. Preventing the key's
1712
  native click and suppressing a same-turn synthetic click avoids the classic
1713
  double activation without making screen-reader click depend on key events. */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1714
  function mfBindNativePress(el,fn,pointerEvent){
1715
  if(!el||typeof fn!=='function'||(el.dataset&&el.dataset.mfNativePress==='1')) return;
1716
  if(el.dataset) el.dataset.mfNativePress='1';
1717
  const now=typeof mfTapNow==='function'?mfTapNow:()=>performance.now();
1718
- let pointerCommit=-1e9,keyCommit=-1e9;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1719
  el.addEventListener(pointerEvent||'pointerdown',ev=>{
1720
  if(el.disabled)return;
 
 
 
 
 
 
 
 
1721
  pointerCommit=now(); fn(ev);
1722
  });
1723
  el.addEventListener('keydown',ev=>{
@@ -2009,11 +2080,11 @@ function renderSettings(){
2009
  const HBD={always:'Bars hover over every visible unit and structure',
2010
  select:'Bars appear above selected units and the opened structure',
2011
  off:'All battlefield health bars are hidden'};
2012
- const explorationOn=!!META.settings.experimentalExploration;
2013
  const textScale=mfTextScaleValue(META.settings.textScale);
2014
  const explorationOpen=explorationOn
2015
- ?'<div class="sItem setRow" data-set="openExperimentalExploration" role="button" tabindex="0"><div class="sTx"><b>Open Experimental Galactic</b>'
2016
- +'<div class="sDs">Side preview only. Home, START, and the Standard war table stay where they are.</div></div>'
2017
  +'<div class="sBuy togB onT">OPEN</div></div>'
2018
  :'';
2019
  h+=group('battle','GAMEPLAY & BATTLEFIELD','Information shown while commanding units and optional experimental experiences.',
@@ -2023,8 +2094,7 @@ function renderSettings(){
2023
  +tog('shake','Impact Camera Shake','Recoil and explosions move the camera')
2024
  +tog('haptics','Haptic Feedback','Short vibration cues for confirmations and impacts')
2025
  +tog('experimentalExploration','Experimental: Galactic Campaign',
2026
- 'Optional side experiment. Does not replace the home menu, START, or the Standard war table. '
2027
- +'Open it from the row below when you want the preview.')
2028
  +explorationOpen);
2029
 
2030
  const perf=META.settings.perf;
 
1711
  pointer timing while adding one guarded keyboard path. Preventing the key's
1712
  native click and suppressing a same-turn synthetic click avoids the classic
1713
  double activation without making screen-reader click depend on key events. */
1714
+ /* Is this control inside something the player can pan?
1715
+ *
1716
+ * Committing on pointerdown is deliberate for the battle HUD -- an order must
1717
+ * not wait for a finger to lift. But the same rule fires the instant a finger
1718
+ * lands on a button in a SCROLLABLE row, so panning the build/production/
1719
+ * command sub-menus activated whatever the pan happened to start on. Asking
1720
+ * the live layout is better than a marker attribute: only one element in the
1721
+ * whole HUD carries data-mf-scroll-rail, and a row only misfires while it
1722
+ * actually overflows, which changes with content and text scale.
1723
+ *
1724
+ * Checked at press time, not bind time, because a row that fits four cards and
1725
+ * later holds nine becomes pannable without ever being rebound. */
1726
+ function mfPressInsideScroller(el){
1727
+ if(!el||typeof getComputedStyle!=='function') return false;
1728
+ for(let p=el.parentElement,hops=0;p&&hops<8&&p!==document.body;p=p.parentElement,hops++){
1729
+ let cs; try{ cs=getComputedStyle(p); }catch(_){ return false; }
1730
+ const ox=cs.overflowX,oy=cs.overflowY;
1731
+ if((ox==='auto'||ox==='scroll')&&p.scrollWidth>p.clientWidth+2) return true;
1732
+ if((oy==='auto'||oy==='scroll')&&p.scrollHeight>p.clientHeight+2) return true;
1733
+ }
1734
+ return false;
1735
+ }
1736
  function mfBindNativePress(el,fn,pointerEvent){
1737
  if(!el||typeof fn!=='function'||(el.dataset&&el.dataset.mfNativePress==='1')) return;
1738
  if(el.dataset) el.dataset.mfNativePress='1';
1739
  const now=typeof mfTapNow==='function'?mfTapNow:()=>performance.now();
1740
+ let pointerCommit=-1e9,keyCommit=-1e9,pan=null;
1741
+ /* Slop matches mfBindTap so the two binders cannot disagree about what
1742
+ counts as a tap on the same screen. */
1743
+ const SLOP=12;
1744
+ /* Held-but-not-yet-committed feedback.
1745
+ *
1746
+ * A deferred control is silent about whether the touch still counts, so a
1747
+ * player panning a row cannot tell the difference between "I am scrolling"
1748
+ * and "I am about to fire this". Light it on contact and extinguish it the
1749
+ * instant the gesture becomes a pan; that teaches the rule in one gesture.
1750
+ *
1751
+ * Deliberately INSTANT and STATIC. Anything that grows or fills reads as a
1752
+ * hold-to-activate progress ring, which would promise something this control
1753
+ * does not do. Inline styles rather than a class because CSS ships only in
1754
+ * the APK and the Space, never over the air. */
1755
+ const arm=on=>{
1756
+ if(!el.style) return;
1757
+ if(on){
1758
+ if(el.dataset&&el.dataset.mfPressLit==='1') return;
1759
+ if(el.dataset) el.dataset.mfPressLit='1';
1760
+ el._mfPressF=el.style.filter; el._mfPressS=el.style.boxShadow;
1761
+ el.style.filter=(el._mfPressF?el._mfPressF+' ':'')+'brightness(1.28)';
1762
+ el.style.boxShadow=(el._mfPressS?el._mfPressS+',':'')+'inset 0 0 0 1px rgba(150,220,255,.85)';
1763
+ }else{
1764
+ if(!el.dataset||el.dataset.mfPressLit!=='1') return;
1765
+ delete el.dataset.mfPressLit;
1766
+ el.style.filter=el._mfPressF||''; el.style.boxShadow=el._mfPressS||'';
1767
+ }
1768
+ };
1769
+ el.addEventListener('pointermove',ev=>{
1770
+ if(!pan||ev.pointerId!==pan.id) return;
1771
+ if(!pan.moved&&Math.hypot(ev.clientX-pan.x,ev.clientY-pan.y)>SLOP){ pan.moved=true; arm(false); }
1772
+ },{passive:true});
1773
+ el.addEventListener('pointercancel',ev=>{ if(pan&&ev.pointerId===pan.id){ pan=null; arm(false); } },{passive:true});
1774
+ el.addEventListener('lostpointercapture',()=>{ if(pan){ pan=null; arm(false); } },{passive:true});
1775
+ el.addEventListener('pointerup',ev=>{
1776
+ if(!pan||ev.pointerId!==pan.id) return;
1777
+ const ok=!pan.moved&&el.contains(ev.target);
1778
+ pan=null; arm(false);
1779
+ if(!ok||el.disabled) return;
1780
+ pointerCommit=now(); fn(ev);
1781
+ });
1782
  el.addEventListener(pointerEvent||'pointerdown',ev=>{
1783
  if(el.disabled)return;
1784
+ /* Inside a pannable row, wait for the lift and require the finger to have
1785
+ stayed put. Everywhere else keep the immediate commit. */
1786
+ if(!pointerEvent&&mfPressInsideScroller(el)){
1787
+ pan={id:ev.pointerId,x:ev.clientX,y:ev.clientY,moved:false};
1788
+ arm(true);
1789
+ return;
1790
+ }
1791
+ pan=null; arm(false);
1792
  pointerCommit=now(); fn(ev);
1793
  });
1794
  el.addEventListener('keydown',ev=>{
 
2080
  const HBD={always:'Bars hover over every visible unit and structure',
2081
  select:'Bars appear above selected units and the opened structure',
2082
  off:'All battlefield health bars are hidden'};
2083
+ const explorationOn=window.__MF_BUILD_HAS_GALACTIC_EXPLORATION===true||!!META.settings.experimentalExploration;
2084
  const textScale=mfTextScaleValue(META.settings.textScale);
2085
  const explorationOpen=explorationOn
2086
+ ?'<div class="sItem setRow" data-set="openExperimentalExploration" role="button" tabindex="0"><div class="sTx"><b>Return to UGA Headquarters</b>'
2087
+ +'<div class="sDs">Manage ship sections, prepare expeditions, or choose a mode at the War Table.</div></div>'
2088
  +'<div class="sBuy togB onT">OPEN</div></div>'
2089
  :'';
2090
  h+=group('battle','GAMEPLAY & BATTLEFIELD','Information shown while commanding units and optional experimental experiences.',
 
2094
  +tog('shake','Impact Camera Shake','Recoil and explosions move the camera')
2095
  +tog('haptics','Haptic Feedback','Short vibration cues for confirmations and impacts')
2096
  +tog('experimentalExploration','Experimental: Galactic Campaign',
2097
+ 'The UGA is your shared strategic layer. This setting enables Galactic entry on older packages; current builds include it.')
 
2098
  +explorationOpen);
2099
 
2100
  const perf=META.settings.perf;
src/game/sim.js CHANGED
@@ -1683,7 +1683,9 @@ function mfNavPass(i,naval,clearance){
1683
  function mfNavDirectApproachClear(x0,y0,x1,y1,T){
1684
  const naval=!!(T&&T.naval),clearance=mfNavUnitClearance(T),grid=mfNavClearanceGrid(naval),
1685
  need=MF_NAV_CLEARANCE_COST[mfNavClearanceToken(clearance,naval)],cell=MAP/PGS,
1686
- dx=x1-x0,dy=y1-y0,steps=Math.max(1,Math.min(64,Math.ceil(Math.hypot(dx,dy)/(cell*.72))));
 
 
1687
  for(let s=1;s<=steps;s++){
1688
  const t=s/steps,c=ffCell(x0+dx*t,y0+dy*t);
1689
  if(!grid||grid[c]<=need)return false;
@@ -1941,6 +1943,55 @@ function requestField(tx,ty,naval,clearance,defer){
1941
  if(defer)mfNavQueueBuild(fields[f]);
1942
  return f;
1943
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1944
  function mfNavFindAttackBlocker(i,gx,gy){
1945
  const T=TYPES[utype[i]];if(!T||!(T.dmg>0)||T.air)return -1;
1946
  const ax=ux[i],ay=uy[i],vx=gx-ax,vy=gy-ay,ll=vx*vx+vy*vy;if(ll<1)return -1;
@@ -2778,7 +2829,7 @@ function addBld(type,team,x,y,instant,rot,suppressPackageGrant){
2778
  const hpM=(team===0?bldHpMult*doctrineHp*resBldHpMult:1);
2779
  const shieldMax=type==='techlab'?900*labBufferMult:0;
2780
  const b={type,team,fac,x,y,hp:(instant?T.hp:T.hp*0.1)*hpM,hpm:T.hp*hpM,r:T.r,alive:true,prog:instant?1:0,
2781
- cool:0,queue:[],repeat:false,prodT:0,heal:0,tier:1,lvl:1,upT:0,upMax:1,tang:team?Math.PI:0,gunPitch:0,
2782
  seen:false,boost:0,boostM:UPLINK_BOOST,res:-1,resT:0,rally:null,rich:false,dep:-1,geo:-1,
2783
  shield:instant?shieldMax:0,shieldMax,shieldT:0,dmgT:0,
2784
  repairOn:false,repairStalled:false,
@@ -8834,13 +8885,14 @@ function unitTick(dt){
8834
  : (umarch[i]===1) ? distGoal>arriveR
8835
  : (engaging ? (!inRange || er > T.rng*rngM*0.92+arriveR) : distGoal>arriveR);
8836
  if(T.air&&typeof mfAirShouldMove==='function') wantMove=!uhold[i]&&spdM>0&&mfAirShouldMove(i);
 
8837
  if(wantMove && distGoal>0.001){
8838
  // ground units make better time on the old highways
8839
  const rd=(!T.air&&!T.naval&&roadAt(ux[i],uy[i]))?ROAD_SPD:1;
8840
  const sp=T.spd*spdM*classSpdMul(i)*broodSpdMul(i)*mfDomainSpeedMul(i)*(ubuff[i]>0?1.35:1)*(uhaz[i]>0?HAZ_SPD:1)*rd*uCohesion[i]*Math.min(1,distGoal/(arriveR+12)+0.25);
8841
  moveCap=sp;
8842
  // flow-field steering for long marches (routes armies around lakes)
8843
- let ffOk=false,fieldAttempted=false,fieldUnreachable=false;
8844
  /* Formation orders share a coarse field to the leg centre, then fan out
8845
  early enough to settle into their own slots before the turn. */
8846
  /* Formations fan into their assigned lanes before the last turn. At 170
@@ -8848,31 +8900,39 @@ function unitTick(dt){
8848
  460 wu final approach preserves the shell mapping while long marches
8849
  retain the shared field around strategic terrain. */
8850
  const slotApproach=uMoveCohort[i]>=0?460:(uPatrolRoute[i]>=0?170:70);
8851
- const directApproachClear=engaging||T.air||ufield[i]<0||distGoal>slotApproach||
8852
  mfNavDirectApproachClear(ux[i],uy[i],gx,gy,T);
8853
- if(!engaging && !T.air && ufield[i]>=0 && (distGoal>slotApproach||!directApproachClear)){
8854
- fieldAttempted=true;
8855
- const F=mfMoveFieldFresh(fields[ufield[i]]);
8856
- if(F&&F.dirs&&!!F.naval===!!T.naval){
8857
  const k=F.dirs[ffCell(ux[i],uy[i])];
8858
  if(k<8){
8859
  const inv=1/Math.hypot(DIRX[k],DIRY[k]);
8860
  mvx=DIRX[k]*inv*sp; mvy=DIRY[k]*inv*sp;
8861
  ffOk=true;
8862
  }else{
8863
- const P=mfNavSectorWaypoint(F,ux[i],uy[i]);
 
 
8864
  if(P){const dx=P.x-ux[i],dy=P.y-uy[i],dl=Math.hypot(dx,dy)||1;mvx=dx/dl*sp;mvy=dy/dl*sp;ffOk=true;}
8865
- else fieldUnreachable=true;
8866
  }
8867
- } else if(!F||!!F.naval!==!!T.naval) ufield[i]=-1;
8868
  }
8869
  if(!ffOk){
8870
- if(fieldAttempted&&fieldUnreachable){
8871
- const b=ustate[i]===1?-1:mfNavFindAttackBlocker(i,gx,gy);
8872
- if(b>=0){
8873
- const B=blds[b];utgt[i]=-2-b;utgtg[i]=-1;ustate[i]=2;umarch[i]=0;utx[i]=B.x;uty[i]=B.y;ufield[i]=-1;
8874
- }else{ustate[i]=0;utgt[i]=-1;utgtg[i]=-1;ufield[i]=-1;umarch[i]=0;utx[i]=ux[i];uty[i]=uy[i];}
8875
  mvx=0;mvy=0;moving=false;
 
 
 
 
 
 
 
 
 
8876
  }else{mvx=(gx-ux[i])/distGoal*sp;mvy=(gy-uy[i])/distGoal*sp;moving=true;}
8877
  }else moving=true;
8878
  }
@@ -9441,6 +9501,21 @@ function mfBldCachedEnemy(B,range,domain,dt){
9441
  }
9442
  return e;
9443
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9444
  function bldTick(dt){
9445
  for(let b=0;b<blds.length;b++){
9446
  const B=blds[b]; if(!B.alive) continue;
@@ -10020,16 +10095,9 @@ function bldTick(dt){
10020
  if(i<0){B.prodStalled='population';continue;}
10021
  B.prodT=0; B.queue.shift();
10022
  if(B.repeat) B.queue.push(t);
 
10023
  ustate[i]=2;
10024
- let rx,ry;
10025
- if(B.team===0 && B.rally){ // player rally point
10026
- rx=clamp(B.rally.x+mfSimRange(-26,26),20,MAP-20);
10027
- ry=clamp(B.rally.y+mfSimRange(-26,26),20,MAP-20);
10028
- } else {
10029
- rx=clamp(B.x+(B.team===0?mfSimRange(60,120):mfSimRange(-120,-60)),20,MAP-20);
10030
- ry=clamp(B.y+(B.team===0?mfSimRange(60,120):mfSimRange(-120,-60)),20,MAP-20);
10031
- }
10032
- const L=TYPES[t].naval? (findWater(rx,ry)||[ux[i],uy[i]]) : findLand(rx,ry);
10033
  utx[i]=L[0]; uty[i]=L[1];
10034
  if(!TYPES[t].air&&dist2(ux[i],uy[i],L[0],L[1])>70*70)
10035
  ufield[i]=requestField(L[0],L[1],!!TYPES[t].naval,mfNavUnitClearance(TYPES[t]));
 
1683
  function mfNavDirectApproachClear(x0,y0,x1,y1,T){
1684
  const naval=!!(T&&T.naval),clearance=mfNavUnitClearance(T),grid=mfNavClearanceGrid(naval),
1685
  need=MF_NAV_CLEARANCE_COST[mfNavClearanceToken(clearance,naval)],cell=MAP/PGS,
1686
+ dx=x1-x0,dy=y1-y0,steps=Math.max(1,Math.ceil(Math.hypot(dx,dy)/(cell*.72)));
1687
+ // A bounded probe must fail closed rather than skip thin walls on long legs.
1688
+ if(steps>64)return false;
1689
  for(let s=1;s<=steps;s++){
1690
  const t=s/steps,c=ffCell(x0+dx*t,y0+dy*t);
1691
  if(!grid||grid[c]<=need)return false;
 
1943
  if(defer)mfNavQueueBuild(fields[f]);
1944
  return f;
1945
  }
1946
+ const uNavProgressGen=new Uint32Array(MAXU),uNavProgressRevision=new Uint32Array(MAXU),uNavGoalX=new Float32Array(MAXU),uNavGoalY=new Float32Array(MAXU),
1947
+ uNavProbeX=new Float32Array(MAXU),uNavProbeY=new Float32Array(MAXU),uNavProbeAge=new Float32Array(MAXU),uNavRetryFor=new Float32Array(MAXU);
1948
+ const uNavPursuitField=new Uint8Array(MAXU);
1949
+ function mfNavProgressReset(){
1950
+ for(const a of [uNavProgressGen,uNavProgressRevision,uNavGoalX,uNavGoalY,uNavProbeX,uNavProbeY,uNavProbeAge,uNavRetryFor,uNavPursuitField])a.fill(0);
1951
+ }
1952
+ function mfNavProgressRepath(i,T,gx,gy,dt,wantMove){
1953
+ uNavRetryFor[i]=Math.max(0,uNavRetryFor[i]-dt);
1954
+ const newUnit=uNavProgressGen[i]!==ugen[i],fresh=newUnit||uNavProgressRevision[i]!==mfMoveBlockRevision;
1955
+ if(fresh||!wantMove||dist2(uNavGoalX[i],uNavGoalY[i],gx,gy)>32*32){
1956
+ uNavProgressGen[i]=ugen[i];uNavProgressRevision[i]=mfMoveBlockRevision;uNavGoalX[i]=gx;uNavGoalY[i]=gy;
1957
+ uNavProbeX[i]=ux[i];uNavProbeY[i]=uy[i];uNavProbeAge[i]=0;
1958
+ if(fresh)uNavRetryFor[i]=0;
1959
+ if(newUnit)uNavPursuitField[i]=0;
1960
+ return false;
1961
+ }
1962
+ uNavProbeAge[i]+=dt;if(uNavProbeAge[i]<1.25)return false;
1963
+ // Net displacement, not velocity intent or accumulated crowd jitter. A
1964
+ // legitimate detour counts as progress even while it moves away from goal.
1965
+ const moved=Math.hypot(ux[i]-uNavProbeX[i],uy[i]-uNavProbeY[i]);
1966
+ uNavProbeAge[i]=0;uNavProbeX[i]=ux[i];uNavProbeY[i]=uy[i];
1967
+ return moved<Math.max(.75,Math.min(4,T.spd*.08));
1968
+ }
1969
+ function mfNavFieldForMove(i,T,gx,gy,engaging,repath){
1970
+ let F=fields[ufield[i]];
1971
+ const resumeStrategic=!engaging&&uNavPursuitField[i]===1;
1972
+ uNavPursuitField[i]=engaging?1:0;
1973
+ const mismatch=resumeStrategic||!F||!!F.naval!==!!T.naval||F.clearance!==mfNavUnitClearance(T)||
1974
+ (engaging&&dist2(F.tx,F.ty,gx,gy)>70*70);
1975
+ if(mismatch){
1976
+ // A completed chase must not leave the unit following that enemy's field
1977
+ // forever. Reacquire the retained order once; requestField still coalesces
1978
+ // nearby goals without imposing a new formation layout.
1979
+ if(F||resumeStrategic)uNavRetryFor[i]=0;
1980
+ ufield[i]=-1;
1981
+ if(uNavRetryFor[i]>0)return null;
1982
+ uNavRetryFor[i]=.75;
1983
+ ufield[i]=requestField(gx,gy,!!T.naval,mfNavUnitClearance(T),true);
1984
+ F=fields[ufield[i]];
1985
+ }
1986
+ if(!F)return null;
1987
+ F=mfMoveFieldFresh(F);
1988
+ // Multiple blocked units share a field. One stalled member must not restart
1989
+ // its queued job every frame and prevent the rest of the army receiving it.
1990
+ if(repath&&!F.pending&&(F.repathTick==null||tick<F.repathTick||tick-F.repathTick>=90)){
1991
+ F.repathTick=tick;F.rev=0;mfNavQueueBuild(F);
1992
+ }
1993
+ return F;
1994
+ }
1995
  function mfNavFindAttackBlocker(i,gx,gy){
1996
  const T=TYPES[utype[i]];if(!T||!(T.dmg>0)||T.air)return -1;
1997
  const ax=ux[i],ay=uy[i],vx=gx-ax,vy=gy-ay,ll=vx*vx+vy*vy;if(ll<1)return -1;
 
2829
  const hpM=(team===0?bldHpMult*doctrineHp*resBldHpMult:1);
2830
  const shieldMax=type==='techlab'?900*labBufferMult:0;
2831
  const b={type,team,fac,x,y,hp:(instant?T.hp:T.hp*0.1)*hpM,hpm:T.hp*hpM,r:T.r,alive:true,prog:instant?1:0,
2832
+ cool:0,queue:[],queueRevision:0,repeat:false,prodT:0,heal:0,tier:1,lvl:1,upT:0,upMax:1,tang:team?Math.PI:0,gunPitch:0,
2833
  seen:false,boost:0,boostM:UPLINK_BOOST,res:-1,resT:0,rally:null,rich:false,dep:-1,geo:-1,
2834
  shield:instant?shieldMax:0,shieldMax,shieldT:0,dmgT:0,
2835
  repairOn:false,repairStalled:false,
 
8885
  : (umarch[i]===1) ? distGoal>arriveR
8886
  : (engaging ? (!inRange || er > T.rng*rngM*0.92+arriveR) : distGoal>arriveR);
8887
  if(T.air&&typeof mfAirShouldMove==='function') wantMove=!uhold[i]&&spdM>0&&mfAirShouldMove(i);
8888
+ const navRepath=!T.air&&mfNavProgressRepath(i,T,gx,gy,dt,wantMove);
8889
  if(wantMove && distGoal>0.001){
8890
  // ground units make better time on the old highways
8891
  const rd=(!T.air&&!T.naval&&roadAt(ux[i],uy[i]))?ROAD_SPD:1;
8892
  const sp=T.spd*spdM*classSpdMul(i)*broodSpdMul(i)*mfDomainSpeedMul(i)*(ubuff[i]>0?1.35:1)*(uhaz[i]>0?HAZ_SPD:1)*rd*uCohesion[i]*Math.min(1,distGoal/(arriveR+12)+0.25);
8893
  moveCap=sp;
8894
  // flow-field steering for long marches (routes armies around lakes)
8895
+ let ffOk=false,routeUnreachable=false;
8896
  /* Formation orders share a coarse field to the leg centre, then fan out
8897
  early enough to settle into their own slots before the turn. */
8898
  /* Formations fan into their assigned lanes before the last turn. At 170
 
8900
  460 wu final approach preserves the shell mapping while long marches
8901
  retain the shared field around strategic terrain. */
8902
  const slotApproach=uMoveCohort[i]>=0?460:(uPatrolRoute[i]>=0?170:70);
8903
+ const directApproachClear=T.air||
8904
  mfNavDirectApproachClear(ux[i],uy[i],gx,gy,T);
8905
+ if(!T.air && (distGoal>slotApproach||!directApproachClear||navRepath)){
8906
+ const F=mfNavFieldForMove(i,T,gx,gy,engaging,navRepath);
8907
+ if(F&&!F.pending&&F.rev===mfMoveBlockRevision&&F.dirs&&!!F.naval===!!T.naval){
 
8908
  const k=F.dirs[ffCell(ux[i],uy[i])];
8909
  if(k<8){
8910
  const inv=1/Math.hypot(DIRX[k],DIRY[k]);
8911
  mvx=DIRX[k]*inv*sp; mvy=DIRY[k]*inv*sp;
8912
  ffOk=true;
8913
  }else{
8914
+ // Exact reachability outranks coarse portal links. Escape hints
8915
+ // remain useful only for spawns outside the clearance graph.
8916
+ const P=!mfNavPass(ffCell(ux[i],uy[i]),F.naval,F.clearance)?mfNavSectorWaypoint(F,ux[i],uy[i]):null;
8917
  if(P){const dx=P.x-ux[i],dy=P.y-uy[i],dl=Math.hypot(dx,dy)||1;mvx=dx/dl*sp;mvy=dy/dl*sp;ffOk=true;}
8918
+ else routeUnreachable=true;
8919
  }
8920
+ }
8921
  }
8922
  if(!ffOk){
8923
+ if(!directApproachClear){
8924
+ // Pending, stale or disconnected is not arrival. Retain the player's
8925
+ // destination/queue and retry after progress timeout or map revision.
 
 
8926
  mvx=0;mvy=0;moving=false;
8927
+ if(routeUnreachable&&ustate[i]===2){
8928
+ const blocker=mfNavFindAttackBlocker(i,gx,gy);
8929
+ if(blocker>=0){
8930
+ // Breach an actual enemy obstruction only after a completed
8931
+ // route proves disconnected. Keep the strategic order intact
8932
+ // so destruction of this temporary target resumes the march.
8933
+ utgt[i]=-2-blocker;utgtg[i]=-1;umarch[i]=0;
8934
+ }
8935
+ }
8936
  }else{mvx=(gx-ux[i])/distGoal*sp;mvy=(gy-uy[i])/distGoal*sp;moving=true;}
8937
  }else moving=true;
8938
  }
 
9501
  }
9502
  return e;
9503
  }
9504
+ function mfFactoryRallyGoal(B,T,spawnX,spawnY){
9505
+ let rx,ry;
9506
+ if(B.rally&&Number.isFinite(B.rally.x)&&Number.isFinite(B.rally.y)){
9507
+ // Opposing human seats own factories too; a team-0 gate discarded their
9508
+ // authoritative marker. AI factories without a marker retain the fallback.
9509
+ // The marker is the order, not the centre of a random 52wu square.
9510
+ // Collision separation and the hull-sized arrival band own final spacing.
9511
+ rx=clamp(B.rally.x,20,MAP-20);ry=clamp(B.rally.y,20,MAP-20);
9512
+ }else{
9513
+ rx=clamp(B.x+(B.team===0?mfSimRange(60,120):mfSimRange(-120,-60)),20,MAP-20);
9514
+ ry=clamp(B.y+(B.team===0?mfSimRange(60,120):mfSimRange(-120,-60)),20,MAP-20);
9515
+ }
9516
+ if(T.air)return [rx,ry];
9517
+ return T.naval?(findWater(rx,ry)||[spawnX,spawnY]):findLand(rx,ry);
9518
+ }
9519
  function bldTick(dt){
9520
  for(let b=0;b<blds.length;b++){
9521
  const B=blds[b]; if(!B.alive) continue;
 
10095
  if(i<0){B.prodStalled='population';continue;}
10096
  B.prodT=0; B.queue.shift();
10097
  if(B.repeat) B.queue.push(t);
10098
+ B.queueRevision=(B.queueRevision||0)+1;
10099
  ustate[i]=2;
10100
+ const L=mfFactoryRallyGoal(B,T,ux[i],uy[i]);
 
 
 
 
 
 
 
 
10101
  utx[i]=L[0]; uty[i]=L[1];
10102
  if(!TYPES[t].air&&dist2(ux[i],uy[i],L[0],L[1])>70*70)
10103
  ufield[i]=requestField(L[0],L[1],!!TYPES[t].naval,mfNavUnitClearance(TYPES[t]));
src/game/statehash.js CHANGED
@@ -66,8 +66,14 @@ function MF_SH_snapshot(){
66
  for(const x of ua)MF_SH_indexed(w,x[0],x[1],units);
67
  const opt=[['uUtilityJob',typeof uUtilityJob!=='undefined'?uUtilityJob:null],['uUtilityGoalX',typeof uUtilityGoalX!=='undefined'?uUtilityGoalX:null],['uUtilityGoalY',typeof uUtilityGoalY!=='undefined'?uUtilityGoalY:null],['uUtilityAuto',typeof uUtilityAuto!=='undefined'?uUtilityAuto:null],['uUtilityProgressX',typeof uUtilityProgressX!=='undefined'?uUtilityProgressX:null],['uUtilityProgressY',typeof uUtilityProgressY!=='undefined'?uUtilityProgressY:null],['uUtilityProgressAt',typeof uUtilityProgressAt!=='undefined'?uUtilityProgressAt:null],['uUtilityRetryAt',typeof uUtilityRetryAt!=='undefined'?uUtilityRetryAt:null],['uAirGen',typeof uAirGen!=='undefined'?uAirGen:null],['uAirTarget',typeof uAirTarget!=='undefined'?uAirTarget:null],['uAirTargetG',typeof uAirTargetG!=='undefined'?uAirTargetG:null],['uAirEscort',typeof uAirEscort!=='undefined'?uAirEscort:null],['uAirEscortG',typeof uAirEscortG!=='undefined'?uAirEscortG:null],['uAirBand',typeof uAirBand!=='undefined'?uAirBand:null],['uAirBandReq',typeof uAirBandReq!=='undefined'?uAirBandReq:null],['uAirMission',typeof uAirMission!=='undefined'?uAirMission:null],['uAirHomeMission',typeof uAirHomeMission!=='undefined'?uAirHomeMission:null],['uAirPhase',typeof uAirPhase!=='undefined'?uAirPhase:null],['uAirFire',typeof uAirFire!=='undefined'?uAirFire:null],['uAirScanClock',typeof uAirScanClock!=='undefined'?uAirScanClock:null],['uAirAlt',typeof uAirAlt!=='undefined'?uAirAlt:null],['uAirPhaseT',typeof uAirPhaseT!=='undefined'?uAirPhaseT:null],['uAirAnchorX',typeof uAirAnchorX!=='undefined'?uAirAnchorX:null],['uAirAnchorY',typeof uAirAnchorY!=='undefined'?uAirAnchorY:null],['uAirGoalX',typeof uAirGoalX!=='undefined'?uAirGoalX:null],['uAirGoalY',typeof uAirGoalY!=='undefined'?uAirGoalY:null],['uAirAimX',typeof uAirAimX!=='undefined'?uAirAimX:null],['uAirAimY',typeof uAirAimY!=='undefined'?uAirAimY:null],['uAirVx',typeof uAirVx!=='undefined'?uAirVx:null],['uAirVy',typeof uAirVy!=='undefined'?uAirVy:null],['uAirCourse',typeof uAirCourse!=='undefined'?uAirCourse:null],['uAirYawRate',typeof uAirYawRate!=='undefined'?uAirYawRate:null],['uAirPass',typeof uAirPass!=='undefined'?uAirPass:null],['uAirReleaseN',typeof uAirReleaseN!=='undefined'?uAirReleaseN:null],['uAirReacquireN',typeof uAirReacquireN!=='undefined'?uAirReacquireN:null],['uAirSortie',typeof uAirSortie!=='undefined'?uAirSortie:null]];
68
  for(const x of opt)if(x[1])MF_SH_indexed(w,x[0],x[1],units);
 
 
 
 
 
 
69
  MF_SH_named(w,'unitQueues',units.map(i=>[i,uQueue[i]||null]));MF_SH_named(w,'patrolRoutes',typeof patrolRoutes!=='undefined'?patrolRoutes:[]);MF_SH_named(w,'moveCohorts',typeof moveCohorts!=='undefined'?moveCohorts:[]);
70
- const bf=['type','team','fac','x','y','hp','hpm','r','alive','prog','cool','queue','repeat','prodT','heal','tier','lvl','upT','upMax','tang','gunPitch','boost','boostM','res','resT','rally','rich','dep','geo','shield','shieldMax','shieldT','guardReady','guardT','guardCharge','rot','footTier','link','conduit','buildPaidM','buildPaidE','buildStalled','repairOn','dmgT','aim','aimG','target','targetG','mode','state','stun','haz'];
71
  MF_SH_named(w,'buildings',blds.map((b,i)=>[i,MF_SH_fields(b,bf)]));MF_SH_named(w,'economy',{resM:typeof resM!=='undefined'?resM:[],resE:typeof resE!=='undefined'?resE:[],mSpendAcc:typeof mSpendAcc!=='undefined'?mSpendAcc:0,eSpendAcc:typeof eSpendAcc!=='undefined'?eSpendAcc:0,spendT:typeof spendT!=='undefined'?spendT:0,stats:typeof stats!=='undefined'?stats:{},researched:typeof researched!=='undefined'?researched:{},researchCarry:typeof researchCarry!=='undefined'?researchCarry:{},researching:typeof researching!=='undefined'?researching:null});
72
  MF_SH_named(w,'pHigh',pHigh);MF_SH_named(w,'projectileFree',typeof pFree!=='undefined'?pFree:[]);
73
  const pa=[['px',px],['py',py],['pvx',pvx],['pvy',pvy],['plife',plife],['pdmg',pdmg],['paoe',paoe],['ptype',ptype],['pteam',pteam],['ptgt',ptgt],['ptgtg',ptgtg],['pmu0',pmu0],['pwk',pwk],['psx',psx],['psy',psy],['pex',pex],['pey',pey],['pt',pt],['pmax',pmax],['pSplit',pSplit],['pCannon',pCannon],['pBio',pBio],['pBarrage',pBarrage],['pFlightId',pFlightId],['pBaseSpeed',pBaseSpeed],['pSpeed',pSpeed],['pAge',pAge],['pSrcUnit',pSrcUnit],['pSrcGen',pSrcGen],['pArc',pArc],['pz0',pz0],['pz1',pz1],['pz',pz],['pLastTX',pLastTX],['pLastTY',pLastTY]];for(const x of pa)MF_SH_indexed(w,x[0],x[1],shots);
 
66
  for(const x of ua)MF_SH_indexed(w,x[0],x[1],units);
67
  const opt=[['uUtilityJob',typeof uUtilityJob!=='undefined'?uUtilityJob:null],['uUtilityGoalX',typeof uUtilityGoalX!=='undefined'?uUtilityGoalX:null],['uUtilityGoalY',typeof uUtilityGoalY!=='undefined'?uUtilityGoalY:null],['uUtilityAuto',typeof uUtilityAuto!=='undefined'?uUtilityAuto:null],['uUtilityProgressX',typeof uUtilityProgressX!=='undefined'?uUtilityProgressX:null],['uUtilityProgressY',typeof uUtilityProgressY!=='undefined'?uUtilityProgressY:null],['uUtilityProgressAt',typeof uUtilityProgressAt!=='undefined'?uUtilityProgressAt:null],['uUtilityRetryAt',typeof uUtilityRetryAt!=='undefined'?uUtilityRetryAt:null],['uAirGen',typeof uAirGen!=='undefined'?uAirGen:null],['uAirTarget',typeof uAirTarget!=='undefined'?uAirTarget:null],['uAirTargetG',typeof uAirTargetG!=='undefined'?uAirTargetG:null],['uAirEscort',typeof uAirEscort!=='undefined'?uAirEscort:null],['uAirEscortG',typeof uAirEscortG!=='undefined'?uAirEscortG:null],['uAirBand',typeof uAirBand!=='undefined'?uAirBand:null],['uAirBandReq',typeof uAirBandReq!=='undefined'?uAirBandReq:null],['uAirMission',typeof uAirMission!=='undefined'?uAirMission:null],['uAirHomeMission',typeof uAirHomeMission!=='undefined'?uAirHomeMission:null],['uAirPhase',typeof uAirPhase!=='undefined'?uAirPhase:null],['uAirFire',typeof uAirFire!=='undefined'?uAirFire:null],['uAirScanClock',typeof uAirScanClock!=='undefined'?uAirScanClock:null],['uAirAlt',typeof uAirAlt!=='undefined'?uAirAlt:null],['uAirPhaseT',typeof uAirPhaseT!=='undefined'?uAirPhaseT:null],['uAirAnchorX',typeof uAirAnchorX!=='undefined'?uAirAnchorX:null],['uAirAnchorY',typeof uAirAnchorY!=='undefined'?uAirAnchorY:null],['uAirGoalX',typeof uAirGoalX!=='undefined'?uAirGoalX:null],['uAirGoalY',typeof uAirGoalY!=='undefined'?uAirGoalY:null],['uAirAimX',typeof uAirAimX!=='undefined'?uAirAimX:null],['uAirAimY',typeof uAirAimY!=='undefined'?uAirAimY:null],['uAirVx',typeof uAirVx!=='undefined'?uAirVx:null],['uAirVy',typeof uAirVy!=='undefined'?uAirVy:null],['uAirCourse',typeof uAirCourse!=='undefined'?uAirCourse:null],['uAirYawRate',typeof uAirYawRate!=='undefined'?uAirYawRate:null],['uAirPass',typeof uAirPass!=='undefined'?uAirPass:null],['uAirReleaseN',typeof uAirReleaseN!=='undefined'?uAirReleaseN:null],['uAirReacquireN',typeof uAirReacquireN!=='undefined'?uAirReacquireN:null],['uAirSortie',typeof uAirSortie!=='undefined'?uAirSortie:null]];
68
  for(const x of opt)if(x[1])MF_SH_indexed(w,x[0],x[1],units);
69
+ const nav=[['uNavProgressGen',typeof uNavProgressGen!=='undefined'?uNavProgressGen:null],
70
+ ['uNavGoalX',typeof uNavGoalX!=='undefined'?uNavGoalX:null],['uNavGoalY',typeof uNavGoalY!=='undefined'?uNavGoalY:null],
71
+ ['uNavProbeX',typeof uNavProbeX!=='undefined'?uNavProbeX:null],['uNavProbeY',typeof uNavProbeY!=='undefined'?uNavProbeY:null],
72
+ ['uNavProbeAge',typeof uNavProbeAge!=='undefined'?uNavProbeAge:null],['uNavRetryFor',typeof uNavRetryFor!=='undefined'?uNavRetryFor:null],
73
+ ['uNavPursuitField',typeof uNavPursuitField!=='undefined'?uNavPursuitField:null]];
74
+ for(const x of nav)if(x[1])MF_SH_indexed(w,x[0],x[1],units);
75
  MF_SH_named(w,'unitQueues',units.map(i=>[i,uQueue[i]||null]));MF_SH_named(w,'patrolRoutes',typeof patrolRoutes!=='undefined'?patrolRoutes:[]);MF_SH_named(w,'moveCohorts',typeof moveCohorts!=='undefined'?moveCohorts:[]);
76
+ const bf=['type','team','fac','x','y','hp','hpm','r','alive','prog','cool','queue','queueRevision','repeat','prodT','heal','tier','lvl','upT','upMax','tang','gunPitch','boost','boostM','res','resT','rally','rich','dep','geo','shield','shieldMax','shieldT','guardReady','guardT','guardCharge','rot','footTier','link','conduit','buildPaidM','buildPaidE','buildStalled','repairOn','dmgT','aim','aimG','target','targetG','mode','state','stun','haz'];
77
  MF_SH_named(w,'buildings',blds.map((b,i)=>[i,MF_SH_fields(b,bf)]));MF_SH_named(w,'economy',{resM:typeof resM!=='undefined'?resM:[],resE:typeof resE!=='undefined'?resE:[],mSpendAcc:typeof mSpendAcc!=='undefined'?mSpendAcc:0,eSpendAcc:typeof eSpendAcc!=='undefined'?eSpendAcc:0,spendT:typeof spendT!=='undefined'?spendT:0,stats:typeof stats!=='undefined'?stats:{},researched:typeof researched!=='undefined'?researched:{},researchCarry:typeof researchCarry!=='undefined'?researchCarry:{},researching:typeof researching!=='undefined'?researching:null});
78
  MF_SH_named(w,'pHigh',pHigh);MF_SH_named(w,'projectileFree',typeof pFree!=='undefined'?pFree:[]);
79
  const pa=[['px',px],['py',py],['pvx',pvx],['pvy',pvy],['plife',plife],['pdmg',pdmg],['paoe',paoe],['ptype',ptype],['pteam',pteam],['ptgt',ptgt],['ptgtg',ptgtg],['pmu0',pmu0],['pwk',pwk],['psx',psx],['psy',psy],['pex',pex],['pey',pey],['pt',pt],['pmax',pmax],['pSplit',pSplit],['pCannon',pCannon],['pBio',pBio],['pBarrage',pBarrage],['pFlightId',pFlightId],['pBaseSpeed',pBaseSpeed],['pSpeed',pSpeed],['pAge',pAge],['pSrcUnit',pSrcUnit],['pSrcGen',pSrcGen],['pArc',pArc],['pz0',pz0],['pz1',pz1],['pz',pz],['pLastTX',pLastTX],['pLastTY',pLastTY]];for(const x of pa)MF_SH_indexed(w,x[0],x[1],shots);
src/intro.js CHANGED
@@ -82,6 +82,7 @@
82
  that never opened. Once only β€” revealFront can be reached twice. */
83
  if(!gateFired){
84
  gateFired=true;
 
85
  setTimeout(function(){
86
  if(typeof mfAuthGate==='function'){ try{ mfAuthGate(); }catch(e){} }
87
  },340);
@@ -181,7 +182,7 @@
181
  shape may bypass the reveal; ordinary launches and look-alike query
182
  strings still receive the title screen. The route owner validates and
183
  consumes the secured session record later in galactic-operations.js. */
184
- try{return /^\?galacticRoute=[A-Za-z0-9_-]{16,128}$/.test(String(location.search||''));}
185
  catch(e){return false;}
186
  }
187
  function dismissIntroForGalacticRoute(){
@@ -223,7 +224,13 @@
223
  established commander returning to the game. */
224
  /* Open on the next frame, not after a 650ms delay: the menu must never
225
  be visible first. The boot cover + intro.css keep it hidden until then. */
226
- if(typeof requestAnimationFrame==='function') requestAnimationFrame(function(){openIntro();});
 
 
 
 
 
 
227
  else openIntro();
228
  }
229
  /* Safety net: if the intro never manages to open (module error, boot failure),
 
82
  that never opened. Once only β€” revealFront can be reached twice. */
83
  if(!gateFired){
84
  gateFired=true;
85
+ try{window.dispatchEvent(new CustomEvent('massfront:intro-complete'));}catch(e){}
86
  setTimeout(function(){
87
  if(typeof mfAuthGate==='function'){ try{ mfAuthGate(); }catch(e){} }
88
  },340);
 
182
  shape may bypass the reveal; ordinary launches and look-alike query
183
  strings still receive the title screen. The route owner validates and
184
  consumes the secured session record later in galactic-operations.js. */
185
+ try{return /^\?(?:galacticRoute|groundOperation)=[A-Za-z0-9_-]{16,128}$/.test(String(location.search||''));}
186
  catch(e){return false;}
187
  }
188
  function dismissIntroForGalacticRoute(){
 
224
  established commander returning to the game. */
225
  /* Open on the next frame, not after a 650ms delay: the menu must never
226
  be visible first. The boot cover + intro.css keep it hidden until then. */
227
+ if(typeof window.mfLauncherAwaitingUpdateIntro==='function'&&window.mfLauncherAwaitingUpdateIntro()){
228
+ // Updating owns the first screen. Do not invoke revealFront here: it opens login.
229
+ document.body.classList.add('mfIntroDone');
230
+ var cover=document.getElementById('mfBootCover');if(cover)cover.remove();
231
+ el.hidden=true;el.setAttribute('aria-hidden','true');
232
+ }
233
+ else if(typeof requestAnimationFrame==='function') requestAnimationFrame(function(){openIntro();});
234
  else openIntro();
235
  }
236
  /* Safety net: if the intro never manages to open (module error, boot failure),
src/launcher.js CHANGED
@@ -44,10 +44,10 @@
44
  'Preserved rollback after the patched runtime passed probation.'],
45
  upcoming:['Large-update resume and clearer update categories.']}
46
  ];
47
- var L={inited:false,gate:true,passed:false,bypass:false,identity:null,update:null,
48
  notesTab:'features',historyTab:'published',release:null,releaseKey:'',primary:'wait',
49
  packBusy:false,packTicket:0,packTimer:0,packAudioReady:false,packAudioIds:[],
50
- galacticBusy:false,galacticTicket:0,galacticReady:false};
51
 
52
  function byId(id){return document.getElementById(id);}
53
  function text(id,value){var el=byId(id);if(el)el.textContent=value==null?'':String(value);return el;}
@@ -74,7 +74,7 @@
74
  return true;
75
  }
76
  function isGalacticReturn(){
77
- try{return /^\?galacticRoute=[A-Za-z0-9_-]{16,128}$/.test(String(location.search||''));}
78
  catch(e){return false;}
79
  }
80
  function identitySnapshot(){
@@ -225,7 +225,7 @@
225
  var busy=['channeling','checking','downloading','staging','applying','rollingBack'].indexOf(state)>=0;
226
  var primary=byId('mfLaunchPlay'),offline=byId('mfLaunchOffline');if(!primary||!offline)return;
227
  offline.style.display='none';offline.disabled=true;
228
- if(id.state==='pending'){
229
  /* Identity can stay pending for reasons the player cannot influence: a
230
  cleared 401 session that never reopened the gate, a missing gate
231
  overlay, or a verification request that never returned. Hiding the
@@ -246,6 +246,9 @@
246
  offline.style.display='block';offline.disabled=false;return;
247
  }
248
  if(busy){L.primary='wait';primary.textContent=String(action.label||'PLEASE WAIT');primary.disabled=true;return;}
 
 
 
249
  if(id.state==='offline'||!connected||state==='unset'||state==='offline'){
250
  /* This is the only enabled control at the gate when identity cannot be
251
  confirmed, so its label has to be honest about what it does. It no
@@ -305,6 +308,12 @@
305
  var sc=byId('mfLauncherScroll');if(sc)sc.scrollTop=0;renderAll();
306
  }
307
  function enterGame(forceOffline){
 
 
 
 
 
 
308
  /* PLAY OFFLINE means "start now without waiting for the network", not
309
  "disable networking on this device from now on". It used to latch
310
  netSetOffline(true), which persists to localStorage, and since the
@@ -324,6 +333,13 @@
324
  if(typeof setupAttract==='function')try{setupAttract();}catch(e){}
325
  var play=byId('startBtn');if(play)setTimeout(function(){try{play.focus({preventScroll:true});}catch(e){play.focus();}},60);
326
  try{window.dispatchEvent(new CustomEvent('massfront:launcher-exit',{detail:{mode:forceOffline?'offline':'connected'}}));}catch(e){}
 
 
 
 
 
 
 
327
  }
328
  function primaryAction(){
329
  if(L.primary==='play-connected'){enterGame(false);return;}
@@ -338,21 +354,15 @@
338
  }
339
  async function renderPacks(){
340
  var ticket=++L.packTicket,button=byId('mfLaunchPackAudio'),state=byId('mfLaunchPackAudioState');if(!button||!state)return;
341
- relocatePackPanel();var online=onlineAllowed(),api=window.MASSFRONT_ASSET_PACKS;
342
- try{
343
- var rows=api&&typeof api.list==='function'?await api.list():[];if(ticket!==L.packTicket)return;
344
- var audio=rows.filter(function(row){return row.id==='voice'||row.id==='music';});
345
- var statuses=api&&typeof api.status==='function'
346
- ?await Promise.all(audio.map(function(row){return api.status(row.id);})) : audio;
347
- if(ticket!==L.packTicket)return;
348
- var installed=audio.length&&statuses.every(function(row){return row&&row.ok!==false&&row.installed&&!row.updateAvailable;});
349
- var total=audio.reduce(function(sum,row){return sum+(Number(row.bytes)||0);},0);
350
- L.packAudioReady=!!installed;L.packAudioIds=audio.map(function(row){return row.id;});
351
- if(installed){state.textContent='INSTALLED Β· '+fmtBytes(total);button.textContent='VERIFY';button.disabled=false;}
352
- else if(!online){state.textContent='REQUIRES INTERNET';button.textContent='OFFLINE';button.disabled=true;}
353
- else {state.textContent=(statuses.some(function(row){return row&&((row.installed||row.partial)&&!row.updateAvailable);})?'RESUME / REPAIR Β· ':'OPTIONAL Β· ')+fmtBytes(total);button.textContent='DOWNLOAD';button.disabled=L.packBusy;}
354
- button.setAttribute('aria-disabled',button.disabled?'true':'false');
355
- }catch(e){state.textContent=online?'STATUS UNAVAILABLE':'REQUIRES INTERNET';button.textContent=online?'RETRY':'OFFLINE';button.disabled=!online||L.packBusy;}
356
  }
357
  function schedulePackRender(delay){
358
  clearTimeout(L.packTimer);L.packTimer=setTimeout(function(){L.packTimer=0;renderPacks();},Math.max(0,delay||0));
@@ -375,16 +385,9 @@
375
  }catch(e){if(typeof toast==='function')toast(online?'Audio download needs attention β€” retry when connected':'Audio repair requires an Internet connection');}
376
  finally{L.packBusy=false;relocatePackPanel();renderPacks();}
377
  }
378
- /* Galactic Exploration is an optional 541.8 MiB expansion. The bytes have
379
- been published and current at
380
- .../massfront-releases/resolve/main/exploration-pack/ the whole time, and
381
- assetpack.js has carried a complete installer for them
382
- (mfInstallExplorationPack: signed manifest, per-file SHA-256, storage
383
- preflight, resumable chunked install, legacy-blob migration). Nothing ever
384
- called it. This card only ever reported whether the *build* happened to
385
- bundle the module, so a player on a slim install was told
386
- "INSTALLER REQUIRED" and given no way to get content that was sitting on
387
- the CDN ready to download. Offer the real install path instead. */
388
  var GALACTIC_PACK_ID='galactic-exploration';
389
  async function renderGalactic(){
390
  var state=byId('mfLaunchPackGalacticState'),button=byId('mfLaunchPackGalactic');if(!state||!button)return;
@@ -402,8 +405,18 @@
402
  if(ticket!==L.galacticTicket)return;
403
  var installed=!!(status&&status.ok!==false&&status.installed&&!status.updateAvailable);
404
  var partial=!!(status&&(status.partial||(status.installed&&status.updateAvailable)));
405
- L.galacticReady=installed;
406
- if(installed){
 
 
 
 
 
 
 
 
 
 
407
  if(card)card.classList.remove('onlineOnly');
408
  state.textContent='INSTALLED'+(status&&status.bytes?' Β· '+fmtBytes(status.bytes):'');
409
  button.textContent='VERIFY';button.disabled=L.galacticBusy;
@@ -412,7 +425,7 @@
412
  state.textContent='REQUIRES INTERNET';button.textContent='OFFLINE';button.disabled=true;
413
  }else{
414
  if(card)card.classList.remove('onlineOnly');
415
- state.textContent=(partial?'RESUME Β· ':'OPTIONAL Β· ')+'541.8 MB';
416
  button.textContent=partial?'RESUME':'INSTALL';button.disabled=L.galacticBusy;
417
  }
418
  button.setAttribute('aria-disabled',button.disabled?'true':'false');
@@ -421,7 +434,15 @@
421
  if(L.galacticBusy)return;
422
  if(window.__MF_BUILD_HAS_GALACTIC_EXPLORATION===true)return;
423
  var verifyOnly=L.galacticReady,online=onlineAllowed(),api=window.MASSFRONT_ASSET_PACKS;
424
- if(!verifyOnly&&!online)return;
 
 
 
 
 
 
 
 
425
  L.galacticBusy=true;
426
  var button=byId('mfLaunchPackGalactic');
427
  if(button){button.disabled=true;button.textContent=verifyOnly?'VERIFYING…':'PREPARING…';}
@@ -478,13 +499,14 @@
478
  if(typeof netSetOffline==='function'&&L.identity.state==='offline'&&explicitOffline)netSetOffline(true);
479
  else if(typeof netSetOffline==='function'&&L.identity.state==='connected')netSetOffline(false);
480
  }catch(e){}
481
- renderIdentity();
482
  /* A cached token can expire after the title closes. Re-open the auth
483
  choice when /me rejects it instead of leaving PLAY disabled forever. */
484
- if(L.identity.state==='pending'&&L.identity.source==='signed-out'&&typeof mfAuthGate==='function')
485
  setTimeout(function(){try{mfAuthGate();}catch(e){}},0);
486
- if(!L.bypass&&(L.identity.state==='connected'||L.identity.state==='offline'))enterGateway();
487
  });
 
488
  window.addEventListener('massfront:update-state',function(event){
489
  var before=L.update&&L.update.state,next=event&&event.detail||updaterSnapshot();renderUpdate(next);
490
  /* Byte progress is deliberately frequent. Pack manifests are unrelated
@@ -492,6 +514,7 @@
492
  updater crosses a material state boundary. */
493
  if(before!==next.state)schedulePackRender(160);
494
  });
 
495
  window.addEventListener('online',function(){
496
  if(L.identity&&L.identity.source==='session-unverified'&&typeof apVerifySession==='function')
497
  try{apVerifySession();}catch(e){}
@@ -519,12 +542,13 @@
519
  }
520
  function mfLauncherShouldDeferAttract(){return !L.bypass&&!L.passed;}
521
  function mfLauncherSnapshot(){
522
- return {gate:L.gate,passed:L.passed,bypass:L.bypass,primary:L.primary,
523
  identity:L.identity?Object.assign({},L.identity):null,update:L.update?{state:L.update.state,status:L.update.status}:null,
524
  notesTab:L.notesTab,historyTab:L.historyTab};
525
  }
526
 
527
  window.initLauncherGateway=initLauncherGateway;
 
528
  window.mfLauncherOpenDetails=mfLauncherOpenDetails;
529
  window.mfLauncherHandleBack=mfLauncherHandleBack;
530
  window.mfLauncherShouldDeferAttract=mfLauncherShouldDeferAttract;
 
44
  'Preserved rollback after the patched runtime passed probation.'],
45
  upcoming:['Large-update resume and clearer update categories.']}
46
  ];
47
+ var L={inited:false,gate:true,passed:false,bypass:false,phase:'updater',entering:false,identity:null,update:null,
48
  notesTab:'features',historyTab:'published',release:null,releaseKey:'',primary:'wait',
49
  packBusy:false,packTicket:0,packTimer:0,packAudioReady:false,packAudioIds:[],
50
+ galacticBusy:false,galacticTicket:0,galacticReady:false,galacticCached:false};
51
 
52
  function byId(id){return document.getElementById(id);}
53
  function text(id,value){var el=byId(id);if(el)el.textContent=value==null?'':String(value);return el;}
 
74
  return true;
75
  }
76
  function isGalacticReturn(){
77
+ try{return /^\?(?:galacticRoute|groundOperation)=[A-Za-z0-9_-]{16,128}$/.test(String(location.search||''));}
78
  catch(e){return false;}
79
  }
80
  function identitySnapshot(){
 
225
  var busy=['channeling','checking','downloading','staging','applying','rollingBack'].indexOf(state)>=0;
226
  var primary=byId('mfLaunchPlay'),offline=byId('mfLaunchOffline');if(!primary||!offline)return;
227
  offline.style.display='none';offline.disabled=true;
228
+ if(id.state==='pending'&&L.phase!=='updater'){
229
  /* Identity can stay pending for reasons the player cannot influence: a
230
  cleared 401 session that never reopened the gate, a missing gate
231
  overlay, or a verification request that never returned. Hiding the
 
246
  offline.style.display='block';offline.disabled=false;return;
247
  }
248
  if(busy){L.primary='wait';primary.textContent=String(action.label||'PLEASE WAIT');primary.disabled=true;return;}
249
+ if(L.phase==='updater'){
250
+ L.primary='play-offline';primary.textContent='CONTINUE TO INTRO';primary.disabled=false;return;
251
+ }
252
  if(id.state==='offline'||!connected||state==='unset'||state==='offline'){
253
  /* This is the only enabled control at the gate when identity cannot be
254
  confirmed, so its label has to be honest about what it does. It no
 
308
  var sc=byId('mfLauncherScroll');if(sc)sc.scrollTop=0;renderAll();
309
  }
310
  function enterGame(forceOffline){
311
+ if(L.phase==='updater'){
312
+ L.phase='intro';L.gate=false;document.body.classList.remove('mfLauncherGate');
313
+ if(typeof window.showPreAlphaIntro==='function')window.showPreAlphaIntro();
314
+ else {L.phase='login';if(typeof mfAuthGate==='function')mfAuthGate();}
315
+ return;
316
+ }
317
  /* PLAY OFFLINE means "start now without waiting for the network", not
318
  "disable networking on this device from now on". It used to latch
319
  netSetOffline(true), which persists to localStorage, and since the
 
333
  if(typeof setupAttract==='function')try{setupAttract();}catch(e){}
334
  var play=byId('startBtn');if(play)setTimeout(function(){try{play.focus({preventScroll:true});}catch(e){play.focus();}},60);
335
  try{window.dispatchEvent(new CustomEvent('massfront:launcher-exit',{detail:{mode:forceOffline?'offline':'connected'}}));}catch(e){}
336
+ if(!L.bypass&&!L.entering&&typeof mfOpenExploration==='function'
337
+ &&(window.__MF_BUILD_HAS_GALACTIC_EXPLORATION===true||window.__MF_OTA_HAS_GALACTIC_DELIVERY===true)){
338
+ L.entering=true;L.phase='career';
339
+ Promise.resolve(mfOpenExploration('system')).catch(function(){
340
+ if(typeof toast==='function')toast('Ship entry needs attention. Use START to retry.');
341
+ }).finally(function(){L.entering=false;});
342
+ }
343
  }
344
  function primaryAction(){
345
  if(L.primary==='play-connected'){enterGame(false);return;}
 
354
  }
355
  async function renderPacks(){
356
  var ticket=++L.packTicket,button=byId('mfLaunchPackAudio'),state=byId('mfLaunchPackAudioState');if(!button||!state)return;
357
+ relocatePackPanel();
358
+ /* The current voice bank and available score are package invariants, not
359
+ optional network packs. Future audio expansions use the generic startup
360
+ delivery lane and its shared progress panel instead of duplicating these
361
+ base files in IndexedDB. */
362
+ if(ticket!==L.packTicket)return;
363
+ L.packAudioReady=true;L.packAudioIds=[];
364
+ state.textContent='INCLUDED IN THIS BUILD';button.textContent='READY';button.disabled=true;
365
+ button.setAttribute('aria-disabled','true');
 
 
 
 
 
 
366
  }
367
  function schedulePackRender(delay){
368
  clearTimeout(L.packTimer);L.packTimer=setTimeout(function(){L.packTimer=0;renderPacks();},Math.max(0,delay||0));
 
385
  }catch(e){if(typeof toast==='function')toast(online?'Audio download needs attention β€” retry when connected':'Audio repair requires an Internet connection');}
386
  finally{L.packBusy=false;relocatePackPanel();renderPacks();}
387
  }
388
+ /* Cached download bytes and a launchable resource tree are different states.
389
+ Older native installs must finish verified staging before this card says
390
+ READY; the content generation is pinned by their new OTA descriptor. */
 
 
 
 
 
 
 
391
  var GALACTIC_PACK_ID='galactic-exploration';
392
  async function renderGalactic(){
393
  var state=byId('mfLaunchPackGalacticState'),button=byId('mfLaunchPackGalactic');if(!state||!button)return;
 
405
  if(ticket!==L.galacticTicket)return;
406
  var installed=!!(status&&status.ok!==false&&status.installed&&!status.updateAvailable);
407
  var partial=!!(status&&(status.partial||(status.installed&&status.updateAvailable)));
408
+ var mount=null;try{mount=window.MFNativeExplorationContent&&window.MFNativeExplorationContent.snapshot();}catch(e){}
409
+ var needsMount=window.__MF_OTA_HAS_GALACTIC_DELIVERY===true;
410
+ var mounted=!!(mount&&[mount.good,mount.prepared].some(function(candidate){
411
+ return candidate&&typeof APP_VERSION==='string'&&candidate.version===APP_VERSION&&candidate.generation!==mount.failed;
412
+ }));
413
+ L.galacticCached=installed;
414
+ L.galacticReady=installed&&(!needsMount||mounted);
415
+ if(installed&&needsMount&&!mounted){
416
+ if(card)card.classList.remove('onlineOnly');
417
+ state.textContent=mount&&mount.failed?'STARTUP NEEDS RETRY':'DOWNLOADED Β· STAGING REQUIRED';
418
+ button.textContent=mount&&mount.failed?'RETRY':'STAGE';button.disabled=L.galacticBusy;
419
+ }else if(installed){
420
  if(card)card.classList.remove('onlineOnly');
421
  state.textContent='INSTALLED'+(status&&status.bytes?' Β· '+fmtBytes(status.bytes):'');
422
  button.textContent='VERIFY';button.disabled=L.galacticBusy;
 
425
  state.textContent='REQUIRES INTERNET';button.textContent='OFFLINE';button.disabled=true;
426
  }else{
427
  if(card)card.classList.remove('onlineOnly');
428
+ state.textContent=(partial?'RESUME Β· ':'CONTENT Β· ')+(status&&status.bytes?fmtBytes(status.bytes):'GALACTIC EXPLORATION');
429
  button.textContent=partial?'RESUME':'INSTALL';button.disabled=L.galacticBusy;
430
  }
431
  button.setAttribute('aria-disabled',button.disabled?'true':'false');
 
434
  if(L.galacticBusy)return;
435
  if(window.__MF_BUILD_HAS_GALACTIC_EXPLORATION===true)return;
436
  var verifyOnly=L.galacticReady,online=onlineAllowed(),api=window.MASSFRONT_ASSET_PACKS;
437
+ /* A bound OTA can stage verified cached bytes without a network. The
438
+ installer still rejects missing/damaged bytes offline; this flag only
439
+ permits reaching its existing local verification/copy path. */
440
+ var localStage=window.__MF_OTA_HAS_GALACTIC_DELIVERY===true&&L.galacticCached;
441
+ if(!verifyOnly&&!online&&!localStage)return;
442
+ try{
443
+ var mountApi=window.MFNativeExplorationContent,mountState=mountApi&&mountApi.snapshot();
444
+ if(mountState&&mountState.failed){mountApi.retry();L.galacticReady=false;verifyOnly=false;}
445
+ }catch(e){}
446
  L.galacticBusy=true;
447
  var button=byId('mfLaunchPackGalactic');
448
  if(button){button.disabled=true;button.textContent=verifyOnly?'VERIFYING…':'PREPARING…';}
 
499
  if(typeof netSetOffline==='function'&&L.identity.state==='offline'&&explicitOffline)netSetOffline(true);
500
  else if(typeof netSetOffline==='function'&&L.identity.state==='connected')netSetOffline(false);
501
  }catch(e){}
502
+ renderAll();
503
  /* A cached token can expire after the title closes. Re-open the auth
504
  choice when /me rejects it instead of leaving PLAY disabled forever. */
505
+ if(L.phase==='login'&&L.identity.state==='pending'&&L.identity.source==='signed-out'&&typeof mfAuthGate==='function')
506
  setTimeout(function(){try{mfAuthGate();}catch(e){}},0);
507
+ if(!L.bypass&&L.phase==='login'&&(L.identity.state==='connected'||L.identity.state==='offline'))enterGame(L.identity.state==='offline');
508
  });
509
+ window.addEventListener('massfront:intro-complete',function(){if(!L.bypass){L.phase='login';renderAll();}});
510
  window.addEventListener('massfront:update-state',function(event){
511
  var before=L.update&&L.update.state,next=event&&event.detail||updaterSnapshot();renderUpdate(next);
512
  /* Byte progress is deliberately frequent. Pack manifests are unrelated
 
514
  updater crosses a material state boundary. */
515
  if(before!==next.state)schedulePackRender(160);
516
  });
517
+ window.addEventListener('massfront:assetpack-state',function(){relocatePackPanel();});
518
  window.addEventListener('online',function(){
519
  if(L.identity&&L.identity.source==='session-unverified'&&typeof apVerifySession==='function')
520
  try{apVerifySession();}catch(e){}
 
542
  }
543
  function mfLauncherShouldDeferAttract(){return !L.bypass&&!L.passed;}
544
  function mfLauncherSnapshot(){
545
+ return {gate:L.gate,passed:L.passed,bypass:L.bypass,phase:L.phase,primary:L.primary,
546
  identity:L.identity?Object.assign({},L.identity):null,update:L.update?{state:L.update.state,status:L.update.status}:null,
547
  notesTab:L.notesTab,historyTab:L.historyTab};
548
  }
549
 
550
  window.initLauncherGateway=initLauncherGateway;
551
+ window.mfLauncherAwaitingUpdateIntro=function(){return !isGalacticReturn()&&L.phase==='updater';};
552
  window.mfLauncherOpenDetails=mfLauncherOpenDetails;
553
  window.mfLauncherHandleBack=mfLauncherHandleBack;
554
  window.mfLauncherShouldDeferAttract=mfLauncherShouldDeferAttract;
src/main.js CHANGED
@@ -605,6 +605,7 @@ function resetWorld(){
605
  if(typeof commanderDialogueReset==='function') commanderDialogueReset();
606
  if(typeof cmdrTxReset==='function') cmdrTxReset();
607
  ualive.fill(0); usel.fill(0); ugen.fill(0); utgtg.fill(-1);
 
608
  freeList=[]; unitHigh=0; teamCount[0]=0; teamCount[1]=0;
609
  if(typeof activeUnitReset==='function')activeUnitReset();
610
  if(typeof tick==='number')tick=0;
@@ -2703,30 +2704,31 @@ function wire(){
2703
  if(typeof renderOps==='function') renderOps();
2704
  });
2705
  });
2706
- /* EXPERIMENTAL SPACE EXPLORATION β€” same-tab strategic layer, with two gates.
2707
  The module is a separate document with its own three.js and its own WebGL
2708
  canvas, so it is opened as a document rather than imported: two contexts
2709
  fighting over one canvas is the failure this avoids, and a crash in an
2710
  unfinished module then cannot take the RTS down with it.
2711
- Gate 1 is the player's setting. Gate 2 is a launch-time HEAD probe, because
2712
- the module is NOT in the installer. The existing home remains the home;
2713
  START enters live space for the world introduction, whose skippable choice
2714
  continues either to protected planetary Training or the Galactic War Table.
2715
  Nothing here runs during a match or touches saves. */
2716
  (function mfWireExploration(){
2717
  const URL_='./modules/space_exploration/index.html';
2718
  mfOpenExploration=async function(entryView){
2719
- const enabled=!!(typeof META!=='undefined'&&META.settings&&META.settings.experimentalExploration);
 
2720
  if(!enabled){ if(typeof toast==='function') toast('Enable Experimental: Galactic Campaign first'); return false; }
2721
  entryView=entryView==='system'?'system':'campaign_hub';
2722
  initAudio(); sfx('ui');
2723
- let present=false,packUrl=URL_;
2724
- try{ const r=await fetch(URL_,{method:'HEAD',cache:'no-store'}); present=r.ok; }
2725
  catch(e){}
2726
  if(!present){
2727
  const packed=typeof mfInstallExplorationPack==='function'&&await mfInstallExplorationPack();
2728
  if(!packed||!packed.ok){ if(typeof toast==='function') toast('Galactic preview is not installed in this build'); return false; }
2729
- /* blob: index.html is a different origin than this ticket; do not navigate. */
2730
  if(packed.openUrl) packUrl=packed.openUrl;
2731
  else { if(typeof toast==='function') toast('Galactic pack downloaded β€” War Room stays available in this build'); return false; }
2732
  }
@@ -2781,7 +2783,12 @@ function wire(){
2781
  }catch(e){}
2782
  if(!ticketReady){ if(typeof toast==='function') toast('Galactic link could not be secured on this device'); return false; }
2783
  /* Same tab: the ship and RTS renderers must never compete for WebGL. */
2784
- try{ location.href=packUrl; return true; }
 
 
 
 
 
2785
  catch(e){ if(typeof toast==='function') toast('Galactic preview could not be opened'); return false; }
2786
  };
2787
  /* boot() is asynchronous while the tail manifest scripts continue loading.
@@ -2859,11 +2866,13 @@ function wire(){
2859
  showFrontScreen('warScr');
2860
  } else if(typeof openSkirmishSetup==='function') openSkirmishSetup();
2861
  };
2862
- mfBindTap($('startBtn'),()=>{ initAudio(); sfx('ui');
2863
- /* Experimental Galactic is a Settings side door. START always keeps the
2864
- installed home β†’ War Room β†’ Standard table. Hijacking this button made
2865
- the experiment look like it had replaced the menu. */
2866
- openLegacyWarRoom();
 
 
2867
  });
2868
  mfBindTap($('warBack'),()=>{ sfx('ui');
2869
  renderMetaHead(); showFrontScreen('startScreen'); });
@@ -3150,6 +3159,8 @@ function wire(){
3150
  mfBindNativePress($('formBtn'),()=>{armFormationOrder();});
3151
  mfBindNativePress($('rallyBtn'),()=>{
3152
  if(openBld<0) return;
 
 
3153
  armRally=openBld;
3154
  closeMenus();
3155
  toast('βš‘ Tap the map to place the rally flag');
@@ -3183,9 +3194,25 @@ function wire(){
3183
  mfBindNativePress($('placeRotR'),e=>{ e.preventDefault(); rotatePlacement(1); });
3184
  mfBindNativePress($('repeatBtn'),()=>{
3185
  if(openBld<0) return;
3186
- const B=blds[openBld]; B.repeat=!B.repeat;
 
 
 
 
 
 
 
 
 
 
 
 
3187
  $('repeatBtn').textContent='REPEAT: '+(B.repeat?'ON':'OFF');
3188
- $('repeatBtn').classList.toggle('on',B.repeat); sfx('ui');
 
 
 
 
3189
  });
3190
  mfBindNativePress($('abOver'),()=>{ if(aiming===0){aiming=-1;return;} tryAbility(0); });
3191
  mfBindNativePress($('abHeal'),()=>tryAbility(1));
 
605
  if(typeof commanderDialogueReset==='function') commanderDialogueReset();
606
  if(typeof cmdrTxReset==='function') cmdrTxReset();
607
  ualive.fill(0); usel.fill(0); ugen.fill(0); utgtg.fill(-1);
608
+ if(typeof mfNavProgressReset==='function')mfNavProgressReset();
609
  freeList=[]; unitHigh=0; teamCount[0]=0; teamCount[1]=0;
610
  if(typeof activeUnitReset==='function')activeUnitReset();
611
  if(typeof tick==='number')tick=0;
 
2704
  if(typeof renderOps==='function') renderOps();
2705
  });
2706
  });
2707
+ /* GALACTIC EXPLORATION β€” same-tab strategic layer.
2708
  The module is a separate document with its own three.js and its own WebGL
2709
  canvas, so it is opened as a document rather than imported: two contexts
2710
  fighting over one canvas is the failure this avoids, and a crash in an
2711
  unfinished module then cannot take the RTS down with it.
2712
+ Complete installers enable this layer directly. Older/slim shells retain
2713
+ their opt-in and installation fallback, checked by a launch-time HEAD probe.
2714
  START enters live space for the world introduction, whose skippable choice
2715
  continues either to protected planetary Training or the Galactic War Table.
2716
  Nothing here runs during a match or touches saves. */
2717
  (function mfWireExploration(){
2718
  const URL_='./modules/space_exploration/index.html';
2719
  mfOpenExploration=async function(entryView){
2720
+ const enabled=window.__MF_BUILD_HAS_GALACTIC_EXPLORATION===true||window.__MF_OTA_HAS_GALACTIC_DELIVERY===true
2721
+ ||!!(typeof META!=='undefined'&&META.settings&&META.settings.experimentalExploration);
2722
  if(!enabled){ if(typeof toast==='function') toast('Enable Experimental: Galactic Campaign first'); return false; }
2723
  entryView=entryView==='system'?'system':'campaign_hub';
2724
  initAudio(); sfx('ui');
2725
+ let present=window.__MF_BUILD_HAS_GALACTIC_EXPLORATION===true,packUrl=URL_;
2726
+ try{ if(!present){const r=await fetch(URL_,{method:'HEAD',cache:'no-store'}); present=r.ok;} }
2727
  catch(e){}
2728
  if(!present){
2729
  const packed=typeof mfInstallExplorationPack==='function'&&await mfInstallExplorationPack();
2730
  if(!packed||!packed.ok){ if(typeof toast==='function') toast('Galactic preview is not installed in this build'); return false; }
2731
+ /* Cached bytes need a same-origin resource mount before navigation. */
2732
  if(packed.openUrl) packUrl=packed.openUrl;
2733
  else { if(typeof toast==='function') toast('Galactic pack downloaded β€” War Room stays available in this build'); return false; }
2734
  }
 
2783
  }catch(e){}
2784
  if(!ticketReady){ if(typeof toast==='function') toast('Galactic link could not be secured on this device'); return false; }
2785
  /* Same tab: the ship and RTS renderers must never compete for WebGL. */
2786
+ try{
2787
+ if(typeof mfBeginExplorationMountLaunch==='function'&&!(await mfBeginExplorationMountLaunch(packUrl))){
2788
+ if(typeof toast==='function')toast('Galactic content is not ready to launch safely');return false;
2789
+ }
2790
+ location.href=packUrl;return true;
2791
+ }
2792
  catch(e){ if(typeof toast==='function') toast('Galactic preview could not be opened'); return false; }
2793
  };
2794
  /* boot() is asynchronous while the tail manifest scripts continue loading.
 
2866
  showFrontScreen('warScr');
2867
  } else if(typeof openSkirmishSetup==='function') openSkirmishSetup();
2868
  };
2869
+ mfBindTap($('startBtn'),async()=>{ initAudio(); sfx('ui');
2870
+ /* Keep the launcher/authentication boundary, then enter the strategic home.
2871
+ Older/slim installations still need their working local War Room. */
2872
+ const enabled=window.__MF_BUILD_HAS_GALACTIC_EXPLORATION===true||window.__MF_OTA_HAS_GALACTIC_DELIVERY===true
2873
+ ||!!(typeof META!=='undefined'&&META.settings&&META.settings.experimentalExploration);
2874
+ const opened=enabled&&typeof mfOpenExploration==='function'&&await mfOpenExploration('system');
2875
+ if(!opened)openLegacyWarRoom();
2876
  });
2877
  mfBindTap($('warBack'),()=>{ sfx('ui');
2878
  renderMetaHead(); showFrontScreen('startScreen'); });
 
3159
  mfBindNativePress($('formBtn'),()=>{armFormationOrder();});
3160
  mfBindNativePress($('rallyBtn'),()=>{
3161
  if(openBld<0) return;
3162
+ const B=blds[openBld];
3163
+ if(!B||!B.alive||!(typeof mfLocalOwnsBuilding==='function'?mfLocalOwnsBuilding(B):B.team===0)||!Array.isArray(B.queue))return;
3164
  armRally=openBld;
3165
  closeMenus();
3166
  toast('βš‘ Tap the map to place the rally flag');
 
3194
  mfBindNativePress($('placeRotR'),e=>{ e.preventDefault(); rotatePlacement(1); });
3195
  mfBindNativePress($('repeatBtn'),()=>{
3196
  if(openBld<0) return;
3197
+ const B=blds[openBld];
3198
+ if(!B||!B.alive||!(typeof mfLocalOwnsBuilding==='function'?mfLocalOwnsBuilding(B):B.team===0)||!Array.isArray(B.queue))return;
3199
+ const C=mfMatchConsumer();
3200
+ if(C&&C.requiresLockstep()){
3201
+ const active=!C.repeatIntent(openBld).active,receipt=C.submitRepeat(openBld,active);
3202
+ if(!receipt){toast(C.lastFailure()||'Repeat order could not be sent');return;}
3203
+ $('repeatBtn').textContent='REPEAT: '+(active?'ON':'OFF')+' β€” PENDING';
3204
+ $('repeatBtn').classList.toggle('on',active);
3205
+ $('repeatBtn').setAttribute('aria-pressed',String(active));
3206
+ toast(active?'Repeat ON queued for the shared match tick':'Repeat OFF queued β€” existing queued units are retained');
3207
+ sfx('ui');return;
3208
+ }
3209
+ B.repeat=!B.repeat;
3210
  $('repeatBtn').textContent='REPEAT: '+(B.repeat?'ON':'OFF');
3211
+ $('repeatBtn').classList.toggle('on',B.repeat);
3212
+ $('repeatBtn').setAttribute('aria-pressed',String(B.repeat));
3213
+ if(typeof toast==='function')toast(B.repeat?'Repeat enabled β€” completed units return to this queue':
3214
+ 'Repeat off β€” no new repeats; '+B.queue.length+' queued unit'+(B.queue.length===1?'':'s')+' remaining');
3215
+ sfx('ui');
3216
  });
3217
  mfBindNativePress($('abOver'),()=>{ if(aiming===0){aiming=-1;return;} tryAbility(0); });
3218
  mfBindNativePress($('abHeal'),()=>tryAbility(1));
src/onboarding.js CHANGED
@@ -202,7 +202,8 @@ function eligible(){
202
  return true;
203
  }
204
  function experimentalEnabled(){
205
- return !!(typeof META!=='undefined'&&META&&META.settings&&META.settings.experimentalExploration);
 
206
  }
207
  function automaticEligible(){
208
  /* Experimental new careers make their choice after live space appears. A
 
202
  return true;
203
  }
204
  function experimentalEnabled(){
205
+ return window.__MF_BUILD_HAS_GALACTIC_EXPLORATION===true
206
+ ||!!(typeof META!=='undefined'&&META&&META.settings&&META.settings.experimentalExploration);
207
  }
208
  function automaticEligible(){
209
  /* Experimental new careers make their choice after live space appears. A
src/ui/hud.js CHANGED
@@ -2851,12 +2851,20 @@ function renderProdMenu(){ if(openBldGone()) return;
2851
  : ('⬆ UPGRADE TO TECH 2 ('+BUP.fac[0].cm+'m '+BUP.fac[0].ce+'e)');
2852
  } else ub.style.display='none';
2853
  renderQueue(true);
2854
- const rb=$('repeatBtn');
2855
- rb.textContent='REPEAT: '+(B.repeat?'ON':'OFF');
2856
- rb.classList.toggle('on',B.repeat);
2857
  const ry=$('rallyBtn');
2858
  if(ry){ ry.style.display='block'; ry.textContent=B.rally?'βš‘ RALLY SET β€” TAP TO MOVE':'βš‘ SET RALLY POINT'; }
2859
  }
 
 
 
 
 
 
 
 
 
 
2860
  function queueStacks(q){
2861
  const out=[];
2862
  if(!q||!q.length) return out;
@@ -2871,8 +2879,16 @@ function queueStacks(q){
2871
  }
2872
  return out;
2873
  }
2874
- function cancelQueuedUnit(B,start){
2875
  if(!B||!B.queue||start<0||start>=B.queue.length) return false;
 
 
 
 
 
 
 
 
2876
  const type=B.queue[start];
2877
  let end=start;
2878
  while(end<B.queue.length&&B.queue[end]===type) end++;
@@ -2891,6 +2907,7 @@ function cancelQueuedUnit(B,start){
2891
  B.queue.shift();
2892
  B.prodT=0;
2893
  } else B.queue.splice(last,1);
 
2894
  return true;
2895
  }
2896
  function renderQueue(forceControls){ if(openBldGone()) return;
@@ -2898,6 +2915,9 @@ function renderQueue(forceControls){ if(openBldGone()) return;
2898
  const B=blds[openBld];
2899
  const prodPanel=$('prodMenu'),prodVisible=!!(prodPanel&&prodPanel.style.display==='block');
2900
  if(forceControls||prodVisible){
 
 
 
2901
  mfRenderBuildingActivityControls(B,'prodMenu');
2902
  mfRenderBuildingServiceControls(B,'prodMenu');
2903
  mfRenderBuildingUpgradeControls(B,'prodMenu');
@@ -2915,7 +2935,7 @@ function renderQueue(forceControls){ if(openBldGone()) return;
2915
  const q=B.queue||[];
2916
  mfSyncProductionQueueCards(B);
2917
  const stacks=queueStacks(q);
2918
- const sig=stacks.map(s=>s.t+':'+s.n+':'+s.i).join(',')+'|'+(B.adj||0);
2919
  if(el._mfQ!==sig){
2920
  el._mfQ=sig;
2921
  el.innerHTML='';
@@ -2927,6 +2947,7 @@ function renderQueue(forceControls){ if(openBldGone()) return;
2927
  const row=document.createElement('div');
2928
  row.className='qRow';
2929
  stacks.forEach((S,si)=>{
 
2930
  const plate=document.createElement('button');
2931
  plate.type='button';
2932
  plate.className='qPlate'+(si===0?' active':'');
@@ -2955,7 +2976,7 @@ function renderQueue(forceControls){ if(openBldGone()) return;
2955
  return;
2956
  }
2957
  plate._mfQCancelAt=0;
2958
- if(cancelQueuedUnit(Bb,S.i)){ if(typeof sfx==='function') sfx('ui'); renderQueue(); }
2959
  };
2960
  mfHudBindQueueCancel(plate,requestCancel);
2961
  row.appendChild(plate);
 
2851
  : ('⬆ UPGRADE TO TECH 2 ('+BUP.fac[0].cm+'m '+BUP.fac[0].ce+'e)');
2852
  } else ub.style.display='none';
2853
  renderQueue(true);
2854
+ mfRenderProductionRepeatControl(B);
 
 
2855
  const ry=$('rallyBtn');
2856
  if(ry){ ry.style.display='block'; ry.textContent=B.rally?'βš‘ RALLY SET β€” TAP TO MOVE':'βš‘ SET RALLY POINT'; }
2857
  }
2858
+ function mfRenderProductionRepeatControl(B){
2859
+ if(!B||!Array.isArray(B.queue))return;
2860
+ const rb=$('repeatBtn');
2861
+ if(!rb)return;
2862
+ const repeatConsumer=window.MFMatchCommandConsumer,
2863
+ repeatState=repeatConsumer?repeatConsumer.repeatIntent(openBld):{active:!!B.repeat,pending:false};
2864
+ rb.textContent='REPEAT: '+(repeatState.active?'ON':'OFF')+(repeatState.pending?' β€” PENDING':'');
2865
+ rb.classList.toggle('on',repeatState.active);
2866
+ rb.setAttribute('aria-pressed',String(repeatState.active));
2867
+ }
2868
  function queueStacks(q){
2869
  const out=[];
2870
  if(!q||!q.length) return out;
 
2879
  }
2880
  return out;
2881
  }
2882
+ function cancelQueuedUnit(B,start,snapshot){
2883
  if(!B||!B.queue||start<0||start>=B.queue.length) return false;
2884
+ const C=window.MFMatchCommandConsumer;
2885
+ if(C&&C.requiresLockstep()){
2886
+ const receipt=C.submitCancelProduction(blds.indexOf(B),start,snapshot);
2887
+ if(typeof toast==='function')toast(receipt?'Cancellation queued for the shared match tick':C.lastFailure()||'Cancellation could not be sent');
2888
+ return !!receipt;
2889
+ }
2890
+ if(snapshot&&((B.queueRevision||0)!==snapshot.revision||B.queue.length!==snapshot.queue.length||
2891
+ !B.queue.every((t,i)=>t===snapshot.queue[i])))return false;
2892
  const type=B.queue[start];
2893
  let end=start;
2894
  while(end<B.queue.length&&B.queue[end]===type) end++;
 
2907
  B.queue.shift();
2908
  B.prodT=0;
2909
  } else B.queue.splice(last,1);
2910
+ B.queueRevision=(B.queueRevision||0)+1;
2911
  return true;
2912
  }
2913
  function renderQueue(forceControls){ if(openBldGone()) return;
 
2915
  const B=blds[openBld];
2916
  const prodPanel=$('prodMenu'),prodVisible=!!(prodPanel&&prodPanel.style.display==='block');
2917
  if(forceControls||prodVisible){
2918
+ // The 800ms production refresh, not menu reopening, clears pending intent
2919
+ // after its authoritative tick (or a reconnect rejection) is acknowledged.
2920
+ mfRenderProductionRepeatControl(B);
2921
  mfRenderBuildingActivityControls(B,'prodMenu');
2922
  mfRenderBuildingServiceControls(B,'prodMenu');
2923
  mfRenderBuildingUpgradeControls(B,'prodMenu');
 
2935
  const q=B.queue||[];
2936
  mfSyncProductionQueueCards(B);
2937
  const stacks=queueStacks(q);
2938
+ const sig=stacks.map(s=>s.t+':'+s.n+':'+s.i).join(',')+'|'+(B.adj||0)+'|'+(B.queueRevision||0);
2939
  if(el._mfQ!==sig){
2940
  el._mfQ=sig;
2941
  el.innerHTML='';
 
2947
  const row=document.createElement('div');
2948
  row.className='qRow';
2949
  stacks.forEach((S,si)=>{
2950
+ const cancelSnapshot={revision:B.queueRevision||0,queue:q.slice()};
2951
  const plate=document.createElement('button');
2952
  plate.type='button';
2953
  plate.className='qPlate'+(si===0?' active':'');
 
2976
  return;
2977
  }
2978
  plate._mfQCancelAt=0;
2979
+ if(cancelQueuedUnit(Bb,S.i,cancelSnapshot)){ if(typeof sfx==='function') sfx('ui'); renderQueue(); }
2980
  };
2981
  mfHudBindQueueCancel(plate,requestCancel);
2982
  row.appendChild(plate);
src/ui/input.js CHANGED
The diff for this file is too large to render. See raw diff
 
src/ui/render3d.js CHANGED
@@ -2324,7 +2324,7 @@ function render(dtDraw){
2324
  build even though the panel button reported "RALLY SET β€” TAP TO MOVE".
2325
  Ground decals, so they inherit depth and survive camera tilt. */
2326
  for(const Bd of blds){
2327
- if(!Bd.alive||Bd.team!==0||!Bd.rally)continue;
2328
  const R2=Bd.rally;
2329
  if(!vis(R2.x,R2.y,40))continue;
2330
  const rh=gh(R2.x,R2.y),rp=0.55+0.45*Math.sin(t*3.1+R2.x*0.01);
 
2324
  build even though the panel button reported "RALLY SET β€” TAP TO MOVE".
2325
  Ground decals, so they inherit depth and survive camera tilt. */
2326
  for(const Bd of blds){
2327
+ if(!Bd.alive||!(typeof mfLocalOwnsBuilding==='function'?mfLocalOwnsBuilding(Bd):Bd.team===0)||!Bd.rally)continue;
2328
  const R2=Bd.rally;
2329
  if(!vis(R2.x,R2.y,40))continue;
2330
  const rh=gh(R2.x,R2.y),rp=0.55+0.45*Math.sin(t*3.1+R2.x*0.01);
src/updater.js CHANGED
@@ -42,14 +42,14 @@
42
  ============================================================================ */
43
 
44
  /* Bumped by the release script. Compared against the manifest's `version`. */
45
- const APP_VERSION = '1.33.74';
46
 
47
  /* Release notes for the PACKAGED build, bumped by the release script beside
48
  APP_VERSION and PACKAGED_REV. A device that has never taken an OTA has no
49
  download history to read notes from, and an offline device can never fetch
50
  them, so the build carries its own copy β€” otherwise a fresh install shows a
51
  permanently empty first entry in the mailbox. */
52
- const APP_NOTES = "System β€” Features: upgrade every owned building of the selected type from its building panel. Construction and production now show progress and faction-colored activity effects. Utility units show their active support role. Bug fixes: restored Brood organic foundations and connecting infestation veins; corrected production timing, healing cadence, and terrain refreshes. Multiplayer reconnects pause and replay missed commands without charging twice. Release delivery now verifies complete recovery files before activating an update. Upcoming: further large-army performance improvements and optional content packs.";
53
 
54
  /* The channel URL in update-config.json remains publisher-configurable, but a
55
  production checker also needs one known-good recovery path. More importantly,
 
42
  ============================================================================ */
43
 
44
  /* Bumped by the release script. Compared against the manifest's `version`. */
45
+ const APP_VERSION = '1.33.76';
46
 
47
  /* Release notes for the PACKAGED build, bumped by the release script beside
48
  APP_VERSION and PACKAGED_REV. A device that has never taken an OTA has no
49
  download history to read notes from, and an offline device can never fetch
50
  them, so the build carries its own copy β€” otherwise a fresh install shows a
51
  permanently empty first entry in the mailbox. */
52
+ const APP_NOTES = "Overhaul β€” Features: cinematic loading with the original title art; authored UGA cutaway sections and upgrade navigation; hired-commander solo exploration, mission debrief and return-to-ship services; Classic opens the original Standard War Table; accepted models, commander voices and adaptive music beds. Fixes: real-time obstacle rerouting; exact rally targets and owner-visible markers for both multiplayer sides; synchronized repeat and queue cancellation with seat-correct refunds; portrait fallback; duplicate-reward protection; lossless UGA delivery; repaired Nova hull, rotors, hatch and ramp in a larger hangar; expandable loadout; download status clear of controls; Galactic music; hash-checked resumable native content staging with same-origin launch and rollback. Known incomplete work: guided practical space onboarding, optional Galactic allies/human co-op and Versus, dropped-session production restoration, and remaining mobile acceptance. These are not completed features.";
53
 
54
  /* The channel URL in update-config.json remains publisher-configurable, but a
55
  production checker also needs one known-good recovery path. More importantly,
sw.js CHANGED
@@ -3,7 +3,7 @@
3
  only after the player has successfully fetched them. */
4
  'use strict';
5
 
6
- const MF_SW_VERSION = '1.33.74-shell3';
7
  const MF_SW_PREFIX = 'massfront-pwa-';
8
  const MF_SW_CACHE = MF_SW_PREFIX + MF_SW_VERSION;
9
  /* Runtime code stays in the shell cache so an installed PWA always reaches the
 
3
  only after the player has successfully fetched them. */
4
  'use strict';
5
 
6
+ const MF_SW_VERSION = '1.33.76-shell3';
7
  const MF_SW_PREFIX = 'massfront-pwa-';
8
  const MF_SW_CACHE = MF_SW_PREFIX + MF_SW_VERSION;
9
  /* Runtime code stays in the shell cache so an installed PWA always reaches the