CREATORJD commited on
Commit
bebd577
·
verified ·
1 Parent(s): 74e2cf1

Publish MASSFRONT web 1.32.0 (part 2)

Browse files
.gitattributes CHANGED
@@ -64,3 +64,4 @@ assets/factions/cinematic/brood-swarm-v1.png filter=lfs diff=lfs merge=lfs -text
64
  assets/factions/cinematic/crimson-dominion-v1.png filter=lfs diff=lfs merge=lfs -text
65
  assets/factions/cinematic/syndicate-coalition-v1.png filter=lfs diff=lfs merge=lfs -text
66
  assets/factions/cinematic/terran-frontline-command-v1.png filter=lfs diff=lfs merge=lfs -text
 
 
64
  assets/factions/cinematic/crimson-dominion-v1.png filter=lfs diff=lfs merge=lfs -text
65
  assets/factions/cinematic/syndicate-coalition-v1.png filter=lfs diff=lfs merge=lfs -text
66
  assets/factions/cinematic/terran-frontline-command-v1.png filter=lfs diff=lfs merge=lfs -text
67
+ assets/modifiers/modifier-art-atlas-v1.png filter=lfs diff=lfs merge=lfs -text
assets/modifiers/modifier-art-atlas-v1.png ADDED

Git LFS Details

  • SHA256: 950b005291b6d7879468a1bbfac4015255e336e3565c2fb0dafd3d42d3629ebf
  • Pointer size: 132 Bytes
  • Size of remote file: 2.89 MB
boot.js CHANGED
@@ -27,15 +27,15 @@
27
  './src/engine/models-legion.js','./src/engine/models-machine.js','./src/engine/models-infestation.js',
28
  './src/game/sim.js','./src/game/economy.js','./src/game/commander.js',
29
  './src/game/meta.js','./src/game/ai.js',
30
- './src/ui/input.js','./src/ui/hud.js','./src/ui/render3d.js',
31
- './src/factions.js','./src/hazards.js','./src/offline.js','./src/audio.js','./src/assetpack.js',
32
- './src/authportal.js','./src/tutorial.js','./src/adboards.js','./src/storeui.js','./src/restree3d.js','./src/develop.js','./src/endgame.js','./src/story.js','./src/daily.js','./src/account.js','./src/economy-net.js','./src/updater.js','./src/main.js'
33
  ];
34
  /* Packaged scripts need a release key. WebViews and development browsers can
35
  otherwise reuse a stale source even after the installer or local preview
36
  has changed, which made new settings appear to be missing until cache was
37
  cleared manually. Patch bundles keep their content-addressed Blob URLs. */
38
- var PACKAGED_REV='1.31.0';
39
  var DB='massfront-updates', STORE='bundles';
40
  var bootShield=null, bootShieldTimer=0;
41
  var bootShieldEvents=['pointerdown','pointerup','touchstart','touchend','click'];
 
27
  './src/engine/models-legion.js','./src/engine/models-machine.js','./src/engine/models-infestation.js',
28
  './src/game/sim.js','./src/game/economy.js','./src/game/commander.js',
29
  './src/game/meta.js','./src/game/ai.js',
30
+ './src/ui/input.js','./src/ui/hud.js','./src/ui/render3d.js','./src/airlift.js','./src/airlift-factions.js',
31
+ './src/factions.js','./src/offline.js','./src/audio.js','./src/assetpack.js','./src/hazards.js',
32
+ './src/authportal.js','./src/tutorial.js','./src/adboards.js','./src/storeui.js','./src/restree3d.js','./src/develop.js','./src/endgame.js','./src/story.js','./src/daily.js','./src/account.js','./src/economy-net.js','./src/updater.js','./src/intro.js','./src/main.js'
33
  ];
34
  /* Packaged scripts need a release key. WebViews and development browsers can
35
  otherwise reuse a stale source even after the installer or local preview
36
  has changed, which made new settings appear to be missing until cache was
37
  cleared manually. Patch bundles keep their content-addressed Blob URLs. */
38
+ var PACKAGED_REV='1.32.0';
39
  var DB='massfront-updates', STORE='bundles';
40
  var bootShield=null, bootShieldTimer=0;
41
  var bootShieldEvents=['pointerdown','pointerup','touchstart','touchend','click'];
index.html CHANGED
@@ -16,12 +16,13 @@
16
  <link rel="icon" href="./assets/icons/icon-192.png">
17
  <link rel="manifest" href="./assets/app.webmanifest">
18
  <title>MASSFRONT — Mobile RTS</title>
19
- <link rel="stylesheet" href="./src/styles/ui.css?v=1.31.0">
20
- <link rel="stylesheet" href="./src/styles/auth.css?v=1.31.0">
21
- <link rel="stylesheet" href="./src/styles/tutorial.css?v=1.31.0">
22
- <link rel="stylesheet" href="./src/styles/ads.css?v=1.31.0">
23
- <link rel="stylesheet" href="./src/styles/store.css?v=1.31.0">
24
- <link rel="stylesheet" href="./src/styles/restree.css?v=1.31.0">
 
25
  </head>
26
  <body>
27
  <canvas id="gl"></canvas>
@@ -89,6 +90,7 @@
89
  <button class="cbtn abtn" id="abHeal"><span class="em">🛠</span><span>Repair</span><div class="cdring"></div></button>
90
  <button class="cbtn abtn" id="abRage"><span class="em">⚡</span><span>Surge</span><div class="cdring"></div></button>
91
  <button class="cbtn abtn" id="abLance"><span class="em">🛰</span><span>Lance</span><div class="cdring"></div></button>
 
92
  <button class="cbtn abtn" id="abBarrage" aria-label="Charged Barrage" title="Charged Barrage"><span class="em">☄</span><span>Barrage</span><div class="cdring"></div></button>
93
  <button class="cbtn abtn" id="abHero"><span class="em">🤖</span><span>CDR</span><span class="lvl" id="heroLvlBadge">1</span></button>
94
  </div>
@@ -185,7 +187,7 @@
185
  <div class="updHead">
186
  <div>
187
  <div id="updTxt">GAME VERSION</div>
188
- <div id="updSub">v1.31.0</div>
189
  </div>
190
  <div class="updBtns">
191
  <button id="updCancel" style="display:none">✕</button>
@@ -208,6 +210,7 @@
208
  <button class="setupTabBtn" data-tab="forces">FORCES</button>
209
  <button class="setupTabBtn" data-tab="rules">RULES</button>
210
  <button class="setupTabBtn" data-tab="economy">ECONOMY</button>
 
211
  </div>
212
  <div class="setupScroll">
213
 
@@ -217,13 +220,13 @@
217
  </section>
218
 
219
  <section class="setupCard" data-setup-tab="map">
220
- <div class="secLbl">ENGAGEMENT SCALE</div>
221
  <div class="optRow" id="battleScaleRow">
222
- <button class="bsbtn dbtn" data-bs="compact">COMPACT</button>
223
- <button class="bsbtn dbtn" data-bs="standard">STANDARD</button>
224
- <button class="bsbtn dbtn on" data-bs="grand">GRAND</button>
225
  </div>
226
- <div class="secHint" id="battleScaleHint">2.6 KM FRONT Full authored deployment spread, more neutral sites and longer warning cycles.</div>
227
  </section>
228
 
229
  <section class="setupCard deployPlan" data-setup-tab="forces">
@@ -318,15 +321,22 @@
318
  <div class="secHint" id="defFocusHint">Classic RTS balance between mobile armies and static defences.</div>
319
  </section>
320
 
321
- <section class="setupCard danger" data-setup-tab="rules">
322
- <div class="secLbl">WILDCARDSRISK = REWARD</div>
 
 
 
 
 
 
 
323
  <div class="optRow optGrid" id="wcRowSel">
324
  <button class="wbtn dbtn on" data-w="0">None</button>
325
  <button class="wbtn dbtn" data-w="1">☠ 1</button>
326
  <button class="wbtn dbtn" data-w="2">☠ 2</button>
327
  <button class="wbtn dbtn" data-w="3">☠ 3</button>
328
  </div>
329
- <div class="secHint danger" id="wcHint">Random danger modifiers · +35% XP &amp; cores each</div>
330
  </section>
331
  </div>
332
 
@@ -348,11 +358,10 @@
348
  <div class="overlay" id="opsScr">
349
  <div class="subMenuHead">
350
  <h2>⚔ OPERATIONS</h2>
351
- <span>Threat · contract modifiers · weekly operation · mastery</span>
352
  </div>
353
  <div class="screenTabs" id="opsTabs" role="tablist" aria-label="Operation categories">
354
  <button class="screenTabBtn on" id="opsTab-threat" type="button" role="tab" data-mf-tab="threat" aria-controls="opsPane-threat"><span class="tabGlyph">⚠</span><span>THREAT</span></button>
355
- <button class="screenTabBtn" id="opsTab-modifiers" type="button" role="tab" data-mf-tab="modifiers" aria-controls="opsPane-modifiers"><span class="tabGlyph">◆</span><span>MODIFIERS</span></button>
356
  <button class="screenTabBtn" id="opsTab-weekly" type="button" role="tab" data-mf-tab="weekly" aria-controls="opsPane-weekly"><span class="tabGlyph">▣</span><span>WEEKLY</span></button>
357
  <button class="screenTabBtn" id="opsTab-mastery" type="button" role="tab" data-mf-tab="mastery" aria-controls="opsPane-mastery"><span class="tabGlyph">★</span><span>MASTERY</span></button>
358
  </div>
@@ -370,11 +379,6 @@
370
  <div id="threatRow"></div>
371
  <div id="threatInfo" class="secHint"></div>
372
  </section>
373
- <section class="setupCard mfTabPanel" id="opsPane-modifiers" role="tabpanel" aria-labelledby="opsTab-modifiers" data-mf-panel="modifiers">
374
- <div class="secLbl">OPERATION MODIFIERS — pick your own fight</div>
375
- <div id="opModRow"></div>
376
- <div id="opPayout"></div>
377
- </section>
378
  <section class="setupCard mfTabPanel" id="opsPane-weekly" role="tabpanel" aria-labelledby="opsTab-weekly" data-mf-panel="weekly">
379
  <div class="secLbl">WEEKLY OPERATION</div>
380
  <div id="weeklyBox"></div>
@@ -554,7 +558,7 @@
554
  <div class="title" style="font-size:34px">PAUSED</div>
555
  <button class="mbtn" id="resumeBtn">▶ &nbsp;RESUME</button>
556
  <button class="mbtn alt" id="pauseSettings">⚙ &nbsp;SETTINGS</button>
557
- <button class="mbtn alt" id="quitBtn">✦ &nbsp;MAIN MENU</button>
558
  </div>
559
  <script src="./boot.js"></script>
560
  </body>
 
16
  <link rel="icon" href="./assets/icons/icon-192.png">
17
  <link rel="manifest" href="./assets/app.webmanifest">
18
  <title>MASSFRONT — Mobile RTS</title>
19
+ <link rel="stylesheet" href="./src/styles/ui.css?v=1.32.0">
20
+ <link rel="stylesheet" href="./src/styles/auth.css?v=1.32.0">
21
+ <link rel="stylesheet" href="./src/styles/tutorial.css?v=1.32.0">
22
+ <link rel="stylesheet" href="./src/styles/ads.css?v=1.32.0">
23
+ <link rel="stylesheet" href="./src/styles/store.css?v=1.32.0">
24
+ <link rel="stylesheet" href="./src/styles/restree.css?v=1.32.0">
25
+ <link rel="stylesheet" href="./src/styles/intro.css?v=1.32.0">
26
  </head>
27
  <body>
28
  <canvas id="gl"></canvas>
 
90
  <button class="cbtn abtn" id="abHeal"><span class="em">🛠</span><span>Repair</span><div class="cdring"></div></button>
91
  <button class="cbtn abtn" id="abRage"><span class="em">⚡</span><span>Surge</span><div class="cdring"></div></button>
92
  <button class="cbtn abtn" id="abLance"><span class="em">🛰</span><span>Lance</span><div class="cdring"></div></button>
93
+ <button class="cbtn abtn" id="abClass" style="display:none" aria-label="Selected unit class ability"><span class="em" id="classAbEm">➤</span><span id="classAbNm">Class</span><div class="cdring"></div></button>
94
  <button class="cbtn abtn" id="abBarrage" aria-label="Charged Barrage" title="Charged Barrage"><span class="em">☄</span><span>Barrage</span><div class="cdring"></div></button>
95
  <button class="cbtn abtn" id="abHero"><span class="em">🤖</span><span>CDR</span><span class="lvl" id="heroLvlBadge">1</span></button>
96
  </div>
 
187
  <div class="updHead">
188
  <div>
189
  <div id="updTxt">GAME VERSION</div>
190
+ <div id="updSub">v1.32.0</div>
191
  </div>
192
  <div class="updBtns">
193
  <button id="updCancel" style="display:none">✕</button>
 
210
  <button class="setupTabBtn" data-tab="forces">FORCES</button>
211
  <button class="setupTabBtn" data-tab="rules">RULES</button>
212
  <button class="setupTabBtn" data-tab="economy">ECONOMY</button>
213
+ <button class="setupTabBtn" data-tab="modifiers">MODIFIERS</button>
214
  </div>
215
  <div class="setupScroll">
216
 
 
220
  </section>
221
 
222
  <section class="setupCard" data-setup-tab="map">
223
+ <div class="secLbl">MAP SIZE</div>
224
  <div class="optRow" id="battleScaleRow">
225
+ <button class="bsbtn dbtn" data-bs="compact"><b>COMPACT</b><span>1.9 KM</span><small>8–12 MIN</small></button>
226
+ <button class="bsbtn dbtn on" data-bs="standard"><b>STANDARD</b><span>2.2 KM</span><small>12–18 MIN</small></button>
227
+ <button class="bsbtn dbtn" data-bs="large"><b>LARGE</b><span>2.6 KM</span><small>18–25 MIN</small></button>
228
  </div>
229
+ <div class="secHint" id="battleScaleHint">2.2 KM · EST. 12–18 MIN Balanced travel, expansion space and contested resources.</div>
230
  </section>
231
 
232
  <section class="setupCard deployPlan" data-setup-tab="forces">
 
321
  <div class="secHint" id="defFocusHint">Classic RTS balance between mobile armies and static defences.</div>
322
  </section>
323
 
324
+ <section class="setupCard" data-setup-tab="modifiers">
325
+ <div class="secLbl">MATCH MODIFIERS CHOOSE YOUR RISK</div>
326
+ <div class="secHint">Optional rules for this deployment. Selected modifiers increase the projected payout and override the random wildcard count below.</div>
327
+ <div id="opModRow"></div>
328
+ <div id="opPayout"></div>
329
+ </section>
330
+
331
+ <section class="setupCard danger" data-setup-tab="modifiers">
332
+ <div class="secLbl">RANDOM MODIFIER LOADOUT</div>
333
  <div class="optRow optGrid" id="wcRowSel">
334
  <button class="wbtn dbtn on" data-w="0">None</button>
335
  <button class="wbtn dbtn" data-w="1">☠ 1</button>
336
  <button class="wbtn dbtn" data-w="2">☠ 2</button>
337
  <button class="wbtn dbtn" data-w="3">☠ 3</button>
338
  </div>
339
+ <div class="secHint danger" id="wcHint">Quick-pick random danger modifiers · +35% XP &amp; cores each</div>
340
  </section>
341
  </div>
342
 
 
358
  <div class="overlay" id="opsScr">
359
  <div class="subMenuHead">
360
  <h2>⚔ OPERATIONS</h2>
361
+ <span>Campaign · threat ladder · weekly operation · mastery</span>
362
  </div>
363
  <div class="screenTabs" id="opsTabs" role="tablist" aria-label="Operation categories">
364
  <button class="screenTabBtn on" id="opsTab-threat" type="button" role="tab" data-mf-tab="threat" aria-controls="opsPane-threat"><span class="tabGlyph">⚠</span><span>THREAT</span></button>
 
365
  <button class="screenTabBtn" id="opsTab-weekly" type="button" role="tab" data-mf-tab="weekly" aria-controls="opsPane-weekly"><span class="tabGlyph">▣</span><span>WEEKLY</span></button>
366
  <button class="screenTabBtn" id="opsTab-mastery" type="button" role="tab" data-mf-tab="mastery" aria-controls="opsPane-mastery"><span class="tabGlyph">★</span><span>MASTERY</span></button>
367
  </div>
 
379
  <div id="threatRow"></div>
380
  <div id="threatInfo" class="secHint"></div>
381
  </section>
 
 
 
 
 
382
  <section class="setupCard mfTabPanel" id="opsPane-weekly" role="tabpanel" aria-labelledby="opsTab-weekly" data-mf-panel="weekly">
383
  <div class="secLbl">WEEKLY OPERATION</div>
384
  <div id="weeklyBox"></div>
 
558
  <div class="title" style="font-size:34px">PAUSED</div>
559
  <button class="mbtn" id="resumeBtn">▶ &nbsp;RESUME</button>
560
  <button class="mbtn alt" id="pauseSettings">⚙ &nbsp;SETTINGS</button>
561
+ <button class="mbtn alt" id="quitBtn">✦ &nbsp;ABANDON MATCH</button>
562
  </div>
563
  <script src="./boot.js"></script>
564
  </body>
src/airlift-factions.js ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================================
2
+ BUILDABLE FACTION AIRLIFTS — geometry-only takeover
3
+ ----------------------------------------------------------------------------
4
+ airlift.js owns the Atlas simulation, cargo manifests and mobile orders.
5
+ This file loads immediately after it and changes only model factories before
6
+ initModels() uploads them. Keeping the art takeover separate means transport
7
+ saves and mechanics cannot fork by faction.
8
+ ============================================================================ */
9
+
10
+ /* Four honest, disconnected nacelles are part of the Atlas silhouette, not
11
+ painted engine marks on one broad wing. The coordinates are also a compact
12
+ art-contract used by the render regression. */
13
+ const MF_AFL_ATLAS_LIFT_PODS=[[-5.4,-7.4],[-5.4,7.4],[4.8,-7.4],[4.8,7.4]];
14
+ const MF_AFL_PHASE_LIFT_NODES=[[5.7,-3.2],[5.7,3.2],[-.8,-5.7],[-.8,5.7],[-6.1,-8.0],[-6.1,8.0]];
15
+ const MF_AFL_PHASE_APERTURE=[-.2,2.0,3.2];
16
+
17
+ /* Custom Coalition colours are material-routed just like the core palette.
18
+ Without these bindings the builder would inherit whichever material the
19
+ previous primitive happened to use, making the Ark's energy nodes matte. */
20
+ const MF_AFL_SYN_HULL=C(72,88,92), MF_AFL_SYN_EDGE=C(142,176,166), MF_AFL_SYN_GLOW=C(104,255,146);
21
+ COL_MAT.set(MF_AFL_SYN_HULL,MAT.TWR_COAT);
22
+ COL_MAT.set(MF_AFL_SYN_EDGE,MAT.TWR_MACH);
23
+ COL_MAT.set(MF_AFL_SYN_GLOW,MAT.LAMP);
24
+
25
+ /* TERRAN FRONTLINE — ATLAS SKYCRANE
26
+ A narrow armoured cargo spine carries a framed forward cockpit and a real
27
+ shadowed rear ramp. Four lift pods sit beyond thin outriggers, leaving clear
28
+ daylight between pod, hull and neighbour at command zoom. */
29
+ function mfAflAtlasModel(){
30
+ const m=MB();
31
+ const spine=[[-10.2,-2.8],[-8.0,-3.5],[4.8,-3.25],[9.5,-2.0],[10.8,0],
32
+ [9.5,2.0],[4.8,3.25],[-8.0,3.5],[-10.2,2.8]];
33
+ m.extrude(0,2.7,0,spine,3.15,MET_D); // cargo pressure hull
34
+ m.extrude(-1.2,5.8,0,spine.map(p=>[p[0]*.72,p[1]*.70]),1.25,MET);
35
+ m.bevelBox(6.9,5.25,0,5.2,1.25,5.0,.38,MET_L); // cockpit frame
36
+ m.extrude(7.4,6.45,0,[[-2.0,-2.05],[1.45,-1.35],[2.0,0],[1.45,1.35],[-2.0,2.05]],
37
+ 1.45,GLASS); // unmistakable forward canopy
38
+ m.box(7.4,7.94,0,3.5,.24,4.0,DARK); // canopy roof bar
39
+ for(const sd of [-1,1])m.box(7.9,6.82,sd*2.18,3.4,.28,.25,TEAM_T,.08*sd);
40
+
41
+ /* Rear opening is layered: housing, black aperture, then a projecting ramp
42
+ lip. At mobile tilt this remains a readable exit instead of a flat end. */
43
+ m.bevelBox(-8.4,3.45,0,4.1,2.6,6.2,.45,MET_D);
44
+ m.box(-10.28,3.78,0,.34,2.15,4.6,DARKER);
45
+ m.wedge(-10.72,2.75,0,1.15,.55,4.9,MET_L,0,true);
46
+ for(const sd of [-1,1])m.box(-10.55,3.05,sd*2.18,.25,.30,.42,LAMP);
47
+
48
+ for(const P of MF_AFL_ATLAS_LIFT_PODS){
49
+ const x=P[0],z=P[1],sd=Math.sign(z);
50
+ m.bevelBox(x,3.7,sd*5.25,3.0,.65,4.2,.22,MET_D); // narrow outrigger
51
+ m.bevelBox(x,2.1,z,4.7,3.0,4.0,.48,MET_D); // separated pod casing
52
+ m.bevelBox(x,5.05,z,3.55,1.0,3.0,.26,MET_L);
53
+ m.cyl(x,.35,z,1.60,1.38,1.55,14,DARKER); // downward lift bell
54
+ m.ring(x,1.94,z,1.02,1.48,14,ENERGY); // visible lift core
55
+ m.tube(x,5.98,z,1.28,.72,.42,12,DARKER); // open intake, not a flat cap
56
+ m.box(x,5.48,z+sd*1.58,2.35,.24,.24,TEAM_A);
57
+ m.box(x+1.42,4.28,z,1.05,.30,2.55,MET_L); // service hatch rail
58
+ }
59
+
60
+ /* Vehicle-scale roof detail breaks up the long transport body without
61
+ changing the narrow load-bearing silhouette. */
62
+ m.bevelBox(-1.8,7.05,0,7.6,.78,4.6,.24,TEAM_T);
63
+ ventBank(m,-2.0,7.86,0,4.8,3.7,6,DARK,0);
64
+ m.greeble(-5.8,7.58,0,2.8,3.3,.55,5,MET_L,0,2471);
65
+ for(const sd of [-1,1]){
66
+ glowStrip(m,1.7,7.26,sd*2.18,5.0,ENERGY,0);
67
+ m.bevelBox(-3.7,2.15,sd*3.45,4.6,.78,.82,.18,DARKER); // landing rail
68
+ for(const x of [-5.2,-2.2])m.cyl(x,.25,sd*3.45,.42,.42,1.25,8,RUBBER);
69
+ }
70
+ sensorMast(m,-6.2,7.25,1.15,1.75,MET_L);
71
+ return {hull:m.build(),tur:null,s:1.16,air:1};
72
+ }
73
+
74
+ /* SYNDICATE COALITION — PHASE ARK
75
+ No cockpit, tail, cargo fuselage or Terran nacelles are reused. Separate
76
+ shallow delta plates form a broad triangular craft around an actual open
77
+ centre; the illuminated ring bridges that opening as a teleport aperture.
78
+ Six distributed nodes provide lift, so its energy language is structural. */
79
+ function mfAflPhaseArkModel(){
80
+ const m=MB();
81
+ for(const sd of [-1,1]){
82
+ /* Two convex plates per side avoid a hidden concave-cap fan while leaving
83
+ the circular middle physically absent. */
84
+ m.extrude(0,.65,0,[[9.8,sd*1.15],[-8.6,sd*10.2],[-6.3,sd*4.15]],1.45,MF_AFL_SYN_HULL);
85
+ m.extrude(0,1.25,0,[[9.8,sd*1.15],[-6.3,sd*4.15],[2.75,sd*3.05]],1.05,TWR_ARM_D);
86
+ m.box(-1.7,2.31,sd*6.1,10.6,.20,.58,MF_AFL_SYN_EDGE,sd*.32);
87
+ m.box(-3.8,2.53,sd*7.65,6.0,.16,.34,TEAM_T,sd*.52);
88
+ }
89
+ m.extrude(0,.72,0,[[10.5,0],[2.75,-2.85],[2.75,2.85]],1.62,TWR_ARM_D); // delta nose, no canopy
90
+ m.extrude(0,.70,0,[[-8.4,-3.9],[-2.8,-3.0],[-2.8,3.0],[-8.4,3.9]],1.55,MF_AFL_SYN_HULL);
91
+ m.bevelBox(-5.7,2.28,0,4.4,.48,5.8,.18,MF_AFL_SYN_EDGE); // rear field bridge, not a tail
92
+
93
+ const A=MF_AFL_PHASE_APERTURE;
94
+ m.ring(A[0],2.18,0,A[1],A[2],28,DARKER); // structural aperture rim
95
+ m.ring(A[0],2.38,0,2.30,2.88,28,MF_AFL_SYN_GLOW); // open luminous teleport annulus
96
+ m.ring(A[0],2.48,0,1.82,2.12,28,MF_AFL_SYN_EDGE);
97
+ for(let k=0;k<8;k++){
98
+ const a=k/8*TAU;
99
+ m.box(A[0]+Math.cos(a)*3.05,2.22,Math.sin(a)*3.05,.72,.36,.34,MF_AFL_SYN_EDGE,-a);
100
+ }
101
+
102
+ for(const P of MF_AFL_PHASE_LIFT_NODES){
103
+ const x=P[0],z=P[1];
104
+ m.cyl(x,.34,z,.88,.72,.58,12,TWR_MACH);
105
+ m.ring(x,1.02,z,.62,1.05,14,MF_AFL_SYN_GLOW);
106
+ m.sphere(x,1.13,z,.56,8,MF_AFL_SYN_GLOW,.62,false);
107
+ m.ring(x,1.63,z,.35,.76,12,MF_AFL_SYN_EDGE);
108
+ }
109
+ /* Small field-control bosses sell scale while keeping the entire craft
110
+ shallower than one Atlas lift pod. */
111
+ for(const sd of [-1,1])for(const x of [-5.4,-2.2,1.2,4.6])
112
+ m.bevelBox(x,2.35,sd*(3.8+(.5-Math.abs(x)*.025)),1.25,.44,.92,.13,
113
+ (x===1.2||x===-5.4)?MF_AFL_SYN_GLOW:MF_AFL_SYN_EDGE);
114
+ return {hull:m.build(),tur:null,s:1.17,air:1};
115
+ }
116
+
117
+ /* Safe because main.js calls initModels only after every manifest script has
118
+ loaded. The default mesh serves Terran/Nova; only the Coalition receives the
119
+ Phase Ark through the existing faction registry. */
120
+ UNIT_MDL[MF_UT_AIRLIFT]=mfAflAtlasModel;
121
+ FAC_KIT.syndicate[MF_UT_AIRLIFT]=mfAflPhaseArkModel;
122
+
src/airlift.js ADDED
@@ -0,0 +1,857 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================================
2
+ ATLAS SKYCRANE — buildable heavy air transport
3
+ ----------------------------------------------------------------------------
4
+ The pre-deployment `carrier` is the orbital base lander. This module never
5
+ reuses that singleton: the Skycrane is an ordinary TYPES unit with its own
6
+ serialized passenger manifest, orders and generation-safe handles.
7
+
8
+ Passengers are deliberately removed from the live simulation while aboard.
9
+ Keeping a hidden ground unit alive made spatial queries, fog and splash
10
+ damage continue to see it; a compact manifest is both cheaper and honest.
11
+ ============================================================================ */
12
+ const MF_UT_AIRLIFT=TYPES.length;
13
+ const MF_AIRLIFT_CAPACITY=12;
14
+ TYPES.push({
15
+ name:'Atlas Skycrane',spr:'raptor',tur:null,size:34,r:12.5,hp:1850,dmg:0,rng:0,cool:9,
16
+ spd:50,psp:0,ptype:0,aoe:0,wk:'n',tg:'a',cm:210,ce:820,bt:13,air:1,tier:2,
17
+ cat:'transport',airTransport:1,transportCap:MF_AIRLIFT_CAPACITY
18
+ });
19
+ UCAT.transport={nm:'AIR TRANSPORT',em:'⇩'};
20
+ UNIT_MODES[MF_UT_AIRLIFT]=[0];
21
+ ARM[MF_UT_AIRLIFT]=2;
22
+ if(typeof INTEL_ROLE_COPY!=='undefined')
23
+ INTEL_ROLE_COPY.transport='Heavy airlift for relocating infantry, constructors and light vehicles';
24
+ if(typeof INTEL_ROLE_GUIDE!=='undefined') INTEL_ROLE_GUIDE.transport={
25
+ tag:'HEAVY AIRLIFT',form:'LOAD / DROP',
26
+ use:'Carry a mixed light force over terrain, then deploy it in a readable formation.',
27
+ avoid:'Unarmed and vulnerable to anti-air; heavy armor, artillery and aircraft cannot board.'
28
+ };
29
+
30
+ /* A broad twin-engine silhouette remains legible at command zoom: deep cargo
31
+ hull, separated wing nacelles, open-looking rear ramp and sparse team trim.
32
+ The geometry uses the same material atlas as the current vehicle pipeline. */
33
+ function mfAirliftModel(){
34
+ const m=MB();
35
+ const hull=[[-9,-3.5],[-6.5,-4.8],[3.5,-4.2],[9,-2.2],[10.5,0],[9,2.2],[3.5,4.2],[-6.5,4.8],[-9,3.5]];
36
+ m.extrude(0,3.0,0,hull,3.4,MET_D);
37
+ m.extrude(-.6,6.4,0,hull.map(p=>[p[0]*.78,p[1]*.76]),2.2,MET);
38
+ m.wedge(7.8,8.6,0,5.8,2.0,5.6,GLASS,0,true); // flight deck
39
+ m.bevelBox(-6.8,5.6,0,4.0,2.2,6.2,.55,DARK); // cargo ramp housing
40
+ m.box(-9.0,4.0,0,.65,2.9,5.0,DARKER); // shadowed rear aperture
41
+ m.box(-9.38,4.2,0,.28,2.4,4.2,MET_L); // ramp lip
42
+ for(const side of [-1,1]){
43
+ m.wedge(-.2,5.8,side*6.0,14.5,1.15,5.6,TEAM_A,Math.PI/2,side<0);
44
+ m.bevelBox(-1.0,5.0,side*7.8,7.4,3.0,3.5,.55,MET_D);
45
+ cylX(m,-4.2,6.0,side*7.8,7.2,1.62,1.20,12,MET,false);
46
+ tubeX(m,2.5,6.0,side*7.8,1.05,1.78,.92,12,TWR_BORE);
47
+ m.box(-.8,8.4,side*7.8,4.8,.32,1.9,ENERGY);
48
+ m.bevelBox(-4.2,1.4,side*3.9,6.2,1.0,1.3,.25,DARKER); // landing rail
49
+ for(const x of [-6.4,-2.0]) m.cyl(x,.25,side*3.9,.48,.48,1.4,8,RUBBER);
50
+ }
51
+ m.bevelBox(-2.0,8.3,0,8.8,1.2,5.8,.35,TEAM_T);
52
+ ventBank(m,-3.0,9.52,0,5.2,4.4,6,MET_D,0);
53
+ glowStrip(m,2.2,8.55,-3.1,5.4,ENERGY,0);
54
+ glowStrip(m,2.2,8.55, 3.1,5.4,ENERGY,0);
55
+ m.box(-7.0,8.1,0,3.8,.7,1.1,LAMP); // cargo status light
56
+ sensorMast(m,-5.2,9.4,1.4,2.1,MET_L);
57
+ return {hull:m.build(),tur:null,s:1.18,air:1};
58
+ }
59
+ /* TYPES gained two non-rendered simulation entries before the transport.
60
+ Filling their fallback factories also keeps initModels' dense loop valid. */
61
+ if(!UNIT_MDL[31]) UNIT_MDL[31]=mdlBroodmother;
62
+ if(!UNIT_MDL[32]) UNIT_MDL[32]=mdlConstructor;
63
+ UNIT_MDL[MF_UT_AIRLIFT]=mfAirliftModel;
64
+
65
+ const mfAirliftHolds=[];
66
+ let mfAirliftBoardOrders=[];
67
+ let mfAirliftAim=null;
68
+ let mfAirliftLastLoss=0;
69
+
70
+ function mfAirliftIsLive(i,gen){
71
+ return mfTransportIsLive(i,gen,'atlas');
72
+ }
73
+ function mfTransportKindByType(type){
74
+ if(type===MF_UT_AIRLIFT)return 'atlas';
75
+ if(typeof MF_UT_MASSFLESH!=='undefined'&&(type===MF_UT_MASSFLESH||type===MF_UT_MASSFLESH_AIR))return 'massflesh';
76
+ return '';
77
+ }
78
+ function mfTransportIsLive(i,gen,kind){
79
+ return i>=0&&ualive[i]&&ugen[i]===gen&&mfTransportKindByType(utype[i])===kind;
80
+ }
81
+ function mfAirliftHold(i,create){
82
+ if(i<0) return null;
83
+ let H=mfAirliftHolds[i];
84
+ if(H&&H.gen!==ugen[i]) H=mfAirliftHolds[i]=null;
85
+ if(!H&&create&&ualive[i]&&utype[i]===MF_UT_AIRLIFT)
86
+ H=mfAirliftHolds[i]={gen:ugen[i],capacity:MF_AIRLIFT_CAPACITY,used:0,cargo:[],mission:null,fx:0};
87
+ return H;
88
+ }
89
+ function mfAirliftSlotCost(i){
90
+ if(i<0||!ualive[i]) return 0;
91
+ const T=TYPES[utype[i]],cat=T.cat||'veh';
92
+ if(T.airTransport||T.air||T.naval||cat==='art'||cat==='exp'||cat==='hero') return 0;
93
+ if(T.builder||T.miner) return 1;
94
+ if(cat==='inf') return 1;
95
+ if(cat==='veh'&&(ARM[utype[i]]||0)<=1&&T.size<=18) return 2;
96
+ return 0;
97
+ }
98
+ function mfAirliftReserved(i,gen){
99
+ let n=0;
100
+ for(const O of mfAirliftBoardOrders) if(O.sky===i&&O.skyGen===gen)n+=O.slots;
101
+ return n;
102
+ }
103
+ function mfAirliftSerialize(i,slots){
104
+ return {
105
+ type:utype[i],team:uteam[i],slots,
106
+ hpRatio:uhpm[i]>0?uhp[i]/uhpm[i]:1,
107
+ veteran:uvet[i],kills:ukills[i],mode:umode[i],cool:Math.max(0,ucool[i])
108
+ };
109
+ }
110
+ function mfAirliftBoardPoint(O,rank){
111
+ const side=(rank&1)?1:-1,row=Math.floor(rank/2),a=uang[O.sky]-Math.PI/2;
112
+ const back=17+row*5,lateral=side*(7+row*1.5);
113
+ return {
114
+ x:ux[O.sky]-Math.cos(a)*back-Math.sin(a)*lateral,
115
+ y:uy[O.sky]-Math.sin(a)*back+Math.cos(a)*lateral
116
+ };
117
+ }
118
+ function mfAirliftEligibleSelection(sky){
119
+ const ok=[],rejected=[];
120
+ for(let i=0;i<unitHigh;i++) if(ualive[i]&&usel[i]&&i!==sky){
121
+ const slots=mfAirliftSlotCost(i);
122
+ (slots?ok:rejected).push({i,gen:ugen[i],slots});
123
+ }
124
+ return {ok,rejected};
125
+ }
126
+ function mfAirliftIssueBoard(sky){
127
+ if(!ualive[sky]||uteam[sky]!==0||utype[sky]!==MF_UT_AIRLIFT)return false;
128
+ const H=mfAirliftHold(sky,true),picked=mfAirliftEligibleSelection(sky);
129
+ let free=H.capacity-H.used-mfAirliftReserved(sky,H.gen),accepted=0,full=0;
130
+ for(const P of picked.ok){
131
+ if(P.slots>free){full++;continue;}
132
+ mfAirliftBoardOrders.push({unit:P.i,unitGen:P.gen,sky,skyGen:H.gen,slots:P.slots});
133
+ free-=P.slots;accepted++;
134
+ }
135
+ if(!accepted){
136
+ toast(picked.rejected.length?'CANNOT BOARD — only infantry, constructors and light vehicles':'SKYCRANE CARGO FULL');
137
+ sfx('ui');return false;
138
+ }
139
+ utgt[sky]=-1;ustate[sky]=0;utx[sky]=ux[sky];uty[sky]=uy[sky];uhold[sky]=1;
140
+ clearSel();usel[sky]=1;updateSelInfo();
141
+ toast('⇩ BOARDING '+accepted+' UNIT'+(accepted===1?'':'S')+(picked.rejected.length?' · '+picked.rejected.length+' TOO HEAVY':'')+(full?' · '+full+' NO SPACE':''));
142
+ uiCommandAck('move',accepted,ux[sky],uy[sky]);
143
+ return true;
144
+ }
145
+ function mfAirliftFormation(cargo,x,y){
146
+ const n=cargo.length,cols=Math.max(1,Math.ceil(Math.sqrt(n*1.45))),rows=Math.ceil(n/cols),out=[];
147
+ let largest=5;
148
+ for(const P of cargo)largest=Math.max(largest,(TYPES[P.type]&&TYPES[P.type].r)||5);
149
+ const gap=Math.max(16,largest*2+7);
150
+ for(let k=0;k<n;k++){
151
+ const col=k%cols,row=Math.floor(k/cols),px=x+(col-(Math.min(cols,n-row*cols)-1)/2)*gap;
152
+ const py=y+(row-(rows-1)/2)*gap;
153
+ let L=findLand(clamp(px,18,MAP-18),clamp(py,18,MAP-18));
154
+ if(typeof battlefieldClampPoint==='function')L=battlefieldClampPoint(L[0],L[1],22);
155
+ out.push({x:L[0],y:L[1]});
156
+ }
157
+ return out;
158
+ }
159
+ function mfAirliftCommandUnload(sky,x,y){
160
+ const H=mfAirliftHold(sky,false);
161
+ if(!H||!H.cargo.length)return false;
162
+ x=clamp(x,24,MAP-24);y=clamp(y,24,MAP-24);
163
+ if(typeof battlefieldClampPoint==='function'){const p=battlefieldClampPoint(x,y,48);x=p[0];y=p[1];}
164
+ H.mission={x,y,slots:mfAirliftFormation(H.cargo,x,y)};
165
+ uhold[sky]=0;utgt[sky]=-1;ustate[sky]=1;utx[sky]=x;uty[sky]=y;
166
+ mfAirliftAim=null;mfAirliftUpdateButton();
167
+ addParticle(3,x,y,0,0,.72,44,92,226,255);
168
+ toast('⇩ DROP ZONE CONFIRMED · '+H.cargo.length+' PASSENGERS');
169
+ uiCommandAck('move',1,x,y);
170
+ return true;
171
+ }
172
+ function mfAirliftArmUnload(sky){
173
+ const H=mfAirliftHold(sky,false);
174
+ if(!H||!H.cargo.length){toast('SKYCRANE CARGO EMPTY');sfx('ui');return false;}
175
+ mfAirliftAim={sky,gen:ugen[sky]};
176
+ if(typeof aiming!=='undefined')aiming=-1;
177
+ if(typeof armPatrol!=='undefined'&&armPatrol)cancelPatrolDraft(true);
178
+ mfAirliftUpdateButton();
179
+ toast('⇩ TAP THE MAP TO SET A DROP ZONE');sfx('ui');return true;
180
+ }
181
+ function mfAirliftPreTick(dt){
182
+ for(let k=mfAirliftBoardOrders.length-1;k>=0;k--){
183
+ const O=mfAirliftBoardOrders[k];
184
+ if(!mfAirliftIsLive(O.sky,O.skyGen)||!ualive[O.unit]||ugen[O.unit]!==O.unitGen){
185
+ mfAirliftBoardOrders.splice(k,1);continue;
186
+ }
187
+ const rank=mfAirliftBoardOrders.filter(Q=>Q.sky===O.sky&&Q.skyGen===O.skyGen).indexOf(O);
188
+ const P=mfAirliftBoardPoint(O,Math.max(0,rank));
189
+ utgt[O.unit]=-1;utgtg[O.unit]=-1;uhold[O.unit]=0;ustate[O.unit]=1;
190
+ utx[O.unit]=P.x;uty[O.unit]=P.y;
191
+ }
192
+ for(let i=0;i<mfAirliftHolds.length;i++){
193
+ const H=mfAirliftHolds[i];if(!H||!mfAirliftIsLive(i,H.gen)||!H.mission)continue;
194
+ utgt[i]=-1;utgtg[i]=-1;uhold[i]=0;ustate[i]=1;utx[i]=H.mission.x;uty[i]=H.mission.y;
195
+ }
196
+ }
197
+ function mfAirliftUnloadNow(i,H){
198
+ const remaining=[],dropped=[];
199
+ for(let k=0;k<H.cargo.length;k++){
200
+ const P=H.cargo[k],S=H.mission.slots[k]||{x:H.mission.x,y:H.mission.y};
201
+ const u=spawnUnit(P.type,P.team,S.x,S.y);
202
+ if(u<0){remaining.push(P);continue;}
203
+ uhp[u]=clamp(uhpm[u]*P.hpRatio,1,uhpm[u]);uvet[u]=P.veteran;ukills[u]=P.kills;ucool[u]=P.cool;
204
+ if(unitModes(P.type).indexOf(P.mode)>=0)umode[u]=P.mode;
205
+ utx[u]=S.x;uty[u]=S.y;ustate[u]=0;
206
+ addBeam(ux[i],uy[i],ux[u],uy[u],3.4,90,225,255,.30,'tractor');
207
+ addParticle(3,ux[u],uy[u],0,0,.55,TYPES[P.type].size*1.9,92,226,255);
208
+ dropped.push(u);
209
+ }
210
+ H.cargo=remaining;H.used=remaining.reduce((n,P)=>n+P.slots,0);H.mission=null;
211
+ uhold[i]=0;ustate[i]=0;utx[i]=ux[i];uty[i]=uy[i];
212
+ sfx('deploy',ux[i],uy[i],1.15);
213
+ if(dropped.length){toast('⇩ '+dropped.length+' UNIT'+(dropped.length===1?'':'S')+' DEPLOYED');uiCommandAck('deploy',dropped.length,ux[i],uy[i]);}
214
+ if(remaining.length)toast('DROP PARTIAL — '+remaining.length+' PASSENGERS REMAIN');
215
+ updateSelInfo();
216
+ }
217
+ function mfAirliftPostTick(dt){
218
+ let changed=false;
219
+ for(let k=mfAirliftBoardOrders.length-1;k>=0;k--){
220
+ const O=mfAirliftBoardOrders[k];
221
+ if(!mfAirliftIsLive(O.sky,O.skyGen)||!ualive[O.unit]||ugen[O.unit]!==O.unitGen){
222
+ mfAirliftBoardOrders.splice(k,1);continue;
223
+ }
224
+ const reach=TYPES[utype[O.unit]].r+TYPES[MF_UT_AIRLIFT].r+8;
225
+ if(dist2(ux[O.unit],uy[O.unit],ux[O.sky],uy[O.sky])>reach*reach)continue;
226
+ const H=mfAirliftHold(O.sky,true),P=mfAirliftSerialize(O.unit,O.slots),px=ux[O.unit],py=uy[O.unit];
227
+ H.cargo.push(P);H.used+=O.slots;
228
+ addBeam(px,py,ux[O.sky],uy[O.sky],3.6,86,226,255,.34,'tractor');
229
+ addParticle(3,px,py,0,0,.45,TYPES[P.type].size*1.8,86,226,255);
230
+ killUnit(O.unit,true);mfAirliftBoardOrders.splice(k,1);changed=true;
231
+ sfx('laser',ux[O.sky],uy[O.sky],.62);
232
+ }
233
+ for(let i=0;i<mfAirliftHolds.length;i++){
234
+ const H=mfAirliftHolds[i];
235
+ if(!H)continue;
236
+ if(!mfAirliftIsLive(i,H.gen)){mfAirliftHolds[i]=null;continue;}
237
+ if(H.mission){
238
+ H.fx-=dt;
239
+ if(H.fx<=0){
240
+ H.fx=.16;
241
+ for(const S of H.mission.slots)addParticle(3,S.x,S.y,0,0,.23,18,86,226,255);
242
+ addParticle(3,H.mission.x,H.mission.y,0,0,.28,42,210,246,255);
243
+ }
244
+ if(dist2(ux[i],uy[i],H.mission.x,H.mission.y)<22*22)mfAirliftUnloadNow(i,H);
245
+ }
246
+ }
247
+ if(changed){
248
+ let loaded=0;for(const H of mfAirliftHolds)if(H)loaded+=H.cargo.length;
249
+ toast('⇩ CARGO SECURED · '+loaded+' ABOARD');updateSelInfo();
250
+ }
251
+ }
252
+
253
+ /* Takeovers preserve the base simulation as the fallback path. */
254
+ const mfAirliftSpawnUnitBase=spawnUnit;
255
+ spawnUnit=function(type,team,x,y){
256
+ const i=mfAirliftSpawnUnitBase(type,team,x,y);
257
+ if(i>=0){mfAirliftHolds[i]=null;if(type===MF_UT_AIRLIFT)mfAirliftHold(i,true);}
258
+ return i;
259
+ };
260
+ const mfAirliftKillUnitBase=killUnit;
261
+ killUnit=function(i,silent){
262
+ if(ualive[i]&&utype[i]===MF_UT_AIRLIFT){
263
+ const H=mfAirliftHold(i,false),lost=H?H.cargo.length:0;
264
+ mfAirliftLastLoss=lost;
265
+ for(const O of mfAirliftBoardOrders) if(O.sky===i&&O.skyGen===ugen[i]&&ualive[O.unit]&&ugen[O.unit]===O.unitGen){
266
+ ustate[O.unit]=0;utgt[O.unit]=-1;utx[O.unit]=ux[O.unit];uty[O.unit]=uy[O.unit];
267
+ }
268
+ mfAirliftBoardOrders=mfAirliftBoardOrders.filter(O=>!(O.sky===i&&O.skyGen===ugen[i]));
269
+ if(mfAirliftAim&&mfAirliftAim.sky===i&&mfAirliftAim.gen===ugen[i])mfAirliftAim=null;
270
+ if(H){H.cargo.length=0;H.used=0;H.mission=null;mfAirliftHolds[i]=null;}
271
+ if(lost&&!silent){
272
+ addParticle(3,ux[i],uy[i],0,0,.8,70,255,112,70);
273
+ toast('AIRLIFT LOST · '+lost+' PASSENGER'+(lost===1?'':'S')+' KILLED');
274
+ }
275
+ }
276
+ return mfAirliftKillUnitBase(i,silent);
277
+ };
278
+ const mfAirliftUnitTickBase=unitTick;
279
+ unitTick=function(dt){mfAirliftPreTick(dt);mfAirliftUnitTickBase(dt);mfAirliftPostTick(dt);};
280
+
281
+ /* A board order is intentionally higher priority than ordinary friendly-unit
282
+ selection: selected troops + tap Skycrane is the whole mobile gesture. */
283
+ const mfAirliftOnTapBase=onTap;
284
+ onTap=function(sx,sy){
285
+ const W=s2w(sx,sy),wx=W[0],wy=W[1];
286
+ if(mfAirliftAim){
287
+ const A=mfAirliftAim;mfAirliftAim=null;mfAirliftUpdateButton();
288
+ if(mfAirliftIsLive(A.sky,A.gen))mfAirliftCommandUnload(A.sky,wx,wy);
289
+ return;
290
+ }
291
+ const pk=pickUnit(wx,wy);
292
+ if(pk.own>=0&&utype[pk.own]===MF_UT_AIRLIFT&&selCount()>0&&!usel[pk.own]){
293
+ if(mfAirliftIssueBoard(pk.own))return;
294
+ const hasOther=(()=>{for(let i=0;i<unitHigh;i++)if(ualive[i]&&usel[i]&&i!==pk.own)return true;return false;})();
295
+ if(hasOther)return;
296
+ }
297
+ return mfAirliftOnTapBase(sx,sy);
298
+ };
299
+
300
+ function mfAirliftSvgIcon(size){
301
+ const w=document.createElement('div');w.className='mfAirliftIcon';w.style.width=size+'px';w.style.height=size+'px';
302
+ w.innerHTML='<svg viewBox="0 0 96 96" aria-hidden="true"><defs><linearGradient id="mfSkyG" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#d8edf7"/><stop offset="1" stop-color="#547187"/></linearGradient></defs><path fill="#17354a" stroke="#75dcff" stroke-width="3" d="M8 53l25-12 7-24h16l7 24 25 12-4 13-27-5-2 18H41l-2-18-27 5z"/><path fill="url(#mfSkyG)" d="M39 33h18l7 22-16 12-16-12z"/><path fill="#7ee8ff" d="M42 24h12v11H42zM16 54h18v6H16zm46 0h18v6H62z"/><path fill="#07121a" d="M41 69h14v8H41z"/></svg>';
303
+ return w;
304
+ }
305
+ const mfAirliftUnitIconBase=unitIconEl;
306
+ unitIconEl=function(tIdx,size){return tIdx===MF_UT_AIRLIFT?mfAirliftSvgIcon(size):mfAirliftUnitIconBase(tIdx,size);};
307
+ const mfAirliftPurposeBase=intelUnitPurpose;
308
+ intelUnitPurpose=function(T){
309
+ if(T&&T.airTransport)return 'Unarmed heavy airlift. Board infantry, constructors and light vehicles, then set a protected formation drop zone.';
310
+ return mfAirliftPurposeBase(T);
311
+ };
312
+
313
+ const mfAirliftShowUnitTypeBase=showUnitTypeCard;
314
+ showUnitTypeCard=function(tIdx,pinned){
315
+ mfAirliftShowUnitTypeBase(tIdx,pinned);
316
+ if(tIdx!==MF_UT_AIRLIFT)return;
317
+ const row=$('unitCard')&&$('unitCard').querySelector('.ucChips');
318
+ if(row)row.insertAdjacentHTML('beforeend',intelChip('⬣','WEIGHT: HEAVY')
319
+ +'<span class="ucChip mfAirliftCapacity"><i>▦</i>'+MF_AIRLIFT_CAPACITY+' SLOTS</span>');
320
+ };
321
+ const mfAirliftShowUnitBase=showUnitCard;
322
+ showUnitCard=function(uIdx,bIdx,pinned){
323
+ mfAirliftShowUnitBase(uIdx,bIdx,pinned);
324
+ if(uIdx<0||!ualive[uIdx]||utype[uIdx]!==MF_UT_AIRLIFT)return;
325
+ const H=mfAirliftHold(uIdx,true),chip=$('unitCard')&&$('unitCard').querySelector('.mfAirliftCapacity');
326
+ if(chip)chip.innerHTML='<i>▦</i>'+H.used+' / '+H.capacity+' SLOTS';
327
+ };
328
+
329
+ function mfAirliftRenderCard(){
330
+ const g=$('prodGrid'),T=TYPES[MF_UT_AIRLIFT];g.innerHTML='';
331
+ renderMenuRoleBrief('unit','transport',[MF_UT_AIRLIFT]);
332
+ const d=document.createElement('div');d.className='bcard mfAirliftCard';
333
+ d.innerHTML='<div class="nm">'+T.name+'</div><div class="cost">'+T.cm+'m <span>'+T.ce+'e</span></div>'
334
+ +'<div class="mfAirliftBadges"><b>HEAVY</b><b>AIR TRANSPORT</b><b>'+T.transportCap+' SLOTS</b></div>'
335
+ +'<div class="cardPurpose">INFANTRY · CONSTRUCTORS · LIGHT VEHICLES</div>';
336
+ d.setAttribute('role','button');d.setAttribute('aria-label','Build Atlas Skycrane heavy air transport, capacity 12 slots');
337
+ const icw=document.createElement('div');icw.className='icw';icw.appendChild(unitIconEl(MF_UT_AIRLIFT,48));d.insertBefore(icw,d.firstChild);
338
+ d.addEventListener('pointerdown',ev=>{
339
+ ev.stopPropagation();if(openBld<0)return;const B=blds[openBld];
340
+ if(B&&B.alive&&B.queue.length<30){B.queue.push(MF_UT_AIRLIFT);sfx('ui');renderQueue();}
341
+ });
342
+ addCardIntelButton(d,'unit',MF_UT_AIRLIFT);g.appendChild(d);
343
+ }
344
+ const mfAirliftRenderProdBase=renderProdMenu;
345
+ renderProdMenu=function(){
346
+ const B=openBld>=0?blds[openBld]:null,
347
+ atlas=!!(B&&B.type==='airfield'&&bldFactionKey(B)!=='horde'),
348
+ wants=atlas&&prodTab==='transport';
349
+ mfAirliftRenderProdBase();
350
+ if(!atlas)return;
351
+ const tr=$('prodTabs');tr.style.display='flex';
352
+ const b=document.createElement('button');b.className='tabBtn'+(wants?' on':'');
353
+ b.innerHTML='<span class="tEm">⇩</span>AIRLIFT';b.setAttribute('aria-label','Air transport production');
354
+ if(wants)for(const x of tr.querySelectorAll('.tabBtn'))x.classList.remove('on');
355
+ if(wants)b.classList.add('on');
356
+ b.addEventListener('pointerdown',ev=>{ev.stopPropagation();prodTab='transport';sfx('ui');renderProdMenu();});
357
+ tr.appendChild(b);
358
+ if(wants){prodTab='transport';mfAirliftRenderCard();}
359
+ };
360
+
361
+ function mfAirliftSelected(){
362
+ let found=-1;
363
+ for(let i=0;i<unitHigh;i++)if(ualive[i]&&usel[i]&&utype[i]===MF_UT_AIRLIFT){if(found>=0)return -1;found=i;}
364
+ return found;
365
+ }
366
+ function mfAirliftUpdateButton(){
367
+ const b=$('mfUnloadBtn');if(!b)return;
368
+ const i=mfAirliftSelected(),H=i>=0?mfAirliftHold(i,true):null;
369
+ b.style.display=i>=0?'flex':'none';b.disabled=!H||!H.cargo.length;
370
+ b.classList.toggle('on',!!(mfAirliftAim&&i===mfAirliftAim.sky&&ugen[i]===mfAirliftAim.gen));
371
+ const nm=b.querySelector('.lbl');if(nm)nm.textContent=H&&H.cargo.length?'UNLOAD '+H.cargo.length:'UNLOAD';
372
+ b.setAttribute('aria-label',H?'Unload Skycrane cargo, '+H.used+' of '+H.capacity+' slots used':'Unload Skycrane cargo');
373
+ }
374
+ const mfAirliftUpdateSelBase=updateSelInfo;
375
+ updateSelInfo=function(){
376
+ mfAirliftUpdateSelBase();
377
+ const i=mfAirliftSelected(),H=i>=0?mfAirliftHold(i,true):null;
378
+ if(i<0&&mfAirliftAim){mfAirliftAim=null;}
379
+ if(H){
380
+ const line=$('selInfo')&&$('selInfo').querySelector('.selIntelCopy span');
381
+ if(line)line.textContent+=' · AIR TRANSPORT · CARGO '+H.used+'/'+H.capacity;
382
+ const chip=$('unitCard')&&$('unitCard').querySelector('.mfAirliftCapacity');
383
+ if(chip)chip.innerHTML='<i>▦</i>'+H.used+' / '+H.capacity+' SLOTS';
384
+ }
385
+ mfAirliftUpdateButton();
386
+ };
387
+ function mfAirliftInitUI(){
388
+ const row=$('tacRow');if(!row||$('mfUnloadBtn'))return;
389
+ const b=document.createElement('button');b.type='button';b.className='cbtn mfAirliftOrder';b.id='mfUnloadBtn';
390
+ b.style.display='none';b.innerHTML='<span class="em">⇩</span><span class="lbl">UNLOAD</span>';
391
+ b.addEventListener('pointerdown',ev=>{ev.preventDefault();ev.stopPropagation();const i=mfAirliftSelected();if(i>=0)mfAirliftArmUnload(i);});
392
+ row.insertBefore(b,$('clearBtn'));
393
+ const st=document.createElement('style');st.textContent='\n'
394
+ +'.mfAirliftIcon{filter:drop-shadow(0 0 7px rgba(90,220,255,.42)) drop-shadow(0 4px 4px rgba(0,0,0,.65))}.mfAirliftIcon svg{width:100%;height:100%;display:block}'
395
+ +'.mfAirliftBadges{display:flex;flex-wrap:wrap;justify-content:center;gap:3px;margin:3px 0}.mfAirliftBadges b{padding:3px 5px;border:1px solid rgba(100,220,255,.38);border-radius:4px;background:rgba(20,92,122,.32);color:#9beaff;font:700 7px/1 var(--fT);letter-spacing:.05em}'
396
+ +'.mfAirliftCard{min-height:126px}.mfAirliftOrder{min-width:49px!important;min-height:44px!important}.mfAirliftOrder:disabled{opacity:.42;filter:saturate(.45)}';
397
+ document.head.appendChild(st);
398
+ }
399
+ mfAirliftInitUI();
400
+
401
+ /* ============================================================================
402
+ MASSFLESH — Brood breakthrough transport
403
+ ----------------------------------------------------------------------------
404
+ This is not a recoloured Skycrane. It is a heavy walking digestive organism
405
+ that absorbs a light brood, unfolds into a short-lived flying form, lashes a
406
+ path through rear structures, then births the stored organisms behind the
407
+ defensive line. Two TYPES entries let the existing simulation and renderer
408
+ apply honest counters without per-frame global flags:
409
+ landed = HEAVY ground target → anti-tank
410
+ winged = AIR target → anti-air
411
+ ============================================================================ */
412
+ const MF_UT_MASSFLESH=TYPES.length,MF_MASS_CAPACITY=18,MF_MASS_FLIGHT=26;
413
+ TYPES.push({
414
+ name:'Massflesh Carrier',spr:'brood',tur:null,size:42,r:15.5,hp:2850,dmg:0,rng:0,cool:9,
415
+ spd:25,psp:0,ptype:0,aoe:0,wk:'n',tg:'g',cm:190,ce:610,bt:12,air:0,tier:2,
416
+ cat:'biomass',brood:1,massflesh:1,transportCap:MF_MASS_CAPACITY
417
+ });
418
+ const MF_UT_MASSFLESH_AIR=TYPES.length;
419
+ TYPES.push({
420
+ name:'Massflesh Ascendant',spr:'brood',tur:null,size:45,r:16,hp:2850,dmg:58,rng:145,cool:1.55,
421
+ spd:58,psp:0,ptype:0,aoe:0,wk:'n',tg:'g',cm:0,ce:0,bt:0,air:1,tier:2,
422
+ cat:'biomass',brood:1,massflesh:1,massfleshAir:1,transportCap:MF_MASS_CAPACITY
423
+ });
424
+ UCAT.biomass={nm:'LIVING TRANSPORT',em:'♒'};
425
+ UNIT_MODES[MF_UT_MASSFLESH]=[0];UNIT_MODES[MF_UT_MASSFLESH_AIR]=[0];
426
+ ARM[MF_UT_MASSFLESH]=2;ARM[MF_UT_MASSFLESH_AIR]=0;
427
+ INTEL_ROLE_COPY.biomass='Living breakthrough carrier that consumes a light brood and births it behind defenses';
428
+ INTEL_ROLE_GUIDE.biomass={
429
+ tag:'BREAKTHROUGH ORGANISM',form:'MERGE / ASCEND / BIRTH',
430
+ use:'Absorb a light brood, cross the defensive line during timed flight, then birth the attack in the rear.',
431
+ avoid:'Counter the landed body with anti-tank and the winged body with dedicated anti-air.'
432
+ };
433
+
434
+ /* Massflesh uses its own biological zones. Reusing CHITIN/BIO_MEM across the
435
+ whole animal produced a pale toy capsule in the tactical preview and a row
436
+ of dark beads in flight. These colours select distinct PBR atlas materials:
437
+ shell, wet muscle, translucent lift sac, membrane, bone, cavity and organ. */
438
+ const MF_CHITIN=C(72,43,39),MF_CHITIN_H=C(132,79,57),MF_FLESH=C(88,35,50),MF_FLESH_H=C(151,61,77);
439
+ const MF_SAC=C(148,196,84),MF_MEM=C(72,48,86),MF_TENDON=C(111,77,94),MF_WEAK=C(218,112,255),MF_WEAK_H=C(232,255,132);
440
+ const MF_BORE=C(11,5,10),MF_BONE=C(211,190,145);
441
+ COL_MAT.set(MF_CHITIN,MAT.CHITIN);COL_MAT.set(MF_CHITIN_H,MAT.CHITIN);
442
+ COL_MAT.set(MF_FLESH,MAT.RUST);COL_MAT.set(MF_FLESH_H,MAT.LEAF);
443
+ COL_MAT.set(MF_SAC,MAT.CRYST);COL_MAT.set(MF_MEM,MAT.LEAF);COL_MAT.set(MF_TENDON,MAT.LEAF);
444
+ COL_MAT.set(MF_WEAK,MAT.TWR_GLOW);COL_MAT.set(MF_WEAK_H,MAT.LAMP);
445
+ COL_MAT.set(MF_BORE,MAT.TWR_BORE);COL_MAT.set(MF_BONE,MAT.STONE);
446
+
447
+ /* Join a tapered primitive between authored anatomy points. Curved limbs use
448
+ several short segments: inexpensive on mobile, but far clearer than boxes
449
+ or disconnected bead chains. */
450
+ function mfMassLimb(m,a,b,r1,r2,col,seg){
451
+ let dx=b[0]-a[0],dy=b[1]-a[1],dz=b[2]-a[2];const len=Math.hypot(dx,dy,dz)||1;dx/=len;dy/=len;dz/=len;
452
+ const rx=Math.abs(dy)>.92?1:0,ry=Math.abs(dy)>.92?0:1,rz=0;
453
+ let vx=ry*dz-rz*dy,vy=rz*dx-rx*dz,vz=rx*dy-ry*dx,vl=Math.hypot(vx,vy,vz)||1;vx/=vl;vy/=vl;vz/=vl;
454
+ const wx=vy*dz-vz*dy,wy=vz*dx-vx*dz,wz=vx*dy-vy*dx,mm=MB();
455
+ mm.cyl(0,0,0,r1,r2,len,seg||7,col,true);
456
+ for(let i=0;i<mm.v.length;i+=12){
457
+ const px=mm.v[i],py=mm.v[i+1],pz=mm.v[i+2],nx=mm.v[i+3],ny=mm.v[i+4],nz=mm.v[i+5];
458
+ mm.v[i]=a[0]+vx*px+dx*py+wx*pz;mm.v[i+1]=a[1]+vy*px+dy*py+wy*pz;mm.v[i+2]=a[2]+vz*px+dz*py+wz*pz;
459
+ mm.v[i+3]=vx*nx+dx*ny+wx*nz;mm.v[i+4]=vy*nx+dy*ny+wy*nz;mm.v[i+5]=vz*nx+dz*ny+wz*nz;
460
+ }
461
+ const off=m.n;for(const v of mm.v)m.v.push(v);for(const ix of mm.i)m.i.push(ix+off);m.n+=mm.n;m.m=mm.m;m.tm=mm.tm;return m;
462
+ }
463
+ function mfMassWeakOrgan(m,x,y,z,r){
464
+ m.sphere(x,y,z,r,8,MF_WEAK,.82,false);m.ring(x,y+r*.78,z,r*1.05,r*1.36,12,MF_WEAK_H);
465
+ }
466
+ function mfMassHook(m,a,b,c,d,r){
467
+ mfMassLimb(m,a,b,r,r*.72,MF_TENDON,7);mfMassLimb(m,b,c,r*.73,r*.40,MF_CHITIN_H,7);
468
+ mfMassLimb(m,c,d,r*.42,.045,MF_BONE,6);m.sphere(b[0],b[1],b[2],r*.84,7,MF_CHITIN,.62,false);
469
+ }
470
+
471
+ function mfMassGroundModel(){
472
+ const m=MB();
473
+ /* Unequal wet belly, armored thorax and shielded head break the old capsule
474
+ silhouette before the smaller anatomy is even visible. */
475
+ m.sphere(-3.8,3.4,0,7.8,13,MF_FLESH,.52,false);
476
+ m.sphere(-8.5,4.0,-.7,5.8,11,MF_CHITIN,.66,false);
477
+ m.sphere(.8,4.5,.5,6.5,12,MF_CHITIN,.58,false);
478
+ m.sphere(7.9,4.1,-.3,4.7,11,MF_CHITIN_H,.64,false);
479
+ /* Layered shell scutes leave wet seams instead of coating the animal in one
480
+ noisy material. Their alternating offset keeps the top outline irregular. */
481
+ for(let k=0;k<5;k++){
482
+ const x=-8.0+k*3.25,z=(k&1)?.85:-.75,r=3.15-k*.12;
483
+ m.sphere(x,7.0+(k%3)*.24,z,r,8,k&1?MF_CHITIN_H:MF_CHITIN,.30,false);
484
+ for(const sd of [-1,1])m.sphere(x,5.3,sd*(3.9+(k%2)*.35),1.45,7,k&1?MF_FLESH_H:MF_CHITIN_H,.55,false);
485
+ }
486
+ /* Open dorsal birthing cavity: black lumen, fleshy lip, hooked teeth and
487
+ visible stored biomass remain legible at production-preview zoom. */
488
+ m.tube(-4.4,6.8,0,4.8,2.35,2.0,13,MF_BORE);m.ring(-4.4,8.82,0,2.25,4.85,14,MF_FLESH_H);
489
+ for(let k=0;k<7;k++){
490
+ const a=k/7*TAU,r=3.7+(k&1)*.25;
491
+ mfMassLimb(m,[-4.4+Math.cos(a)*r,8.6,Math.sin(a)*r],[-4.4+Math.cos(a)*r*1.13,10.0,Math.sin(a)*r*1.13],.34,.035,MF_BONE,6);
492
+ }
493
+ for(const p of [[-5.4,8.2,-1.1],[-3.5,8.4,.8],[-4.8,8.15,1.5]])m.sphere(p[0],p[1],p[2],.82,7,MF_SAC,.78,false);
494
+ /* Folded asymmetric lift sacs telegraph the later air state. Bright cores
495
+ are exposed weak organs rather than decorative team lights. */
496
+ const sacs=[[-1.1,6.0,-5.1,2.65],[1.0,6.5,5.7,3.45],[-6.8,5.7,5.0,2.25]];
497
+ for(const S of sacs){m.sphere(S[0],S[1],S[2],S[3],9,MF_SAC,.72,false);m.ring(S[0],S[1]+S[3]*.72,S[2],S[3]*.65,S[3]*.88,11,MF_CHITIN_H);}
498
+ mfMassWeakOrgan(m,1.0,8.0,5.7,1.02);mfMassWeakOrgan(m,-1.1,7.55,-5.1,.82);
499
+ for(const sd of [-1,1]){
500
+ /* Five load-bearing hooks per side create a low predatory stance. */
501
+ for(let k=0;k<5;k++){
502
+ const x=-7.0+k*3.35,a=[x,3.3,sd*4.5],b=[x-.7,1.4,sd*(8.0+(k&1)*.8)],c=[x+1.1,.35,sd*(11.0+k*.38)],d=[x+2.35,1.0,sd*(9.8+k*.32)];
503
+ mfMassHook(m,a,b,c,d,.82-k*.035);
504
+ }
505
+ /* Forward mandibles establish +X and curl back toward the mouth. */
506
+ mfMassHook(m,[8.2,4.0,sd*2.5],[11.2,3.0,sd*4.1],[13.5,2.0,sd*2.8],[12.0,2.7,sd*.85],.72);
507
+ }
508
+ tubeX(m,10.0,3.8,0,2.35,1.9,.9,10,MF_BORE);
509
+ mfMassWeakOrgan(m,8.9,6.5,-2.15,.82);mfMassWeakOrgan(m,9.5,6.25,1.75,.64);
510
+ for(let k=0;k<7;k++){
511
+ const x=-9+k*2.25,h=2.6+(k%3)*.65;mfMassLimb(m,[x,7.5,(k&1)?.45:-.45],[x-.5,7.5+h,(k&1)?1.0:-1.0],.38,.025,MF_BONE,6);
512
+ }
513
+ return {hull:m.build(),tur:null,s:1.08};
514
+ }
515
+ function mfMassAirModel(){
516
+ const m=MB();
517
+ /* Ascension rotates the siege pod upright. A pendant muscular body carries
518
+ the payload below a swollen neural lift organ; there are no machine-like
519
+ wings or aircraft surfaces. */
520
+ m.sphere(-1.0,8.0,0,5.7,11,MF_FLESH,.92,false);
521
+ m.sphere(-1.8,12.2,.3,6.0,12,MF_CHITIN,.72,false);
522
+ m.sphere(-2.0,18.3,.7,7.2,13,MF_SAC,1.02,false);
523
+ m.sphere(-4.2,17.0,-1.0,6.4,12,MF_CHITIN,.60,false);
524
+ m.sphere(1.4,17.8,1.0,5.3,11,MF_CHITIN_H,.58,false);
525
+ /* A radial armored cranial halo protects—but does not completely cover—the
526
+ translucent lift sac. Horns point away from the weak core. */
527
+ for(let k=0;k<10;k++){
528
+ const a=k/10*TAU+.16,ca=Math.cos(a),sa=Math.sin(a),r=7.2+(k%3)*.35;
529
+ m.sphere(-2+ca*r,18.2+(k&1)*.35,.7+sa*r,2.25,8,k&1?MF_CHITIN_H:MF_CHITIN,.46,false);
530
+ mfMassLimb(m,[-2+ca*r,18.5,.7+sa*r],[-2+ca*(r+5.0),21.0+(k%2)*1.1,.7+sa*(r+5.0)],.54,.035,k%3?MF_BONE:MF_CHITIN_H,6);
531
+ }
532
+ /* The large exposed organ is the visually honest anti-air target. */
533
+ m.sphere(1.9,20.0,-.5,3.55,10,MF_SAC,.94,false);mfMassWeakOrgan(m,2.8,21.2,-.5,2.15);
534
+ /* Raised above the horn plane so command-view cameras cannot hide the
535
+ anti-air weak organ behind the cranial shell. */
536
+ mfMassWeakOrgan(m,-1.0,23.7,.6,3.15);m.sphere(-.7,24.8,.4,2.35,9,MF_WEAK_H,1,false);
537
+ m.ring(-2.0,24.1,.7,3.2,5.25,15,MF_WEAK_H);
538
+ /* Birthing throat faces forward beneath the cranial shell. */
539
+ tubeX(m,2.0,12.0,0,3.8,3.0,1.45,11,MF_BORE);ringX(m,5.55,12.0,0,1.5,3.05,12,MF_FLESH_H);
540
+ for(const sd of [-1,1]){
541
+ mfMassWeakOrgan(m,2.2,15.0,sd*3.15,sd>0?.82:.62);
542
+ mfMassHook(m,[2.5,13.0,sd*2.6],[6.0,10.0,sd*4.3],[8.6,6.0,sd*3.3],[6.5,7.0,sd*.9],.72);
543
+ }
544
+ /* Six secondary tendrils hang from the lower body; three long primaries form
545
+ the recognizable hooked cephalopod silhouette and remain separated. */
546
+ for(let k=0;k<6;k++){
547
+ const a=k/6*TAU+.35,ca=Math.cos(a),sa=Math.sin(a),start=[-1+ca*3.8,9.0,sa*3.8];
548
+ mfMassHook(m,start,[-1+ca*6.0,5.7,sa*6.2],[-1+ca*8.2,1.0,sa*8.6],[-1+ca*6.1,2.0,sa*6.4],.68-k*.025);
549
+ }
550
+ for(const sd of [-1,0,1]){
551
+ const z=sd*3.4;mfMassLimb(m,[1.3,10.0,z],[5.0,5.7,z+sd*4.0],1.0,.70,MF_TENDON,8);
552
+ mfMassLimb(m,[5.0,5.7,z+sd*4.0],[11.5,.4,z+sd*8.0],.72,.35,MF_CHITIN_H,8);
553
+ mfMassLimb(m,[11.5,.4,z+sd*8.0],[8.2,1.8,z+sd*5.6],.36,.045,MF_BONE,6);
554
+ }
555
+ return {hull:m.build(),tur:null,s:1.02,air:1};
556
+ }
557
+ UNIT_MDL[MF_UT_MASSFLESH]=mfMassGroundModel;
558
+ UNIT_MDL[MF_UT_MASSFLESH_AIR]=mfMassAirModel;
559
+
560
+ const mfMassHolds=[];
561
+ let mfMassBoardOrders=[],mfMassBirthAim=null,mfMassAlertUnit=-1,mfMassAiNext=135,mfMassAiLastT=0;
562
+ function mfMassIsLive(i,gen){return mfTransportIsLive(i,gen,'massflesh');}
563
+ function mfMassHold(i,create){
564
+ if(i<0)return null;let H=mfMassHolds[i];
565
+ if(H&&H.gen!==ugen[i])H=mfMassHolds[i]=null;
566
+ if(!H&&create&&ualive[i]&&mfTransportKindByType(utype[i])==='massflesh')
567
+ H=mfMassHolds[i]={gen:ugen[i],capacity:MF_MASS_CAPACITY,used:0,cargo:[],flight:0,mission:null,fx:0,attack:0,aiT:2};
568
+ return H;
569
+ }
570
+ function mfMassSlotCost(i,team){
571
+ if(i<0||!ualive[i]||uteam[i]!==team)return 0;
572
+ const T=TYPES[utype[i]],cat=T.cat||'veh';
573
+ if(T.massflesh||T.air||T.naval||cat==='art'||cat==='exp'||cat==='hero'||(ARM[utype[i]]||0)>1)return 0;
574
+ if(!unitIsBrood(i))return 0;
575
+ return T.size<=17?1:2;
576
+ }
577
+ function mfMassReserved(i,gen){let n=0;for(const O of mfMassBoardOrders)if(O.mass===i&&O.massGen===gen)n+=O.slots;return n;}
578
+ function mfMassBoardPoint(O,rank){
579
+ const a=uang[O.mass]-Math.PI/2,side=(rank&1)?1:-1,row=Math.floor(rank/2);
580
+ return {x:ux[O.mass]-Math.cos(a)*(20+row*5)-Math.sin(a)*side*(8+row),
581
+ y:uy[O.mass]-Math.sin(a)*(20+row*5)+Math.cos(a)*side*(8+row)};
582
+ }
583
+ function mfMassQueueBoard(mass,entries,playerOrder){
584
+ const H=mfMassHold(mass,true);if(!H||utype[mass]!==MF_UT_MASSFLESH)return 0;
585
+ let free=H.capacity-H.used-mfMassReserved(mass,H.gen),n=0;
586
+ for(const i of entries){
587
+ const slots=mfMassSlotCost(i,uteam[mass]);
588
+ if(!slots||slots>free||mfMassBoardOrders.some(O=>O.unit===i&&O.unitGen===ugen[i]))continue;
589
+ mfMassBoardOrders.push({unit:i,unitGen:ugen[i],mass,massGen:H.gen,slots});free-=slots;n++;
590
+ }
591
+ if(n){
592
+ utgt[mass]=-1;ustate[mass]=0;utx[mass]=ux[mass];uty[mass]=uy[mass];uhold[mass]=1;
593
+ if(playerOrder){clearSel();usel[mass]=1;updateSelInfo();toast('♒ '+n+' BROOD MERGING INTO BIOMASS');uiCommandAck('move',n,ux[mass],uy[mass]);}
594
+ }
595
+ return n;
596
+ }
597
+ function mfMassIssueBoard(mass){
598
+ const candidates=[],rejected=[];
599
+ for(let i=0;i<unitHigh;i++)if(ualive[i]&&usel[i]&&i!==mass)(mfMassSlotCost(i,uteam[mass])?candidates:rejected).push(i);
600
+ const n=mfMassQueueBoard(mass,candidates,true);
601
+ if(!n){toast(rejected.length?'MASSFLESH REJECTS HEAVY OR NON-BROOD UNITS':'MASSFLESH BIOMASS FULL');sfx('ui');}
602
+ return n>0;
603
+ }
604
+ function mfMassAlert(i,state){
605
+ if(i<0||!ualive[i]||uteam[i]===0)return;
606
+ mfMassAlertUnit=i;const el=$('mfMassAlert');if(!el)return;
607
+ el.style.display='block';el.innerHTML='<b>⚠ MASSFLESH '+state+'</b><span>BREAKTHROUGH ORGANISM · TAP TO TRACK</span>';
608
+ }
609
+ function mfMassBeginFlight(i){
610
+ const H=mfMassHold(i,false);if(!H||!H.cargo.length||utype[i]!==MF_UT_MASSFLESH)return false;
611
+ utype[i]=MF_UT_MASSFLESH_AIR;H.flight=MF_MASS_FLIGHT;H.mission=null;H.attack=.25;uhold[i]=0;ustate[i]=0;
612
+ addParticle(3,ux[i],uy[i],0,0,1.0,100,185,92,255);
613
+ for(let k=0;k<10;k++)addParticle(4,ux[i]+rr(-12,12),uy[i]+rr(-12,12),rr(-18,18),rr(-18,18),.8,8,135,236,72);
614
+ sfx('cre_attack',ux[i],uy[i],1.6);
615
+ if(uteam[i]!==0){mfMassAlert(i,'AIRBORNE');sfx('alarm',ux[i],uy[i],1.15);if(typeof radioAck==='function')radioAck('attack',1,ux[i],uy[i]);}
616
+ else toast('♒ MASSFLESH ASCENDED · '+Math.ceil(H.flight)+'s FLIGHT WINDOW');
617
+ updateSelInfo();return true;
618
+ }
619
+ function mfMassBehindPlayerTarget(i){
620
+ let tx=heroIdx>=0&&ualive[heroIdx]?ux[heroIdx]:MAP*.25,ty=heroIdx>=0&&ualive[heroIdx]?uy[heroIdx]:MAP*.25;
621
+ for(const B of blds)if(B.alive&&B.team===0&&(B.type==='hq'||B.type==='fac')){tx=B.x;ty=B.y;if(B.type==='hq')break;}
622
+ let dx=tx-ux[i],dy=ty-uy[i],d=Math.hypot(dx,dy)||1;dx/=d;dy/=d;
623
+ const L=findLand(clamp(tx+dx*150,24,MAP-24),clamp(ty+dy*150,24,MAP-24));return {x:L[0],y:L[1]};
624
+ }
625
+ function mfMassCommandBirth(i,x,y){
626
+ const H=mfMassHold(i,false);if(!H||utype[i]!==MF_UT_MASSFLESH_AIR||!H.cargo.length)return false;
627
+ x=clamp(x,24,MAP-24);y=clamp(y,24,MAP-24);H.mission={x,y,slots:mfAirliftFormation(H.cargo,x,y)};
628
+ utgt[i]=-1;ustate[i]=1;utx[i]=x;uty[i]=y;mfMassBirthAim=null;mfMassUpdateUI();
629
+ addParticle(3,x,y,0,0,.8,58,168,92,255);
630
+ if(uteam[i]===0){toast('♒ BIRTH SITE MARKED · '+H.cargo.length+' ORGANISMS');uiCommandAck('deploy',H.cargo.length,x,y);}
631
+ return true;
632
+ }
633
+ function mfMassBirthNow(i,H){
634
+ const target=H.mission||{x:ux[i],y:uy[i],slots:mfAirliftFormation(H.cargo,ux[i],uy[i])},remaining=[],born=[];
635
+ for(let k=0;k<H.cargo.length;k++){
636
+ const P=H.cargo[k],S=target.slots[k]||target,u=spawnUnit(P.type,P.team,S.x,S.y);
637
+ if(u<0){remaining.push(P);continue;}
638
+ uhp[u]=clamp(uhpm[u]*P.hpRatio,1,uhpm[u]);uvet[u]=P.veteran;ukills[u]=P.kills;ucool[u]=P.cool;
639
+ if(unitModes(P.type).indexOf(P.mode)>=0)umode[u]=P.mode;
640
+ if(P.team===1){const q=mfMassBehindPlayerTarget(i);ustate[u]=2;utx[u]=q.x;uty[u]=q.y;}
641
+ addBeam(ux[i],uy[i],ux[u],uy[u],4.5,168,88,255,.36,'tractor');
642
+ addParticle(6,ux[u],uy[u],0,0,.55,TYPES[P.type].size*1.8,150,242,82);born.push(u);
643
+ }
644
+ H.cargo=remaining;H.used=remaining.reduce((n,P)=>n+P.slots,0);H.mission=null;H.flight=0;
645
+ utype[i]=MF_UT_MASSFLESH;ustate[i]=0;utx[i]=ux[i];uty[i]=uy[i];uhold[i]=0;
646
+ addParticle(3,ux[i],uy[i],0,0,.9,86,185,92,255);sfx('deploy',ux[i],uy[i],1.25);sfx('cre_attack',ux[i],uy[i],1.1);
647
+ if(uteam[i]===0)toast('♒ '+born.length+' ORGANISMS BORN INTO FORMATION');
648
+ updateSelInfo();return born;
649
+ }
650
+ function mfMassTentacleAttack(i,H){
651
+ let target=-1,best=145*145,kind='unit';
652
+ forUnitsIn(ux[i],uy[i],145,j=>{
653
+ if(uteam[j]===uteam[i]||TYPES[utype[j]].air)return;
654
+ const d=dist2(ux[i],uy[i],ux[j],uy[j]),light=(ARM[utype[j]]||0)===0;
655
+ const score=d*(light?.55:1.45);if(score<best){best=score;target=j;kind='unit';}
656
+ });
657
+ const b=findEnemyBld(ux[i],uy[i],uteam[i],145);
658
+ if(b>=0){const d=dist2(ux[i],uy[i],blds[b].x,blds[b].y)*.42;if(d<best){best=d;target=b;kind='building';}}
659
+ if(target<0)return null;
660
+ const x=kind==='unit'?ux[target]:blds[target].x,y=kind==='unit'?uy[target]:blds[target].y;
661
+ let dmg=TYPES[MF_UT_MASSFLESH_AIR].dmg;
662
+ if(kind==='building'){dmg*=1.75;damageBld(target,dmg,uteam[i]);}
663
+ else{dmg*=(ARM[utype[target]]||0)===0?1.45:.52;dealDamage(target,dmg,uteam[i],i);}
664
+ addBeam(ux[i],uy[i],x,y,5.2,176,78,255,.30,'lightning');addBeam(ux[i],uy[i],x,y,2.2,132,242,78,.34,'tractor');
665
+ addParticle(4,x,y,0,0,.42,15,135,238,76);sfx('cre_attack',ux[i],uy[i],1.25);sfx('sonic',x,y,.72);
666
+ H.attack=TYPES[MF_UT_MASSFLESH_AIR].cool;return {kind,target,dmg};
667
+ }
668
+ function mfMassPreTick(dt){
669
+ for(let k=mfMassBoardOrders.length-1;k>=0;k--){
670
+ const O=mfMassBoardOrders[k];
671
+ if(!mfMassIsLive(O.mass,O.massGen)||utype[O.mass]!==MF_UT_MASSFLESH||!ualive[O.unit]||ugen[O.unit]!==O.unitGen){mfMassBoardOrders.splice(k,1);continue;}
672
+ const rank=mfMassBoardOrders.filter(Q=>Q.mass===O.mass&&Q.massGen===O.massGen).indexOf(O),P=mfMassBoardPoint(O,Math.max(0,rank));
673
+ utgt[O.unit]=-1;utgtg[O.unit]=-1;ustate[O.unit]=1;utx[O.unit]=P.x;uty[O.unit]=P.y;uhold[O.unit]=0;
674
+ }
675
+ for(let i=0;i<mfMassHolds.length;i++){
676
+ const H=mfMassHolds[i];if(!H||!mfMassIsLive(i,H.gen)||utype[i]!==MF_UT_MASSFLESH_AIR||!H.mission)continue;
677
+ utgt[i]=-1;utgtg[i]=-1;ustate[i]=1;utx[i]=H.mission.x;uty[i]=H.mission.y;uhold[i]=0;
678
+ }
679
+ }
680
+ function mfMassPostTick(dt){
681
+ for(let k=mfMassBoardOrders.length-1;k>=0;k--){
682
+ const O=mfMassBoardOrders[k];
683
+ if(!mfMassIsLive(O.mass,O.massGen)||!ualive[O.unit]||ugen[O.unit]!==O.unitGen){mfMassBoardOrders.splice(k,1);continue;}
684
+ const reach=TYPES[utype[O.unit]].r+TYPES[MF_UT_MASSFLESH].r+9;
685
+ if(dist2(ux[O.unit],uy[O.unit],ux[O.mass],uy[O.mass])>reach*reach)continue;
686
+ const H=mfMassHold(O.mass,true),P=mfAirliftSerialize(O.unit,O.slots),x=ux[O.unit],y=uy[O.unit];
687
+ P.biomass=Math.round(uhp[O.unit]+TYPES[P.type].size*4);H.cargo.push(P);H.used+=O.slots;
688
+ for(let q=0;q<3;q++)addBeam(x+rr(-3,3),y+rr(-3,3),ux[O.mass],uy[O.mass],2.8,145,235,76,.32,'tractor');
689
+ addParticle(4,x,y,0,0,.55,TYPES[P.type].size*1.7,153,238,78);killUnit(O.unit,true);mfMassBoardOrders.splice(k,1);
690
+ sfx('cre_attack',ux[O.mass],uy[O.mass],.68);
691
+ }
692
+ for(let i=0;i<mfMassHolds.length;i++){
693
+ const H=mfMassHolds[i];if(!H)continue;
694
+ if(!mfMassIsLive(i,H.gen)){mfMassHolds[i]=null;continue;}
695
+ H.aiT-=dt;
696
+ if(utype[i]===MF_UT_MASSFLESH){
697
+ if(uteam[i]===1&&typeof AI!=='undefined'&&AI.fac==='horde'&&H.aiT<=0){
698
+ H.aiT=2.2;
699
+ if(H.used<6){const near=[];forUnitsIn(ux[i],uy[i],260,j=>{if(j!==i&&mfMassSlotCost(j,uteam[i]))near.push(j);});mfMassQueueBoard(i,near.slice(0,6),false);}
700
+ if(H.used>=4&&!mfMassReserved(i,H.gen)){
701
+ mfMassBeginFlight(i);const D=mfMassBehindPlayerTarget(i);mfMassCommandBirth(i,D.x,D.y);
702
+ }
703
+ }
704
+ continue;
705
+ }
706
+ H.flight=Math.max(0,H.flight-dt);H.attack=Math.max(0,H.attack-dt);
707
+ if(H.attack<=0)mfMassTentacleAttack(i,H);
708
+ if(uteam[i]!==0)mfMassAlert(i,'AIRBORNE · '+Math.ceil(H.flight)+'s');
709
+ /* Flight is a temporary breakthrough state, not a permanent aircraft mode.
710
+ Birth at the current position when the membrane timer ruptures even if a
711
+ distant order is still pending. This keeps anti-air counterplay finite. */
712
+ if(H.flight<=0){
713
+ H.mission={x:ux[i],y:uy[i],slots:mfAirliftFormation(H.cargo,ux[i],uy[i])};mfMassBirthNow(i,H);
714
+ }else if(H.mission){
715
+ H.fx-=dt;if(H.fx<=0){H.fx=.17;for(const S of H.mission.slots)addParticle(3,S.x,S.y,0,0,.24,20,168,92,255);}
716
+ if(dist2(ux[i],uy[i],H.mission.x,H.mission.y)<24*24)mfMassBirthNow(i,H);
717
+ }
718
+ }
719
+ mfMassRefreshAlert();mfMassUpdateUI();
720
+ }
721
+
722
+ const mfMassSpawnBase=spawnUnit;
723
+ spawnUnit=function(type,team,x,y){
724
+ const i=mfMassSpawnBase(type,team,x,y);
725
+ if(i>=0){mfMassHolds[i]=null;if(type===MF_UT_MASSFLESH||type===MF_UT_MASSFLESH_AIR)mfMassHold(i,true);}
726
+ return i;
727
+ };
728
+ const mfMassKillBase=killUnit;
729
+ killUnit=function(i,silent){
730
+ if(ualive[i]&&mfTransportKindByType(utype[i])==='massflesh'){
731
+ const H=mfMassHold(i,false),lost=H?H.cargo.length:0;
732
+ for(const O of mfMassBoardOrders)if(O.mass===i&&O.massGen===ugen[i]&&ualive[O.unit]&&ugen[O.unit]===O.unitGen){ustate[O.unit]=0;utx[O.unit]=ux[O.unit];uty[O.unit]=uy[O.unit];}
733
+ mfMassBoardOrders=mfMassBoardOrders.filter(O=>!(O.mass===i&&O.massGen===ugen[i]));
734
+ if(H){H.cargo.length=0;H.used=0;H.mission=null;mfMassHolds[i]=null;}
735
+ if(lost&&!silent){toast('MASSFLESH SLAIN · '+lost+' STORED ORGANISMS LOST');addParticle(4,ux[i],uy[i],0,0,1.0,80,150,238,72);}
736
+ if(mfMassAlertUnit===i)mfMassAlertUnit=-1;
737
+ }
738
+ return mfMassKillBase(i,silent);
739
+ };
740
+ const mfMassUnitTickBase=unitTick;
741
+ unitTick=function(dt){mfMassPreTick(dt);mfMassUnitTickBase(dt);mfMassPostTick(dt);};
742
+
743
+ const mfMassOnTapBase=onTap;
744
+ onTap=function(sx,sy){
745
+ const W=s2w(sx,sy),wx=W[0],wy=W[1];
746
+ if(mfMassBirthAim){const A=mfMassBirthAim;mfMassBirthAim=null;if(mfMassIsLive(A.i,A.gen))mfMassCommandBirth(A.i,wx,wy);return;}
747
+ const pk=pickUnit(wx,wy);
748
+ if(pk.own>=0&&utype[pk.own]===MF_UT_MASSFLESH&&selCount()>0&&!usel[pk.own]){
749
+ if(mfMassIssueBoard(pk.own))return;
750
+ for(let i=0;i<unitHigh;i++)if(ualive[i]&&usel[i]&&i!==pk.own)return;
751
+ }
752
+ return mfMassOnTapBase(sx,sy);
753
+ };
754
+
755
+ const mfMassPurposeBase=intelUnitPurpose;
756
+ intelUnitPurpose=function(T){
757
+ if(T&&T.massflesh)return T.massfleshAir
758
+ ?'Timed winged breakthrough state. Tentacles rend structures and light units; dedicated anti-air brings it down.'
759
+ :'Living heavy carrier. Merge a light Brood into biomass, then ascend and birth it behind the defensive line.';
760
+ return mfMassPurposeBase(T);
761
+ };
762
+ const mfMassShowTypeBase=showUnitTypeCard;
763
+ showUnitTypeCard=function(tIdx,pinned){
764
+ mfMassShowTypeBase(tIdx,pinned);const T=TYPES[tIdx];if(!T||!T.massflesh)return;
765
+ const row=$('unitCard')&&$('unitCard').querySelector('.ucChips');
766
+ if(row)row.insertAdjacentHTML('beforeend',T.massfleshAir
767
+ ?intelChip('✈','AIRBORNE')+intelChip('⌖','COUNTER: ANTI-AIR','bad')
768
+ :intelChip('⬣','GROUND HEAVY')+intelChip('⌖','COUNTER: ANTI-TANK','bad')+'<span class="ucChip mfMassCapacity"><i>◉</i>'+MF_MASS_CAPACITY+' BIOMASS</span>');
769
+ const card=$('unitCard'),warn=card&&card.querySelector('.ucCounter.caution'),ammo=card&&card.querySelector('.ucAmmo');
770
+ if(T.massfleshAir){if(warn)warn.remove();if(ammo)ammo.textContent='TENTACLES · +75% STRUCTURES · +45% LIGHT · WEAK VS HEAVY';}
771
+ };
772
+ function mfMassSvgIcon(size){
773
+ const w=document.createElement('div');w.className='mfMassIcon';w.style.width=size+'px';w.style.height=size+'px';
774
+ w.innerHTML='<svg viewBox="0 0 96 96" aria-hidden="true"><path fill="#27143a" stroke="#bd76ff" stroke-width="3" d="M13 52c4-22 18-37 35-37s31 15 35 37c-8 17-19 27-35 28-16-1-27-11-35-28z"/><path fill="#6c933e" d="M22 51c9-9 13-22 26-25 13 3 17 16 26 25-9 9-13 18-26 19-13-1-17-10-26-19z"/><circle cx="48" cy="45" r="11" fill="#c06cff"/><path stroke="#9eea58" stroke-width="5" stroke-linecap="round" d="M26 65L13 82m28-12-5 17m34-22 13 17M55 70l5 17"/></svg>';
775
+ return w;
776
+ }
777
+ const mfMassIconBase=unitIconEl;
778
+ unitIconEl=function(tIdx,size){return (tIdx===MF_UT_MASSFLESH||tIdx===MF_UT_MASSFLESH_AIR)?mfMassSvgIcon(size):mfMassIconBase(tIdx,size);};
779
+ function mfMassRenderCard(){
780
+ const g=$('prodGrid'),T=TYPES[MF_UT_MASSFLESH];g.innerHTML='';renderMenuRoleBrief('unit','biomass',[MF_UT_MASSFLESH]);
781
+ const d=document.createElement('div');d.className='bcard mfMassCard';
782
+ d.innerHTML='<div class="nm">'+T.name+'</div><div class="cost">'+T.cm+'m <span>'+T.ce+'e</span></div>'
783
+ +'<div class="mfMassBadges"><b>GROUND HEAVY</b><b>ANTI-TANK COUNTERS</b><b>'+T.transportCap+' BIOMASS</b></div>'
784
+ +'<div class="cardPurpose">MERGE · ASCEND · BIRTH BEHIND DEFENSES</div>';
785
+ d.setAttribute('role','button');d.setAttribute('aria-label','Grow Massflesh Brood breakthrough carrier');
786
+ const icw=document.createElement('div');icw.className='icw';icw.appendChild(unitIconEl(MF_UT_MASSFLESH,48));d.insertBefore(icw,d.firstChild);
787
+ d.addEventListener('pointerdown',ev=>{ev.stopPropagation();const B=openBld>=0?blds[openBld]:null;if(B&&bldFactionKey(B)==='horde'&&B.queue.length<30){B.queue.push(MF_UT_MASSFLESH);sfx('ui');renderQueue();}});
788
+ addCardIntelButton(d,'unit',MF_UT_MASSFLESH);g.appendChild(d);
789
+ }
790
+ const mfMassRenderProdBase=renderProdMenu;
791
+ renderProdMenu=function(){
792
+ const B=openBld>=0?blds[openBld]:null,brood=!!(B&&B.type==='fac'&&bldFactionKey(B)==='horde'),wants=brood&&prodTab==='biomass';
793
+ mfMassRenderProdBase();if(!brood)return;
794
+ const tr=$('prodTabs');tr.style.display='flex';const b=document.createElement('button');b.className='tabBtn'+(wants?' on':'');
795
+ b.innerHTML='<span class="tEm">♒</span>MASSFLESH';b.setAttribute('aria-label','Brood living transport production');
796
+ if(wants)for(const x of tr.querySelectorAll('.tabBtn'))x.classList.remove('on');
797
+ b.addEventListener('pointerdown',ev=>{ev.stopPropagation();prodTab='biomass';sfx('ui');renderProdMenu();});
798
+ /* A faction-defining organism must not be hidden beyond the phone-width tab
799
+ scroller. Keep it first for Brood factories while leaving Terran clean. */
800
+ tr.insertBefore(b,tr.firstChild);
801
+ if(wants){prodTab='biomass';mfMassRenderCard();}
802
+ };
803
+ function mfMassSelected(){let found=-1;for(let i=0;i<unitHigh;i++)if(ualive[i]&&usel[i]&&mfTransportKindByType(utype[i])==='massflesh'){if(found>=0)return -1;found=i;}return found;}
804
+ function mfMassUpdateUI(){
805
+ const b=$('mfMassActionBtn');if(!b)return;const i=mfMassSelected(),H=i>=0?mfMassHold(i,true):null,air=i>=0&&utype[i]===MF_UT_MASSFLESH_AIR;
806
+ b.style.display=i>=0?'flex':'none';b.disabled=!H||!H.cargo.length;
807
+ const label=b.querySelector('.lbl');if(label)label.textContent=air?'BIRTH '+Math.ceil(H.flight)+'s':'TAKE FLIGHT';
808
+ b.classList.toggle('on',!!(mfMassBirthAim&&i===mfMassBirthAim.i));
809
+ b.setAttribute('aria-label',air?'Set Massflesh birth site; airborne counter is anti-air':'Take flight; landed Massflesh counter is anti-tank');
810
+ }
811
+ const mfMassUpdateSelBase=updateSelInfo;
812
+ updateSelInfo=function(){
813
+ mfMassUpdateSelBase();const i=mfMassSelected(),H=i>=0?mfMassHold(i,true):null;
814
+ if(i<0)mfMassBirthAim=null;
815
+ if(H){const line=$('selInfo')&&$('selInfo').querySelector('.selIntelCopy span'),air=utype[i]===MF_UT_MASSFLESH_AIR;
816
+ if(line)line.textContent+=' · '+(air?'AIRBORNE / ANTI-AIR':'GROUND HEAVY / ANTI-TANK')+' · BIOMASS '+H.used+'/'+H.capacity;
817
+ const chip=$('unitCard')&&$('unitCard').querySelector('.mfMassCapacity');if(chip)chip.innerHTML='<i>◉</i>'+H.used+' / '+H.capacity+' BIOMASS';
818
+ }
819
+ mfMassUpdateUI();
820
+ };
821
+ function mfMassRefreshAlert(){
822
+ const el=$('mfMassAlert');if(!el)return;let best=-1,state='APPROACHING';
823
+ for(let i=0;i<unitHigh;i++)if(ualive[i]&&uteam[i]!==0&&mfTransportKindByType(utype[i])==='massflesh'){
824
+ if(utype[i]===MF_UT_MASSFLESH_AIR){best=i;state='AIRBORNE';break;}
825
+ for(const B of blds)if(B.alive&&B.team===0&&dist2(ux[i],uy[i],B.x,B.y)<620*620){best=i;break;}
826
+ }
827
+ if(best>=0)mfMassAlert(best,state);else{el.style.display='none';mfMassAlertUnit=-1;}
828
+ }
829
+ function mfMassInitUI(){
830
+ const row=$('tacRow');if(!row||$('mfMassActionBtn'))return;
831
+ const b=document.createElement('button');b.type='button';b.id='mfMassActionBtn';b.className='cbtn mfMassAction';b.style.display='none';
832
+ b.innerHTML='<span class="em">♒</span><span class="lbl">TAKE FLIGHT</span>';
833
+ b.addEventListener('pointerdown',ev=>{ev.preventDefault();ev.stopPropagation();const i=mfMassSelected();if(i<0)return;if(utype[i]===MF_UT_MASSFLESH)mfMassBeginFlight(i);else{mfMassBirthAim={i,gen:ugen[i]};toast('♒ TAP BEHIND THE DEFENSIVE LINE TO BIRTH THE BROOD');mfMassUpdateUI();}});
834
+ row.insertBefore(b,$('clearBtn'));
835
+ const a=document.createElement('button');a.type='button';a.id='mfMassAlert';a.style.display='none';a.setAttribute('aria-label','Track inbound Massflesh breakthrough carrier');
836
+ a.addEventListener('pointerdown',()=>{const i=mfMassAlertUnit;if(i>=0&&ualive[i]){cam.x=ux[i];cam.y=uy[i];camFollow=i;clampCam();camUpdateMatrices();sfx('ui');}});document.body.appendChild(a);
837
+ const st=document.createElement('style');st.textContent='\n'
838
+ +'.mfMassIcon{filter:drop-shadow(0 0 8px rgba(184,102,255,.5))}.mfMassIcon svg{width:100%;height:100%;display:block}'
839
+ +'.mfMassCard{width:156px!important;min-height:176px}.mfMassBadges{display:flex;flex-wrap:wrap;justify-content:center;gap:3px;margin:3px 0}.mfMassBadges b{padding:3px 5px;border:1px solid rgba(185,112,255,.48);border-radius:4px;background:rgba(72,28,92,.42);color:#d9aaff;font:700 7px/1 var(--fT)}'
840
+ +'.mfMassAction{min-width:58px!important;min-height:44px!important}.mfMassAction:disabled{opacity:.42}'
841
+ +'#mfMassAlert{position:fixed;left:50%;transform:translateX(-50%);top:calc(var(--sat) + 158px);z-index:60;min-width:260px;min-height:50px;padding:8px 14px;border:1px solid #c274ff;border-left:5px solid #aaf05e;border-radius:10px;background:linear-gradient(180deg,rgba(55,18,74,.96),rgba(21,10,35,.97));color:#f0d8ff;box-shadow:0 0 22px rgba(182,94,255,.34);font-family:var(--fT)}#mfMassAlert b{display:block;font-size:12px;letter-spacing:.08em}#mfMassAlert span{display:block;margin-top:3px;font-size:9px;color:#c9f88f;letter-spacing:.06em}body.menuMode #mfMassAlert{display:none!important}';
842
+ document.head.appendChild(st);
843
+ }
844
+ mfMassInitUI();
845
+
846
+ /* Seed one genuine breakthrough organism into a developed Brood AI economy.
847
+ The normal AI continues to own every other production choice. */
848
+ const mfMassAiTickBase=aiTick;
849
+ aiTick=function(dt){
850
+ if(stats.t<mfMassAiLastT){mfMassAiNext=135;}mfMassAiLastT=stats.t;
851
+ if(typeof AI!=='undefined'&&AI.fac==='horde'&&stats.t>=mfMassAiNext){
852
+ const live=(()=>{let n=0;for(let i=0;i<unitHigh;i++)if(ualive[i]&&uteam[i]===1&&mfTransportKindByType(utype[i])==='massflesh')n++;return n;})();
853
+ const B=blds.find(B=>B.alive&&B.team===1&&B.type==='fac'&&B.tier>=2&&B.prog>=1&&B.queue.length<3);
854
+ if(!live&&B){B.queue.unshift(MF_UT_MASSFLESH);mfMassAiNext=stats.t+190;}else mfMassAiNext=stats.t+12;
855
+ }
856
+ return mfMassAiTickBase(dt);
857
+ };
src/audio.js CHANGED
@@ -657,7 +657,7 @@ function radioFaction(){
657
  else if(typeof carrier!=='undefined'&&carrier.fac) key=carrier.fac;
658
  }catch(e){}
659
  const A=typeof facArt==='function'?facArt(key):null;
660
- return A||{id:'nova',nm:'Nova Federation',cdr:'Command',col:'#5db6ff'};
661
  }
662
  function radioPanel(){
663
  let el=document.getElementById('radioAck');
 
657
  else if(typeof carrier!=='undefined'&&carrier.fac) key=carrier.fac;
658
  }catch(e){}
659
  const A=typeof facArt==='function'?facArt(key):null;
660
+ return A||{id:'nova',nm:'Terran Frontline Command',cdr:'Command',col:'#5db6ff'};
661
  }
662
  function radioPanel(){
663
  let el=document.getElementById('radioAck');
src/develop.js CHANGED
@@ -50,12 +50,26 @@ function matCostStr(c){
50
  }
51
  /* What a match pays out. Scaled by the threat the player chose to fight at, so
52
  the endgame ladder feeds the crafting economy instead of running beside it. */
 
 
 
 
 
 
 
 
 
 
 
 
53
  function matsFromMatch(res){
 
54
  const t=(typeof threatSel==='function')?threatSel():1;
55
  const s=res.win?1:0.35;
 
56
  return {
57
- alloy : Math.round((6+res.kills*0.004+res.built*0.5)*s*(1+t*0.14)),
58
- circuit: Math.round((2+res.kills*0.0016)*s*(1+t*0.12)),
59
  isotope: Math.round((res.win?2:0)+res.kills*0.0009*s*(1+t*0.10)),
60
  relic : (res.nests>0||res.win&&t>=4)? Math.round(s*(1+t*0.08)) : 0,
61
  };
@@ -79,8 +93,17 @@ const DEVTREE=[
79
  cost:{alloy:25}, data:6, req:[]},
80
  {id:'logistics', br:'DOCTRINE', nm:'Forward Logistics',ds:'Modules wear 25% slower',
81
  cost:{alloy:50,circuit:18}, data:12, req:['salvage']},
 
 
 
 
 
 
82
  {id:'refit', br:'DOCTRINE', nm:'Field Refit', ds:'Unlocks repairing worn modules',
83
  cost:{alloy:70,circuit:26}, data:18, req:['logistics']},
 
 
 
84
  {id:'firemission',br:'DOCTRINE', nm:'Fire Mission Protocol',
85
  ds:'Unlocks the player-commanded Charged Barrage for selected artillery',
86
  cost:{alloy:90,circuit:44,isotope:8}, data:26, req:['logistics'], art:'res_refit'},
@@ -118,10 +141,11 @@ function moduleSlots(){ return 1+(devHas('slot2')?1:0)+(devHas('slot3')?1:0); }
118
  function wearRate(){ return devHas('logistics')?0.75:1; }
119
  function matYield(){ return devHas('salvage')?1.35:1; }
120
 
121
- /* The field Research Complex is the source of account research. Completed
122
- studies bank three Data each; keeping a live network through debrief adds a
123
- smaller survival bonus. Short surrender loops cannot farm it, and difficulty
124
- only pays when the player actually completed science. */
 
125
  function researchDataFromMatch(win){
126
  const studies=Math.max(0,typeof resDone!=='undefined'?resDone|0:0);
127
  let labs=0;
@@ -130,12 +154,15 @@ function researchDataFromMatch(win){
130
  const studyData=studies*3;
131
  const network=Math.min(3,labs);
132
  const hold=win&&labs?2:0;
133
- const challenge=studies?clamp(difficulty|0,0,2):0;
134
- const raw=studyData+network+hold+challenge;
 
 
135
  const threat=(typeof threatSel==='function')?1+Math.max(0,threatSel()-1)*0.06:1;
136
  const total=Math.min(24,Math.round(raw*threat));
137
  META.researchData=(META.researchData||0)+total;
138
- return {total,parts:[['Studies',studyData],['Lab network',network],['Objective hold',hold],['Challenge',challenge]].filter(p=>p[1]>0)};
 
139
  }
140
 
141
  /* ---- MODULES ---------------------------------------------------------------
@@ -343,10 +370,10 @@ function renderDevelop(){
343
  }
344
  /* Called at match end, after rewards. */
345
  function developRecord(res){
346
- const g=matsFromMatch(res), y=matYield();
347
  for(const k in g) g[k]=Math.round(g[k]*y);
348
  matGrant(g);
349
  const broke=wearModules();
350
- return {mats:g, broke};
351
  }
352
  function initDevelop(){ matBag(); devDone(); modOwned(); modEquipped(); renderDevelop(); }
 
50
  }
51
  /* What a match pays out. Scaled by the threat the player chose to fight at, so
52
  the endgame ladder feeds the crafting economy instead of running beside it. */
53
+ function fieldRecoveryFromMatch(res){
54
+ const committed=typeof matchCommitted==='function'?matchCommitted(res.win):(res.win||(stats.t|0)>=60);
55
+ if(!committed) return {alloy:0,circuit:0};
56
+ const lossScale=res.win?1:0.45;
57
+ const reserveAlloy=Math.min(res.win?12:5,Math.floor(Math.max(0,res.fieldMass||0)/300)*lossScale)|0;
58
+ const reclaimedAlloy=Math.min(res.win?8:4,Math.floor(Math.max(0,res.reclaimed||0)/180)*lossScale)|0;
59
+ return {
60
+ alloy:reserveAlloy+reclaimedAlloy,
61
+ circuit:Math.min(res.win?8:3,Math.floor(Math.max(0,res.fieldEnergy||0)/1400)*lossScale)|0,
62
+ reserveAlloy,reclaimedAlloy,
63
+ };
64
+ }
65
  function matsFromMatch(res){
66
+ if(typeof matchCommitted==='function'&&!matchCommitted(res.win)) return {alloy:0,circuit:0,isotope:0,relic:0};
67
  const t=(typeof threatSel==='function')?threatSel():1;
68
  const s=res.win?1:0.35;
69
+ const recovery=fieldRecoveryFromMatch(res);
70
  return {
71
+ alloy : Math.round((8+res.kills*0.004+res.built*0.5)*s*(1+t*0.14))+recovery.alloy,
72
+ circuit: Math.round((3+res.kills*0.0016)*s*(1+t*0.12))+recovery.circuit,
73
  isotope: Math.round((res.win?2:0)+res.kills*0.0009*s*(1+t*0.10)),
74
  relic : (res.nests>0||res.win&&t>=4)? Math.round(s*(1+t*0.08)) : 0,
75
  };
 
93
  cost:{alloy:25}, data:6, req:[]},
94
  {id:'logistics', br:'DOCTRINE', nm:'Forward Logistics',ds:'Modules wear 25% slower',
95
  cost:{alloy:50,circuit:18}, data:12, req:['salvage']},
96
+ {id:'breakthrough',br:'DOCTRINE', nm:'Breakthrough Protocol',
97
+ ds:'Unlocks a timed assault push for infantry, armour and anti-tank platoons',
98
+ cost:{alloy:72,circuit:30,isotope:5}, data:20, req:['logistics'], art:'res_ability'},
99
+ {id:'intercept', br:'DOCTRINE', nm:'Interceptor Protocol',
100
+ ds:'Unlocks high-speed interception for aircraft and anti-air screens',
101
+ cost:{alloy:58,circuit:42,isotope:7}, data:22, req:['logistics'], art:'res_optics'},
102
  {id:'refit', br:'DOCTRINE', nm:'Field Refit', ds:'Unlocks repairing worn modules',
103
  cost:{alloy:70,circuit:26}, data:18, req:['logistics']},
104
+ {id:'fieldservice',br:'DOCTRINE', nm:'Field Service Net',
105
+ ds:'Unlocks a selected support platoon repair-and-shield pulse',
106
+ cost:{alloy:82,circuit:48,isotope:6}, data:24, req:['refit'], art:'res_servos'},
107
  {id:'firemission',br:'DOCTRINE', nm:'Fire Mission Protocol',
108
  ds:'Unlocks the player-commanded Charged Barrage for selected artillery',
109
  cost:{alloy:90,circuit:44,isotope:8}, data:26, req:['logistics'], art:'res_refit'},
 
141
  function wearRate(){ return devHas('logistics')?0.75:1; }
142
  function matYield(){ return devHas('salvage')?1.35:1; }
143
 
144
+ /* Every committed operation advances account research, including a defeat.
145
+ The Research Complex remains the fastest source through studies and a live
146
+ network bonus, but making it the ONLY source created a progression deadlock:
147
+ a newcomer who lost the lab earned zero Data and could never improve the lab.
148
+ Short surrender loops still pay nothing. */
149
  function researchDataFromMatch(win){
150
  const studies=Math.max(0,typeof resDone!=='undefined'?resDone|0:0);
151
  let labs=0;
 
154
  const studyData=studies*3;
155
  const network=Math.min(3,labs);
156
  const hold=win&&labs?2:0;
157
+ const committed=typeof matchCommitted==='function'?matchCommitted(win):(win||(stats.t|0)>=60);
158
+ const mission=committed?(win?4:2):0;
159
+ const challenge=win?clamp(difficulty|0,0,2):0;
160
+ const raw=mission+studyData+network+hold+challenge;
161
  const threat=(typeof threatSel==='function')?1+Math.max(0,threatSel()-1)*0.06:1;
162
  const total=Math.min(24,Math.round(raw*threat));
163
  META.researchData=(META.researchData||0)+total;
164
+ return {total,parts:[['Mission recovery',mission],['Studies',studyData],['Lab network',network],
165
+ ['Objective hold',hold],['Challenge',challenge]].filter(p=>p[1]>0)};
166
  }
167
 
168
  /* ---- MODULES ---------------------------------------------------------------
 
370
  }
371
  /* Called at match end, after rewards. */
372
  function developRecord(res){
373
+ const recovery=fieldRecoveryFromMatch(res),g=matsFromMatch(res), y=matYield();
374
  for(const k in g) g[k]=Math.round(g[k]*y);
375
  matGrant(g);
376
  const broke=wearModules();
377
+ return {mats:g,recovery,broke};
378
  }
379
  function initDevelop(){ matBag(); devDone(); modOwned(); modEquipped(); renderDevelop(); }
src/endgame.js CHANGED
@@ -42,22 +42,48 @@ const THREAT_NM=['','SKIRMISH','PROBE','RAID','OFFENSIVE','CAMPAIGN','WAR',
42
  something cosmetic or something crippling. Choosing which knives to hold is
43
  the interesting decision; the dice were taking it away. */
44
  const OPMODS=[
45
- {id:'iron', nm:'Iron Enemy', ds:'Enemy units +25% health', rw:0.30},
46
- {id:'titan', nm:'Early Titans', ds:'The enemy fields TITANs early', rw:0.45},
47
- {id:'fogb', nm:'Fog Bank', ds:'Vision reduced by 40%', rw:0.25},
48
- {id:'veins', nm:'Scarce Veins', ds:'Your mass income -30%', rw:0.40},
49
- {id:'swarm', nm:'Hiveworld', ds:'Wildlife escalates twice as fast',rw:0.35},
50
- {id:'blitz', nm:'Blitz Doctrine', ds:'Enemy waves arrive 40% sooner', rw:0.35},
51
- {id:'brittle',nm:'Brittle Frames', ds:'Your units -20% health', rw:0.40},
52
- {id:'nofab', nm:'No Salvage', ds:'Wrecks cannot be reclaimed', rw:0.20},
53
- {id:'volatile',nm:'Volatile Cores', ds:'Every death detonates', rw:0.15},
54
- {id:'dark', nm:'Blood Moon', ds:'Permanent night', rw:0.10},
55
  ];
56
  function opModsOn(){ META.opmods=META.opmods||{}; return META.opmods; }
57
- function opModActive(id){ return !!opModsOn()[id]; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  function toggleOpMod(id){
 
 
59
  const o=opModsOn();
60
  if(o[id]) delete o[id]; else o[id]=1;
 
 
 
 
61
  metaSave(); renderOps(); sfx('ui');
62
  }
63
  function opModMult(){
@@ -65,7 +91,10 @@ function opModMult(){
65
  return m;
66
  }
67
  /* The total the player is playing for, shown before they commit. */
68
- function payoutMult(){ return threatReward()*opModMult(); }
 
 
 
69
 
70
  /* ---- SCORE -----------------------------------------------------------------
71
  One number so runs are comparable — to your own past runs, and to the weekly.
@@ -218,9 +247,8 @@ function renderOpsBrief(mode){
218
  }
219
  function renderOps(){
220
  const el=document.getElementById('opsScr'); if(!el) return;
221
- /* Keep the combined plan visible while the player moves between categories.
222
- Threat and modifiers only make sense together; hiding the multiplier on
223
- the Modifiers tab made every other tab look like an unrelated menu. */
224
  renderOpsBrief((MF_TAB_STATE&&MF_TAB_STATE.opsScr)||'threat');
225
  /* Threat ladder */
226
  const tl=document.getElementById('threatRow');
@@ -245,13 +273,32 @@ function renderOps(){
245
  /* Modifiers */
246
  const mr=document.getElementById('opModRow');
247
  if(mr){
248
- mr.innerHTML=OPMODS.map(k=>'<div class="opMod'+(opModActive(k.id)?' on':'')+'" data-id="'+k.id+'">'
249
- +'<div class="opTx"><b>'+k.nm+'</b><span>'+k.ds+'</span></div>'
250
- +'<div class="opRw">+'+Math.round(k.rw*100)+'%</div></div>').join('');
251
- mr.querySelectorAll('.opMod').forEach(d=>d.addEventListener('pointerdown',()=>toggleOpMod(d.dataset.id)));
 
 
 
 
 
 
 
 
 
 
 
 
252
  }
253
  const pm=document.getElementById('opPayout');
254
- if(pm) pm.innerHTML='PAYOUT <b>×'+payoutMult().toFixed(2)+'</b>';
 
 
 
 
 
 
 
255
  /* Weekly */
256
  const wd=weeklyDef(), wb=weeklyBest();
257
  const wk=document.getElementById('weeklyBox');
 
42
  something cosmetic or something crippling. Choosing which knives to hold is
43
  the interesting decision; the dice were taking it away. */
44
  const OPMODS=[
45
+ {id:'iron', nm:'Iron Enemy', ds:'Enemy units +25% health', rw:0.30,gate:{kind:'rank',n:0}},
46
+ {id:'fogb', nm:'Fog Bank', ds:'Vision reduced by 40%', rw:0.25,gate:{kind:'rank',n:1}},
47
+ {id:'veins', nm:'Scarce Veins', ds:'Your mass income -30%', rw:0.40,gate:{kind:'wins',n:1}},
48
+ {id:'swarm', nm:'Hiveworld', ds:'Wildlife escalates twice as fast',rw:0.35,gate:{kind:'tutorial',n:1}},
49
+ {id:'blitz', nm:'Blitz Doctrine', ds:'Enemy waves arrive 40% sooner', rw:0.35,gate:{kind:'rank',n:2}},
50
+ {id:'volatile',nm:'Volatile Cores', ds:'Every death detonates', rw:0.15,gate:{kind:'matches',n:3}},
51
+ {id:'titan', nm:'Early Titans', ds:'The enemy fields TITANs early', rw:0.45,gate:{kind:'threat',n:4}},
52
+ {id:'brittle',nm:'Brittle Frames', ds:'Your units -20% health', rw:0.40,gate:{kind:'rank',n:3}},
53
+ {id:'nofab', nm:'No Salvage', ds:'Wrecks cannot be reclaimed', rw:0.20,gate:{kind:'mastery',n:3}},
54
+ {id:'dark', nm:'Blood Moon', ds:'Permanent night', rw:0.10,gate:{kind:'wins',n:5}},
55
  ];
56
  function opModsOn(){ META.opmods=META.opmods||{}; return META.opmods; }
57
+ function opModUnlockState(mod){
58
+ const g=mod&&mod.gate||{kind:'rank',n:0}; let value=0,label='Available';
59
+ if(g.kind==='rank'){
60
+ value=typeof metaRankIdx==='function'?metaRankIdx():0;
61
+ const rank=(typeof RANKS!=='undefined'&&RANKS[g.n])||{nm:'Commander Rank '+(g.n+1)};
62
+ label=g.n===0?'Recruit access':'Reach '+rank.nm+' rank';
63
+ }else if(g.kind==='wins'){
64
+ value=META.wins||0; label='Win '+g.n+' operation'+(g.n===1?'':'s');
65
+ }else if(g.kind==='matches'){
66
+ value=META.matches||0; label='Complete '+g.n+' operations';
67
+ }else if(g.kind==='tutorial'){
68
+ value=META.tutorial&&META.tutorial.done?1:0; label='Complete First Command training';
69
+ }else if(g.kind==='threat'){
70
+ value=typeof threatUnlocked==='function'?threatUnlocked():1; label='Unlock Threat T'+g.n;
71
+ }else if(g.kind==='mastery'){
72
+ value=typeof masteryTotal==='function'?masteryTotal().done:0; label='Earn '+g.n+' battlefield masteries';
73
+ }
74
+ return {open:value>=g.n,label,progress:Math.min(value,g.n)+' / '+g.n};
75
+ }
76
+ function opModUnlocked(mod){ return opModUnlockState(typeof mod==='string'?OPMODS.find(x=>x.id===mod):mod).open; }
77
+ function opModActive(id){ return opModUnlocked(id)&&!!opModsOn()[id]; }
78
  function toggleOpMod(id){
79
+ const mod=OPMODS.find(x=>x.id===id),unlock=opModUnlockState(mod);
80
+ if(!mod||!unlock.open){ toast('🔒 '+(mod?mod.nm:'Modifier')+' — '+unlock.label+' ('+unlock.progress+')'); sfx('ui'); return; }
81
  const o=opModsOn();
82
  if(o[id]) delete o[id]; else o[id]=1;
83
+ /* Hand-picked rules and the random quick-pick are two alternatives. Keeping
84
+ both selected made the random buttons appear broken because chosen rules
85
+ intentionally win at match start. */
86
+ wcChoice=0; META.wcPref=0;
87
  metaSave(); renderOps(); sfx('ui');
88
  }
89
  function opModMult(){
 
91
  return m;
92
  }
93
  /* The total the player is playing for, shown before they commit. */
94
+ function payoutMult(){
95
+ const chosen=OPMODS.some(k=>opModActive(k.id));
96
+ return threatReward()*(chosen?opModMult():(1+0.35*Math.max(0,wcChoice|0)));
97
+ }
98
 
99
  /* ---- SCORE -----------------------------------------------------------------
100
  One number so runs are comparable — to your own past runs, and to the weekly.
 
247
  }
248
  function renderOps(){
249
  const el=document.getElementById('opsScr'); if(!el) return;
250
+ /* Keep the combined plan visible in Operations while modifier selection
251
+ itself lives with the rest of the per-match Map Settings. */
 
252
  renderOpsBrief((MF_TAB_STATE&&MF_TAB_STATE.opsScr)||'threat');
253
  /* Threat ladder */
254
  const tl=document.getElementById('threatRow');
 
273
  /* Modifiers */
274
  const mr=document.getElementById('opModRow');
275
  if(mr){
276
+ mr.innerHTML=OPMODS.map((k,mi)=>{ const u=opModUnlockState(k),active=opModActive(k.id),
277
+ /* The atlas is row-major 5x2. CSS percentage positions are relative to
278
+ the remaining overflow, so five columns land at 0/25/50/75/100. */
279
+ artX=(mi%5)*25,artY=mi<5?0:100; return '<div class="opMod'
280
+ +(active?' on':'')+(u.open?'':' lock')+'" data-id="'+k.id+'" role="button" tabindex="0" aria-disabled="'+(!u.open)+'">'
281
+ +'<div class="opModArt" aria-hidden="true" style="--opx:'+artX+'%;--opy:'+artY+'%"><i></i></div>'
282
+ +'<div class="opTx"><b>'+k.nm+'</b><span>'+k.ds+'</span><small class="opUnlock '+(u.open?'earned':'')+'">'
283
+ +(u.open?'✓ ':'🔒 ')+u.label+(u.open?'':' · '+u.progress)+'</small></div>'
284
+ +'<div class="opRw">+'+Math.round(k.rw*100)+'%</div></div>'; }).join('');
285
+ mr.querySelectorAll('.opMod').forEach(d=>{
286
+ d.addEventListener('pointerdown',()=>toggleOpMod(d.dataset.id));
287
+ d.addEventListener('keydown',ev=>{
288
+ if(ev.key!=='Enter'&&ev.key!==' ') return;
289
+ ev.preventDefault(); toggleOpMod(d.dataset.id);
290
+ });
291
+ });
292
  }
293
  const pm=document.getElementById('opPayout');
294
+ if(pm) pm.innerHTML=(wcChoice?'RANDOM ×'+wcChoice+' · ':'PAYOUT ')+'<b>×'+payoutMult().toFixed(2)+'</b>';
295
+ const available=OPMODS.filter(opModUnlocked).length;
296
+ document.querySelectorAll('.wbtn').forEach(b=>{
297
+ const n=+b.dataset.w,blocked=n>available;
298
+ b.disabled=blocked; b.classList.toggle('lock',blocked);
299
+ b.title=blocked?'Unlock '+n+' modifiers first ('+available+' available)':'';
300
+ b.classList.toggle('on',!blocked&&n===wcChoice);
301
+ });
302
  /* Weekly */
303
  const wd=weeklyDef(), wb=weeklyBest();
304
  const wk=document.getElementById('weeklyBox');
src/engine/mesh.js CHANGED
@@ -729,7 +729,9 @@ layout(location=3) in vec2 aUV;
729
  layout(location=4) in float aMat;
730
  uniform mat4 uVP;
731
  uniform vec3 uEye;
732
- out vec3 vNrm; out vec2 vMapUV; out vec2 vDetUV; out float vFog; out vec3 vCol;
 
 
733
  void main(){
734
  vNrm=aNrm; vCol=aCol;
735
  vMapUV=vec2(aPos.x,aPos.z)/uMapSize;
@@ -737,18 +739,41 @@ void main(){
737
  float d=length(aPos-uEye);
738
  vFog=clamp((d-2600.0)/4200.0,0.0,0.5);
739
  float bd=min(min(aPos.x, MAPSIZE_CONST-aPos.x), min(aPos.z, MAPSIZE_CONST-aPos.z));
740
- vFog=max(vFog, clamp((BFOG_CONST-bd)/BFOG_CONST,0.0,1.0)); // border haze
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
741
  gl_Position=uVP*vec4(aPos,1.0);
742
  }`.replace('uMapSize','MAPSIZE_CONST');
743
  const FST=`#version 300 es
744
  precision highp float;
745
- in vec3 vNrm; in vec2 vMapUV; in vec2 vDetUV; in float vFog; in vec3 vCol;
746
  uniform sampler2D uMap;
747
  uniform sampler2D uDetail;
748
  uniform sampler2D uFogMap;
749
  uniform float uFogActive;
750
  uniform vec3 uSun; uniform vec3 uSunC;
751
  uniform vec3 uAmbSky; uniform vec3 uAmbGnd; uniform vec3 uFogC;
 
752
  out vec4 o;
753
  void main(){
754
  vec3 n=normalize(vNrm);
@@ -764,13 +789,59 @@ void main(){
764
  vec3 amb=mix(uAmbGnd,uAmbSky,hemi);
765
  vec3 lit=base*(amb + uSunC*(ndl*0.80+wrap*0.20));
766
  lit=vec3(1.0)-exp(-lit*1.55); // same curve as the model shader
767
- lit=mix(lit,uFogC,vFog); // fog is a display-space colour: blend after
768
  /* Fog-of-war follows the terrain itself. A screen overlay could not survive
769
  camera tilt and the first ground-quad attempt visibly cut hills into dark
770
  tiles. Sampling the 64x64 sensor texture in map UV space gives smooth,
771
  depth-correct current/explored/unexplored transitions for one texture read. */
772
- float fow=texture(uFogMap,vMapUV).a*uFogActive;
 
 
 
 
 
 
 
 
 
773
  lit=mix(lit,vec3(0.008,0.016,0.030),fow);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
774
  o=vec4(pow(max(lit,vec3(0.0)),vec3(0.4545)),1.0);
775
  }`;
776
  /* ============================================================================
@@ -1105,7 +1176,8 @@ function initGL3D(){
1105
  gl.uniform1i(U3.uMat,0); gl.uniform1i(U3.uNrm,2); gl.uniform1i(U3.uOrm,3);
1106
  UG.uVP=gl.getUniformLocation(progG,'uVP');
1107
  progT=mkProg(VST.replace(/MAPSIZE_CONST/g,MAP.toFixed(1)).replace(/BFOG_CONST/g,'430.0'),FST);
1108
- for(const k of ['uVP','uEye','uSun','uSunC','uAmbSky','uAmbGnd','uFogC','uMap','uDetail','uFogMap','uFogActive'])
 
1109
  UT[k]=gl.getUniformLocation(progT,k);
1110
  gl.useProgram(progT); gl.uniform1i(UT.uMap,0); gl.uniform1i(UT.uDetail,1); gl.uniform1i(UT.uFogMap,7);
1111
  initAO();
 
729
  layout(location=4) in float aMat;
730
  uniform mat4 uVP;
731
  uniform vec3 uEye;
732
+ uniform vec2 uPlayBounds;
733
+ uniform float uEdgeStyle;
734
+ out vec3 vNrm; out vec2 vMapUV; out vec2 vDetUV; out float vFog; out float vBorder; out float vPlayBorder; out float vOutside; out float vExclusion; out vec3 vCol;
735
  void main(){
736
  vNrm=aNrm; vCol=aCol;
737
  vMapUV=vec2(aPos.x,aPos.z)/uMapSize;
 
739
  float d=length(aPos-uEye);
740
  vFog=clamp((d-2600.0)/4200.0,0.0,0.5);
741
  float bd=min(min(aPos.x, MAPSIZE_CONST-aPos.x), min(aPos.z, MAPSIZE_CONST-aPos.z));
742
+ vBorder=clamp((BFOG_CONST-bd)/BFOG_CONST,0.0,1.0);
743
+ vOutside=max(0.0,-bd);
744
+ /* Match the authored theatre silhouette used by the red tactical grid.
745
+ Finite superellipse powers keep every biome from ending in the same hard
746
+ square, while the harmonics create broad coast/inlet/waste variation
747
+ without introducing high-frequency collision traps. */
748
+ vec2 pc=vec2((uPlayBounds.x+uPlayBounds.y)*0.5);
749
+ vec2 pd=aPos.xz-pc;
750
+ float pa=atan(pd.y,pd.x), ca=abs(cos(pa)), sa=abs(sin(pa));
751
+ float power=uEdgeStyle>0.5&&uEdgeStyle<1.5?3.15:(uEdgeStyle>1.5&&uEdgeStyle<2.5?6.25:(uEdgeStyle>2.5?4.15:5.0));
752
+ float halfSpan=(uPlayBounds.y-uPlayBounds.x)*0.5;
753
+ float baseR=halfSpan/pow(pow(ca,power)+pow(sa,power),1.0/power);
754
+ float shape=uEdgeStyle>0.5&&uEdgeStyle<1.5
755
+ ?0.945+0.024*sin(pa*3.0+0.65)+0.016*sin(pa*7.0-1.10)
756
+ :(uEdgeStyle>1.5&&uEdgeStyle<2.5
757
+ ?0.958+0.020*sin(pa*5.0+0.30)+0.012*sin(pa*9.0+1.20)
758
+ :(uEdgeStyle>2.5
759
+ ?0.948+0.025*sin(pa*2.0-0.80)+0.016*sin(pa*6.0+0.45)
760
+ :0.966+0.014*sin(pa*4.0+0.35)+0.010*sin(pa*7.0-0.55)));
761
+ float pbd=baseR*shape-length(pd);
762
+ vPlayBorder=clamp((160.0-pbd)/160.0,0.0,1.0);
763
+ vExclusion=max(0.0,-pbd);
764
+ vFog=max(vFog,vBorder); // border haze
765
  gl_Position=uVP*vec4(aPos,1.0);
766
  }`.replace('uMapSize','MAPSIZE_CONST');
767
  const FST=`#version 300 es
768
  precision highp float;
769
+ in vec3 vNrm; in vec2 vMapUV; in vec2 vDetUV; in float vFog; in float vBorder; in float vPlayBorder; in float vOutside; in float vExclusion; in vec3 vCol;
770
  uniform sampler2D uMap;
771
  uniform sampler2D uDetail;
772
  uniform sampler2D uFogMap;
773
  uniform float uFogActive;
774
  uniform vec3 uSun; uniform vec3 uSunC;
775
  uniform vec3 uAmbSky; uniform vec3 uAmbGnd; uniform vec3 uFogC;
776
+ uniform float uEdgeStyle; uniform float uEdgeTime; uniform vec3 uEdgeTint;
777
  out vec4 o;
778
  void main(){
779
  vec3 n=normalize(vNrm);
 
789
  vec3 amb=mix(uAmbGnd,uAmbSky,hemi);
790
  vec3 lit=base*(amb + uSunC*(ndl*0.80+wrap*0.20));
791
  lit=vec3(1.0)-exp(-lit*1.55); // same curve as the model shader
 
792
  /* Fog-of-war follows the terrain itself. A screen overlay could not survive
793
  camera tilt and the first ground-quad attempt visibly cut hills into dark
794
  tiles. Sampling the 64x64 sensor texture in map UV space gives smooth,
795
  depth-correct current/explored/unexplored transitions for one texture read. */
796
+ /* Border weather is a physical cover over the sensor map, not another layer
797
+ beneath it. The old order fogged the terrain and THEN mixed it toward FOW
798
+ black, which cut a navy polygon out of the otherwise matching clear
799
+ colour. Fade sensor darkness out as haze starts, apply it to the terrain,
800
+ and put the atmospheric veil on last. */
801
+ /* Sensor darkness must not redraw the tactical frontier as a black slab.
802
+ Protect both the physical terrain skirt and the currently selected safe
803
+ theatre edge; Compact and Standard deliberately end before the mesh does. */
804
+ float borderProtect=1.0-smoothstep(0.02,0.18,max(vBorder,vPlayBorder));
805
+ float fow=texture(uFogMap,vMapUV).a*uFogActive*borderProtect;
806
  lit=mix(lit,vec3(0.008,0.016,0.030),fow);
807
+ lit=mix(lit,uFogC,vFog); // final veil: FOW can never darken it
808
+ /* The low-density skirt outside the simulation receives a restrained fog
809
+ variation. It suggests rolling ground continuing into weather without
810
+ becoming navigable terrain or revealing a second hard edge. Both ends of
811
+ the band return to the exact fog colour, so the authored seam and the far
812
+ framebuffer clear remain continuous. */
813
+ float outerBand=smoothstep(10.0,180.0,vOutside)*(1.0-smoothstep(640.0,940.0,vOutside));
814
+ float outerNoise=(d1*0.68+d2*0.32)-0.5;
815
+ vec3 outerFog=uFogC*(0.965+outerNoise*0.065);
816
+ lit=mix(lit,outerFog,outerBand*0.72);
817
+ /* The red command boundary is drawn on the last safe ground. Only AFTER
818
+ crossing it does this procedural exclusion treatment begin. Detail noise
819
+ perturbs the onset so coastlines and wasteland fronts do not read as a
820
+ second perfect rectangle, while a long fade returns to atmospheric fog
821
+ before the low-density skirt itself ends. */
822
+ float irregular=((d1*0.68+d2*0.32)-0.5)*42.0;
823
+ float onset=max(8.0,14.0+irregular);
824
+ float zone=smoothstep(onset,max(onset+34.0,82.0+irregular),vExclusion)
825
+ *(1.0-smoothstep(760.0,1180.0,vExclusion));
826
+ vec3 zoneCol=mix(uFogC,uEdgeTint,0.42);
827
+ if(uEdgeStyle>0.5&&uEdgeStyle<1.5){
828
+ // Coastal/island exclusion: dark water with narrow moving foam trains.
829
+ float wave=0.5+0.5*sin(vExclusion*0.064+vDetUV.x*0.31-vDetUV.y*0.19+uEdgeTime*0.72);
830
+ float foam=pow(wave,12.0)*(1.0-smoothstep(250.0,560.0,vExclusion));
831
+ zoneCol=mix(uFogC,uEdgeTint,0.58)+vec3(0.10,0.16,0.18)*foam;
832
+ }else if(uEdgeStyle>1.5&&uEdgeStyle<2.5){
833
+ // Dry exclusion: ash plates split by ember-lit procedural cracks.
834
+ float crack=pow(abs(sin(vDetUV.x*0.73+sin(vDetUV.y*.17))*sin(vDetUV.y*.61-vDetUV.x*.11)),18.0);
835
+ zoneCol=mix(uFogC,uEdgeTint,0.56)+vec3(0.20,0.035,0.008)*crack*(0.35+0.20*sin(uEdgeTime*.45));
836
+ }else if(uEdgeStyle>2.5){
837
+ // Highland/arctic exclusion: broad storm bands moving through dense mist.
838
+ float storm=0.5+0.5*sin((vDetUV.x+vDetUV.y)*0.34+outerNoise*5.0+uEdgeTime*0.34);
839
+ zoneCol=mix(uFogC,uEdgeTint,0.34)*(0.88+storm*0.16);
840
+ }else{
841
+ // Temperate maps retain muted terrain colour under deep perimeter mist.
842
+ zoneCol=mix(uFogC,uEdgeTint,0.38)*(0.94+outerNoise*0.10);
843
+ }
844
+ lit=mix(lit,zoneCol,zone*0.78);
845
  o=vec4(pow(max(lit,vec3(0.0)),vec3(0.4545)),1.0);
846
  }`;
847
  /* ============================================================================
 
1176
  gl.uniform1i(U3.uMat,0); gl.uniform1i(U3.uNrm,2); gl.uniform1i(U3.uOrm,3);
1177
  UG.uVP=gl.getUniformLocation(progG,'uVP');
1178
  progT=mkProg(VST.replace(/MAPSIZE_CONST/g,MAP.toFixed(1)).replace(/BFOG_CONST/g,'430.0'),FST);
1179
+ for(const k of ['uVP','uEye','uSun','uSunC','uAmbSky','uAmbGnd','uFogC','uMap','uDetail','uFogMap','uFogActive',
1180
+ 'uPlayBounds','uEdgeStyle','uEdgeTime','uEdgeTint'])
1181
  UT[k]=gl.getUniformLocation(progT,k);
1182
  gl.useProgram(progT); gl.uniform1i(UT.uMap,0); gl.uniform1i(UT.uDetail,1); gl.uniform1i(UT.uFogMap,7);
1183
  initAO();
src/engine/terrain.js CHANGED
@@ -24,6 +24,14 @@ const SEABED=-26; // floor the ocean bottom so water has dept
24
 
25
  let terrMesh=null, terrVerts=null, terrVAO=null, terrVBO=null, terrIBO=null, terrIdxCount=0;
26
  let waterMesh=null, waterVAO=null, waterVBO=null, waterIBO=null, waterIdxCount=0, waterVerts=null;
 
 
 
 
 
 
 
 
27
 
28
  /* World height in world units. Bilinear so slopes are smooth and the camera
29
  ray-march in s2w() converges instead of stair-stepping. */
@@ -118,10 +126,78 @@ function buildTerrainMesh(themeKey){
118
  gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,idx,gl.STATIC_DRAW);
119
  gl.bindVertexArray(null);
120
  }
 
121
  buildWaterMesh(TH);
122
  return terrVAO;
123
  }
124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  /* Rewrite a rectangular window of terrain vertices — positions, normals and
126
  colours — from the current heightfield. Normals are central differences of
127
  the heightfield rather than face normals, which keeps the surface smooth
@@ -279,6 +355,12 @@ function drawTerrain(){
279
  gl.drawElements(gl.TRIANGLES,terrIdxCount,gl.UNSIGNED_INT,0);
280
  drawCalls++; triCount+=terrIdxCount/3;
281
  }
 
 
 
 
 
 
282
  function drawWater(){
283
  if(!waterVAO||!waterIdxCount) return;
284
  gl.bindVertexArray(waterVAO);
 
24
 
25
  let terrMesh=null, terrVerts=null, terrVAO=null, terrVBO=null, terrIBO=null, terrIdxCount=0;
26
  let waterMesh=null, waterVAO=null, waterVBO=null, waterIBO=null, waterIdxCount=0, waterVerts=null;
27
+ /* The playable heightfield still ends at MAP, but the camera is deliberately
28
+ allowed to overhang it so a corner base can remain centred while the player
29
+ rotates. A cheap low-density skirt gives that overhang real depth instead
30
+ of exposing the framebuffer clear as a flat blue/black wedge. The terrain
31
+ shader turns the skirt into atmospheric fake-land; it never participates in
32
+ pathfinding, fog sensors, placement or deformation. */
33
+ const TERR_EDGE_EXT=960;
34
+ let terrEdgeVAO=null, terrEdgeVBO=null, terrEdgeIBO=null, terrEdgeIdxCount=0;
35
 
36
  /* World height in world units. Bilinear so slopes are smooth and the camera
37
  ray-march in s2w() converges instead of stair-stepping. */
 
126
  gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,idx,gl.STATIC_DRAW);
127
  gl.bindVertexArray(null);
128
  }
129
+ buildTerrainEdgeMesh(TH);
130
  buildWaterMesh(TH);
131
  return terrVAO;
132
  }
133
 
134
+ /* Four rectilinear strips cover the world outside [0,MAP]. Keeping 0 and MAP
135
+ as authored vertices makes the join exact, while a handful of radial bands
136
+ is enough because dense haze intentionally removes high-frequency detail.
137
+ North/south extend around the corners; west/east only fill the middle, so
138
+ there are no holes and no overlapping coplanar corner sheets. */
139
+ function buildTerrainEdgeMesh(TH){
140
+ const verts=[],idx=[];
141
+ const edgeKind=curMap==='isles'?1:(curTheme==='ashland'||curMap==='crater'?2:
142
+ (curTheme==='arctic'||curMap==='highland'?3:0));
143
+ const sink=edgeKind===1?38:edgeKind===2?13:edgeKind===3?27:20;
144
+ const reliefAmp=edgeKind===2?7.2:edgeKind===3?2.8:4.2;
145
+ const axis=(a,b,n)=>Array.from({length:n+1},(_,i)=>a+(b-a)*i/n);
146
+ const along=axis(-TERR_EDGE_EXT,MAP+TERR_EDGE_EXT,48);
147
+ const middle=axis(0,MAP,32);
148
+ const bands=[0,130,330,610,TERR_EDGE_EXT];
149
+ const smooth=q=>q*q*(3-2*q);
150
+ const addRect=(xs,zs)=>{
151
+ const base=verts.length/12, nx=xs.length;
152
+ for(const z of zs) for(const x of xs){
153
+ const outside=Math.max(0,-x,x-MAP,-z,z-MAP), q=clamp(outside/TERR_EDGE_EXT,0,1);
154
+ const bx=clamp(x,0,MAP), bz=clamp(z,0,MAP), join=clamp(outside/150,0,1);
155
+ /* Continue the edge silhouette, then let it settle slightly into the
156
+ weather. Relief starts at zero on the seam, so even a grazing camera
157
+ cannot reveal a vertical crack between authored and fake terrain. */
158
+ const relief=(Math.sin(x*.0067+z*.0031)+Math.sin(z*.0083-x*.0027))*(1-q)*reliefAmp*join;
159
+ /* Isles fall away fastest into their drowned perimeter; dry maps keep
160
+ harder broken shelves; storm maps flatten into a low obscured waste. */
161
+ const h=terrainH(bx,bz)-smooth(q)*sink+relief;
162
+ const cc=TH.cliff||[112,116,122];
163
+ verts.push(x,h,z, 0,1,0, cc[0]/255,cc[1]/255,cc[2]/255,
164
+ x*.035,z*.035,MAT.EARTH);
165
+ }
166
+ for(let z=0;z<zs.length-1;z++) for(let x=0;x<xs.length-1;x++){
167
+ const a=base+z*nx+x,b=a+1,c=a+nx,d=c+1;
168
+ idx.push(a,c,b,b,c,d);
169
+ }
170
+ };
171
+ addRect(along,bands.slice().reverse().map(d=>-d));
172
+ addRect(along,bands.map(d=>MAP+d));
173
+ addRect(bands.slice().reverse().map(d=>-d),middle);
174
+ addRect(bands.map(d=>MAP+d),middle);
175
+ terrEdgeIdxCount=idx.length;
176
+ const data=new Float32Array(verts),indices=new Uint32Array(idx);
177
+ if(!terrEdgeVAO){
178
+ terrEdgeVAO=gl.createVertexArray(); gl.bindVertexArray(terrEdgeVAO);
179
+ terrEdgeVBO=gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,terrEdgeVBO);
180
+ gl.bufferData(gl.ARRAY_BUFFER,data,gl.STATIC_DRAW);
181
+ gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0,3,gl.FLOAT,false,VSTRIDE,0);
182
+ gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1,3,gl.FLOAT,false,VSTRIDE,12);
183
+ gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2,3,gl.FLOAT,false,VSTRIDE,24);
184
+ gl.enableVertexAttribArray(3); gl.vertexAttribPointer(3,2,gl.FLOAT,false,VSTRIDE,36);
185
+ gl.enableVertexAttribArray(4); gl.vertexAttribPointer(4,1,gl.FLOAT,false,VSTRIDE,44);
186
+ terrEdgeIBO=gl.createBuffer(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,terrEdgeIBO);
187
+ gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,indices,gl.STATIC_DRAW);
188
+ gl.disableVertexAttribArray(5); gl.vertexAttrib4f(5,0,0,0,1);
189
+ gl.disableVertexAttribArray(6); gl.vertexAttrib1f(6,0);
190
+ gl.disableVertexAttribArray(7); gl.vertexAttrib4f(7,1,1,1,1);
191
+ gl.disableVertexAttribArray(8); gl.vertexAttrib1f(8,1);
192
+ gl.bindVertexArray(null);
193
+ }else{
194
+ gl.bindVertexArray(terrEdgeVAO);
195
+ gl.bindBuffer(gl.ARRAY_BUFFER,terrEdgeVBO); gl.bufferData(gl.ARRAY_BUFFER,data,gl.STATIC_DRAW);
196
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,terrEdgeIBO); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,indices,gl.STATIC_DRAW);
197
+ gl.bindVertexArray(null);
198
+ }
199
+ }
200
+
201
  /* Rewrite a rectangular window of terrain vertices — positions, normals and
202
  colours — from the current heightfield. Normals are central differences of
203
  the heightfield rather than face normals, which keeps the surface smooth
 
355
  gl.drawElements(gl.TRIANGLES,terrIdxCount,gl.UNSIGNED_INT,0);
356
  drawCalls++; triCount+=terrIdxCount/3;
357
  }
358
+ function drawTerrainEdge(){
359
+ if(!terrEdgeVAO||!terrEdgeIdxCount) return;
360
+ gl.bindVertexArray(terrEdgeVAO);
361
+ gl.drawElements(gl.TRIANGLES,terrEdgeIdxCount,gl.UNSIGNED_INT,0);
362
+ drawCalls++; triCount+=terrEdgeIdxCount/3;
363
+ }
364
  function drawWater(){
365
  if(!waterVAO||!waterIdxCount) return;
366
  gl.bindVertexArray(waterVAO);
src/factions.js CHANGED
@@ -18,29 +18,38 @@
18
 
19
  const FACART={
20
  nova:{
21
- id:'nova', nm:'Nova Federation', key:'nova', side:'player',
22
- motto:'ORDER. SCIENCE. UNITY.',
23
  col:'#5db6ff', col2:'#0d2740',
24
  cdr:'Captain Elara Kai',
25
  quote:'For peace. For the people. For tomorrow.',
26
- lore:'The Nova Federation brings stability to a fragmented galaxy through '+
27
- 'advanced technology, disciplined military, and an unshakable belief '+
28
- 'in a united future.',
 
 
 
 
29
  bonusNm:'Advanced Engineering',
30
  bonus:'Units build faster and cost less.',
31
  units:[['STRIKER','Light Skirmisher','First on the field, first to fall. Numbers, not armour.'],
32
  ['RHINO','Main Battle Tank','The line everything else is built behind.'],
33
  ['GOLIATH','Assault Walker','450 hull and a splash cannon. Breaks a defended position.'],
34
- ['WASP','Interceptor','Air cover. Cheap enough to lose and fast enough not to.']]},
 
35
  ascendancy:{
36
- id:'ascendancy', nm:'Red Ascendancy', key:'legion', side:'ai',
37
- motto:'STRENGTH. DUTY. ASCENSION.',
38
  col:'#ff6b58', col2:'#3a0f12',
39
  cdr:'Lord Darion Vex',
40
  quote:'The weak are fuel. The strong ascend.',
41
- lore:'The Red Ascendancy believes in merit through might. Through relentless '+
42
- 'conquest and absolute discipline, they forge humanity into its '+
43
- 'strongest form.',
 
 
 
 
44
  bonusNm:'Martial Doctrine',
45
  bonus:'All combat units deal more damage the longer they remain in combat.',
46
  units:[['GOLIATH','Assault Walker','Ascendancy doctrine starts here: walk forward, do not stop.'],
@@ -53,30 +62,40 @@ const FACART={
53
  col:'#8ce85a', col2:'#12300f',
54
  cdr:'Broker Lys Renn',
55
  quote:'Information is power. Profit is freedom.',
56
- lore:'The Syndicate Coalition thrives in chaos. Using superior intel, '+
57
- 'guerrilla tactics and economic control, they turn uncertainty into '+
58
- 'power.',
 
 
 
 
59
  bonusNm:'Black Market',
60
  bonus:'Gain additional resources from captured deposits and structures.',
61
  units:[['KESTREL','Recon Flyer','150 range on a 120-hull airframe. Sees first, leaves first.'],
62
  ['WASP','Interceptor','Air superiority bought cheaply and replaced often.'],
63
  ['RAPTOR','Strike Craft','85 damage in a 32 splash. Hits a worker line and is gone.'],
64
- ['RESONATOR','Sonic Platform','Its sound passes straight through shields.']]},
 
65
  horde:{
66
- id:'horde', nm:'Umbral Brood', key:'horde', side:'ai',
67
- motto:'CONSUME. SPREAD. ENDURE.',
68
  col:'#b978ff', col2:'#22103a',
69
  cdr:'The Brood Sovereign',
70
  quote:'It does not negotiate. It arrives.',
71
- lore:'No one has found where the Brood began. It answers no hail, holds no '+
72
- 'ground it has not eaten, and every world it leaves behind is quiet in '+
73
- 'the same way.',
 
 
 
 
74
  bonusNm:'Endless Spawn',
75
  bonus:'Cheaper broods, faster hatcheries, and packs that grow as they feed.',
76
- units:[['STRIKER','Brood Skirmisher','Worthless alone. Never alone.'],
77
- ['PYRO','Spitter','Short-ranged burning splash. Made for packed formations.'],
78
- ['CINDER','Immolator','46 splash of flame. What the swarm sends at a defended wall.'],
79
- ['ALPHA RAVAGER','Brood Alpha','1500 hull at melee range. The thing behind the tide.']]},
 
80
  };
81
 
82
  /* Map an ai.js faction key back to its art entry. */
@@ -140,6 +159,9 @@ function facCard(id){
140
  +'<div class="facUnits">'
141
  +A.units.map(u=>'<div class="facUnit"><b>'+u[0]+'</b><span>'+u[1]+'</span><i>'+u[2]+'</i></div>').join('')
142
  +'</div>'
 
 
 
143
  +'<div class="facBonus"><span class="facBonusNm">'+A.bonusNm+'</span>'+A.bonus+'</div>'
144
  +'</section>';
145
  }
 
18
 
19
  const FACART={
20
  nova:{
21
+ id:'nova', nm:'Terran Frontline Command', key:'nova', side:'player',
22
+ motto:'DISCIPLINE. TECHNOLOGY. UNITY.',
23
  col:'#5db6ff', col2:'#0d2740',
24
  cdr:'Captain Elara Kai',
25
  quote:'For peace. For the people. For tomorrow.',
26
+ lore:'Frontline Command is a combined-arms expedition built around hardened positions, '+
27
+ 'precision fire and dependable battlefield logistics. It wins by seeing first, '+
28
+ 'building a line, then advancing that line without breaking it.',
29
+ strong:'Long-range fire · durable defence · flexible combined arms',
30
+ weak:'Slower deployment · costly advanced upgrades · flanking and EMP pressure',
31
+ endgoal:'Restore the Halcyon relay lattice, evacuate the surviving colonies, and found a defensive alliance strong enough to stop the next invasion.',
32
+ arc:'LANDING → RELIEF CORRIDOR → PLANETARY SHIELD',
33
  bonusNm:'Advanced Engineering',
34
  bonus:'Units build faster and cost less.',
35
  units:[['STRIKER','Light Skirmisher','First on the field, first to fall. Numbers, not armour.'],
36
  ['RHINO','Main Battle Tank','The line everything else is built behind.'],
37
  ['GOLIATH','Assault Walker','450 hull and a splash cannon. Breaks a defended position.'],
38
+ ['WASP','Interceptor','Air cover. Cheap enough to lose and fast enough not to.'],
39
+ ['ATLAS SKYCRANE','Heavy Air Transport','Four lift pods carry a mixed light force over terrain and deploy it in formation.']]},
40
  ascendancy:{
41
+ id:'ascendancy', nm:'Crimson Dominion', key:'legion', side:'ai',
42
+ motto:'STRENGTH. AUTHORITY. CONQUEST.',
43
  col:'#ff6b58', col2:'#3a0f12',
44
  cdr:'Lord Darion Vex',
45
  quote:'The weak are fuel. The strong ascend.',
46
+ lore:'The Dominion is an authoritarian war machine of armoured offensives, '+
47
+ 'suppression batteries and siege engines. It wins by forcing one decisive '+
48
+ 'front and breaking it with overwhelming burst damage.',
49
+ strong:'Heavy frontal assaults · anti-armour · siege and morale pressure',
50
+ weak:'Predictable lanes · expensive elites · disruption and mobile harassment',
51
+ endgoal:'Seize every pre-collapse war forge on Halcyon and crown Vex as the sole authority capable of unifying humanity by force.',
52
+ arc:'CONQUEST → WAR FORGES → IMPERIAL ASCENSION',
53
  bonusNm:'Martial Doctrine',
54
  bonus:'All combat units deal more damage the longer they remain in combat.',
55
  units:[['GOLIATH','Assault Walker','Ascendancy doctrine starts here: walk forward, do not stop.'],
 
62
  col:'#8ce85a', col2:'#12300f',
63
  cdr:'Broker Lys Renn',
64
  quote:'Information is power. Profit is freedom.',
65
+ lore:'The Coalition fields autonomous constructs, predictive targeting and the '+
66
+ 'most advanced energy weapons on Halcyon. Its machine cadres reshape an '+
67
+ 'engagement before slower armies understand the threat.',
68
+ strong:'Precision energy weapons · drones · shields · area control',
69
+ weak:'Low hull strength · setup time · melee pressure · EMP and resource drain',
70
+ endgoal:'Awaken the buried planetary intelligence, merge every market and defence network into it, and make the Coalition indispensable to all survivors.',
71
+ arc:'INFILTRATION → MACHINE AWAKENING → PERFECT NETWORK',
72
  bonusNm:'Black Market',
73
  bonus:'Gain additional resources from captured deposits and structures.',
74
  units:[['KESTREL','Recon Flyer','150 range on a 120-hull airframe. Sees first, leaves first.'],
75
  ['WASP','Interceptor','Air superiority bought cheaply and replaced often.'],
76
  ['RAPTOR','Strike Craft','85 damage in a 32 splash. Hits a worker line and is gone.'],
77
+ ['RESONATOR','Sonic Platform','Its sound passes straight through shields.'],
78
+ ['PHASE ARK','Energy Air Transport','A cockpitless delta carrier that phase-transfers light constructs through defended terrain.']]},
79
  horde:{
80
+ id:'horde', nm:'Brood Swarm', key:'horde', side:'ai',
81
+ motto:'EVOLVE. ASSIMILATE. ENDURE.',
82
  col:'#b978ff', col2:'#22103a',
83
  cdr:'The Brood Sovereign',
84
  quote:'It does not negotiate. It arrives.',
85
+ lore:'The Brood is a wholly biological hive: no vehicles, factories or machine '+
86
+ 'weapons. It grows organisms, spreads living territory, mutates under '+
87
+ 'pressure and overwhelms defences through relentless numbers.',
88
+ strong:'Critical mass · anti-light claws · structure rending · caster utility',
89
+ weak:'Area fire · long-range siege · detection · exposed growth organs',
90
+ endgoal:'Root a world-spanning neural organism in Halcyon’s mantle, assimilate every useful genome, and launch living seed-ships toward the inner systems.',
91
+ arc:'INFESTATION → PLANETARY NERVOUS SYSTEM → STAR SEEDING',
92
  bonusNm:'Endless Spawn',
93
  bonus:'Cheaper broods, faster hatcheries, and packs that grow as they feed.',
94
+ units:[['RAVAGER','Ravager','118 hull. Fast claws rend light armor and structures; isolated bodies die quickly.'],
95
+ ['TIDECASTER','Brood Tidecaster','Forms from 28 nearby creatures and organizes the mass into one targeted tide.'],
96
+ ['SPITTER','Spitter','Biological ranged utility for breaking packed light formations.'],
97
+ ['ALPHA RAVAGER','Brood Alpha','A tougher living breach-organism, still dependent on numbers and leadership.'],
98
+ ['MASSFLESH','Living Breach Carrier','Broods merge into its body; it can float briefly, lash structures, and birth a tide behind defensive lines.']]},
99
  };
100
 
101
  /* Map an ai.js faction key back to its art entry. */
 
159
  +'<div class="facUnits">'
160
  +A.units.map(u=>'<div class="facUnit"><b>'+u[0]+'</b><span>'+u[1]+'</span><i>'+u[2]+'</i></div>').join('')
161
  +'</div>'
162
+ +'<div class="facDoctrine"><div class="facStrong"><b>STRONG AT</b>'+A.strong+'</div>'
163
+ +'<div class="facWeak"><b>WEAK AGAINST</b>'+A.weak+'</div></div>'
164
+ +'<div class="facEndgoal"><span>CAMPAIGN ENDGOAL</span><b>'+A.arc+'</b><p>'+A.endgoal+'</p></div>'
165
  +'<div class="facBonus"><span class="facBonusNm">'+A.bonusNm+'</span>'+A.bonus+'</div>'
166
  +'</section>';
167
  }
src/game/ai.js CHANGED
@@ -172,6 +172,7 @@ function aiFreeSpot(type){
172
  const a=Math.random()*TAU, d=70+Math.random()*320;
173
  const x=clamp(AI.base.x+Math.cos(a)*d,edge,MAP-edge);
174
  const y=clamp(AI.base.y+Math.sin(a)*d,edge,MAP-edge);
 
175
  if(!footOnLand(type,x,y,0,fac)||footBlocked(type,x,y,0,null,fac)) continue;
176
  let ok=true;
177
  for(const D of deposits){
@@ -240,7 +241,7 @@ function aiTick(dt){
240
  const reach=(900+AI.t*0.9)*(900+AI.t*0.9);
241
  for(let d=0;d<deposits.length;d++){
242
  const D=deposits[d];
243
- if(D.taken) continue;
244
  const dd=dist2(D.x,D.y,AI.base.x,AI.base.y);
245
  if(dd<bd && dd<reach){ bd=dd; bestD=d; }
246
  }
@@ -525,9 +526,16 @@ function aiTick(dt){
525
  const px3=-dy2/L, py3=dx2/L;
526
  const c1=[clamp(mx2+px3*320,60,MAP-60),clamp(my2+py3*320,60,MAP-60)];
527
  const c2=[clamp(mx2-px3*320,60,MAP-60),clamp(my2-py3*320,60,MAP-60)];
 
 
 
 
528
  const w=defenseAt(c1[0],c1[1],0)<=defenseAt(c2[0],c2[1],0)?c1:c2;
529
  tx=clamp(tx+(w[0]-mx2)*0.7,60,MAP-60);
530
  ty=clamp(ty+(w[1]-my2)*0.7,60,MAP-60);
 
 
 
531
  }
532
  /* Easy commits about half its standing army instead of nearly all of it, so
533
  a wave is something you repel rather than something you survive — and the
 
172
  const a=Math.random()*TAU, d=70+Math.random()*320;
173
  const x=clamp(AI.base.x+Math.cos(a)*d,edge,MAP-edge);
174
  const y=clamp(AI.base.y+Math.sin(a)*d,edge,MAP-edge);
175
+ if(typeof battlefieldContains==='function'&&!battlefieldContains(x,y,edge))continue;
176
  if(!footOnLand(type,x,y,0,fac)||footBlocked(type,x,y,0,null,fac)) continue;
177
  let ok=true;
178
  for(const D of deposits){
 
241
  const reach=(900+AI.t*0.9)*(900+AI.t*0.9);
242
  for(let d=0;d<deposits.length;d++){
243
  const D=deposits[d];
244
+ if(D.taken||depositTier(D)<=0) continue;
245
  const dd=dist2(D.x,D.y,AI.base.x,AI.base.y);
246
  if(dd<bd && dd<reach){ bd=dd; bestD=d; }
247
  }
 
526
  const px3=-dy2/L, py3=dx2/L;
527
  const c1=[clamp(mx2+px3*320,60,MAP-60),clamp(my2+py3*320,60,MAP-60)];
528
  const c2=[clamp(mx2-px3*320,60,MAP-60),clamp(my2-py3*320,60,MAP-60)];
529
+ if(typeof battlefieldClampPoint==='function'){
530
+ const a1=battlefieldClampPoint(c1[0],c1[1],60),a2=battlefieldClampPoint(c2[0],c2[1],60);
531
+ c1[0]=a1[0];c1[1]=a1[1];c2[0]=a2[0];c2[1]=a2[1];
532
+ }
533
  const w=defenseAt(c1[0],c1[1],0)<=defenseAt(c2[0],c2[1],0)?c1:c2;
534
  tx=clamp(tx+(w[0]-mx2)*0.7,60,MAP-60);
535
  ty=clamp(ty+(w[1]-my2)*0.7,60,MAP-60);
536
+ if(typeof battlefieldClampPoint==='function'){
537
+ const p=battlefieldClampPoint(tx,ty,60);tx=p[0];ty=p[1];
538
+ }
539
  }
540
  /* Easy commits about half its standing army instead of nearly all of it, so
541
  a wave is something you repel rather than something you survive — and the
src/game/commander.js CHANGED
@@ -172,6 +172,87 @@ function artBarrageButtonState(){
172
  b.title=desc;b.setAttribute('aria-label',desc);
173
  }
174
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  function heroXP(x){
176
  if(heroIdx<0) return;
177
  heroXp+=x;
@@ -290,6 +371,7 @@ function fireBlast(wx,wy){
290
  }
291
  function abilTick(dt){
292
  for(let i=0;i<abCool.length;i++) if(abCool[i]>0) abCool[i]-=dt;
 
293
  artBarrageTick(dt);
294
  }
295
  /* ---------- ORBITAL LANCE — a sweeping beam bought in the Armory ---------- */
 
172
  b.title=desc;b.setAttribute('aria-label',desc);
173
  }
174
 
175
+ /* ---------- CONTEXTUAL CLASS DOCTRINES ------------------------------------
176
+ One adaptive button keeps the phone HUD readable while still giving three
177
+ major unit families a real unlocked active. The selected composition decides
178
+ which command is presented; mixed selections choose the family with the most
179
+ eligible units. Artillery retains its authored target-and-charge Barrage. */
180
+ const CLASS_AB={
181
+ assault:{nm:'BREAK',em:'➤',req:'breakthrough',energy:130,cool:44,dur:9,
182
+ cats:['inf','veh','at','aoe','exp'],
183
+ ds:'+28% damage, +22% speed and faster fire for 9s; units take +12% damage.'},
184
+ intercept:{nm:'INTERCEPT',em:'⌁',req:'intercept',energy:95,cool:38,dur:8,
185
+ cats:['air','aa'],
186
+ ds:'+58% speed, +22% range and faster tracking for 8s.'},
187
+ service:{nm:'SERVICE',em:'✚',req:'fieldservice',energy:115,cool:34,dur:7,
188
+ cats:['sup'],
189
+ ds:'Support units restore nearby allies and project a 28% damage screen for 7s.'}
190
+ };
191
+ const classAbCool={assault:0,intercept:0,service:0};
192
+ function classAbilityEligible(A){
193
+ const out=[];
194
+ for(let i=0;i<unitHigh;i++)if(ualive[i]&&uteam[i]===0&&usel[i]&&A.cats.indexOf(TYPES[utype[i]].cat)>=0)out.push(i);
195
+ return out;
196
+ }
197
+ function classAbilityChoice(){
198
+ let best=null,bestN=0;
199
+ for(const k of ['service','intercept','assault']){
200
+ const n=classAbilityEligible(CLASS_AB[k]).length;
201
+ if(n>bestN){best=k;bestN=n;}
202
+ }
203
+ return best?{key:best,A:CLASS_AB[best],units:classAbilityEligible(CLASS_AB[best])}:null;
204
+ }
205
+ function classAbilityUnlocked(A){return typeof devHas==='function'&&devHas(A.req);}
206
+ function tryClassAbility(){
207
+ const C=classAbilityChoice();
208
+ if(!C){toast('Select assault, interceptor, anti-air, or support units first');sfx('ui');return;}
209
+ const A=C.A;
210
+ if(!classAbilityUnlocked(A)){
211
+ const n=(typeof DEVTREE!=='undefined'&&DEVTREE.find(x=>x.id===A.req));
212
+ toast('LOCKED — research '+(n?n.nm:A.req)+' in DEVELOPMENT > DOCTRINE');sfx('ui');return;
213
+ }
214
+ if(classAbCool[C.key]>0){toast(A.nm+' REARMING — '+Math.ceil(classAbCool[C.key])+'s');return;}
215
+ if(!canAfford(0,0,A.energy)){toast(A.nm+' NEEDS '+A.energy+' ENERGY');return;}
216
+ pay(0,0,A.energy);classAbCool[C.key]=A.cool;
217
+ const touched=new Set(),centres=C.units;
218
+ if(C.key==='service'){
219
+ for(const i of centres){
220
+ uclassBuff[i]=3;uclassBuffT[i]=A.dur;
221
+ forUnitsIn(ux[i],uy[i],125,j=>{
222
+ if(uteam[j]!==0)return;touched.add(j);uclassBuff[j]=3;uclassBuffT[j]=Math.max(uclassBuffT[j],A.dur);
223
+ uhp[j]=Math.min(uhpm[j],uhp[j]+uhpm[j]*.26+55);
224
+ addParticle(0,ux[j],uy[j],0,-8,.42,6,82,255,164);
225
+ });
226
+ for(const B of blds)if(B.alive&&B.team===0&&dist2(ux[i],uy[i],B.x,B.y)<=145*145){
227
+ B.hp=Math.min(B.hpm,B.hp+B.hpm*.18+80);touched.add(B);
228
+ }
229
+ addParticle(3,ux[i],uy[i],0,0,.75,250,82,255,164);
230
+ }
231
+ }else{
232
+ const kind=C.key==='intercept'?2:1;
233
+ for(const i of centres){uclassBuff[i]=kind;uclassBuffT[i]=A.dur;touched.add(i);
234
+ const col=kind===2?[75,225,255]:[255,112,54];
235
+ addParticle(3,ux[i],uy[i],0,0,.5,TYPES[utype[i]].size*2.5,col[0],col[1],col[2]);
236
+ }
237
+ }
238
+ let cx=0,cy=0;for(const i of centres){cx+=ux[i];cy+=uy[i];}cx/=centres.length;cy/=centres.length;
239
+ toast(A.em+' '+A.nm+' — '+touched.size+' '+(touched.size===1?'ASSET':'ASSETS')+' AFFECTED');
240
+ sfx(C.key==='service'?'heal':'surge',cx,cy,1.05);
241
+ if(typeof radioAck==='function')radioAck('ability',touched.size,cx,cy);
242
+ }
243
+ function classAbilityReset(){for(const k in classAbCool)classAbCool[k]=0;}
244
+ function classAbilityTick(dt){for(const k in classAbCool)if(classAbCool[k]>0)classAbCool[k]=Math.max(0,classAbCool[k]-dt);}
245
+ function classAbilityButtonState(){
246
+ const b=document.getElementById('abClass');if(!b)return;
247
+ const C=classAbilityChoice(),cd=b.querySelector('.cdring'),em=document.getElementById('classAbEm'),nm=document.getElementById('classAbNm');
248
+ b.style.display=C?'flex':'none';if(!C)return;
249
+ const A=C.A,locked=!classAbilityUnlocked(A);em.textContent=A.em;nm.textContent=A.nm;
250
+ let cover='';if(locked)cover='LOCK';else if(classAbCool[C.key]>0)cover=Math.ceil(classAbCool[C.key]);else if(resE[0]<A.energy)cover='E';
251
+ b.classList.toggle('cd',!!cover);cd.style.display=cover?'flex':'none';cd.textContent=cover;
252
+ const desc=A.nm+' — '+A.energy+' energy, '+A.cool+'s cooldown. '+A.ds;
253
+ b.title=desc;b.setAttribute('aria-label',desc);
254
+ }
255
+
256
  function heroXP(x){
257
  if(heroIdx<0) return;
258
  heroXp+=x;
 
371
  }
372
  function abilTick(dt){
373
  for(let i=0;i<abCool.length;i++) if(abCool[i]>0) abCool[i]-=dt;
374
+ classAbilityTick(dt);
375
  artBarrageTick(dt);
376
  }
377
  /* ---------- ORBITAL LANCE — a sweeping beam bought in the Armory ---------- */
src/game/economy.js CHANGED
@@ -42,7 +42,20 @@ function econTick(dt){
42
  for(const B of bldLive){
43
  if(!B.alive||B.team!==team||B.prog<1) continue;
44
  if(B.type==='hq'){ mi+=5.0; ei+=26; }
45
- if(B.type==='mex') mi+= (B.lvl===3?11 : B.lvl===2?7 : 4)*(B.rich?2:1)*(team===0&&WC.veins?0.7:1);
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  else if(B.type==='pgen') ei+= B.lvl===3?38 : B.lvl===2?24 : 14;
47
  else if(B.type==='geo') ei+=30;
48
  else if(B.type==='silo') silos++;
@@ -131,6 +144,10 @@ function snapPlace(){
131
  x = ax!==null? ax : Math.round((x-offX)/SNAP)*SNAP+offX;
132
  y = ay!==null? ay : Math.round((y-offY)/SNAP)*SNAP+offY;
133
  placing.x=clamp(x,40,MAP-40); placing.y=clamp(y,40,MAP-40);
 
 
 
 
134
  }
135
  function startPlacing(type){
136
  // pull back to a framing where the build grid is legible, if we're very close
@@ -149,6 +166,10 @@ function startPlacing(type){
149
  function placementValid(){
150
  if(!placing) return false;
151
  const T=BT[placing.type];
 
 
 
 
152
  if(!canAfford(0,T.cm,T.ce)) return false;
153
  if(placing.type==='mex'){
154
  return depositAt(placing.x,placing.y,34)>=0;
 
42
  for(const B of bldLive){
43
  if(!B.alive||B.team!==team||B.prog<1) continue;
44
  if(B.type==='hq'){ mi+=5.0; ei+=26; }
45
+ if(B.type==='mex'){
46
+ const D=B.dep>=0?deposits[B.dep]:null, before=depositTier(D);
47
+ if(before>0){
48
+ const base=(B.lvl===3?11 : B.lvl===2?7 : 4)*(DEPOSIT_YIELD[before]||1)*(team===0&&WC.veins?0.7:1);
49
+ const got=drainDeposit(D,base*dt);
50
+ mi+=got/Math.max(dt,.0001);
51
+ B.nodeTier=depositTier(D); B.nodeRemaining=D.remaining;
52
+ if(team===0&&B.nodeTier!==before){
53
+ if(B.nodeTier>0) toast('◇ PHASE FIELD DOWNGRADED — TIER '+B.nodeTier+' · '+Math.ceil(D.remaining)+' ore remains');
54
+ else toast('◇ PHASE FIELD DEPLETED — relocate the Prospector or claim another node');
55
+ sfx('notify',B.x,B.y,.72);
56
+ }
57
+ } else { B.nodeTier=0; B.nodeRemaining=0; }
58
+ }
59
  else if(B.type==='pgen') ei+= B.lvl===3?38 : B.lvl===2?24 : 14;
60
  else if(B.type==='geo') ei+=30;
61
  else if(B.type==='silo') silos++;
 
144
  x = ax!==null? ax : Math.round((x-offX)/SNAP)*SNAP+offX;
145
  y = ay!==null? ay : Math.round((y-offY)/SNAP)*SNAP+offY;
146
  placing.x=clamp(x,40,MAP-40); placing.y=clamp(y,40,MAP-40);
147
+ if(typeof battlefieldClampPoint==='function'){
148
+ const edgePad=Math.hypot(fw,fh)*.5+8,p=battlefieldClampPoint(placing.x,placing.y,edgePad);
149
+ placing.x=p[0];placing.y=p[1];
150
+ }
151
  }
152
  function startPlacing(type){
153
  // pull back to a framing where the build grid is legible, if we're very close
 
166
  function placementValid(){
167
  if(!placing) return false;
168
  const T=BT[placing.type];
169
+ if(typeof battlefieldContains==='function'){
170
+ const f=bldFoot(placing.type),edgePad=Math.hypot(f[0],f[1])*.5+8;
171
+ if(!battlefieldContains(placing.x,placing.y,edgePad))return false;
172
+ }
173
  if(!canAfford(0,T.cm,T.ce)) return false;
174
  if(placing.type==='mex'){
175
  return depositAt(placing.x,placing.y,34)>=0;
src/game/meta.js CHANGED
@@ -232,6 +232,7 @@ function invRarityRoll(win,seed){
232
  function invGrantMatchLoot(win){
233
  const b=invBag(), p=activeProf(), seed=(p?p.id:'p')+'|'+META.matches+'|'+(stats.kills[0]|0)+'|'+(stats.t|0)+'|'+difficulty;
234
  const rarity=invRarityRoll(win,seed), loot={gear:null,consumables:[]};
 
235
  /* Victories always award gear. A committed loss can still recover a piece,
236
  but a quick surrender cannot be used to farm the inventory. */
237
  const gearDrop=win||((stats.t|0)>=180&&(invHash(seed+'|gear')%100)<35);
@@ -363,7 +364,8 @@ function pickWildcards(n){
363
  fallback for a player who has never opened that screen, not a tax on one
364
  who has. */
365
  if(typeof opModsOn==='function'){
366
- const chosen=Object.keys(opModsOn());
 
367
  if(chosen.length){
368
  for(const id of chosen){
369
  const w=WILDCARDS.find(x=>x.id===id);
@@ -376,7 +378,11 @@ function pickWildcards(n){
376
  return;
377
  }
378
  }
379
- const pool=[...WILDCARDS];
 
 
 
 
380
  for(let k=0;k<n&&pool.length;k++){
381
  const w=pool.splice(Math.random()*pool.length|0,1)[0];
382
  WC[w.id]=true; wcActive.push(w);
@@ -403,11 +409,18 @@ function renderWcRow(){
403
  function rewardDayKey(){
404
  const d=new Date(); return d.getFullYear()+'-'+(d.getMonth()+1)+'-'+d.getDate();
405
  }
 
 
 
 
 
 
406
  /* Cores used to be one opaque formula dominated by raw kill count. That made a
407
  defensive victory feel worse than farming wildlife and gave no clue what the
408
  economy valued. This ledger caps farmable categories, pays the objective and
409
  chosen difficulty, and rewards a genuinely fortified base. */
410
  function coreRewardLedger(win){
 
411
  const secs=stats.t|0, kills=stats.kills[0]|0;
412
  const fort=(typeof fortOf==='function'&&fortOf(0))?fortOf(0).tier|0:0;
413
  const completion=secs>=90?18:6;
@@ -427,12 +440,13 @@ function coreRewardLedger(win){
427
  }
428
  function metaGrant(win){
429
  const mult=wcRewardMult();
 
430
  /* Boosters apply on top of the wildcard multiplier, so a player who stacked
431
  both sees a genuinely large number — which is the point of spending an
432
  hour-long booster on a hard match rather than an easy one. */
433
  const bx=(typeof boostMul==='function')?boostMul('xp'):1;
434
  const bc=(typeof boostMul==='function')?boostMul('cores'):1;
435
- const xp=Math.round((40+stats.kills[0]*0.35+(win?120:30)+difficulty*40)*mult*bx);
436
  const ledger=coreRewardLedger(win);
437
  const cores=Math.round(ledger.base*mult*bc);
438
  const data=(typeof researchDataFromMatch==='function')?researchDataFromMatch(win):{total:0,parts:[]};
@@ -601,6 +615,17 @@ function mfSetTabs(root,key,focus){
601
  if(scroller) scroller.scrollTop=0;
602
  const b=tabs.find(x=>x.dataset.mfTab===active);
603
  if(b) try{ b.focus({preventScroll:true}); }catch(e){ b.focus(); }
 
 
 
 
 
 
 
 
 
 
 
604
  if(typeof sfx==='function') sfx('ui');
605
  }
606
  }
 
232
  function invGrantMatchLoot(win){
233
  const b=invBag(), p=activeProf(), seed=(p?p.id:'p')+'|'+META.matches+'|'+(stats.kills[0]|0)+'|'+(stats.t|0)+'|'+difficulty;
234
  const rarity=invRarityRoll(win,seed), loot={gear:null,consumables:[]};
235
+ if(typeof matchCommitted==='function'&&!matchCommitted(win)) return loot;
236
  /* Victories always award gear. A committed loss can still recover a piece,
237
  but a quick surrender cannot be used to farm the inventory. */
238
  const gearDrop=win||((stats.t|0)>=180&&(invHash(seed+'|gear')%100)<35);
 
364
  fallback for a player who has never opened that screen, not a tax on one
365
  who has. */
366
  if(typeof opModsOn==='function'){
367
+ const chosen=Object.keys(opModsOn()).filter(id=>typeof opModUnlocked!=='function'||opModUnlocked(id)||
368
+ (typeof weeklyMode!=='undefined'&&weeklyMode));
369
  if(chosen.length){
370
  for(const id of chosen){
371
  const w=WILDCARDS.find(x=>x.id===id);
 
378
  return;
379
  }
380
  }
381
+ const pool=WILDCARDS.filter(w=>{
382
+ if(typeof opModUnlocked!=='function') return true;
383
+ const mod=typeof OPMODS!=='undefined'&&OPMODS.find(o=>o.id===w.id);
384
+ return !mod||opModUnlocked(mod);
385
+ });
386
  for(let k=0;k<n&&pool.length;k++){
387
  const w=pool.splice(Math.random()*pool.length|0,1)[0];
388
  WC[w.id]=true; wcActive.push(w);
 
409
  function rewardDayKey(){
410
  const d=new Date(); return d.getFullYear()+'-'+(d.getMonth()+1)+'-'+d.getDate();
411
  }
412
+ /* One anti-farm rule for every persistent reward. A real early defeat still
413
+ counts when the player fought or built; opening a match and immediately
414
+ quitting cannot mint XP, cores, loot, Data or crafting materials. */
415
+ function matchCommitted(win){
416
+ return !!win||(stats.t|0)>=60||(stats.kills[0]|0)>=8||((stats.built&&stats.built[0])|0)>=3||(stats.nests|0)>=1;
417
+ }
418
  /* Cores used to be one opaque formula dominated by raw kill count. That made a
419
  defensive victory feel worse than farming wildlife and gave no clue what the
420
  economy valued. This ledger caps farmable categories, pays the objective and
421
  chosen difficulty, and rewards a genuinely fortified base. */
422
  function coreRewardLedger(win){
423
+ if(!matchCommitted(win)) return {base:0,parts:[]};
424
  const secs=stats.t|0, kills=stats.kills[0]|0;
425
  const fort=(typeof fortOf==='function'&&fortOf(0))?fortOf(0).tier|0:0;
426
  const completion=secs>=90?18:6;
 
440
  }
441
  function metaGrant(win){
442
  const mult=wcRewardMult();
443
+ const committed=matchCommitted(win);
444
  /* Boosters apply on top of the wildcard multiplier, so a player who stacked
445
  both sees a genuinely large number — which is the point of spending an
446
  hour-long booster on a hard match rather than an easy one. */
447
  const bx=(typeof boostMul==='function')?boostMul('xp'):1;
448
  const bc=(typeof boostMul==='function')?boostMul('cores'):1;
449
+ const xp=committed?Math.round((40+stats.kills[0]*0.35+(win?120:30)+difficulty*40)*mult*bx):0;
450
  const ledger=coreRewardLedger(win);
451
  const cores=Math.round(ledger.base*mult*bc);
452
  const data=(typeof researchDataFromMatch==='function')?researchDataFromMatch(win):{total:0,parts:[]};
 
615
  if(scroller) scroller.scrollTop=0;
616
  const b=tabs.find(x=>x.dataset.mfTab===active);
617
  if(b) try{ b.focus({preventScroll:true}); }catch(e){ b.focus(); }
618
+ /* Keep the active category fully inside its horizontal rail without using
619
+ scrollIntoView, which can also move the entire Android page vertically. */
620
+ if(b){
621
+ const rail=b.closest('.screenTabs');
622
+ if(rail) requestAnimationFrame(()=>{
623
+ const left=b.offsetLeft,right=left+b.offsetWidth,viewL=rail.scrollLeft,viewR=viewL+rail.clientWidth;
624
+ if(b===tabs[0]) rail.scrollLeft=0;
625
+ else if(left<viewL+4) rail.scrollLeft=Math.max(0,left-8);
626
+ else if(right>viewR-4) rail.scrollLeft=Math.max(0,right-rail.clientWidth+8);
627
+ });
628
+ }
629
  if(typeof sfx==='function') sfx('ui');
630
  }
631
  }
src/game/sim.js CHANGED
@@ -32,8 +32,8 @@ const TYPES=[
32
  {name:'Pyro', spr:'pyro', tur:null, size:16, r:6.0, hp:240, dmg:11, rng:58, cool:.38, spd:52, psp:130, ptype:5, aoe:18, wk:'m', tg:'g', cm:40, ce:160, bt:3.5, air:0, tier:1},
33
  {name:'Vulture', spr:'vulture',tur:null, size:16, r:6.0, hp:170, dmg:52, rng:190, cool:1.3, spd:42, psp:300, ptype:8, aoe:26, wk:'e', tg:'air',cm:42, ce:170, bt:3.6, air:0, tier:1},
34
  {name:'Bulwark', spr:'bulwark',tur:null, size:21, r:7.8, hp:950, dmg:0, rng:0, cool:9, spd:28, psp:0, ptype:0, aoe:0, wk:'n', tg:'a', cm:90, ce:380, bt:7.0, air:0, tier:2, upkeepE:5},
35
- {name:'Ravager', spr:'rav', tur:null, size:16, r:5.6, hp:230, dmg:30, rng:20, cool:1.0, spd:46, psp:0, ptype:0, aoe:0, wk:'m', tg:'g', cm:0, ce:0, bt:0, air:0, tier:0, legs:1},
36
- {name:'Alpha Ravager',spr:'rav',tur:null, size:28, r:10, hp:1500, dmg:90, rng:26, cool:1.4, spd:36, psp:0, ptype:0, aoe:12, wk:'m', tg:'g', cm:0, ce:0, bt:0, air:0, tier:0, legs:1},
37
  {name:'Corvette', spr:'corv', tur:null, size:20, r:7.5, hp:320, dmg:24, rng:115, cool:1.0, spd:42, psp:120, ptype:6, aoe:0, wk:'i', tg:'a', cm:55, ce:220, bt:5.0, air:0, tier:1, naval:1},
38
  {name:'Dreadnought',spr:'dread',tur:null, size:32, r:12, hp:1300, dmg:75, rng:290, cool:5.0, spd:20, psp:150, ptype:2, aoe:34, wk:'e', tg:'g', cm:170, ce:680, bt:12, air:0, tier:2, naval:1},
39
  {name:'Bombard', spr:'bombH', tur:'bombT', size:21, r:8, hp:400, dmg:95, rng:400, cool:7.0, spd:14, psp:150, ptype:9, aoe:44, wk:'e', tg:'g', cm:140, ce:560, bt:10, air:0, tier:2, minRng:100},
@@ -62,7 +62,16 @@ const TYPES=[
62
  hitting harder. They are never buildable: a faction fields its own. */
63
  {name:'Lord Darion Vex', spr:'praetor',tur:null, size:34, r:12, hp:6200, dmg:130,rng:250, cool:2.4, spd:22, psp:210, ptype:9, aoe:70, wk:'e', tg:'a', cm:0, ce:0, bt:0, air:0, tier:0, cat:'hero', legs:1, hero:'legion'},
64
  {name:'Broker Lys Renn', spr:'archon', tur:null, size:30, r:11, hp:4600, dmg:70, rng:165, cool:.55, spd:38, psp:0, ptype:6, aoe:10, wk:'s', tg:'a', cm:0, ce:0, bt:0, air:0, tier:0, cat:'hero', hero:'syndicate'},
65
- {name:'The Brood Sovereign',spr:'brood',tur:null, size:38, r:14, hp:7400, dmg:110,rng:34, cool:1.2, spd:30, psp:0, ptype:0, aoe:26, wk:'m', tg:'g', cm:0, ce:0, bt:0, air:0, tier:0, cat:'hero', legs:1, hero:'horde'},
 
 
 
 
 
 
 
 
 
66
  ];
67
  /* UNIT CATEGORIES. Used by the build menu, the unit card and the AI's
68
  composition logic, so a role is a real thing the game reasons about rather
@@ -89,6 +98,7 @@ const UCAT={
89
  for(const T of TYPES) if(!T.cat) T.cat=byName[T.name]||'veh';
90
  })();
91
  const UT_ENGINEER=19; // index of the Constructor in TYPES
 
92
  const SHIELD_REDUCE=0.72, SHIELD_R=95;
93
  const AGGRO_MULT=1.9, AGGRO_ADD=70;
94
  const TITAN_STOMP_DMG=210, TITAN_STOMP_R=85;
@@ -99,6 +109,24 @@ const uang=new Float32Array(MAXU), uturr=new Float32Array(MAXU);
99
  const utx=new Float32Array(MAXU), uty=new Float32Array(MAXU);
100
  const uhp=new Float32Array(MAXU), uhpm=new Float32Array(MAXU);
101
  const ucool=new Float32Array(MAXU), ubuff=new Float32Array(MAXU), ustomp=new Float32Array(MAXU);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  const utype=new Uint8Array(MAXU), uteam=new Uint8Array(MAXU);
103
  const ualive=new Uint8Array(MAXU), ustate=new Uint8Array(MAXU);
104
  const usel=new Uint8Array(MAXU);
@@ -160,7 +188,7 @@ function livingEnemyCommanders(){
160
  return enemyHeroIdxs;
161
  }
162
  function unitIsBrood(i){
163
- return uteam[i]===2 || utype[i]===12 || utype[i]===13 || utype[i]===30 ||
164
  (uteam[i]===1 && typeof AI!=='undefined' && AI.fac==='horde');
165
  }
166
 
@@ -251,6 +279,8 @@ const MODES=[
251
  {id:3,nm:'OVERDRIVE',em:'⏵', ds:'Redlined: +60% fire rate, +35% speed, bleeds HP'},
252
  {id:4,nm:'GHOST', em:'◌', ds:'Silent running: unseen until you fire, −35% speed'},
253
  {id:5,nm:'SUPPRESS', em:'≋', ds:'Sustained fire: +85% rate, −30% range, no movement'},
 
 
254
  ];
255
  /* Which modes a chassis can actually take. Artillery roots to shoot further;
256
  assault armour digs in; light frames redline or go quiet; flamers suppress. */
@@ -286,6 +316,8 @@ const UNIT_MODES=[
286
  [0,1], // 28 Praetor — siege
287
  [0,2], // 29 Archon — guard
288
  [0,3], // 30 Broodmother — overdrive
 
 
289
  ];
290
  const MODE_SWITCH=1.6; // seconds locked while deploying
291
  /* MODES ARE TRADES.
@@ -305,6 +337,10 @@ function modeCoolMul(m){ return m===3?0.625 : m===5?0.54 : 1; }
305
  function modeSpdMul(m){ return (m===1||m===5)?0 : m===2?0.40 : m===3?1.35 : m===4?0.65 : 1; }
306
  function modeTakenMul(m){ return m===2?0.55 : m===1?1.35 : m===3?1.15 : m===4?1.25 : 1; }
307
  function unitModes(ty){ return UNIT_MODES[ty]||[0]; }
 
 
 
 
308
  function cycleMode(i){
309
  const list=unitModes(utype[i]);
310
  if(list.length<2) return false;
@@ -331,13 +367,20 @@ function spawnUnit(type,team,x,y){
331
  let i;
332
  if(freeList.length) i=freeList.pop();
333
  else { if(unitHigh>=MAXU) return -1; i=unitHigh++; }
 
 
 
334
  const L=TYPES[type].naval? (findWater(x,y)||[x,y]) : findLand(x,y);
335
  x=L[0]; y=L[1];
 
 
 
336
  const T=TYPES[type];
337
  ux[i]=x; uy[i]=y; uang[i]=team?Math.PI:0; uturr[i]=uang[i];
338
  utx[i]=x; uty[i]=y;
339
  uhp[i]=T.hp*(team===0?resHpMult:(team===1?aiHpMult*(WC.iron?1.25:1):1)); uhpm[i]=uhp[i];
340
  ucool[i]=Math.random()*T.cool; ubuff[i]=0; ustomp[i]=0;
 
341
  utype[i]=type; uteam[i]=team; ualive[i]=1; ustate[i]=0; usel[i]=0;
342
  ugen[i]=(ugen[i]+1)|0; // this slot is now a different unit
343
  uwalk[i]=Math.random()*6.283; // desynchronise the gait across a squad
@@ -568,7 +611,7 @@ const BT={
568
  })();
569
  const ARM=[0,1,2,1,2, 0,1,1,2, 1,1,2, 0,2, 1,2, 1,0, 1, 0,
570
  1,1,1,1,0,0,2,2, // Reaper Cinder Lancer Resonator Warden Kestrel Basilisk Harbinger
571
- 2,1,2]; // Praetor Archon Broodmother
572
  const ARM_NM=['LIGHT','MEDIUM','HEAVY'];
573
  /* WEAPON vs ARMOUR.
574
  The original spread was +-15..30%, which sounds like a counter system and
@@ -604,7 +647,9 @@ const STM={p:0.9,b:1.0,m:1.1,e:1.55,g:1.20,f:0.65,s:1.15,i:1.05,n:1}; // vs st
604
  const AMMO_PTYPE={0:'CASELESS TRACER',1:'AP CANNON SHELL',2:'BALLISTIC HE SHELL',3:'COMMANDER HE SHELL',
605
  4:'UNGUIDED ROCKET',5:'THERMAL GEL',6:'ION PLASMA ORB',7:'GUIDED MISSILE',8:'FLAK AIRBURST',9:'CLUSTER MUNITION'};
606
  function ammoName(T){
607
- if(!T||T.wk==='n') return 'UNARMED';
 
 
608
  if(T.wk==='m') return T.ptype===5?'NAPALM JET':'MELEE STRIKE';
609
  if(T.wk==='b') return T.ptype===5?'THERMAL PLASMA BEAM':T.name==='Longbow'?'COHERENT SNIPER BEAM':T.name==='TITAN'?'TITAN PARTICLE LANCE':'LASER ENERGY';
610
  if(T.wk==='g') return 'GAUSS PENETRATOR';
@@ -736,7 +781,7 @@ const RESEARCH=[
736
  {id:'bal1', nm:'Ballistics I', em:'🗡', ds:'+12% unit damage', cm:120, ce:500, t:35, req:null, clvl:3},
737
  {id:'plate1',nm:'Plating I', em:'🛡', ds:'+15% unit HP', cm:130, ce:550, t:40, req:null, clvl:3},
738
  {id:'optics',nm:'Optics Array', em:'🔭', ds:'+12% unit range', cm:110, ce:480, t:35, req:null, clvl:4},
739
- {id:'nano', nm:'Nano Assembly', em:'⚙', ds:'+25% build speed', cm:140, ce:600, t:40, req:null, clvl:4},
740
  {id:'fusion',nm:'Fusion Cells', em:'⚡', ds:'+30% energy income', cm:120, ce:500, t:35, req:null, clvl:4},
741
  {id:'hardpoint',nm:'Hardpoint Bracing',em:'🏰',ds:'+25% structure HP',cm:140,ce:580,t:42,req:null,clvl:3},
742
  {id:'contain',nm:'Containment Lattice',em:'◆',ds:'+50% Research Complex shield',cm:135,ce:620,t:45,req:null,clvl:4},
@@ -838,7 +883,11 @@ function bldPanelStatText(B){
838
  if(B.type==='sgen') return 'FIELD '+bldNum(S.field)+' · STRUCTURE REPAIR '+bldNum(S.repair)+'/s';
839
  if(B.type==='uplink') return 'FIELD '+bldNum(S.field)+' · RANGE BOOST +'+bldNum((S.boost-1)*100)+'%';
840
  if(B.type==='nova') return 'STRIKE '+NOVA.dmg+' · RECHARGE '+bldNum(S.recharge)+'s';
841
- if(B.type==='mex') return 'MASS INCOME +'+S.income+'/s';
 
 
 
 
842
  if(B.type==='pgen') return 'ENERGY INCOME +'+S.income+'/s';
843
  return (BT[B.type].bcat||'STRUCTURE').toUpperCase()+' · HP '+Math.ceil(B.hpm);
844
  }
@@ -907,16 +956,20 @@ function addBld(type,team,x,y,instant,rot){
907
  const shieldMax=type==='techlab'?900*labBufferMult:0;
908
  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,
909
  cool:0,queue:[],repeat:false,prodT:0,heal:0,tier:1,lvl:1,upT:0,upMax:1,tang:team?Math.PI:0,
910
- seen:false,boost:0,boostM:UPLINK_BOOST,res:-1,resT:0,rally:null,rich:false,
911
  shield:instant?shieldMax:0,shieldMax,shieldT:0,dmgT:0,
912
  guardReady:type==='techlab',guardT:0,guardCharge:0,
913
  rot:rot||0,footTier:bldFootTierCount(type,fac),link:0,anim:Math.random()*10,animS:0,conduit:[]};
914
- if(type==='mex') for(const D of deposits) if(dist2(D.x,D.y,x,y)<9){ b.rich=D.rich; break; }
 
 
 
915
  if(type==='nova') b.cool=NOVA.cd*0.6; // first charge after construction
916
  blds.push(b); rebuildBGrid();
917
  /* Level and pave the ground for it. Doing this at placement rather than at
918
  completion means the site is visibly prepared while construction runs. */
919
  if(type!=='nest') makeFoundation(b);
 
920
  return b;
921
  }
922
  /* BASE UNDER ATTACK.
@@ -961,6 +1014,7 @@ function damageBld(b,dmg,attTeam){
961
  B.hitT=0.35; // drives the flinch/spark anim
962
  if(B.hp<=0){
963
  B.alive=false;
 
964
  spawnExplosion(B.x,B.y,BT[B.type].size*1.1,B.team===2?1:B.team);
965
  addCrater(B.x,B.y,BT[B.type].size*1.9);
966
  addRubble(B.x,B.y,BT[B.type].size*0.9);
@@ -989,6 +1043,30 @@ function damageBld(b,dmg,attTeam){
989
  // Procedurally seeded per map — every battlefield has its own economy layout,
990
  // always point-mirrored so both commanders get a fair start.
991
  const deposits=[], geysers=[];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
992
  function setupDeposits(){
993
  deposits.length=0; geysers.length=0;
994
  const def=MAPDEFS[curMap]||MAPDEFS.vanguard;
@@ -997,39 +1075,53 @@ function setupDeposits(){
997
  const starts=skirmishSpawnPoints(),all=[],gall=[];
998
  const addNode=(arr,x,y,rich,minD,starter)=>{
999
  x=clamp(x,100,MAP-100); y=clamp(y,100,MAP-100);
 
 
 
 
 
 
1000
  for(const p of arr) if(dist2(x,y,p[0],p[1])<minD*minD) return false;
1001
  arr.push(rich==null?[x,y,null,starter||'']:[x,y,rich,starter||'']); return true;
1002
  };
1003
  /* The selected start owns equal nearby resources. Choosing another edge
1004
  of the map must never secretly remove the opening economy. */
 
 
 
1005
  for(let si=0;si<starts.length;si++){
1006
  const S=starts[si],a=Math.atan2(MAP*.5-S.y,MAP*.5-S.x),fx=Math.cos(a),fy=Math.sin(a),rx=-fy,ry=fx;
1007
- addNode(all,S.x+fx*150,S.y+fy*150,0,95,S.zone);
1008
- addNode(all,S.x+fx*250+rx*115,S.y+fy*250+ry*115,0,95,S.zone);
1009
- addNode(all,S.x+fx*250-rx*115,S.y+fy*250-ry*115,0,95,S.zone);
1010
- addNode(gall,S.x+fx*355+rx*(si&1?100:-100),S.y+fy*355+ry*(si&1?100:-100),null,140,S.zone);
 
1011
  }
1012
- const bp=typeof battlefieldPresetDef==='function'?battlefieldPresetDef():{extra:0};
1013
- let tries=0,target=Math.max(18,starts.length*3+8+(bp.extra||0));
1014
  while(all.length<target&&tries++<700){
1015
- const x=rr(220,MAP-220),y=rr(220,MAP-220);
1016
- let clear=true; for(const S of starts) if(dist2(x,y,S.x,S.y)<360*360){clear=false;break;}
1017
- if(clear) addNode(all,x,y,(all.length%7===3)?1:0,155);
 
1018
  }
1019
- tries=0; const gTarget=Math.max(4,starts.length+2);
1020
  while(gall.length<gTarget&&tries++<500){
1021
- const x=rr(260,MAP-260),y=rr(260,MAP-260);
1022
- let clear=true; for(const p of all) if(dist2(x,y,p[0],p[1])<150*150){clear=false;break;}
1023
- if(clear) addNode(gall,x,y,null,210);
 
1024
  }
1025
  window.__depPts=all.concat(gall);
1026
- for(const p of all) deposits.push({x:p[0],y:p[1],taken:false,rich:!!p[2],starter:p[3]||''});
1027
  for(const p of gall) geysers.push({x:p[0],y:p[1],taken:false,starter:p[3]||''});
1028
  return;
1029
  }
1030
  const pts=[]; // [x,y,rich]
1031
  const bx=MAP*SP_LO, by=MAP*SP_HI;
1032
  const farOK=(x,y,minD)=>{
 
 
1033
  for(const p of pts){
1034
  if(dist2(x,y,p[0],p[1])<minD*minD) return false;
1035
  if(dist2(x,y,MAP-p[0],MAP-p[1])<minD*minD) return false;
@@ -1052,6 +1144,7 @@ function setupDeposits(){
1052
  while(gp.length<2 && tries++<300){
1053
  const x=rr(300,MAP-300), y=rr(300,MAP-300);
1054
  let ok=dist2(x,y,bx,by)>380*380 && dist2(x,y,MAP-bx,MAP-by)>380*380;
 
1055
  if(ok) for(const p of pts) if(dist2(x,y,p[0],p[1])<150*150||dist2(x,y,MAP-p[0],MAP-p[1])<150*150){ ok=false; break; }
1056
  if(ok) for(const g of gp) if(dist2(x,y,g[0],g[1])<220*220||dist2(x,y,MAP-g[0],MAP-g[1])<220*220){ ok=false; break; }
1057
  if(ok) gp.push([x,y]);
@@ -1060,7 +1153,7 @@ function setupDeposits(){
1060
  for(const p of pts){ all.push(p); all.push([MAP-p[0],MAP-p[1],p[2]]); }
1061
  for(const p of gp){ gall.push(p); gall.push([MAP-p[0],MAP-p[1]]); }
1062
  window.__depPts=all.concat(gall); // terrain raises land under all nodes
1063
- for(const p of all) deposits.push({x:p[0],y:p[1],taken:false,rich:!!p[2],starter:''});
1064
  for(const p of gall) geysers.push({x:p[0],y:p[1],taken:false,starter:''});
1065
  }
1066
  function geyserAt(x,y,rad){
@@ -1110,12 +1203,14 @@ function setupNests(){
1110
  const placed=[];
1111
  while(n<nestGoal && tries++<200){
1112
  const px2=rr(500,MAP-500), py2=rr(500,MAP-500);
 
1113
  if(typeof farFromStartZones==='function'&&!farFromStartZones(px2,py2,650)) continue;
1114
  if(laneDist(px2,py2)<LANE_CLEAR) continue; // keep the corridor passable
1115
  let clear=true;
1116
  for(const q of placed) if(dist2(px2,py2,q[0],q[1])<380*380){ clear=false; break; }
1117
  if(!clear) continue;
1118
- const L=findLand(px2,py2);
 
1119
  placed.push(L);
1120
  addBld('nest',2,L[0],L[1],true);
1121
  /* Guard units at the hive door scale down too — on Easy a wandering scout
@@ -1363,8 +1458,10 @@ function envTick(dt){
1363
  const nests=liveNests();
1364
  if(nests.length && nests.length<[3+tier,5+tier*1.5,6+tier*2][D]){
1365
  const N=nests[Math.random()*nests.length|0];
1366
- const L=findLand(N.x+rr(-300,300),N.y+rr(-300,300));
1367
- if(dist2(L[0],L[1],MAP*SP_LO,MAP*SP_HI)>480*480 && dist2(L[0],L[1],MAP*SP_HI,MAP*SP_LO)>480*480
 
 
1368
  && laneDist(L[0],L[1])>=LANE_CLEAR){
1369
  let clear=true;
1370
  for(const B of blds) if(B.alive&&dist2(L[0],L[1],B.x,B.y)<120*120){ clear=false; break; }
@@ -1509,7 +1606,15 @@ function spawnCrate(x,y,forced){
1509
  for(const q of CRATE_KINDS){ roll-=q.w; if(roll<=0){k=q;break;} }
1510
  k=k||CRATE_KINDS[0];
1511
  }
1512
- const L=findLand(x,y);
 
 
 
 
 
 
 
 
1513
  crates.push({x:L[0],y:L[1],kind:k,alt:520,t:0,seen:false});
1514
  return crates[crates.length-1];
1515
  }
@@ -1635,8 +1740,11 @@ function planDistricts(){
1635
  :dist2(x,y,spawnA[0],spawnA[1])>d*d&&dist2(x,y,spawnB[0],spawnB[1])>d*d;
1636
 
1637
  const plot=(x,y,w,h,a,kind,zone)=>{
 
 
1638
  cityPlan.push({x,y,w,h,a,kind,zone});
1639
  cityZones[zone].total++;
 
1640
  };
1641
  const makeDistrict=(cx2,cy2,ind)=>{
1642
  const zi=cityZones.length;
@@ -1688,6 +1796,7 @@ function planDistricts(){
1688
  const tryPlace=(ind,minD)=>{
1689
  for(let a=0;a<70;a++){
1690
  const x=rr(MAP*0.18,MAP*0.82), y=rr(MAP*0.18,MAP*0.82);
 
1691
  if(!farFromSpawns(x,y,700)||!isWalkable(x,y)) continue;
1692
  let clash=false;
1693
  for(const p of placed) if(dist2(x,y,p[0],p[1])<minD*minD){ clash=true; break; }
@@ -1718,20 +1827,23 @@ function setupRelics(){
1718
  // loot scattered through every district
1719
  for(const Z of cityZones) for(let k=0;k<(Z.ind?3:2);k++){
1720
  const a=rnd()*TAU, d=rr(60,250);
1721
- const L=findLand(clamp(Z.x+Math.cos(a)*d,80,MAP-80), clamp(Z.y+Math.sin(a)*d,80,MAP-80));
 
1722
  spawnCrate(L[0],L[1]);
1723
  if(crates.length) crates[crates.length-1].alt=0;
1724
  }
1725
  // industrial belts are ringed with volatile tanks — dangerous ground to fight over
1726
  for(const Z of cityZones) if(Z.ind) for(let k=0;k<5;k++){
1727
  const a=rnd()*TAU, d=rr(190,310);
1728
- const L=findLand(clamp(Z.x+Math.cos(a)*d,80,MAP-80), clamp(Z.y+Math.sin(a)*d,80,MAP-80));
 
1729
  tanks.push({x:L[0],y:L[1],s:rr(30,42),hp:260,alive:true,fuse:0});
1730
  }
1731
  const spawnA=[MAP*SP_LO,MAP*SP_HI], spawnB=[MAP*SP_HI,MAP*SP_LO];
1732
  const tn=10+(rnd()*8|0);
1733
  for(let k=0;k<tn;k++){
1734
- const L=findLand(rr(260,MAP-260),rr(260,MAP-260));
 
1735
  if(typeof farFromStartZones==='function'&&!farFromStartZones(L[0],L[1],420)) continue;
1736
  tanks.push({x:L[0],y:L[1],s:rr(28,40),hp:260,alive:true,fuse:0});
1737
  }
@@ -2408,7 +2520,7 @@ function band(a,b2){ return a+Math.random()*(b2-a); }
2408
  function depositAt(x,y,rad){
2409
  for(let d=0;d<deposits.length;d++){
2410
  const D=deposits[d];
2411
- if(!D.taken && dist2(x,y,D.x,D.y)<rad*rad) return d;
2412
  }
2413
  return -1;
2414
  }
@@ -2521,11 +2633,28 @@ function setupDoodads(){
2521
  if(trees.length<THEMES[curTheme].trees && h>0.42 && h<0.60 && rnd()<0.75) trees.push({x,y,s:rr(16,34),a:rr(0,TAU)});
2522
  else if(rocks.length<60 && rnd()<0.3) rocks.push({x,y,s:rr(16,44),a:rr(0,TAU)});
2523
  }
2524
- // crystal clusters near deposits
2525
- for(const D of deposits){
2526
- for(let k=0;k<2;k++){
2527
- const a=rnd()*TAU, d=rr(38,64);
2528
- crystals.push({x:D.x+Math.cos(a)*d,y:D.y+Math.sin(a)*d,s:rr(12,20),a:rr(-0.4,0.4)});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2529
  }
2530
  }
2531
  }
@@ -2787,6 +2916,7 @@ function dealDamage(j,dmg,attTeam,attacker,mu,wk){
2787
  if(uteam[j]===0&&attTeam!==0&&META.settings.godMode){uhp[j]=uhpm[j];return;}
2788
  if(ushielded[j]>0 && !(wk&&WK_PIERCE[wk])) dmg*=SHIELD_REDUCE;
2789
  if(umode[j]) dmg*=modeTakenMul(umode[j]); // GUARD stance eats the hit
 
2790
  uhp[j]-=dmg;
2791
  if(uhp[j]>0&&unitIsBrood(j)&&dmg>=uhpm[j]*0.04&&Math.random()<0.16)
2792
  sfx('cre_pain',ux[j],uy[j],clamp(TYPES[utype[j]].size/18,0.65,1.5));
@@ -2928,9 +3058,202 @@ function unitSeparation(i,T,isBug,swarmLOD,total){
2928
  if(mag>cap){sepVX=sepVX/mag*cap;sepVY=sepVY/mag*cap;}
2929
  }
2930
  let tick=0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2931
  function unitTick(dt){
2932
  rebuildGrid();
2933
  tickMoveCohorts();
 
2934
  /* Patrol planning lives in input.js, which loads after the simulation. The
2935
  guarded call preserves boot/replay tooling that evaluates sim.js alone. */
2936
  if(typeof tickPatrolRoutes==='function')tickPatrolRoutes(dt);
@@ -2949,6 +3272,8 @@ function unitTick(dt){
2949
  const T=TYPES[utype[i]];
2950
  if(ucool[i]>0) ucool[i]-=dt;
2951
  if(ubuff[i]>0) ubuff[i]-=dt;
 
 
2952
  if(uhaz[i]>0) uhaz[i]-=dt;
2953
  if(ustomp[i]>0) ustomp[i]-=dt;
2954
  if(ushielded[i]>0) ushielded[i]-=dt;
@@ -3001,6 +3326,7 @@ function unitTick(dt){
3001
  } else if(uteam[i]===0 && (tick&63)===0) stallE=0.8;
3002
  }
3003
  }
 
3004
  // validate target
3005
  let tg=utgt[i];
3006
  if(tg>=0 && !foeTgt(i,tg,utgtg[i])){ tg=utgt[i]=-1; }
@@ -3014,7 +3340,7 @@ function unitTick(dt){
3014
  else if((i+tick)%9===0&&perfScale>0.4)
3015
  addParticle(1,ux[i],uy[i],rr(-4,4),rr(-14,-6),.5,3, 255,140,60);
3016
  }
3017
- const rngM=(uteam[i]===0?resRngMult:1)*modeRngMul(md)*(uhaz[i]>0?HAZ_RNG:1);
3018
  // staggered acquisition
3019
  if(T.wk!=='n' && (i+tick)%acqMod===0 && ustate[i]!==1 && ustate[i]!==6){
3020
  /* On the march, acquisition shrinks to self-defence range: the column
@@ -3067,9 +3393,9 @@ function unitTick(dt){
3067
  // fire
3068
  if(inRange && ucool[i]<=0 && !(T.minRng && er<T.minRng)){
3069
  const vet=1+uvet[i]*0.15;
3070
- ucool[i]=T.cool*modeCoolMul(md)/(ubuff[i]>0?1.4:1);
3071
  if(md===4){ umode[i]=0; umodeT[i]=MODE_SWITCH*0.4; } // firing breaks GHOST cover
3072
- const dmg=T.dmg*vet*modeDmgMul(md)*(ubuff[i]>0?1.5:1)*(uteam[i]===1?aiDmgMult:(uteam[i]===0?armyDmgMult:1))*(i===heroIdx?heroDmgMult:1);
3073
  const ma=(T.tur?uturr[i]:uang[i])-Math.PI/2;
3074
  const mx=ux[i]+Math.cos(ma)*T.size*0.62, my=uy[i]+Math.sin(ma)*T.size*0.62;
3075
  if(T.wk==='m'){
@@ -3077,8 +3403,8 @@ function unitTick(dt){
3077
  if(tg>=0){
3078
  dealDamage(tg,dmg*dmgMul('m',utype[tg]),uteam[i],i,dmgMul('m',utype[tg]));
3079
  if(T.aoe>0) forUnitsIn(ex,ey,T.aoe,j=>{ if(uteam[j]!==uteam[i]&&j!==tg) dealDamage(j,dmg*0.5*dmgMul('m',utype[j]),uteam[i],i); });
3080
- } else if(isRelicTg(tg)) damageRelic(relics[relicOf(tg)],dmg*STM.m,uteam[i]);
3081
- else damageBld(-2-tg,dmg*STM.m,uteam[i]);
3082
  addParticle(0,ex,ey,0,0,.14,9, 255,190,90);
3083
  addParticle(5,ex,ey,rr(-40,40),rr(-40,40),.25,4, 255,170,60);
3084
  if((i&3)===0) sfx(unitIsBrood(i)?'cre_attack':(T.ptype===5?'flame':'hit'),ux[i],uy[i],1);
@@ -3105,7 +3431,7 @@ function unitTick(dt){
3105
  if((i&3)===0) sfx(T.ptype===5?'flame':'laser',ux[i],uy[i],utype[i]===8?1.8:1);
3106
  } else {
3107
  const pmu=tg>=0?dmgMul(T.wk||'p',utype[tg]):(STM[T.wk||'p']||1);
3108
- const pk=fireProj(T.ptype,uteam[i],mx,my,ex+rr(-3,3),ey+rr(-3,3),T.psp,dmg*pmu,T.aoe,tg);
3109
  const commanderCannon=utype[i]===4;
3110
  if(pk>=0){
3111
  pmu0[pk]=pmu; pwk[pk]=T.wk||'p'; pCannon[pk]=commanderCannon?1:0;
@@ -3160,7 +3486,7 @@ function unitTick(dt){
3160
  if(wantMove && distGoal>0.001){
3161
  // ground units make better time on the old highways
3162
  const rd=(!T.air&&!T.naval&&roadAt(ux[i],uy[i]))?ROAD_SPD:1;
3163
- const sp=T.spd*spdM*(ubuff[i]>0?1.35:1)*(uhaz[i]>0?HAZ_SPD:1)*rd*uCohesion[i]*Math.min(1,distGoal/18+0.25);
3164
  // flow-field steering for long marches (routes armies around lakes)
3165
  let ffOk=false;
3166
  /* Formation orders share a coarse field to the leg centre, then fan out
@@ -3214,6 +3540,10 @@ function unitTick(dt){
3214
  // apply with medium constraint: ground stays on land, ships stay on water, air flies anywhere
3215
  const ox=ux[i], oy=uy[i];
3216
  let nx=clamp(ox+mvx*dt,8,MAP-8), ny=clamp(oy+mvy*dt,8,MAP-8);
 
 
 
 
3217
  if(!T.air && (mvx||mvy)){
3218
  const okAt=T.naval? ((X,Y)=>!isWalkable(X,Y)) : isWalkable;
3219
  if(!okAt(nx,ny)){
@@ -3412,14 +3742,16 @@ function bldTick(dt){
3412
  if(B.prog<1){
3413
  /* FORCED TEMPO booster: construction only, and only for the player. */
3414
  const doctrine=(B.team===0&&typeof defenseFocus!=='undefined'&&defenseFocus&&DEFT[B.type])?1.333:1;
 
3415
  const bs=(B.team===0?((typeof boostMul==='function'?boostMul('build'):1)*
3416
- (typeof bldSpeedMult!=='undefined'?bldSpeedMult:1)*doctrine):1);
3417
  const wasProg=B.prog;
3418
  B.prog=Math.min(1,B.prog+dt*bs/BT[B.type].bt);
3419
  B.hp=Math.min(B.hpm,B.hp+B.hpm*0.9*dt/BT[B.type].bt);
3420
  if((tick&7)===0) addParticle(2,B.x+rr(-B.r,B.r),B.y+rr(-B.r,B.r),rr(-4,4),rr(-10,-2),.3,3, 160,230,255);
3421
  if(wasProg<1&&B.prog>=1){
3422
  if(B.type==='techlab') B.shield=B.shieldMax;
 
3423
  if(B.team===0) sfx('build',B.x,B.y,1);
3424
  }
3425
  continue;
@@ -3433,6 +3765,7 @@ function bldTick(dt){
3433
  if(B.dmgT>0) B.dmgT-=dt;
3434
  if(B.shieldT>0) B.shieldT-=dt;
3435
  if(B.guardT>0) B.guardT-=dt;
 
3436
  const bfac=bldFactionKey(B);
3437
  if(B.team<2){ // fortification regen
3438
  const F=fortOf(B.team);
@@ -3799,7 +4132,8 @@ function bldTick(dt){
3799
  addParticle(1,B.x+rr(0.2,0.3)*szf,B.y-szf*0.34,rr(-2,2),rr(-10,-6),.9,5, 118,118,124);
3800
  }
3801
  const t=B.queue[0], T=TYPES[t];
3802
- const speed=(B.team===1?aiBuildMult:playerBuildMult)*(1+0.12*Math.min(2,B.adj||0))*fortOf(B.team).prod;
 
3803
  const frac=dt*speed/T.bt;
3804
  if(!payStream(B.team, T.cm*frac, T.ce*frac)){
3805
  if(B.team===0){ if(resM[0]<T.cm*frac) stallM=0.8; if(resE[0]<T.ce*frac) stallE=0.8; }
 
32
  {name:'Pyro', spr:'pyro', tur:null, size:16, r:6.0, hp:240, dmg:11, rng:58, cool:.38, spd:52, psp:130, ptype:5, aoe:18, wk:'m', tg:'g', cm:40, ce:160, bt:3.5, air:0, tier:1},
33
  {name:'Vulture', spr:'vulture',tur:null, size:16, r:6.0, hp:170, dmg:52, rng:190, cool:1.3, spd:42, psp:300, ptype:8, aoe:26, wk:'e', tg:'air',cm:42, ce:170, bt:3.6, air:0, tier:1},
34
  {name:'Bulwark', spr:'bulwark',tur:null, size:21, r:7.8, hp:950, dmg:0, rng:0, cool:9, spd:28, psp:0, ptype:0, aoe:0, wk:'n', tg:'a', cm:90, ce:380, bt:7.0, air:0, tier:2, upkeepE:5},
35
+ {name:'Ravager', spr:'rav', tur:null, size:16, r:5.6, hp:118, dmg:27, rng:20, cool:.9, spd:49, psp:0, ptype:0, aoe:0, wk:'m', tg:'g', cm:0, ce:0, bt:0, air:0, tier:0, legs:1, brood:1, bldMul:1.55},
36
+ {name:'Alpha Ravager',spr:'rav',tur:null, size:28, r:10, hp:780, dmg:82, rng:26, cool:1.25,spd:38, psp:0, ptype:0, aoe:12, wk:'m', tg:'g', cm:0, ce:0, bt:0, air:0, tier:0, legs:1, brood:1, bldMul:1.75},
37
  {name:'Corvette', spr:'corv', tur:null, size:20, r:7.5, hp:320, dmg:24, rng:115, cool:1.0, spd:42, psp:120, ptype:6, aoe:0, wk:'i', tg:'a', cm:55, ce:220, bt:5.0, air:0, tier:1, naval:1},
38
  {name:'Dreadnought',spr:'dread',tur:null, size:32, r:12, hp:1300, dmg:75, rng:290, cool:5.0, spd:20, psp:150, ptype:2, aoe:34, wk:'e', tg:'g', cm:170, ce:680, bt:12, air:0, tier:2, naval:1},
39
  {name:'Bombard', spr:'bombH', tur:'bombT', size:21, r:8, hp:400, dmg:95, rng:400, cool:7.0, spd:14, psp:150, ptype:9, aoe:44, wk:'e', tg:'g', cm:140, ce:560, bt:10, air:0, tier:2, minRng:100},
 
62
  hitting harder. They are never buildable: a faction fields its own. */
63
  {name:'Lord Darion Vex', spr:'praetor',tur:null, size:34, r:12, hp:6200, dmg:130,rng:250, cool:2.4, spd:22, psp:210, ptype:9, aoe:70, wk:'e', tg:'a', cm:0, ce:0, bt:0, air:0, tier:0, cat:'hero', legs:1, hero:'legion'},
64
  {name:'Broker Lys Renn', spr:'archon', tur:null, size:30, r:11, hp:4600, dmg:70, rng:165, cool:.55, spd:38, psp:0, ptype:6, aoe:10, wk:'s', tg:'a', cm:0, ce:0, bt:0, air:0, tier:0, cat:'hero', hero:'syndicate'},
65
+ {name:'The Brood Sovereign',spr:'brood',tur:null, size:38, r:14, hp:7400, dmg:110,rng:34, cool:1.2, spd:30, psp:0, ptype:0, aoe:26, wk:'m', tg:'g', cm:0, ce:0, bt:0, air:0, tier:0, cat:'hero', legs:1, hero:'horde', brood:1, bldMul:1.65},
66
+ /* A Tidecaster is not built. A critical mass of nearby Ravagers grows one,
67
+ trading an individual body for coordination, speed and a target-minded
68
+ tide. Its own health stays deliberately low: the counterplay is to pick the
69
+ leader out of the crowd and let the mass dissolve back into animals. */
70
+ {name:'Brood Tidecaster',spr:'brood',tur:null, size:24, r:8.5, hp:390, dmg:24, rng:142, cool:2.4, spd:40, psp:175, ptype:6, aoe:26, wk:'s', tg:'a', cm:0, ce:0, bt:0, air:0, tier:0, cat:'sup', legs:1, brood:1, caster:1, bldMul:1.35},
71
+ /* Mobile resource utility. Prospectors earn less than an Extractor but can
72
+ work a field before territory reaches it; the authored mining beam makes
73
+ the economic action readable from the ordinary battle camera. */
74
+ {name:'Prospector',spr:'warden',tur:null, size:17, r:6.2, hp:190, dmg:0, rng:0, cool:9, spd:39, psp:0, ptype:0, aoe:0, wk:'n', tg:'a', cm:52, ce:210, bt:5.0, air:0, tier:1, cat:'sup', miner:1},
75
  ];
76
  /* UNIT CATEGORIES. Used by the build menu, the unit card and the AI's
77
  composition logic, so a role is a real thing the game reasons about rather
 
98
  for(const T of TYPES) if(!T.cat) T.cat=byName[T.name]||'veh';
99
  })();
100
  const UT_ENGINEER=19; // index of the Constructor in TYPES
101
+ const UT_BROOD_CASTER=31, UT_MINER=32;
102
  const SHIELD_REDUCE=0.72, SHIELD_R=95;
103
  const AGGRO_MULT=1.9, AGGRO_ADD=70;
104
  const TITAN_STOMP_DMG=210, TITAN_STOMP_R=85;
 
109
  const utx=new Float32Array(MAXU), uty=new Float32Array(MAXU);
110
  const uhp=new Float32Array(MAXU), uhpm=new Float32Array(MAXU);
111
  const ucool=new Float32Array(MAXU), ubuff=new Float32Array(MAXU), ustomp=new Float32Array(MAXU);
112
+ /* Contextual class abilities use their own effect channel. `ubuff` is the
113
+ Commander surge and intentionally boosts everything; class doctrine needs
114
+ narrower, readable trades so an interceptor does not receive the same buff
115
+ as a breakthrough tank. 1=breakthrough, 2=intercept, 3=service shield. */
116
+ const uclassBuff=new Uint8Array(MAXU), uclassBuffT=new Float32Array(MAXU);
117
+ function classDmgMul(i){return uclassBuffT[i]>0&&uclassBuff[i]===1?1.28:uclassBuffT[i]>0&&uclassBuff[i]===2?1.12:1;}
118
+ function classCoolMul(i){return uclassBuffT[i]>0&&uclassBuff[i]===1?.78:uclassBuffT[i]>0&&uclassBuff[i]===2?.65:1;}
119
+ function classSpdMul(i){return uclassBuffT[i]>0&&uclassBuff[i]===1?1.22:uclassBuffT[i]>0&&uclassBuff[i]===2?1.58:1;}
120
+ function classRngMul(i){return uclassBuffT[i]>0&&uclassBuff[i]===2?1.22:1;}
121
+ function classTakenMul(i){return uclassBuffT[i]>0&&uclassBuff[i]===1?1.12:uclassBuffT[i]>0&&uclassBuff[i]===2?.86:uclassBuffT[i]>0&&uclassBuff[i]===3?.72:1;}
122
+ /* Brood leadership is intentionally offensive utility rather than hidden
123
+ bonus health. A player who kills the visible Tidecaster immediately strips
124
+ the speed, damage and cadence from every nearby creature. */
125
+ const ubroodLed=new Float32Array(MAXU), uMineT=new Float32Array(MAXU);
126
+ const uMineNode=new Int16Array(MAXU); uMineNode.fill(-1);
127
+ function broodDmgMul(i){return ubroodLed[i]>0?1.18:1;}
128
+ function broodCoolMul(i){return ubroodLed[i]>0?.82:1;}
129
+ function broodSpdMul(i){return ubroodLed[i]>0?1.14:1;}
130
  const utype=new Uint8Array(MAXU), uteam=new Uint8Array(MAXU);
131
  const ualive=new Uint8Array(MAXU), ustate=new Uint8Array(MAXU);
132
  const usel=new Uint8Array(MAXU);
 
188
  return enemyHeroIdxs;
189
  }
190
  function unitIsBrood(i){
191
+ return uteam[i]===2 || !!(TYPES[utype[i]]&&TYPES[utype[i]].brood) ||
192
  (uteam[i]===1 && typeof AI!=='undefined' && AI.fac==='horde');
193
  }
194
 
 
279
  {id:3,nm:'OVERDRIVE',em:'⏵', ds:'Redlined: +60% fire rate, +35% speed, bleeds HP'},
280
  {id:4,nm:'GHOST', em:'◌', ds:'Silent running: unseen until you fire, −35% speed'},
281
  {id:5,nm:'SUPPRESS', em:'≋', ds:'Sustained fire: +85% rate, −30% range, no movement'},
282
+ {id:6,nm:'ASSIST', em:'↯', ds:'Tractor beam: accelerate a factory, build site or Command HQ'},
283
+ {id:7,nm:'SURVEY', em:'⌾', ds:'Explore the nearest unclaimed phase-crystal field'},
284
  ];
285
  /* Which modes a chassis can actually take. Artillery roots to shoot further;
286
  assault armour digs in; light frames redline or go quiet; flamers suppress. */
 
316
  [0,1], // 28 Praetor — siege
317
  [0,2], // 29 Archon — guard
318
  [0,3], // 30 Broodmother — overdrive
319
+ [0], // 31 Tidecaster — biological leader
320
+ [0,6,7], // 32 Prospector — mine / assist / survey
321
  ];
322
  const MODE_SWITCH=1.6; // seconds locked while deploying
323
  /* MODES ARE TRADES.
 
337
  function modeSpdMul(m){ return (m===1||m===5)?0 : m===2?0.40 : m===3?1.35 : m===4?0.65 : 1; }
338
  function modeTakenMul(m){ return m===2?0.55 : m===1?1.35 : m===3?1.15 : m===4?1.25 : 1; }
339
  function unitModes(ty){ return UNIT_MODES[ty]||[0]; }
340
+ function unitModeDef(ty,m){
341
+ if(ty===UT_MINER&&m===0) return {id:0,nm:'MINE',em:'⛏',ds:'Lock a mining tractor beam onto an active phase-crystal field'};
342
+ return MODES[m]||MODES[0];
343
+ }
344
  function cycleMode(i){
345
  const list=unitModes(utype[i]);
346
  if(list.length<2) return false;
 
367
  let i;
368
  if(freeList.length) i=freeList.pop();
369
  else { if(unitHigh>=MAXU) return -1; i=unitHigh++; }
370
+ if(typeof battlefieldClampPoint==='function'){
371
+ const bp=battlefieldClampPoint(x,y,(TYPES[type].r||4)+8);x=bp[0];y=bp[1];
372
+ }
373
  const L=TYPES[type].naval? (findWater(x,y)||[x,y]) : findLand(x,y);
374
  x=L[0]; y=L[1];
375
+ if(typeof battlefieldClampPoint==='function'){
376
+ const bp=battlefieldClampPoint(x,y,(TYPES[type].r||4)+8);x=bp[0];y=bp[1];
377
+ }
378
  const T=TYPES[type];
379
  ux[i]=x; uy[i]=y; uang[i]=team?Math.PI:0; uturr[i]=uang[i];
380
  utx[i]=x; uty[i]=y;
381
  uhp[i]=T.hp*(team===0?resHpMult:(team===1?aiHpMult*(WC.iron?1.25:1):1)); uhpm[i]=uhp[i];
382
  ucool[i]=Math.random()*T.cool; ubuff[i]=0; ustomp[i]=0;
383
+ uclassBuff[i]=0;uclassBuffT[i]=0;ubroodLed[i]=0;uMineT[i]=0;uMineNode[i]=-1;
384
  utype[i]=type; uteam[i]=team; ualive[i]=1; ustate[i]=0; usel[i]=0;
385
  ugen[i]=(ugen[i]+1)|0; // this slot is now a different unit
386
  uwalk[i]=Math.random()*6.283; // desynchronise the gait across a squad
 
611
  })();
612
  const ARM=[0,1,2,1,2, 0,1,1,2, 1,1,2, 0,2, 1,2, 1,0, 1, 0,
613
  1,1,1,1,0,0,2,2, // Reaper Cinder Lancer Resonator Warden Kestrel Basilisk Harbinger
614
+ 2,1,2,0,0]; // Praetor Archon Broodmother Tidecaster Prospector
615
  const ARM_NM=['LIGHT','MEDIUM','HEAVY'];
616
  /* WEAPON vs ARMOUR.
617
  The original spread was +-15..30%, which sounds like a counter system and
 
647
  const AMMO_PTYPE={0:'CASELESS TRACER',1:'AP CANNON SHELL',2:'BALLISTIC HE SHELL',3:'COMMANDER HE SHELL',
648
  4:'UNGUIDED ROCKET',5:'THERMAL GEL',6:'ION PLASMA ORB',7:'GUIDED MISSILE',8:'FLAK AIRBURST',9:'CLUSTER MUNITION'};
649
  function ammoName(T){
650
+ if(!T) return 'UNARMED';
651
+ if(T.miner) return 'MINING TRACTOR LASER';
652
+ if(T.wk==='n') return 'UNARMED';
653
  if(T.wk==='m') return T.ptype===5?'NAPALM JET':'MELEE STRIKE';
654
  if(T.wk==='b') return T.ptype===5?'THERMAL PLASMA BEAM':T.name==='Longbow'?'COHERENT SNIPER BEAM':T.name==='TITAN'?'TITAN PARTICLE LANCE':'LASER ENERGY';
655
  if(T.wk==='g') return 'GAUSS PENETRATOR';
 
781
  {id:'bal1', nm:'Ballistics I', em:'🗡', ds:'+12% unit damage', cm:120, ce:500, t:35, req:null, clvl:3},
782
  {id:'plate1',nm:'Plating I', em:'🛡', ds:'+15% unit HP', cm:130, ce:550, t:40, req:null, clvl:3},
783
  {id:'optics',nm:'Optics Array', em:'🔭', ds:'+12% unit range', cm:110, ce:480, t:35, req:null, clvl:4},
784
+ {id:'nano', nm:'Field Automation', em:'⚙', ds:'+25% build speed · +2 Constructor/Prospector cap', cm:140, ce:600, t:40, req:null, clvl:4},
785
  {id:'fusion',nm:'Fusion Cells', em:'⚡', ds:'+30% energy income', cm:120, ce:500, t:35, req:null, clvl:4},
786
  {id:'hardpoint',nm:'Hardpoint Bracing',em:'🏰',ds:'+25% structure HP',cm:140,ce:580,t:42,req:null,clvl:3},
787
  {id:'contain',nm:'Containment Lattice',em:'◆',ds:'+50% Research Complex shield',cm:135,ce:620,t:45,req:null,clvl:4},
 
883
  if(B.type==='sgen') return 'FIELD '+bldNum(S.field)+' · STRUCTURE REPAIR '+bldNum(S.repair)+'/s';
884
  if(B.type==='uplink') return 'FIELD '+bldNum(S.field)+' · RANGE BOOST +'+bldNum((S.boost-1)*100)+'%';
885
  if(B.type==='nova') return 'STRIKE '+NOVA.dmg+' · RECHARGE '+bldNum(S.recharge)+'s';
886
+ if(B.type==='mex'){
887
+ const D=B.dep>=0?deposits[B.dep]:null,tier=depositTier(D),rate=S.income*(DEPOSIT_YIELD[tier]||0);
888
+ return tier>0?'MASS +'+bldNum(rate)+'/s · FIELD TIER '+tier+' · '+Math.ceil(D.remaining)+' ORE LEFT'
889
+ :'FIELD DEPLETED · RELOCATE MINERS';
890
+ }
891
  if(B.type==='pgen') return 'ENERGY INCOME +'+S.income+'/s';
892
  return (BT[B.type].bcat||'STRUCTURE').toUpperCase()+' · HP '+Math.ceil(B.hpm);
893
  }
 
956
  const shieldMax=type==='techlab'?900*labBufferMult:0;
957
  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,
958
  cool:0,queue:[],repeat:false,prodT:0,heal:0,tier:1,lvl:1,upT:0,upMax:1,tang:team?Math.PI:0,
959
+ seen:false,boost:0,boostM:UPLINK_BOOST,res:-1,resT:0,rally:null,rich:false,dep:-1,
960
  shield:instant?shieldMax:0,shieldMax,shieldT:0,dmgT:0,
961
  guardReady:type==='techlab',guardT:0,guardCharge:0,
962
  rot:rot||0,footTier:bldFootTierCount(type,fac),link:0,anim:Math.random()*10,animS:0,conduit:[]};
963
+ if(type==='mex') for(let di=0;di<deposits.length;di++){
964
+ const D=deposits[di];
965
+ if(dist2(D.x,D.y,x,y)<9){ b.dep=di; b.rich=(D.initialTier||1)>=3; break; }
966
+ }
967
  if(type==='nova') b.cool=NOVA.cd*0.6; // first charge after construction
968
  blds.push(b); rebuildBGrid();
969
  /* Level and pave the ground for it. Doing this at placement rather than at
970
  completion means the site is visibly prepared while construction runs. */
971
  if(type!=='nest') makeFoundation(b);
972
+ if(type==='mex'&&instant) deployExtractorMiner(b);
973
  return b;
974
  }
975
  /* BASE UNDER ATTACK.
 
1014
  B.hitT=0.35; // drives the flinch/spark anim
1015
  if(B.hp<=0){
1016
  B.alive=false;
1017
+ if(B.type==='mex'&&B.dep>=0) redirectProspectorsFromNode(B.dep,B.team);
1018
  spawnExplosion(B.x,B.y,BT[B.type].size*1.1,B.team===2?1:B.team);
1019
  addCrater(B.x,B.y,BT[B.type].size*1.9);
1020
  addRubble(B.x,B.y,BT[B.type].size*0.9);
 
1043
  // Procedurally seeded per map — every battlefield has its own economy layout,
1044
  // always point-mirrored so both commanders get a fair start.
1045
  const deposits=[], geysers=[];
1046
+ const DEPOSIT_BAND=1000;
1047
+ const DEPOSIT_YIELD=[0,1,1.48,2.12];
1048
+ function makeDeposit(x,y,rich,starter){
1049
+ /* Tier is both a gameplay value and a silhouette. Starter fields begin at
1050
+ Tier II, rare contested veins at Tier III, and ordinary expansion pockets
1051
+ at Tier I. Every band is finite and visibly collapses into the next. */
1052
+ const patterned=((Math.floor(x/97)+Math.floor(y/113))&3)===0;
1053
+ const tier=rich?3:(starter||patterned?2:1);
1054
+ return {x,y,taken:false,rich:tier===3,starter:starter||'',initialTier:tier,
1055
+ tier,capacity:DEPOSIT_BAND*tier,remaining:DEPOSIT_BAND*tier,depleted:false,pulse:rr(0,TAU)};
1056
+ }
1057
+ function depositTier(D){
1058
+ if(!D||D.remaining<=0){ if(D){D.remaining=0;D.tier=0;D.depleted=true;} return 0; }
1059
+ D.tier=clamp(Math.ceil(D.remaining/DEPOSIT_BAND),1,D.initialTier||3);
1060
+ D.depleted=false; return D.tier;
1061
+ }
1062
+ function depositYield(D){ return DEPOSIT_YIELD[depositTier(D)]||0; }
1063
+ function drainDeposit(D,amount){
1064
+ if(!D||amount<=0||depositTier(D)<=0) return 0;
1065
+ const got=Math.min(D.remaining,amount),before=D.tier;
1066
+ D.remaining-=got; const after=depositTier(D);
1067
+ if(after!==before) D.phaseFlash=1.4;
1068
+ return got;
1069
+ }
1070
  function setupDeposits(){
1071
  deposits.length=0; geysers.length=0;
1072
  const def=MAPDEFS[curMap]||MAPDEFS.vanguard;
 
1075
  const starts=skirmishSpawnPoints(),all=[],gall=[];
1076
  const addNode=(arr,x,y,rich,minD,starter)=>{
1077
  x=clamp(x,100,MAP-100); y=clamp(y,100,MAP-100);
1078
+ /* Objectives belong inside the red tactical line. Starter fields near an
1079
+ inlet are projected inward; random expansion fields are rejected by
1080
+ the loops below so their authored spacing remains natural. */
1081
+ if(typeof battlefieldClampPoint==='function'){
1082
+ const p=battlefieldClampPoint(x,y,112); x=p[0]; y=p[1];
1083
+ }
1084
  for(const p of arr) if(dist2(x,y,p[0],p[1])<minD*minD) return false;
1085
  arr.push(rich==null?[x,y,null,starter||'']:[x,y,rich,starter||'']); return true;
1086
  };
1087
  /* The selected start owns equal nearby resources. Choosing another edge
1088
  of the map must never secretly remove the opening economy. */
1089
+ const bp=typeof battlefieldPresetDef==='function'?battlefieldPresetDef():
1090
+ {spread:1,nodes:18,geysers:2,world:1};
1091
+ const spread=bp.spread||1;
1092
  for(let si=0;si<starts.length;si++){
1093
  const S=starts[si],a=Math.atan2(MAP*.5-S.y,MAP*.5-S.x),fx=Math.cos(a),fy=Math.sin(a),rx=-fy,ry=fx;
1094
+ addNode(all,S.x+fx*150*spread,S.y+fy*150*spread,0,88*spread,S.zone);
1095
+ addNode(all,S.x+fx*250*spread+rx*115*spread,S.y+fy*250*spread+ry*115*spread,0,88*spread,S.zone);
1096
+ addNode(all,S.x+fx*250*spread-rx*115*spread,S.y+fy*250*spread-ry*115*spread,0,88*spread,S.zone);
1097
+ addNode(gall,S.x+fx*355*spread+rx*(si&1?100:-100)*spread,
1098
+ S.y+fy*355*spread+ry*(si&1?100:-100)*spread,null,125*spread,S.zone);
1099
  }
1100
+ const bounds=typeof battlefieldPlayBounds==='function'?battlefieldPlayBounds(170*spread):{lo:220,hi:MAP-220};
1101
+ let tries=0,target=Math.max(starts.length*3+4,(bp.nodes||18)+Math.max(0,starts.length-2)*3);
1102
  while(all.length<target&&tries++<700){
1103
+ const x=rr(bounds.lo,bounds.hi),y=rr(bounds.lo,bounds.hi);
1104
+ if(typeof battlefieldContains==='function'&&!battlefieldContains(x,y,112))continue;
1105
+ let clear=true; for(const S of starts) if(dist2(x,y,S.x,S.y)<(330*spread)**2){clear=false;break;}
1106
+ if(clear) addNode(all,x,y,(all.length%7===3)?1:0,145*spread);
1107
  }
1108
+ tries=0; const gTarget=starts.length+(bp.geysers||2);
1109
  while(gall.length<gTarget&&tries++<500){
1110
+ const x=rr(bounds.lo,bounds.hi),y=rr(bounds.lo,bounds.hi);
1111
+ if(typeof battlefieldContains==='function'&&!battlefieldContains(x,y,112))continue;
1112
+ let clear=true; for(const p of all) if(dist2(x,y,p[0],p[1])<(145*spread)**2){clear=false;break;}
1113
+ if(clear) addNode(gall,x,y,null,190*spread);
1114
  }
1115
  window.__depPts=all.concat(gall);
1116
+ for(const p of all) deposits.push(makeDeposit(p[0],p[1],!!p[2],p[3]||''));
1117
  for(const p of gall) geysers.push({x:p[0],y:p[1],taken:false,starter:p[3]||''});
1118
  return;
1119
  }
1120
  const pts=[]; // [x,y,rich]
1121
  const bx=MAP*SP_LO, by=MAP*SP_HI;
1122
  const farOK=(x,y,minD)=>{
1123
+ if(typeof battlefieldContains==='function'&&(!battlefieldContains(x,y,112)||
1124
+ !battlefieldContains(MAP-x,MAP-y,112)))return false;
1125
  for(const p of pts){
1126
  if(dist2(x,y,p[0],p[1])<minD*minD) return false;
1127
  if(dist2(x,y,MAP-p[0],MAP-p[1])<minD*minD) return false;
 
1144
  while(gp.length<2 && tries++<300){
1145
  const x=rr(300,MAP-300), y=rr(300,MAP-300);
1146
  let ok=dist2(x,y,bx,by)>380*380 && dist2(x,y,MAP-bx,MAP-by)>380*380;
1147
+ if(ok&&typeof battlefieldContains==='function')ok=battlefieldContains(x,y,112)&&battlefieldContains(MAP-x,MAP-y,112);
1148
  if(ok) for(const p of pts) if(dist2(x,y,p[0],p[1])<150*150||dist2(x,y,MAP-p[0],MAP-p[1])<150*150){ ok=false; break; }
1149
  if(ok) for(const g of gp) if(dist2(x,y,g[0],g[1])<220*220||dist2(x,y,MAP-g[0],MAP-g[1])<220*220){ ok=false; break; }
1150
  if(ok) gp.push([x,y]);
 
1153
  for(const p of pts){ all.push(p); all.push([MAP-p[0],MAP-p[1],p[2]]); }
1154
  for(const p of gp){ gall.push(p); gall.push([MAP-p[0],MAP-p[1]]); }
1155
  window.__depPts=all.concat(gall); // terrain raises land under all nodes
1156
+ for(const p of all) deposits.push(makeDeposit(p[0],p[1],!!p[2],''));
1157
  for(const p of gall) geysers.push({x:p[0],y:p[1],taken:false,starter:''});
1158
  }
1159
  function geyserAt(x,y,rad){
 
1203
  const placed=[];
1204
  while(n<nestGoal && tries++<200){
1205
  const px2=rr(500,MAP-500), py2=rr(500,MAP-500);
1206
+ if(typeof battlefieldContains==='function'&&!battlefieldContains(px2,py2,135))continue;
1207
  if(typeof farFromStartZones==='function'&&!farFromStartZones(px2,py2,650)) continue;
1208
  if(laneDist(px2,py2)<LANE_CLEAR) continue; // keep the corridor passable
1209
  let clear=true;
1210
  for(const q of placed) if(dist2(px2,py2,q[0],q[1])<380*380){ clear=false; break; }
1211
  if(!clear) continue;
1212
+ let L=findLand(px2,py2);
1213
+ if(typeof battlefieldClampPoint==='function')L=battlefieldClampPoint(L[0],L[1],135);
1214
  placed.push(L);
1215
  addBld('nest',2,L[0],L[1],true);
1216
  /* Guard units at the hive door scale down too — on Easy a wandering scout
 
1458
  const nests=liveNests();
1459
  if(nests.length && nests.length<[3+tier,5+tier*1.5,6+tier*2][D]){
1460
  const N=nests[Math.random()*nests.length|0];
1461
+ let L=findLand(N.x+rr(-300,300),N.y+rr(-300,300));
1462
+ if(typeof battlefieldClampPoint==='function')L=battlefieldClampPoint(L[0],L[1],135);
1463
+ if((typeof battlefieldContains!=='function'||battlefieldContains(L[0],L[1],135))
1464
+ && dist2(L[0],L[1],MAP*SP_LO,MAP*SP_HI)>480*480 && dist2(L[0],L[1],MAP*SP_HI,MAP*SP_LO)>480*480
1465
  && laneDist(L[0],L[1])>=LANE_CLEAR){
1466
  let clear=true;
1467
  for(const B of blds) if(B.alive&&dist2(L[0],L[1],B.x,B.y)<120*120){ clear=false; break; }
 
1606
  for(const q of CRATE_KINDS){ roll-=q.w; if(roll<=0){k=q;break;} }
1607
  k=k||CRATE_KINDS[0];
1608
  }
1609
+ /* Random drops sample the physical square, while Compact and authored map
1610
+ edges do not. Clamp before and after the land search: the first gives the
1611
+ search a reachable in-theatre seed, and the second prevents findLand from
1612
+ stepping back across a notched/coastal command boundary. 36 covers the
1613
+ largest rarity crate's ground footprint; two extra world units absorb
1614
+ projection/float error instead of leaving its outer pixel on the line. */
1615
+ let P=typeof battlefieldClampPoint==='function'?battlefieldClampPoint(x,y,38):[x,y];
1616
+ let L=findLand(P[0],P[1]);
1617
+ if(typeof battlefieldClampPoint==='function')L=battlefieldClampPoint(L[0],L[1],38);
1618
  crates.push({x:L[0],y:L[1],kind:k,alt:520,t:0,seen:false});
1619
  return crates[crates.length-1];
1620
  }
 
1740
  :dist2(x,y,spawnA[0],spawnA[1])>d*d&&dist2(x,y,spawnB[0],spawnB[1])>d*d;
1741
 
1742
  const plot=(x,y,w,h,a,kind,zone)=>{
1743
+ const pad=Math.max(58,Math.hypot(w,h)*.55);
1744
+ if(typeof battlefieldContains==='function'&&!battlefieldContains(x,y,pad))return false;
1745
  cityPlan.push({x,y,w,h,a,kind,zone});
1746
  cityZones[zone].total++;
1747
+ return true;
1748
  };
1749
  const makeDistrict=(cx2,cy2,ind)=>{
1750
  const zi=cityZones.length;
 
1796
  const tryPlace=(ind,minD)=>{
1797
  for(let a=0;a<70;a++){
1798
  const x=rr(MAP*0.18,MAP*0.82), y=rr(MAP*0.18,MAP*0.82);
1799
+ if(typeof battlefieldContains==='function'&&!battlefieldContains(x,y,310))continue;
1800
  if(!farFromSpawns(x,y,700)||!isWalkable(x,y)) continue;
1801
  let clash=false;
1802
  for(const p of placed) if(dist2(x,y,p[0],p[1])<minD*minD){ clash=true; break; }
 
1827
  // loot scattered through every district
1828
  for(const Z of cityZones) for(let k=0;k<(Z.ind?3:2);k++){
1829
  const a=rnd()*TAU, d=rr(60,250);
1830
+ let L=findLand(clamp(Z.x+Math.cos(a)*d,80,MAP-80), clamp(Z.y+Math.sin(a)*d,80,MAP-80));
1831
+ if(typeof battlefieldClampPoint==='function')L=battlefieldClampPoint(L[0],L[1],70);
1832
  spawnCrate(L[0],L[1]);
1833
  if(crates.length) crates[crates.length-1].alt=0;
1834
  }
1835
  // industrial belts are ringed with volatile tanks — dangerous ground to fight over
1836
  for(const Z of cityZones) if(Z.ind) for(let k=0;k<5;k++){
1837
  const a=rnd()*TAU, d=rr(190,310);
1838
+ let L=findLand(clamp(Z.x+Math.cos(a)*d,80,MAP-80), clamp(Z.y+Math.sin(a)*d,80,MAP-80));
1839
+ if(typeof battlefieldClampPoint==='function')L=battlefieldClampPoint(L[0],L[1],70);
1840
  tanks.push({x:L[0],y:L[1],s:rr(30,42),hp:260,alive:true,fuse:0});
1841
  }
1842
  const spawnA=[MAP*SP_LO,MAP*SP_HI], spawnB=[MAP*SP_HI,MAP*SP_LO];
1843
  const tn=10+(rnd()*8|0);
1844
  for(let k=0;k<tn;k++){
1845
+ let L=findLand(rr(260,MAP-260),rr(260,MAP-260));
1846
+ if(typeof battlefieldClampPoint==='function')L=battlefieldClampPoint(L[0],L[1],70);
1847
  if(typeof farFromStartZones==='function'&&!farFromStartZones(L[0],L[1],420)) continue;
1848
  tanks.push({x:L[0],y:L[1],s:rr(28,40),hp:260,alive:true,fuse:0});
1849
  }
 
2520
  function depositAt(x,y,rad){
2521
  for(let d=0;d<deposits.length;d++){
2522
  const D=deposits[d];
2523
+ if(!D.taken && depositTier(D)>0 && dist2(x,y,D.x,D.y)<rad*rad) return d;
2524
  }
2525
  return -1;
2526
  }
 
2633
  if(trees.length<THEMES[curTheme].trees && h>0.42 && h<0.60 && rnd()<0.75) trees.push({x,y,s:rr(16,34),a:rr(0,TAU)});
2634
  else if(rocks.length<60 && rnd()<0.3) rocks.push({x,y,s:rr(16,44),a:rr(0,TAU)});
2635
  }
2636
+ /* PHASE-CRYSTAL FIELDS. Two decorative shards could not communicate value,
2637
+ ownership or depletion. Each economic tier now owns a concentric stratum:
2638
+ outer Tier III spires disappear first, then Tier II, leaving a small Tier I
2639
+ core before exhaustion. The fields are original Halcyon phase ore rather
2640
+ than a copy of another game's resource silhouette. */
2641
+ for(let di=0;di<deposits.length;di++){
2642
+ const D=deposits[di],tier=D.initialTier||1;
2643
+ /* A readable central crown makes the field recognizable at phone zoom.
2644
+ These are real instanced crystal meshes in the 3D pass, not map pins. */
2645
+ for(let k=0;k<3;k++){
2646
+ const a=k/3*TAU+rr(-.18,.18),d=k?rr(7,15):rr(1,5);
2647
+ crystals.push({x:D.x+Math.cos(a)*d,y:D.y+Math.sin(a)*d,
2648
+ s:rr(30,41)+(tier-1)*3,a:rr(-0.28,0.28),dep:di,band:1,phase:rr(0,TAU),core:1});
2649
+ }
2650
+ for(let band=1;band<=tier;band++){
2651
+ const count=band===1?7:band===2?6:7;
2652
+ for(let k=0;k<count;k++){
2653
+ const a=(k/count)*TAU+rr(-.18,.18)+band*.41;
2654
+ const d=band===1?rr(17,37):band===2?rr(40,62):rr(65,88);
2655
+ crystals.push({x:D.x+Math.cos(a)*d,y:D.y+Math.sin(a)*d,
2656
+ s:rr(14+band*2,22+band*3),a:rr(-0.42,0.42),dep:di,band,phase:rr(0,TAU)});
2657
+ }
2658
  }
2659
  }
2660
  }
 
2916
  if(uteam[j]===0&&attTeam!==0&&META.settings.godMode){uhp[j]=uhpm[j];return;}
2917
  if(ushielded[j]>0 && !(wk&&WK_PIERCE[wk])) dmg*=SHIELD_REDUCE;
2918
  if(umode[j]) dmg*=modeTakenMul(umode[j]); // GUARD stance eats the hit
2919
+ dmg*=classTakenMul(j);
2920
  uhp[j]-=dmg;
2921
  if(uhp[j]>0&&unitIsBrood(j)&&dmg>=uhpm[j]*0.04&&Math.random()<0.16)
2922
  sfx('cre_pain',ux[j],uy[j],clamp(TYPES[utype[j]].size/18,0.65,1.5));
 
3058
  if(mag>cap){sepVX=sepVX/mag*cap;sepVY=sepVY/mag*cap;}
3059
  }
3060
  let tick=0;
3061
+ const BROOD_MASS=28, BROOD_AURA=285;
3062
+ let broodMassT=0;
3063
+ function broodCriticalMassTick(dt){
3064
+ broodMassT-=dt; if(broodMassT>0) return;
3065
+ broodMassT=1.35;
3066
+ /* Wildlife and the Brood opponent both currently emerge through the hive
3067
+ team. Keeping the mechanic on the biological roster prevents a recolored
3068
+ machine from accidentally qualifying as a caster just because the enemy
3069
+ faction selector says Brood. */
3070
+ const fighters=[],casters=[];
3071
+ for(let i=0;i<unitHigh;i++) if(ualive[i]&&uteam[i]===2){
3072
+ if(utype[i]===12||utype[i]===13) fighters.push(i);
3073
+ else if(utype[i]===UT_BROOD_CASTER) casters.push(i);
3074
+ }
3075
+ const desired=Math.min(7,Math.floor(fighters.length/BROOD_MASS));
3076
+ if(casters.length<desired){
3077
+ let seed=-1,best=0;
3078
+ /* Sample the mass rather than doing an all-pairs search. The spatial hash
3079
+ still verifies that the chosen body sits inside a genuine local crowd. */
3080
+ for(let n=0;n<fighters.length;n+=Math.max(1,(fighters.length/40)|0)){
3081
+ const i=fighters[n]; if(utype[i]!==12) continue;
3082
+ let local=0,led=false;
3083
+ forUnitsIn(ux[i],uy[i],225,j=>{
3084
+ if(uteam[j]!==2) return;
3085
+ if(utype[j]===12||utype[j]===13) local++;
3086
+ if(utype[j]===UT_BROOD_CASTER) led=true;
3087
+ });
3088
+ if(!led&&local>best){best=local;seed=i;}
3089
+ }
3090
+ if(seed>=0&&best>=BROOD_MASS){
3091
+ const x=ux[seed],y=uy[seed]; killUnit(seed,true);
3092
+ const c=spawnUnit(UT_BROOD_CASTER,2,x,y);
3093
+ if(c>=0){
3094
+ casters.push(c); ustate[c]=2; ubroodLed[c]=3;
3095
+ addParticle(3,x,y,0,0,.8,92,180,92,255);
3096
+ addParticle(3,x,y,0,0,1.25,150,92,230,155);
3097
+ for(let q=0;q<12;q++) addParticle(0,x,y,rr(-46,46),rr(-46,46),.55,5,178,255,96);
3098
+ if(typeof fogPointVisible!=='function'||fogPointVisible(x,y)){
3099
+ toast('☣ CRITICAL MASS — a Brood Tidecaster has formed');
3100
+ sfx('alarm',x,y,.72);
3101
+ }
3102
+ }
3103
+ }
3104
+ }
3105
+ /* A caster converts a loose crowd into one purposeful wave. It does not make
3106
+ units occupy the same point: staggered target offsets retain the existing
3107
+ soft separation while the entire tide advances on one strategic object. */
3108
+ for(const c of casters){
3109
+ if(!ualive[c]) continue;
3110
+ const bi=findEnemyBld(ux[c],uy[c],2,MAP*1.5);
3111
+ let tx=utx[c],ty=uty[c],tg=-1;
3112
+ if(bi>=0){tx=blds[bi].x;ty=blds[bi].y;tg=-2-bi;}
3113
+ else {
3114
+ const e=findEnemy(ux[c],uy[c],2,MAP*1.25);
3115
+ if(e>=0){tx=ux[e];ty=uy[e];tg=e;}
3116
+ }
3117
+ if(tg===-1&&bi<0) continue;
3118
+ const fld=requestField(tx,ty); let slot=0;
3119
+ forUnitsIn(ux[c],uy[c],BROOD_AURA,j=>{
3120
+ if(uteam[j]!==2||(utype[j]!==12&&utype[j]!==13&&utype[j]!==UT_BROOD_CASTER)) return;
3121
+ ubroodLed[j]=2.8;
3122
+ const lane=(slot++%7)-3,row=(slot/7|0);
3123
+ ustate[j]=2; utgt[j]=-1; utgtg[j]=-1; ufield[j]=fld; umarch[j]=1;
3124
+ utx[j]=clamp(tx+lane*15,20,MAP-20); uty[j]=clamp(ty+row*12,20,MAP-20);
3125
+ });
3126
+ }
3127
+ }
3128
+ function nearestOreNode(x,y,rad){
3129
+ let best=-1,bd=rad*rad;
3130
+ for(let d=0;d<deposits.length;d++){
3131
+ const D=deposits[d]; if(depositTier(D)<=0) continue;
3132
+ const dd=dist2(x,y,D.x,D.y); if(dd<bd){bd=dd;best=d;}
3133
+ }
3134
+ return best;
3135
+ }
3136
+ function supportUnitCap(team){
3137
+ if(team!==0) return 10;
3138
+ const lab=hasBld(0,'techlab')?2:0,automation=(typeof researched!=='undefined'&&researched.nano)?2:0;
3139
+ return Math.min(12,3+Math.floor(Math.max(0,heroLvl-1)/2)+lab+automation);
3140
+ }
3141
+ function supportUnitCount(team,includeQueues){
3142
+ let n=0;
3143
+ for(let i=0;i<unitHigh;i++) if(ualive[i]&&uteam[i]===team&&(utype[i]===UT_ENGINEER||utype[i]===UT_MINER)) n++;
3144
+ if(includeQueues) for(const B of bldLive) if(B.alive&&B.team===team&&B.queue)
3145
+ for(const q of B.queue) if(q===UT_ENGINEER||q===UT_MINER)n++;
3146
+ return n;
3147
+ }
3148
+ function nearestSupportBuilding(i,types,rad){
3149
+ let best=-1,bd=rad*rad;
3150
+ for(let b=0;b<blds.length;b++){
3151
+ const B=blds[b]; if(!B.alive||B.team!==uteam[i]||types.indexOf(B.type)<0)continue;
3152
+ const d=dist2(ux[i],uy[i],B.x,B.y);if(d<bd){bd=d;best=b;}
3153
+ }
3154
+ return best;
3155
+ }
3156
+ function redirectProspector(i){
3157
+ if(!ualive[i]||utype[i]!==UT_MINER)return false;
3158
+ let best=-1,bd=Infinity;
3159
+ for(const B of bldLive){
3160
+ if(!B.alive||B.team!==uteam[i]||B.type!=='mex'||B.dep<0||depositTier(deposits[B.dep])<=0)continue;
3161
+ const d=dist2(ux[i],uy[i],B.x,B.y);if(d<bd){bd=d;best=B.dep;}
3162
+ }
3163
+ if(best>=0){
3164
+ const D=deposits[best];umode[i]=0;uMineNode[i]=best;ustate[i]=2;utgt[i]=-1;
3165
+ utx[i]=D.x+rr(-30,30);uty[i]=D.y+rr(-30,30);ufield[i]=requestField(D.x,D.y);
3166
+ return true;
3167
+ }
3168
+ const h=nearestSupportBuilding(i,['hq','fac','airfield','harbor','tgate'],MAP*2);
3169
+ umode[i]=6;uMineNode[i]=-1;
3170
+ if(h>=0){const B=blds[h];ustate[i]=2;utx[i]=B.x+rr(-24,24);uty[i]=B.y+rr(-24,24);ufield[i]=requestField(B.x,B.y);return true;}
3171
+ return false;
3172
+ }
3173
+ function redirectProspectorsFromNode(dep,team){
3174
+ for(let i=0;i<unitHigh;i++)if(ualive[i]&&uteam[i]===team&&utype[i]===UT_MINER&&uMineNode[i]===dep){
3175
+ uMineNode[i]=-1;redirectProspector(i);
3176
+ if(team===0)toast('⛏ EXTRACTOR LOST — Prospector reassigned automatically');
3177
+ }
3178
+ }
3179
+ function deployExtractorMiner(B){
3180
+ if(!B||B.type!=='mex'||B.freeMiner)return -1;
3181
+ B.freeMiner=true;
3182
+ /* The Extractor's attached Prospector is part of the structure package and
3183
+ is never denied by the manual support cap. If grants push the roster over
3184
+ that cap, factories simply cannot recruit more support until Commander or
3185
+ Research Lab progression catches up. */
3186
+ const i=spawnUnit(UT_MINER,B.team,B.x+B.r+18,B.y+B.r*.45);
3187
+ if(i>=0){uMineNode[i]=B.dep;utx[i]=B.x;uty[i]=B.y;if(B.team===0)toast('⛏ PROSPECTOR DEPLOYED — MINE / ASSIST / SURVEY orders unlocked');}
3188
+ return i;
3189
+ }
3190
+ function prospectorAssistTick(i,dt){
3191
+ const range=145;
3192
+ let bi=nearestSupportBuilding(i,['hq','fac','airfield','harbor','tgate'],range);
3193
+ if(bi<0){
3194
+ if(ustate[i]===2&&dist2(ux[i],uy[i],utx[i],uty[i])>35*35)return false;
3195
+ bi=nearestSupportBuilding(i,['hq','fac','airfield','harbor','tgate'],MAP*2);
3196
+ if(bi<0)return false;
3197
+ const G=blds[bi];ustate[i]=2;utx[i]=G.x+rr(-28,28);uty[i]=G.y+rr(-28,28);ufield[i]=requestField(G.x,G.y);return false;
3198
+ }
3199
+ const B=blds[bi],a=Math.atan2(B.y-uy[i],B.x-ux[i]);umov[i]=0;uang[i]=a+Math.PI/2;
3200
+ B.tractorT=.18;B.tractorN=Math.min(2,(B.tractorFrame===tick?(B.tractorN||0)+1:1));B.tractorFrame=tick;
3201
+ const mx=ux[i]+Math.cos(a)*TYPES[UT_MINER].size*.55,my=uy[i]+Math.sin(a)*TYPES[UT_MINER].size*.55;
3202
+ addBeam(mx,my,B.x,B.y,3.2,90,225,255,.11,'laser');
3203
+ if(B.type==='hq'&&uteam[i]<2){resM[uteam[i]]=Math.min(RES_MCAP[uteam[i]],resM[uteam[i]]+.42*dt);resE[uteam[i]]=Math.min(RES_ECAP[uteam[i]],resE[uteam[i]]+1.8*dt);}
3204
+ return true;
3205
+ }
3206
+ function prospectorSurveyTick(i,dt){
3207
+ let di=uMineNode[i],bit=1<<Math.min(2,uteam[i]);
3208
+ if(di<0||depositTier(deposits[di])<=0||((deposits[di].surveyed||0)&bit)){
3209
+ let best=-1,bd=Infinity;
3210
+ for(let d=0;d<deposits.length;d++){
3211
+ const D=deposits[d];if(depositTier(D)<=0||((D.surveyed||0)&bit))continue;
3212
+ const dd=dist2(ux[i],uy[i],D.x,D.y);if(dd<bd){bd=dd;best=d;}
3213
+ }
3214
+ if(best<0){umode[i]=6;return redirectProspector(i);}
3215
+ di=uMineNode[i]=best;const D=deposits[di];ustate[i]=2;utx[i]=D.x+50;uty[i]=D.y;ufield[i]=requestField(D.x,D.y);return false;
3216
+ }
3217
+ const D=deposits[di];if(dist2(ux[i],uy[i],D.x,D.y)>110*110)return false;
3218
+ D.surveyed=(D.surveyed||0)|bit;uMineT[i]=1.5;
3219
+ addParticle(3,D.x,D.y,0,0,.8,155,80,225,255);if(typeof mmPing==='function')mmPing(D.x,D.y);
3220
+ if(uteam[i]===0){resM[0]=Math.min(RES_MCAP[0],resM[0]+35+depositTier(D)*15);toast('⌾ SURVEY COMPLETE — Tier '+depositTier(D)+' phase field charted');sfx('notify',D.x,D.y,.8);}
3221
+ uMineNode[i]=-1;return true;
3222
+ }
3223
+ function minerUnitTick(i,dt){
3224
+ if(umode[i]===6)return prospectorAssistTick(i,dt);
3225
+ if(umode[i]===7)return prospectorSurveyTick(i,dt);
3226
+ const R=125,existing=uMineNode[i];
3227
+ let di=existing>=0&&depositTier(deposits[existing])>0?existing:-1;
3228
+ if(di<0||dist2(ux[i],uy[i],deposits[di].x,deposits[di].y)>R*R) di=nearestOreNode(ux[i],uy[i],R);
3229
+ if(di<0){uMineNode[i]=-1;if(!redirectProspector(i))return false;return false;}
3230
+ const D=deposits[di];
3231
+ /* A fresh move order always wins. The Prospector only locks onto the field
3232
+ after arriving, so it cannot feel as if it ignored the player's drag. */
3233
+ if(ustate[i]===2&&dist2(ux[i],uy[i],utx[i],uty[i])>30*30&&dist2(utx[i],uty[i],D.x,D.y)>R*R) return false;
3234
+ uMineNode[i]=di; umov[i]=0;
3235
+ const a=Math.atan2(D.y-uy[i],D.x-ux[i]); uang[i]=a+Math.PI/2;
3236
+ const mx=ux[i]+Math.cos(a)*TYPES[utype[i]].size*.55,my=uy[i]+Math.sin(a)*TYPES[utype[i]].size*.55;
3237
+ const tier=depositTier(D),col=tier===3?[205,105,255]:tier===2?[80,255,165]:[80,220,255];
3238
+ addBeam(mx,my,D.x+Math.cos(D.pulse||0)*5,D.y+Math.sin(D.pulse||0)*5,3.4,col[0],col[1],col[2],.11,'laser');
3239
+ addParticle(0,D.x+rr(-9,9),D.y+rr(-9,9),rr(-5,5),rr(-14,-4),.25,4,col[0],col[1],col[2]);
3240
+ uMineT[i]-=dt;
3241
+ if(uMineT[i]<=0){
3242
+ uMineT[i]=.68; const before=D.tier,got=drainDeposit(D,1.15);
3243
+ if(uteam[i]<2) resM[uteam[i]]=Math.min(RES_MCAP[uteam[i]],resM[uteam[i]]+got);
3244
+ if((tick+i)%9===0) sfx('laser',ux[i],uy[i],.42);
3245
+ if(uteam[i]===0&&D.tier!==before){
3246
+ toast(D.tier?'◇ MOBILE MINING — field dropped to Tier '+D.tier:'◇ MOBILE MINING — field depleted');
3247
+ sfx('notify',D.x,D.y,.7);
3248
+ }
3249
+ if(D.tier===0)redirectProspector(i);
3250
+ }
3251
+ return true;
3252
+ }
3253
  function unitTick(dt){
3254
  rebuildGrid();
3255
  tickMoveCohorts();
3256
+ broodCriticalMassTick(dt);
3257
  /* Patrol planning lives in input.js, which loads after the simulation. The
3258
  guarded call preserves boot/replay tooling that evaluates sim.js alone. */
3259
  if(typeof tickPatrolRoutes==='function')tickPatrolRoutes(dt);
 
3272
  const T=TYPES[utype[i]];
3273
  if(ucool[i]>0) ucool[i]-=dt;
3274
  if(ubuff[i]>0) ubuff[i]-=dt;
3275
+ if(uclassBuffT[i]>0){uclassBuffT[i]-=dt;if(uclassBuffT[i]<=0){uclassBuffT[i]=0;uclassBuff[i]=0;}}
3276
+ if(ubroodLed[i]>0) ubroodLed[i]-=dt;
3277
  if(uhaz[i]>0) uhaz[i]-=dt;
3278
  if(ustomp[i]>0) ustomp[i]-=dt;
3279
  if(ushielded[i]>0) ushielded[i]-=dt;
 
3326
  } else if(uteam[i]===0 && (tick&63)===0) stallE=0.8;
3327
  }
3328
  }
3329
+ if(T.miner&&minerUnitTick(i,dt)) continue;
3330
  // validate target
3331
  let tg=utgt[i];
3332
  if(tg>=0 && !foeTgt(i,tg,utgtg[i])){ tg=utgt[i]=-1; }
 
3340
  else if((i+tick)%9===0&&perfScale>0.4)
3341
  addParticle(1,ux[i],uy[i],rr(-4,4),rr(-14,-6),.5,3, 255,140,60);
3342
  }
3343
+ const rngM=(uteam[i]===0?resRngMult:1)*modeRngMul(md)*classRngMul(i)*(uhaz[i]>0?HAZ_RNG:1);
3344
  // staggered acquisition
3345
  if(T.wk!=='n' && (i+tick)%acqMod===0 && ustate[i]!==1 && ustate[i]!==6){
3346
  /* On the march, acquisition shrinks to self-defence range: the column
 
3393
  // fire
3394
  if(inRange && ucool[i]<=0 && !(T.minRng && er<T.minRng)){
3395
  const vet=1+uvet[i]*0.15;
3396
+ ucool[i]=T.cool*modeCoolMul(md)*classCoolMul(i)*broodCoolMul(i)/(ubuff[i]>0?1.4:1);
3397
  if(md===4){ umode[i]=0; umodeT[i]=MODE_SWITCH*0.4; } // firing breaks GHOST cover
3398
+ const dmg=T.dmg*vet*modeDmgMul(md)*classDmgMul(i)*broodDmgMul(i)*(ubuff[i]>0?1.5:1)*(uteam[i]===1?aiDmgMult:(uteam[i]===0?armyDmgMult:1))*(i===heroIdx?heroDmgMult:1);
3399
  const ma=(T.tur?uturr[i]:uang[i])-Math.PI/2;
3400
  const mx=ux[i]+Math.cos(ma)*T.size*0.62, my=uy[i]+Math.sin(ma)*T.size*0.62;
3401
  if(T.wk==='m'){
 
3403
  if(tg>=0){
3404
  dealDamage(tg,dmg*dmgMul('m',utype[tg]),uteam[i],i,dmgMul('m',utype[tg]));
3405
  if(T.aoe>0) forUnitsIn(ex,ey,T.aoe,j=>{ if(uteam[j]!==uteam[i]&&j!==tg) dealDamage(j,dmg*0.5*dmgMul('m',utype[j]),uteam[i],i); });
3406
+ } else if(isRelicTg(tg)) damageRelic(relics[relicOf(tg)],dmg*STM.m*(T.bldMul||1),uteam[i]);
3407
+ else damageBld(-2-tg,dmg*STM.m*(T.bldMul||1),uteam[i]);
3408
  addParticle(0,ex,ey,0,0,.14,9, 255,190,90);
3409
  addParticle(5,ex,ey,rr(-40,40),rr(-40,40),.25,4, 255,170,60);
3410
  if((i&3)===0) sfx(unitIsBrood(i)?'cre_attack':(T.ptype===5?'flame':'hit'),ux[i],uy[i],1);
 
3431
  if((i&3)===0) sfx(T.ptype===5?'flame':'laser',ux[i],uy[i],utype[i]===8?1.8:1);
3432
  } else {
3433
  const pmu=tg>=0?dmgMul(T.wk||'p',utype[tg]):(STM[T.wk||'p']||1);
3434
+ const pk=fireProj(T.ptype,uteam[i],mx,my,ex+rr(-3,3),ey+rr(-3,3),T.psp,dmg*pmu*(tg<=-2?(T.bldMul||1):1),T.aoe,tg);
3435
  const commanderCannon=utype[i]===4;
3436
  if(pk>=0){
3437
  pmu0[pk]=pmu; pwk[pk]=T.wk||'p'; pCannon[pk]=commanderCannon?1:0;
 
3486
  if(wantMove && distGoal>0.001){
3487
  // ground units make better time on the old highways
3488
  const rd=(!T.air&&!T.naval&&roadAt(ux[i],uy[i]))?ROAD_SPD:1;
3489
+ const sp=T.spd*spdM*classSpdMul(i)*broodSpdMul(i)*(ubuff[i]>0?1.35:1)*(uhaz[i]>0?HAZ_SPD:1)*rd*uCohesion[i]*Math.min(1,distGoal/18+0.25);
3490
  // flow-field steering for long marches (routes armies around lakes)
3491
  let ffOk=false;
3492
  /* Formation orders share a coarse field to the leg centre, then fan out
 
3540
  // apply with medium constraint: ground stays on land, ships stay on water, air flies anywhere
3541
  const ox=ux[i], oy=uy[i];
3542
  let nx=clamp(ox+mvx*dt,8,MAP-8), ny=clamp(oy+mvy*dt,8,MAP-8);
3543
+ if(typeof battlefieldClampPoint==='function'){
3544
+ const bp=battlefieldClampPoint(nx,ny,T.r+8);nx=bp[0];ny=bp[1];
3545
+ const goal=battlefieldClampPoint(utx[i],uty[i],T.r+8);utx[i]=goal[0];uty[i]=goal[1];
3546
+ }
3547
  if(!T.air && (mvx||mvy)){
3548
  const okAt=T.naval? ((X,Y)=>!isWalkable(X,Y)) : isWalkable;
3549
  if(!okAt(nx,ny)){
 
3742
  if(B.prog<1){
3743
  /* FORCED TEMPO booster: construction only, and only for the player. */
3744
  const doctrine=(B.team===0&&typeof defenseFocus!=='undefined'&&defenseFocus&&DEFT[B.type])?1.333:1;
3745
+ const tractor=B.tractorT>0?1+.22*Math.min(2,B.tractorN||1):1;
3746
  const bs=(B.team===0?((typeof boostMul==='function'?boostMul('build'):1)*
3747
+ (typeof bldSpeedMult!=='undefined'?bldSpeedMult:1)*doctrine):1)*tractor;
3748
  const wasProg=B.prog;
3749
  B.prog=Math.min(1,B.prog+dt*bs/BT[B.type].bt);
3750
  B.hp=Math.min(B.hpm,B.hp+B.hpm*0.9*dt/BT[B.type].bt);
3751
  if((tick&7)===0) addParticle(2,B.x+rr(-B.r,B.r),B.y+rr(-B.r,B.r),rr(-4,4),rr(-10,-2),.3,3, 160,230,255);
3752
  if(wasProg<1&&B.prog>=1){
3753
  if(B.type==='techlab') B.shield=B.shieldMax;
3754
+ if(B.type==='mex') deployExtractorMiner(B);
3755
  if(B.team===0) sfx('build',B.x,B.y,1);
3756
  }
3757
  continue;
 
3765
  if(B.dmgT>0) B.dmgT-=dt;
3766
  if(B.shieldT>0) B.shieldT-=dt;
3767
  if(B.guardT>0) B.guardT-=dt;
3768
+ if(B.tractorT>0)B.tractorT-=dt;
3769
  const bfac=bldFactionKey(B);
3770
  if(B.team<2){ // fortification regen
3771
  const F=fortOf(B.team);
 
4132
  addParticle(1,B.x+rr(0.2,0.3)*szf,B.y-szf*0.34,rr(-2,2),rr(-10,-6),.9,5, 118,118,124);
4133
  }
4134
  const t=B.queue[0], T=TYPES[t];
4135
+ const tractor=B.tractorT>0?1+.22*Math.min(2,B.tractorN||1):1;
4136
+ const speed=(B.team===1?aiBuildMult:playerBuildMult)*(1+0.12*Math.min(2,B.adj||0))*fortOf(B.team).prod*tractor;
4137
  const frac=dt*speed/T.bt;
4138
  if(!payStream(B.team, T.cm*frac, T.ce*frac)){
4139
  if(B.team===0){ if(resM[0]<T.cm*frac) stallM=0.8; if(resE[0]<T.ce*frac) stallE=0.8; }
src/intro.js ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================================
2
+ PRE-ALPHA TITLE REVEAL
3
+ ----------------------------------------------------------------------------
4
+ The opening deliberately uses no faction dossier/contact-sheet imagery.
5
+ Those assets belong in the codex. Cinematic faction chapters will return
6
+ only when purpose-built key art exists for them.
7
+ ============================================================================ */
8
+ (function(){
9
+ 'use strict';
10
+
11
+ var SEEN_KEY='mf_prealpha_cinematic_v2';
12
+ var LEGACY_SEEN_KEY='mf_prealpha_cinematic_v1';
13
+ var el=null,timer=0,open=false,reduced=false,lastFocus=null,motionQuery=null;
14
+ var hadCareer=false;
15
+ try{
16
+ hadCareer=!!localStorage.getItem('massfront_profiles_v1')||
17
+ !!localStorage.getItem('massfront_meta_v1');
18
+ }catch(e){}
19
+
20
+ function version(){
21
+ try{
22
+ if(typeof currentRunningVersion==='function') return currentRunningVersion();
23
+ if(typeof APP_VERSION!=='undefined') return APP_VERSION;
24
+ if(typeof PACKAGED_REV!=='undefined') return PACKAGED_REV;
25
+ }catch(e){}
26
+ return 'PREVIEW';
27
+ }
28
+ function escapeHtml(s){
29
+ return String(s||'').replace(/[&<>"']/g,function(c){
30
+ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
31
+ });
32
+ }
33
+ function seen(){
34
+ try{return !!localStorage.getItem(SEEN_KEY)||!!localStorage.getItem(LEGACY_SEEN_KEY);}catch(e){return false;}
35
+ }
36
+ function markSeen(){
37
+ try{localStorage.setItem(SEEN_KEY,JSON.stringify({version:version(),seenAt:Date.now()}));}catch(e){}
38
+ }
39
+ function markup(){
40
+ return '<section id="mfPreAlphaIntro" class="mfTitleReveal" role="dialog" aria-modal="true" '+
41
+ 'aria-labelledby="mfTitleWordmark" aria-describedby="mfTitleSubtitle" hidden>'+
42
+ '<div class="mfTitleBackdrop" aria-hidden="true"><div class="mfTitleGrid"></div><div class="mfTitleSweep"></div></div>'+
43
+ '<div class="mfTitleTop">'+
44
+ '<div class="mfTitleBuild"><b>PRE-ALPHA</b><span>v'+escapeHtml(version())+'</span></div>'+
45
+ '<button id="mfIntroSkip" class="mfTitleSkip" type="button">SKIP <span aria-hidden="true">&#10005;</span></button>'+
46
+ '</div>'+
47
+ '<main class="mfTitleCenter">'+
48
+ '<div class="mfTitleInsignia"><img src="./assets/icons/icon-192.png" alt=""><i></i></div>'+
49
+ '<div class="mfTitleEyebrow">COMMAND UPLINK ESTABLISHED</div>'+
50
+ '<h1 id="mfTitleWordmark">MASSFRONT</h1>'+
51
+ '<div id="mfTitleSubtitle" class="mfTitleSubtitle"><i></i><span>SUPREME MOBILE WARFARE</span><i></i></div>'+
52
+ '<div class="mfTitleStatus"><span></span> THE FRONT AWAITS YOUR ORDERS</div>'+
53
+ '</main>'+
54
+ '<div class="mfTitleBottom">'+
55
+ '<button id="mfIntroStart" class="mfTitleStart" type="button"><span aria-hidden="true">&#9654;</span> START NOW</button>'+
56
+ '</div>'+
57
+ '</section>';
58
+ }
59
+ function clearTimer(){if(timer){clearTimeout(timer);timer=0;}}
60
+ function scheduleClose(){
61
+ clearTimer();
62
+ if(!open||reduced) return;
63
+ timer=setTimeout(function(){closeIntro(false);},2800);
64
+ }
65
+ function closeIntro(startNow){
66
+ if(!el||!open) return;
67
+ markSeen();clearTimer();open=false;
68
+ el.classList.remove('open');el.setAttribute('aria-hidden','true');
69
+ document.body.classList.remove('mfIntroOpen');
70
+ var front=document.getElementById('startScreen');if(front) front.removeAttribute('aria-hidden');
71
+ setTimeout(function(){if(el&&!open) el.hidden=true;},260);
72
+ if(typeof initAudio==='function'){try{initAudio();}catch(e){}}
73
+ if(typeof sfx==='function'){try{sfx('ui');}catch(e){}}
74
+ var play=document.getElementById('startBtn');
75
+ if(startNow&&play) setTimeout(function(){play.click();},660);
76
+ else if(play) setTimeout(function(){try{play.focus({preventScroll:true});}catch(e){play.focus();}},90);
77
+ else if(lastFocus) lastFocus.focus();
78
+ }
79
+ function openIntro(force){
80
+ if(!el) return;
81
+ if(!force&&(hadCareer||seen())) return;
82
+ lastFocus=document.activeElement;open=true;el.hidden=false;el.removeAttribute('aria-hidden');
83
+ document.body.classList.add('mfIntroOpen');
84
+ var front=document.getElementById('startScreen');if(front) front.setAttribute('aria-hidden','true');
85
+ requestAnimationFrame(function(){
86
+ if(!open) return;el.classList.add('open');
87
+ var start=document.getElementById('mfIntroStart');if(start) start.focus({preventScroll:true});
88
+ });
89
+ scheduleClose();
90
+ }
91
+ function bindTap(node,fn){
92
+ if(typeof mfBindTap==='function') mfBindTap(node,fn);else node.addEventListener('click',fn);
93
+ }
94
+ function onKey(e){
95
+ if(!open) return;
96
+ if(e.key==='Escape'){e.preventDefault();closeIntro(false);return;}
97
+ if(e.key!=='Tab') return;
98
+ var buttons=Array.prototype.slice.call(el.querySelectorAll('button:not([disabled])')).filter(function(b){return b.offsetParent!==null;});
99
+ if(!buttons.length) return;
100
+ var first=buttons[0],last=buttons[buttons.length-1];
101
+ if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus();}
102
+ else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus();}
103
+ }
104
+ function addReplay(){
105
+ if(document.getElementById('mfIntroReplay')) return;
106
+ var front=document.getElementById('startScreen'),anchor=document.getElementById('updPanel');
107
+ if(!front) return;
108
+ var b=document.createElement('button');b.id='mfIntroReplay';b.className='mfIntroReplay';b.type='button';
109
+ b.innerHTML='<span aria-hidden="true">&#9673;</span><b>PRE-ALPHA TITLE</b><small>REPLAY OPENING</small>';
110
+ bindTap(b,function(){openIntro(true);});
111
+ if(anchor) front.insertBefore(b,anchor);else front.appendChild(b);
112
+ }
113
+ function motionChanged(e){
114
+ reduced=!!e.matches;if(el) el.dataset.motion=reduced?'reduce':'full';
115
+ if(reduced) clearTimer();else scheduleClose();
116
+ }
117
+ function initIntro(){
118
+ if(el) return;
119
+ var host=document.createElement('div');host.innerHTML=markup();el=host.firstElementChild;document.body.appendChild(el);
120
+ motionQuery=window.matchMedia?matchMedia('(prefers-reduced-motion: reduce)'):null;
121
+ reduced=!!(motionQuery&&motionQuery.matches);el.dataset.motion=reduced?'reduce':'full';
122
+ if(motionQuery){
123
+ if(typeof motionQuery.addEventListener==='function') motionQuery.addEventListener('change',motionChanged);
124
+ else if(typeof motionQuery.addListener==='function') motionQuery.addListener(motionChanged);
125
+ }
126
+ bindTap(document.getElementById('mfIntroSkip'),function(){closeIntro(false);});
127
+ bindTap(document.getElementById('mfIntroStart'),function(){closeIntro(true);});
128
+ document.addEventListener('keydown',onKey,true);addReplay();
129
+ var force=false;try{force=new URLSearchParams(location.search).get('intro')==='1';}catch(e){}
130
+ setTimeout(function(){openIntro(force);},650);
131
+ }
132
+
133
+ window.initIntro=initIntro;
134
+ window.showPreAlphaIntro=function(){openIntro(true);};
135
+ window.__mfIntroDebug=function(){return {open:open,phase:'title',stage:0,stageId:'title',reduced:reduced,
136
+ hadCareer:hadCareer,seen:seen(),wouldAuto:!hadCareer&&!seen(),version:version(),usesFactionArt:false};};
137
+ })();
src/main.js CHANGED
@@ -37,20 +37,69 @@ const START_ZONES=[
37
  {id:'se',n:'5',x:.82,y:.82},{id:'s', n:'6',x:.50,y:.85},
38
  {id:'sw',n:'7',x:.18,y:.82},{id:'w', n:'8',x:.15,y:.50}
39
  ];
40
- /* MAP is baked into terrain, pathfinding, fog and shaders. Resizing it at
41
- runtime would desynchronise all four, so engagement scale changes the
42
- authored opening theatre inside that fixed 2.6 km world instead. Compact
43
- starts pull toward the centre; Grand keeps the original coordinates. */
 
44
  const BATTLEFIELD_PRESETS={
45
- compact:{nm:'COMPACT', km:'1.9 KM FRONT', start:.72, extra:0, site:0.82, haz:0.88,
46
- ds:'Close opening lines, frequent resource-site caches and faster hazards.'},
47
- standard:{nm:'STANDARD',km:'2.2 KM FRONT', start:.86, extra:2, site:1, haz:1,
48
- ds:'Balanced opening distance, contested resources and hazard cadence.'},
49
- grand:{nm:'GRAND',km:'2.6 KM FRONT', start:1, extra:5, site:1.18, haz:1.12,
50
- ds:'Full authored deployment spread, more neutral sites and longer warning cycles.'}
51
  };
52
- let battlefieldPreset='grand';
53
- function battlefieldPresetDef(){ return BATTLEFIELD_PRESETS[battlefieldPreset]||BATTLEFIELD_PRESETS.grand; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  let playerStartZone='sw', spawnPick='player';
55
  let aiSlots=[
56
  {on:true, diff:1,zone:'ne'},
@@ -189,11 +238,13 @@ function resetWorld(){
189
  if(typeof mmPings!=='undefined') mmPings.length=0;
190
  meteors.length=0; stormTimer=200;
191
  fields.length=0; ffNext=0; ufield.fill(-1); ushielded.fill(0); umarch.fill(0);
 
192
  researched={}; resDone=0; researchCarry={}; resHpMult=1; resRngMult=1; resEnergyMult=1;
193
  resBldHpMult=1; resDefDmgMult=1; labBufferMult=1;
194
  deformQ.length=0; musicInt=0; lastDmgTotal=0; dmgAccum=[0,0,0]; dayT=0.08; shLife.fill(0);
195
  abCool=[0,0,0,0]; abUnlock=[true,false,false,false]; aiming=-1;
196
  if(typeof artBarrageReset==='function') artBarrageReset();
 
197
  salvageMult=1; bldHpMult=1; matchClock=timeLimit; goalDone=false;
198
  infestT=230; nestSpreadT=170; infestLvl=0; tideT=260; birds.length=0; bugQ.length=0;
199
  if(typeof hazReset==='function') hazReset(); // map hazard clock
@@ -283,7 +334,7 @@ function newSkirmish(){
283
  }
284
  let bd=1e12,bi=-1;
285
  for(let d=0;d<deposits.length;d++){ const D=deposits[d];
286
- if(D.taken) continue; const dd=dist2(D.x,D.y,exs,eys); if(dd<bd){bd=dd;bi=d;} }
287
  if(bi>=0){ deposits[bi].taken=true; addBld('mex',1,deposits[bi].x,deposits[bi].y,true); }
288
  aiBases.push({x:egx,y:egy,diff:S.diff,slot:S.slot,commander:h});
289
  aiDeployArrivals.push({x:egx,y:egy,fac:AI.fac,ang:a+Math.PI,depart:0});
@@ -507,7 +558,9 @@ function endGame(win,reason){
507
  /* The endgame layer scores the run, advances the threat ladder, ticks the
508
  mastery grid and records a weekly best — all from what actually happened. */
509
  const dv=demoMode?null:(typeof developRecord==='function'?developRecord({
510
- win:!!win, kills:stats.kills[0]|0, built:(stats.built[0])|0, nests:stats.nests|0}):null);
 
 
511
  const eg=demoMode?null:(typeof endgameRecord==='function'?endgameRecord({
512
  win:!!win, kills:stats.kills[0]|0, built:(stats.built[0])|0,
513
  seconds:stats.t|0, difficulty:difficulty|0}):null);
@@ -531,6 +584,7 @@ function endGame(win,reason){
531
  const fld=rw.field||{mass:0,energy:0,reclaimed:0};
532
  const mats=dv?Object.keys(dv.mats).filter(k=>dv.mats[k]>0).map(k=>
533
  '<div class="goRes"><b>'+MATS[k].em+' '+dv.mats[k]+'</b><span>'+MATS[k].nm.toUpperCase()+'</span></div>').join(''):'';
 
534
  let loot='';
535
  if(rw.loot&&rw.loot.gear){
536
  const g=rw.loot.gear, rr=invRarity(g.rarity);
@@ -545,11 +599,14 @@ function endGame(win,reason){
545
  +(rw.mult>1.001?'<small>×'+rw.mult.toFixed(2)+' OPERATION MULTIPLIER</small>':'')+'</h3>'
546
  +'<div class="goPayout"><div><b>+'+rw.xp+'</b><span>XP</span></div><div><b>+'+rw.cores+'</b><span>⬡ CORES</span></div>'
547
  +'<div><b>+'+(rw.data||0)+'</b><span>◆ RESEARCH DATA</span></div></div>'
548
- +'<div class="goBreak">'+rw.parts.map(p=>'<span>'+p[0]+' <b>+'+p[1]+'</b></span>').join('')+'</div></section>'
 
549
  +'<section class="goSection"><h3>FIELD RECOVERY</h3><div class="goResourceGrid">'
550
  +'<div class="goRes"><b>'+fld.mass.toLocaleString()+'</b><span>MASS IN RESERVE</span></div>'
551
  +'<div class="goRes"><b>'+fld.energy.toLocaleString()+'</b><span>ENERGY IN RESERVE</span></div>'
552
- +'<div class="goRes"><b>'+fld.reclaimed.toLocaleString()+'</b><span>MASS RECLAIMED</span></div>'+mats+'</div></section>'
 
 
553
  +(loot?'<section class="goSection"><h3>ITEMS ACQUIRED</h3>'+loot+'</section>':'')
554
  +(rw.rankUp?'<div class="goNotice good">◈ PROMOTED — '+rw.rankUp.em+' '+rw.rankUp.nm.toUpperCase()+'</div>':'')
555
  +(eg&&eg.msgs.length?'<div class="goNotice">'+eg.msgs.join('<br>')+'</div>':'')
@@ -938,9 +995,9 @@ function wire(){
938
  pick('.pcbtn',b=>{ resPace=+b.dataset.p; });
939
  pick('.crbtn',b=>{ crateRate=crateRateBase=+b.dataset.c; });
940
  pick('.bsbtn',b=>{
941
- battlefieldPreset=BATTLEFIELD_PRESETS[b.dataset.bs]?b.dataset.bs:'grand';
942
- const h=$('battleScaleHint'),P=battlefieldPresetDef();
943
- if(h) h.textContent=P.km+' — '+P.ds;
944
  renderSpawnPlanner();
945
  });
946
  pick('.ifbtn',b=>{
@@ -956,13 +1013,16 @@ function wire(){
956
  :'Classic RTS balance between mobile armies and static defences.';
957
  });
958
  initSpawnPlanner();
959
- /* Battle setup is split into four short task-focused pages. SHOW ALL remains
 
 
960
  useful for experienced players who want to audit an entire custom match. */
961
  const setup=$('setupScr');setup.dataset.tab=setup.dataset.tab||'map';
962
  document.querySelectorAll('.setupTabBtn').forEach(b=>mfBindTap(b,()=>{
963
  setup.dataset.tab=b.dataset.tab;setup.classList.remove('setupAll');
964
  document.querySelectorAll('.setupTabBtn').forEach(x=>x.classList.toggle('on',x===b));
965
  $('advToggle').classList.remove('on');$('advToggle').textContent='SHOW ALL';sfx('ui');
 
966
  const sc=setup.querySelector('.setupScroll');if(sc)sc.scrollTop=0;
967
  }));
968
  $('advToggle').addEventListener('pointerdown',()=>{
@@ -978,9 +1038,14 @@ function wire(){
978
  if(typeof renderFacRow==='function') renderFacRow();
979
  document.querySelectorAll('.wbtn').forEach(b=>{
980
  b.addEventListener('pointerdown',()=>{
 
981
  document.querySelectorAll('.wbtn').forEach(x=>x.classList.remove('on'));
982
  b.classList.add('on'); wcChoice=+b.dataset.w;
 
 
 
983
  META.wcPref=wcChoice; metaSave(); initAudio(); sfx('ui');
 
984
  });
985
  });
986
  mfBindTap($('armoryBtn'),()=>{
@@ -1001,6 +1066,7 @@ function wire(){
1001
  });
1002
  mfBindTap($('startBtn'),()=>{ initAudio(); sfx('ui');
1003
  renderMapRow(); renderSpawnPlanner();
 
1004
  showFrontScreen('setupScr'); });
1005
  mfBindTap($('setupBack'),()=>{ sfx('ui');
1006
  renderMetaHead(); showFrontScreen('startScreen'); });
@@ -1028,7 +1094,11 @@ function wire(){
1028
  mfBindTap($('restartBtn'),returnToMainMenu);
1029
  mfBindTap($('menuBtn'),()=>{ if(!running) return; paused=true; $('pauseOverlay').style.display='flex'; });
1030
  mfBindTap($('resumeBtn'),()=>{ paused=false; $('pauseOverlay').style.display='none'; });
1031
- mfBindTap($('quitBtn'),returnToMainMenu);
 
 
 
 
1032
  mfBindTap($('pauseSettings'),()=>{ openSettings('pause'); });
1033
  mfBindTap($('setBack'),()=>{
1034
  $('settingsScr').style.display='none'; sfx('ui');
@@ -1112,7 +1182,10 @@ function wire(){
1112
  if(!B.alive) return;
1113
  resM[0]=Math.min(RES_MCAP[0],resM[0]+Math.round(BT[B.type].cm*0.5));
1114
  B.alive=false; rebuildBGrid();
1115
- if(B.type==='mex') for(const D of deposits) if(D.x===B.x&&D.y===B.y) D.taken=false;
 
 
 
1116
  if(B.type==='geo') for(const G of geysers) if(G.x===B.x&&G.y===B.y) G.taken=false;
1117
  addParticle(3,B.x,B.y,0,0,.5,BT[B.type].size, 120,230,255);
1118
  toast('♻ '+BT[B.type].name+' sold');
@@ -1194,6 +1267,7 @@ function wire(){
1194
  $('abRage').addEventListener('pointerdown',()=>tryAbility(2));
1195
  $('abLance').addEventListener('pointerdown',()=>tryAbility(3));
1196
  $('abBarrage').addEventListener('pointerdown',()=>tryArtilleryBarrage());
 
1197
  $('abHero').addEventListener('pointerdown',()=>selectHero());
1198
  mfBindTap($('heroBar'),()=>{
1199
  selectHero();
@@ -1226,7 +1300,9 @@ function boot(){
1226
  if(su.d!=null&&su.d>=0&&su.d<=2) difficulty=su.d;
1227
  if(THEMES[su.t]) curTheme=su.t;
1228
  if(MAPDEFS[su.m]) curMap=su.m;
1229
- if(BATTLEFIELD_PRESETS[su.bs]) battlefieldPreset=su.bs;
 
 
1230
  if(su.f&&(su.f==='random'||FACTIONS[su.f])) aiFactionSel=su.f;
1231
  if(su.g&&GOALS.some(g=>g.id===su.g)) goalSel=su.g;
1232
  if(su.tl!=null) timeLimit=+su.tl||0;
@@ -1255,8 +1331,8 @@ function boot(){
1255
  document.querySelectorAll('.pcbtn').forEach(b=>b.classList.toggle('on',+b.dataset.p===resPace));
1256
  document.querySelectorAll('.crbtn').forEach(b=>b.classList.toggle('on',+b.dataset.c===crateRate));
1257
  document.querySelectorAll('.bsbtn').forEach(b=>b.classList.toggle('on',b.dataset.bs===battlefieldPreset));
1258
- const bsh=$('battleScaleHint'),bsp=battlefieldPresetDef();
1259
- if(bsh) bsh.textContent=bsp.km+' — '+bsp.ds;
1260
  document.querySelectorAll('.ifbtn').forEach(b=>b.classList.toggle('on',!!+b.dataset.i===infestationOn));
1261
  const ifh=$('infestHint'); if(ifh) ifh.textContent=infestationOn
1262
  ?'Neutral nests spread and erupt during the battle.'
@@ -1329,7 +1405,7 @@ function boot(){
1329
  if(typeof storyRefreshBadge==='function') storyRefreshBadge(); sfx('ui'); });
1330
  /* New subsystems. Each lives in its own module and registers its own UI, so
1331
  none of them need to touch index.html or this file beyond this line. */
1332
- for(const fn of ['initOffline','initSampleAudio','initAssetPacks','initAuthPortal','initTutorial','initAdBoards','initStoreUI','initResTree3D','initEconomyNet'])
1333
  if(typeof window[fn]==='function'){ try{ window[fn](); }catch(e){ console.error(fn,e); } }
1334
  /* The auth portal loads its cached AP_SESSION synchronously. Initialise the
1335
  Profile account/save panel afterwards so it renders that real session,
 
37
  {id:'se',n:'5',x:.82,y:.82},{id:'s', n:'6',x:.50,y:.85},
38
  {id:'sw',n:'7',x:.18,y:.82},{id:'w', n:'8',x:.15,y:.50}
39
  ];
40
+ /* MAP is baked into terrain, pathfinding, fog and shaders. Resizing that buffer
41
+ at runtime would desynchronise all four, so map size defines a playable
42
+ theatre inside the fixed 2.6 km terrain. Spawn separation, neutral resource
43
+ bounds and resource counts all use the same theatre scale; this produces a
44
+ genuinely faster Compact match without allocating a second terrain stack. */
45
  const BATTLEFIELD_PRESETS={
46
+ compact:{nm:'COMPACT',km:'1.9 KM',dur:'8\u201312 MIN',world:.72,start:.72,spread:.84,nodes:14,geysers:1,site:.82,haz:.88,
47
+ ds:'Close spawns, concentrated resource fields and quick clashes.'},
48
+ standard:{nm:'STANDARD',km:'2.2 KM',dur:'12\u201318 MIN',world:.86,start:.86,spread:1,nodes:18,geysers:2,site:1,haz:1,
49
+ ds:'Balanced travel, expansion space and contested resources.'},
50
+ large:{nm:'LARGE',km:'2.6 KM',dur:'18\u201325 MIN',world:1,start:1,spread:1.16,nodes:22,geysers:3,site:1.18,haz:1.12,
51
+ ds:'Maximum separation, additional fields and room for multi-front wars.'}
52
  };
53
+ let battlefieldPreset='standard';
54
+ function battlefieldPresetKey(key){ return key==='grand'?'large':(BATTLEFIELD_PRESETS[key]?key:'standard'); }
55
+ function battlefieldPresetDef(){ return BATTLEFIELD_PRESETS[battlefieldPresetKey(battlefieldPreset)]; }
56
+ function battlefieldPresetHint(){
57
+ const P=battlefieldPresetDef();
58
+ return P.km+' \u00b7 EST. '+P.dur+' \u2014 '+P.ds;
59
+ }
60
+ function battlefieldPlayBounds(pad){
61
+ const P=battlefieldPresetDef(),span=MAP*(P.world||1),inset=(MAP-span)*.5;
62
+ const lo=inset+(pad||0),hi=MAP-inset-(pad||0);
63
+ return lo<hi?{lo,hi,span}:{lo:MAP*.5-40,hi:MAP*.5+40,span:80};
64
+ }
65
+ /* THEATRE SILHOUETTES.
66
+ Map size still owns one stable square allocation, but the playable edge is
67
+ not the same square stamped onto every biome. Each theme receives a
68
+ deterministic superellipse plus low-frequency authored inlets/notches.
69
+ The same formula is duplicated in the terrain vertex shader and sampled by
70
+ the holographic border, so gameplay, red line and exclusion art agree. */
71
+ function battlefieldShapeStyle(){
72
+ return curMap==='isles'?1:(curTheme==='ashland'||curMap==='crater'?2:
73
+ (curTheme==='arctic'||curMap==='highland'?3:0));
74
+ }
75
+ function battlefieldShapeRadius(angle,pad){
76
+ const B=battlefieldPlayBounds(0),half=Math.max(45,(B.hi-B.lo)*.5-(pad||0));
77
+ const style=battlefieldShapeStyle(),ca=Math.abs(Math.cos(angle)),sa=Math.abs(Math.sin(angle));
78
+ const power=style===1?3.15:style===2?6.25:style===3?4.15:5.0;
79
+ const base=half/Math.pow(Math.pow(ca,power)+Math.pow(sa,power),1/power);
80
+ let shape;
81
+ if(style===1) shape=.945+.024*Math.sin(angle*3+.65)+.016*Math.sin(angle*7-1.10);
82
+ else if(style===2) shape=.958+.020*Math.sin(angle*5+.30)+.012*Math.sin(angle*9+1.20);
83
+ else if(style===3) shape=.948+.025*Math.sin(angle*2-.80)+.016*Math.sin(angle*6+.45);
84
+ else shape=.966+.014*Math.sin(angle*4+.35)+.010*Math.sin(angle*7-.55);
85
+ return Math.max(36,base*shape);
86
+ }
87
+ function battlefieldSignedDistance(x,y,pad){
88
+ const B=battlefieldPlayBounds(0),cx=(B.lo+B.hi)*.5,cy=cx,dx=x-cx,dy=y-cy;
89
+ const r=Math.hypot(dx,dy),a=r>0.001?Math.atan2(dy,dx):0;
90
+ return battlefieldShapeRadius(a,pad)-r;
91
+ }
92
+ function battlefieldContains(x,y,pad){ return battlefieldSignedDistance(x,y,pad)>=0; }
93
+ function battlefieldClampPoint(x,y,pad){
94
+ const B=battlefieldPlayBounds(0),cx=(B.lo+B.hi)*.5,cy=cx,dx=x-cx,dy=y-cy,r=Math.hypot(dx,dy);
95
+ const a=r>0.001?Math.atan2(dy,dx):0,limit=battlefieldShapeRadius(a,pad);
96
+ if(r<=limit)return [x,y];
97
+ const q=limit/Math.max(.001,r);return [cx+dx*q,cy+dy*q];
98
+ }
99
+ function battlefieldBoundaryPoint(angle,pad){
100
+ const B=battlefieldPlayBounds(0),c=(B.lo+B.hi)*.5,r=battlefieldShapeRadius(angle,pad);
101
+ return [c+Math.cos(angle)*r,c+Math.sin(angle)*r];
102
+ }
103
  let playerStartZone='sw', spawnPick='player';
104
  let aiSlots=[
105
  {on:true, diff:1,zone:'ne'},
 
238
  if(typeof mmPings!=='undefined') mmPings.length=0;
239
  meteors.length=0; stormTimer=200;
240
  fields.length=0; ffNext=0; ufield.fill(-1); ushielded.fill(0); umarch.fill(0);
241
+ ubroodLed.fill(0);uMineT.fill(0);uMineNode.fill(-1);broodMassT=0;
242
  researched={}; resDone=0; researchCarry={}; resHpMult=1; resRngMult=1; resEnergyMult=1;
243
  resBldHpMult=1; resDefDmgMult=1; labBufferMult=1;
244
  deformQ.length=0; musicInt=0; lastDmgTotal=0; dmgAccum=[0,0,0]; dayT=0.08; shLife.fill(0);
245
  abCool=[0,0,0,0]; abUnlock=[true,false,false,false]; aiming=-1;
246
  if(typeof artBarrageReset==='function') artBarrageReset();
247
+ if(typeof classAbilityReset==='function') classAbilityReset();
248
  salvageMult=1; bldHpMult=1; matchClock=timeLimit; goalDone=false;
249
  infestT=230; nestSpreadT=170; infestLvl=0; tideT=260; birds.length=0; bugQ.length=0;
250
  if(typeof hazReset==='function') hazReset(); // map hazard clock
 
334
  }
335
  let bd=1e12,bi=-1;
336
  for(let d=0;d<deposits.length;d++){ const D=deposits[d];
337
+ if(D.taken||depositTier(D)<=0) continue; const dd=dist2(D.x,D.y,exs,eys); if(dd<bd){bd=dd;bi=d;} }
338
  if(bi>=0){ deposits[bi].taken=true; addBld('mex',1,deposits[bi].x,deposits[bi].y,true); }
339
  aiBases.push({x:egx,y:egy,diff:S.diff,slot:S.slot,commander:h});
340
  aiDeployArrivals.push({x:egx,y:egy,fac:AI.fac,ang:a+Math.PI,depart:0});
 
558
  /* The endgame layer scores the run, advances the threat ladder, ticks the
559
  mastery grid and records a weekly best — all from what actually happened. */
560
  const dv=demoMode?null:(typeof developRecord==='function'?developRecord({
561
+ win:!!win, kills:stats.kills[0]|0, built:(stats.built[0])|0, nests:stats.nests|0,
562
+ fieldMass:Math.max(0,Math.floor(resM[0]||0)),fieldEnergy:Math.max(0,Math.floor(resE[0]||0)),
563
+ reclaimed:Math.max(0,Math.round(stats.reclaimed||0))}):null);
564
  const eg=demoMode?null:(typeof endgameRecord==='function'?endgameRecord({
565
  win:!!win, kills:stats.kills[0]|0, built:(stats.built[0])|0,
566
  seconds:stats.t|0, difficulty:difficulty|0}):null);
 
584
  const fld=rw.field||{mass:0,energy:0,reclaimed:0};
585
  const mats=dv?Object.keys(dv.mats).filter(k=>dv.mats[k]>0).map(k=>
586
  '<div class="goRes"><b>'+MATS[k].em+' '+dv.mats[k]+'</b><span>'+MATS[k].nm.toUpperCase()+'</span></div>').join(''):'';
587
+ const dataBreak=(rw.dataParts||[]).map(p=>'<span>'+p[0]+' <b>+'+p[1]+'</b></span>').join('');
588
  let loot='';
589
  if(rw.loot&&rw.loot.gear){
590
  const g=rw.loot.gear, rr=invRarity(g.rarity);
 
599
  +(rw.mult>1.001?'<small>×'+rw.mult.toFixed(2)+' OPERATION MULTIPLIER</small>':'')+'</h3>'
600
  +'<div class="goPayout"><div><b>+'+rw.xp+'</b><span>XP</span></div><div><b>+'+rw.cores+'</b><span>⬡ CORES</span></div>'
601
  +'<div><b>+'+(rw.data||0)+'</b><span>◆ RESEARCH DATA</span></div></div>'
602
+ +'<div class="goBreak">'+rw.parts.map(p=>'<span>'+p[0]+' <b>+'+p[1]+'</b></span>').join('')+'</div>'
603
+ +(dataBreak?'<div class="goBreak research"><strong>RESEARCH DATA</strong>'+dataBreak+'</div>':'')+'</section>'
604
  +'<section class="goSection"><h3>FIELD RECOVERY</h3><div class="goResourceGrid">'
605
  +'<div class="goRes"><b>'+fld.mass.toLocaleString()+'</b><span>MASS IN RESERVE</span></div>'
606
  +'<div class="goRes"><b>'+fld.energy.toLocaleString()+'</b><span>ENERGY IN RESERVE</span></div>'
607
+ +'<div class="goRes"><b>'+fld.reclaimed.toLocaleString()+'</b><span>MASS RECLAIMED</span></div></div>'
608
+ +(mats?'<h4 class="goAccountSalvage">ACCOUNT SALVAGE <small>Persisted to Development inventory</small></h4>'
609
+ +'<div class="goResourceGrid account">'+mats+'</div>':'')+'</section>'
610
  +(loot?'<section class="goSection"><h3>ITEMS ACQUIRED</h3>'+loot+'</section>':'')
611
  +(rw.rankUp?'<div class="goNotice good">◈ PROMOTED — '+rw.rankUp.em+' '+rw.rankUp.nm.toUpperCase()+'</div>':'')
612
  +(eg&&eg.msgs.length?'<div class="goNotice">'+eg.msgs.join('<br>')+'</div>':'')
 
995
  pick('.pcbtn',b=>{ resPace=+b.dataset.p; });
996
  pick('.crbtn',b=>{ crateRate=crateRateBase=+b.dataset.c; });
997
  pick('.bsbtn',b=>{
998
+ battlefieldPreset=battlefieldPresetKey(b.dataset.bs);
999
+ const h=$('battleScaleHint');
1000
+ if(h) h.textContent=battlefieldPresetHint();
1001
  renderSpawnPlanner();
1002
  });
1003
  pick('.ifbtn',b=>{
 
1013
  :'Classic RTS balance between mobile armies and static defences.';
1014
  });
1015
  initSpawnPlanner();
1016
+ /* Battle setup is split into five short task-focused pages. Match modifiers
1017
+ live here because they change one deployment, not the Operations hub.
1018
+ SHOW ALL remains
1019
  useful for experienced players who want to audit an entire custom match. */
1020
  const setup=$('setupScr');setup.dataset.tab=setup.dataset.tab||'map';
1021
  document.querySelectorAll('.setupTabBtn').forEach(b=>mfBindTap(b,()=>{
1022
  setup.dataset.tab=b.dataset.tab;setup.classList.remove('setupAll');
1023
  document.querySelectorAll('.setupTabBtn').forEach(x=>x.classList.toggle('on',x===b));
1024
  $('advToggle').classList.remove('on');$('advToggle').textContent='SHOW ALL';sfx('ui');
1025
+ if(b.dataset.tab==='modifiers'&&typeof renderOps==='function') renderOps();
1026
  const sc=setup.querySelector('.setupScroll');if(sc)sc.scrollTop=0;
1027
  }));
1028
  $('advToggle').addEventListener('pointerdown',()=>{
 
1038
  if(typeof renderFacRow==='function') renderFacRow();
1039
  document.querySelectorAll('.wbtn').forEach(b=>{
1040
  b.addEventListener('pointerdown',()=>{
1041
+ if(b.disabled) return;
1042
  document.querySelectorAll('.wbtn').forEach(x=>x.classList.remove('on'));
1043
  b.classList.add('on'); wcChoice=+b.dataset.w;
1044
+ /* Random quick-picks and hand-selected modifiers are intentionally
1045
+ exclusive, so neither control can silently override the other. */
1046
+ META.opmods={};
1047
  META.wcPref=wcChoice; metaSave(); initAudio(); sfx('ui');
1048
+ if(typeof renderOps==='function') renderOps();
1049
  });
1050
  });
1051
  mfBindTap($('armoryBtn'),()=>{
 
1066
  });
1067
  mfBindTap($('startBtn'),()=>{ initAudio(); sfx('ui');
1068
  renderMapRow(); renderSpawnPlanner();
1069
+ if(typeof renderOps==='function') renderOps();
1070
  showFrontScreen('setupScr'); });
1071
  mfBindTap($('setupBack'),()=>{ sfx('ui');
1072
  renderMetaHead(); showFrontScreen('startScreen'); });
 
1094
  mfBindTap($('restartBtn'),returnToMainMenu);
1095
  mfBindTap($('menuBtn'),()=>{ if(!running) return; paused=true; $('pauseOverlay').style.display='flex'; });
1096
  mfBindTap($('resumeBtn'),()=>{ paused=false; $('pauseOverlay').style.display='none'; });
1097
+ mfBindTap($('quitBtn'),()=>{
1098
+ const go=()=>returnToMainMenu();
1099
+ if(typeof accConfirm==='function') accConfirm('Abandon this operation? The unfinished match grants no mission payout.',go);
1100
+ else go();
1101
+ });
1102
  mfBindTap($('pauseSettings'),()=>{ openSettings('pause'); });
1103
  mfBindTap($('setBack'),()=>{
1104
  $('settingsScr').style.display='none'; sfx('ui');
 
1182
  if(!B.alive) return;
1183
  resM[0]=Math.min(RES_MCAP[0],resM[0]+Math.round(BT[B.type].cm*0.5));
1184
  B.alive=false; rebuildBGrid();
1185
+ if(B.type==='mex'){
1186
+ if(B.dep>=0) redirectProspectorsFromNode(B.dep,B.team);
1187
+ for(const D of deposits) if(D.x===B.x&&D.y===B.y) D.taken=false;
1188
+ }
1189
  if(B.type==='geo') for(const G of geysers) if(G.x===B.x&&G.y===B.y) G.taken=false;
1190
  addParticle(3,B.x,B.y,0,0,.5,BT[B.type].size, 120,230,255);
1191
  toast('♻ '+BT[B.type].name+' sold');
 
1267
  $('abRage').addEventListener('pointerdown',()=>tryAbility(2));
1268
  $('abLance').addEventListener('pointerdown',()=>tryAbility(3));
1269
  $('abBarrage').addEventListener('pointerdown',()=>tryArtilleryBarrage());
1270
+ if($('abClass')) $('abClass').addEventListener('pointerdown',()=>tryClassAbility());
1271
  $('abHero').addEventListener('pointerdown',()=>selectHero());
1272
  mfBindTap($('heroBar'),()=>{
1273
  selectHero();
 
1300
  if(su.d!=null&&su.d>=0&&su.d<=2) difficulty=su.d;
1301
  if(THEMES[su.t]) curTheme=su.t;
1302
  if(MAPDEFS[su.m]) curMap=su.m;
1303
+ /* `grand` was the pre-1.32 name for Large. Keep old setup saves valid while
1304
+ storing only the player-facing Compact / Standard / Large vocabulary. */
1305
+ if(BATTLEFIELD_PRESETS[su.bs]||su.bs==='grand') battlefieldPreset=battlefieldPresetKey(su.bs);
1306
  if(su.f&&(su.f==='random'||FACTIONS[su.f])) aiFactionSel=su.f;
1307
  if(su.g&&GOALS.some(g=>g.id===su.g)) goalSel=su.g;
1308
  if(su.tl!=null) timeLimit=+su.tl||0;
 
1331
  document.querySelectorAll('.pcbtn').forEach(b=>b.classList.toggle('on',+b.dataset.p===resPace));
1332
  document.querySelectorAll('.crbtn').forEach(b=>b.classList.toggle('on',+b.dataset.c===crateRate));
1333
  document.querySelectorAll('.bsbtn').forEach(b=>b.classList.toggle('on',b.dataset.bs===battlefieldPreset));
1334
+ const bsh=$('battleScaleHint');
1335
+ if(bsh) bsh.textContent=battlefieldPresetHint();
1336
  document.querySelectorAll('.ifbtn').forEach(b=>b.classList.toggle('on',!!+b.dataset.i===infestationOn));
1337
  const ifh=$('infestHint'); if(ifh) ifh.textContent=infestationOn
1338
  ?'Neutral nests spread and erupt during the battle.'
 
1405
  if(typeof storyRefreshBadge==='function') storyRefreshBadge(); sfx('ui'); });
1406
  /* New subsystems. Each lives in its own module and registers its own UI, so
1407
  none of them need to touch index.html or this file beyond this line. */
1408
+ for(const fn of ['initOffline','initSampleAudio','initAssetPacks','initAuthPortal','initTutorial','initAdBoards','initStoreUI','initResTree3D','initEconomyNet','initIntro'])
1409
  if(typeof window[fn]==='function'){ try{ window[fn](); }catch(e){ console.error(fn,e); } }
1410
  /* The auth portal loads its cached AP_SESSION synchronously. Initialise the
1411
  Profile account/save panel afterwards so it renders that real session,
src/restree3d.js CHANGED
@@ -1,274 +1,333 @@
1
  /* ============================================================================
2
- RESTREE3Dspatial 3D research tree
3
  ----------------------------------------------------------------------------
4
- Replaces the flat research list in the Development screen with a navigable
5
- CSS-3D tree: branches sit at different depths, nodes lift toward the viewer
6
- as they become owned/affordable, and prerequisite lines are drawn behind
7
- the node layer. No WebGL the battlefield already owns that context, so
8
- this uses `perspective` + `transform-style:preserve-3d` on plain DOM nodes
9
- (the same technique #startScreen already uses for its parallax, see
10
- ui.css). Everything here is display only: purchasing goes through the real
11
- devHas/devAvail/devBuy in develop.js, never reimplemented.
12
 
13
- Hook-in: takes over the global `renderDevelop` (same takeover pattern
14
- src/audio.js uses for `sfx` a function declaration is a reassignable
15
- global binding). The takeover happens inside initResTree3D(), which
16
- main.js calls once at boot, after develop.js has already declared the
17
- original renderDevelop. On every call, the original still runs first
18
- (so materials row + tabs + the craft/loadout tabs are untouched); if the
19
- research tab is active, its flat-list output in #devBody is then replaced
20
- with the 3D tree.
21
  ============================================================================ */
22
  (function(){
23
-
24
- /* index.html already links this stylesheet; inject defensively in case a
25
- stripped-down host page does not carry that tag. */
26
  if(!document.querySelector('link[href*="restree.css"]')){
27
  var lk=document.createElement('link');
28
  lk.rel='stylesheet'; lk.href='./src/styles/restree.css';
29
  document.head.appendChild(lk);
30
  }
31
 
32
- /* ---- layout constants ----------------------------------------------------
33
- Branch = row (Y) and base depth (Z). Tier within a branch = column (X).
34
- DEVTREE lists each branch's 4 nodes in prerequisite-chain order already
35
- (metallurgy->optics->servos->slot2, etc.), same as develop.js's own flat
36
- loop, so array index within a branch IS the tier — no topo-sort needed. */
37
- var BRS=['FABRICATION','DOCTRINE','XENOLOGY'];
38
- var TIER_X=98, ROW_Y=132;
39
- var BASE_Z={FABRICATION:36,DOCTRINE:-18,XENOLOGY:-84};
40
- var LIFT={owned:26,afford:14,avail:0,locked:-16};
41
- var SVG_W=420,SVG_H=400,SVG_CX=210,SVG_CY=200,SVG_ZBACK=-160;
42
-
43
- /* Camera/gesture state lives outside the DOM so it survives every rebuild
44
- (e.g. renderDevelop() re-running after a purchase). */
45
- var RT={rx:-11,ry:-15,zoom:.92,panX:0,panY:0,sel:null,pts:new Map(),multi:false,lastTap:0};
46
- var RX_MIN=-42,RX_MAX=14,RY_MIN=-72,RY_MAX=72,ZOOM_MIN=.55,ZOOM_MAX=1.9,PAN_MAX=130;
47
-
48
- function clamp(v,a,b){ return v<a?a:(v>b?b:v); }
49
- function is3D(){ return !(typeof META!=='undefined'&&META.settings&&META.settings.perf==='low'); }
50
 
51
- function stateOf(n){
 
 
 
 
 
 
52
  if(devHas(n.id)) return 'owned';
53
  if(!devAvail(n)) return 'locked';
54
- return ((META.researchData||0)>=n.data&&matHas(n.cost))?'afford':'avail';
55
  }
56
- function blockerNames(n){
57
- return n.req.filter(function(r){ return !devHas(r); })
58
- .map(function(r){ var d=DEVTREE.find(function(x){ return x.id===r; }); return d?d.nm:r; });
 
 
 
 
59
  }
60
-
61
- /* Every node's position + current state, computed fresh on every build so
62
- a purchase (which changes devHas/devAvail) immediately reflows the tree. */
63
- function layoutRows(){
64
- var out=[];
65
- BRS.forEach(function(br,bi){
66
- var nodes=DEVTREE.filter(function(x){ return x.br===br; });
67
- /* Doctrine carries a lateral active-ability leaf. Centre each row from
68
- its real count so the fifth node stays inside the mobile stage. */
69
- var step=Math.min(TIER_X,352/Math.max(1,nodes.length-1));
70
- nodes.forEach(function(n,ti){
71
- var st=stateOf(n);
72
- out.push({
73
- n:n, br:br, state:st,
74
- x:(ti-(nodes.length-1)*.5)*step, y:(bi-1)*ROW_Y, z:BASE_Z[br]+LIFT[st]
 
 
 
75
  });
76
  });
 
 
77
  });
78
- return out;
79
  }
80
 
81
- function applyTransform(scene){
82
- if(!scene) return;
83
- if(!is3D()){ scene.style.transform='translate3d(0,0,0)'; return; }
84
- scene.style.transform='translate('+RT.panX+'px,'+RT.panY+'px) scale('+RT.zoom+') '
85
- +'rotateX('+RT.rx+'deg) rotateY('+RT.ry+'deg)';
 
 
 
 
 
86
  }
87
-
88
- /* ---- detail panel ---------------------------------------------------------- */
89
- function detailHtml(){
90
- var n=RT.sel && DEVTREE.find(function(x){ return x.id===RT.sel; });
91
- if(!n) return '<div class="rt3d-detail" id="rtDetail"></div>';
92
- var st=stateOf(n);
93
- var body='<button class="rt3d-detail-x" id="rtClose" type="button">✕</button>'
94
- +'<div class="rt3d-detail-hd"><div class="rt-ic">'+itemArt(n.art||'res_'+n.id,'🔬',30)+'</div>'
95
- +'<div><b>'+n.nm+'</b><span>'+n.ds+'</span></div></div>'
96
- +'<div class="rt3d-detail-row"><div class="rt3d-detail-lbl">COST</div>'
97
- +matCostStr(n.cost)+'<span class="costOne">◆ '+n.data+' DATA</span></div>';
98
- if(n.req.length){
99
- body+='<div class="rt3d-detail-row"><div class="rt3d-detail-lbl">PREREQUISITES</div><div class="rt3d-reqs">'
100
- +n.req.map(function(r){
101
- var rd=DEVTREE.find(function(x){ return x.id===r; });
102
- var ok=devHas(r);
103
- return '<span class="rt3d-reqChip '+(ok?'ok':'no')+'">'+(ok?'✓ ':'')+(rd?rd.nm:r)+'</span>';
104
- }).join('')+'</div></div>';
105
  }
106
- var label = st==='owned' ? '✓ RESEARCHED' : (st==='locked' ? '🔒 LOCKED' : 'RESEARCH');
107
- var bcls = st==='owned' ? 'done' : (st==='afford' ? 'go' : '');
108
- body+='<button class="rt3d-detail-buy '+bcls+'" id="rtBuy" type="button">'+label+'</button>';
109
- return '<div class="rt3d-detail show" id="rtDetail">'+body+'</div>';
110
  }
111
- function wireDetail(){
112
- var x=document.getElementById('rtClose');
113
- if(x) x.addEventListener('pointerdown',function(ev){ ev.stopPropagation(); closeDetail(); });
114
- var b=document.getElementById('rtBuy');
115
- if(b) b.addEventListener('pointerdown',function(ev){
116
- ev.stopPropagation();
117
- var n=RT.sel && DEVTREE.find(function(x){ return x.id===RT.sel; });
118
- if(n) devBuy(n); // real purchase rules live in develop.js — never duplicated here
119
- });
 
 
120
  }
121
- function refreshDetail(){
122
- var host=document.getElementById('rtDetail'); if(!host) return;
123
- host.outerHTML=detailHtml();
124
- wireDetail();
125
- var back=document.getElementById('rtBack'); if(back) back.classList.toggle('show',!!RT.sel);
126
- document.querySelectorAll('.rt3d-node').forEach(function(el){
127
- el.classList.toggle('sel', el.dataset.id===RT.sel);
128
- });
 
 
 
 
 
 
 
 
 
 
129
  }
130
- function openDetail(id){ RT.sel=id; refreshDetail(); }
131
- function closeDetail(){ RT.sel=null; refreshDetail(); }
132
 
133
- /* ---- gestures --------------------------------------------------------------
134
- One finger drags the whole scene into rotation; two fingers pinch to zoom
135
- and pan together (distance -> zoom, midpoint travel -> pan). A tap (short,
136
- near-stationary) on a node opens its detail panel; a second tap within
137
- ~330ms of the first (double-tap) also steps the zoom in/out. Everything is
138
- Pointer Events so touch, mouse and pen all go through one path. */
139
- function wireGestures(stage,scene){
140
- function onDown(e){
141
- try{ stage.setPointerCapture(e.pointerId); }catch(err){}
142
- RT.pts.set(e.pointerId,{x:e.clientX,y:e.clientY,sx:e.clientX,sy:e.clientY,t:Date.now()});
143
- if(RT.pts.size>=2){
144
- RT.multi=true;
145
- var arr=Array.from(RT.pts.values());
146
- RT.pd=Math.hypot(arr[0].x-arr[1].x,arr[0].y-arr[1].y);
147
- RT.pz=RT.zoom;
148
- RT.pm={x:(arr[0].x+arr[1].x)/2,y:(arr[0].y+arr[1].y)/2};
149
- RT.pb={x:RT.panX,y:RT.panY};
150
- }
 
 
151
  }
152
- function onMove(e){
153
- var p=RT.pts.get(e.pointerId); if(!p) return;
154
- var dx=e.clientX-p.x, dy=e.clientY-p.y;
155
- p.x=e.clientX; p.y=e.clientY;
156
- if(!is3D()) return;
157
- if(RT.pts.size===1 && !RT.multi){
158
- RT.ry=clamp(RT.ry+dx*0.35, RY_MIN,RY_MAX);
159
- RT.rx=clamp(RT.rx-dy*0.35, RX_MIN,RX_MAX);
160
- applyTransform(scene);
161
- } else if(RT.pts.size===2){
162
- var arr=Array.from(RT.pts.values());
163
- var d=Math.hypot(arr[0].x-arr[1].x,arr[0].y-arr[1].y);
164
- if(RT.pd>0) RT.zoom=clamp(RT.pz*(d/RT.pd), ZOOM_MIN,ZOOM_MAX);
165
- var m={x:(arr[0].x+arr[1].x)/2,y:(arr[0].y+arr[1].y)/2};
166
- RT.panX=clamp(RT.pb.x+(m.x-RT.pm.x), -PAN_MAX,PAN_MAX);
167
- RT.panY=clamp(RT.pb.y+(m.y-RT.pm.y), -PAN_MAX,PAN_MAX);
168
- applyTransform(scene);
169
- }
170
  }
171
- function onUp(e){
172
- var p=RT.pts.get(e.pointerId);
173
- RT.pts.delete(e.pointerId);
174
- if(p){
175
- var dt=Date.now()-p.t, dd=Math.hypot(p.x-p.sx,p.y-p.sy);
176
- if(!RT.multi && dt<500 && dd<10){
177
- var el=document.elementFromPoint(p.x,p.y);
178
- var nodeEl=el && el.closest && el.closest('.rt3d-node');
179
- var now=Date.now();
180
- if(is3D() && now-RT.lastTap<330){
181
- RT.zoom = RT.zoom<1.2 ? 1.5 : 0.92; applyTransform(scene);
182
- }
183
- RT.lastTap=now;
184
- if(nodeEl) openDetail(nodeEl.dataset.id);
185
- }
186
- }
187
- if(RT.pts.size===0) RT.multi=false;
188
  }
189
- stage.addEventListener('pointerdown',onDown);
190
- stage.addEventListener('pointermove',onMove);
191
- stage.addEventListener('pointerup',onUp);
192
- stage.addEventListener('pointercancel',onUp);
193
  }
194
 
195
- /* ---- build -------------------------------------------------------------- */
196
- function buildTree(){
197
- var body=document.getElementById('devBody'); if(!body) return;
198
- var rows=layoutRows();
199
- var flat=!is3D();
200
-
201
- var html='<div class="rt3d-wrap">'
202
- +'<div class="rt3d-legend">'
203
- +'<span><i class="own"></i>OWNED</span>'
204
- +'<span><i class="aff"></i>READY</span>'
205
- +'<span><i class="ava"></i>NEED MORE</span>'
206
- +'<span><i class="lok"></i>LOCKED</span>'
207
- +'</div>'
208
- +'<div class="rt3d-stage'+(flat?' flat':'')+'" id="rtStage">'
209
- +(flat?'':'<button class="rt3d-reset" id="rtReset" type="button">⟲</button>')
210
- +'<div class="rt3d-scene" id="rtScene">'
211
- +'<svg class="rt3d-links" width="'+SVG_W+'" height="'+SVG_H+'" '
212
- +'style="transform:translate3d('+(-SVG_CX)+'px,'+(-SVG_CY)+'px,'+SVG_ZBACK+'px)">';
 
 
 
 
 
 
213
 
214
- rows.forEach(function(row){
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  row.n.req.forEach(function(rid){
216
- var pr=rows.find(function(x){ return x.n.id===rid; }); if(!pr) return;
217
- var cls = devHas(row.n.id) ? 'on' : (devHas(rid) ? 'next' : 'off');
218
- html+='<line x1="'+(SVG_CX+pr.x)+'" y1="'+(SVG_CY+pr.y)+'" '
219
- +'x2="'+(SVG_CX+row.x)+'" y2="'+(SVG_CY+row.y)+'" class="'+cls+'"></line>';
 
 
220
  });
221
  });
222
- html+='</svg>';
 
 
 
 
 
 
 
 
 
 
 
223
 
224
- rows.forEach(function(row){
225
- var n=row.n, st=row.state;
226
- var badge = st==='owned'?'✓':st==='afford'?'⚡':st==='avail'?'⏳':'🔒';
227
- var sub = st==='owned' ? 'RESEARCHED'
228
- : st==='locked' ? ('NEEDS '+blockerNames(n).join(', '))
229
- : ('◆ '+n.data+' DATA');
230
- html+='<div class="rt3d-node '+st+(RT.sel===n.id?' sel':'')+'" data-id="'+n.id+'" '
231
- +'style="transform:translate3d('+row.x+'px,'+row.y+'px,'+row.z+'px)">'
232
- +'<div class="rt-badge">'+badge+'</div>'
233
- +'<div class="rt-ic">'+itemArt(n.art||'res_'+n.id,'🔬',20)+'</div>'
234
- +'<span class="rt-nm">'+n.nm+'</span>'
235
- +'<span class="rt-sub">'+sub+'</span></div>';
 
 
 
 
 
 
236
  });
237
- html+='</div>' /* scene */
238
- +'<div class="rt3d-back'+(RT.sel?' show':'')+'" id="rtBack"></div>'
239
- +detailHtml()
240
- +'</div>' /* stage */
241
- +'<div class="rt3d-hint">'
242
- +(flat?'TAP A NODE FOR DETAILS':'DRAG TO ROTATE · TWO FINGERS TO PAN / PINCH TO ZOOM · TAP A NODE')
243
- +'</div></div>'; /* wrap */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
 
245
- body.innerHTML=html;
246
- var back=document.getElementById('rtBack');
 
 
 
 
 
 
247
 
248
- var stage=document.getElementById('rtStage'), scene=document.getElementById('rtScene');
249
- applyTransform(scene);
250
- wireGestures(stage,scene);
251
- wireDetail();
252
- if(back) back.addEventListener('pointerdown',function(){ closeDetail(); });
253
- var reset=document.getElementById('rtReset');
254
- if(reset) reset.addEventListener('pointerdown',function(ev){
255
- ev.stopPropagation();
256
- RT.rx=-11; RT.ry=-15; RT.zoom=.92; RT.panX=0; RT.panY=0;
257
- applyTransform(scene); if(typeof sfx==='function') sfx('ui');
258
- });
259
  }
260
 
261
- /* ---- hook-in -------------------------------------------------------------- */
262
  function initResTree3D(){
263
- if(typeof renderDevelop!=='function' || typeof DEVTREE==='undefined') return;
264
  var flatRender=renderDevelop;
265
  renderDevelop=function(){
266
  flatRender();
267
- /* The spatial overview remains available on roomy landscape displays.
268
- Portrait phones use develop.js's branch cards: readable text and exact
269
- deficits beat tiny nodes in the app's locked orientation. */
270
- var roomy=window.matchMedia&&window.matchMedia('(min-width: 700px) and (orientation: landscape)').matches;
271
- if(typeof devTab!=='undefined' && devTab==='research' && roomy) buildTree();
 
 
272
  };
273
  }
274
 
 
1
  /* ============================================================================
2
+ RESEARCH NETWORK touch-first prerequisite graph
3
  ----------------------------------------------------------------------------
4
+ develop.js remains the authority for research data, costs, availability and
5
+ effects. This module only takes over the RESEARCH presentation after the
6
+ normal Development renderer has populated materials and tabs. Keeping the
7
+ purchase call routed through devBuy() prevents the visual tree from becoming
8
+ a second, subtly different progression system.
 
 
 
9
 
10
+ The old spatial view only ran above 700 px in landscape, which meant the
11
+ game's actual phone orientation silently fell back to the original list.
12
+ This graph is deliberately 2D and scrollable: prerequisite direction stays
13
+ readable under a thumb, cross-branch links are real SVG paths, and every
14
+ node remains at least 52 px tall on a 320 px-wide device.
 
 
 
15
  ============================================================================ */
16
  (function(){
 
 
 
17
  if(!document.querySelector('link[href*="restree.css"]')){
18
  var lk=document.createElement('link');
19
  lk.rel='stylesheet'; lk.href='./src/styles/restree.css';
20
  document.head.appendChild(lk);
21
  }
22
 
23
+ var RT_BRANCHES=['FABRICATION','DOCTRINE','XENOLOGY'];
24
+ var RT_QUEUE_SLOTS=5;
25
+ var RT_NODE_W=126,RT_NODE_H=76,RT_COL=154,RT_GAP_Y=94;
26
+ var RT={sel:null,scrollX:0,scrollY:0,branch:'FABRICATION',renderCount:0};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
+ function rtEsc(s){
29
+ return String(s==null?'':s).replace(/[&<>"']/g,function(c){
30
+ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
31
+ });
32
+ }
33
+ function rtNode(id){ return DEVTREE.find(function(n){ return n.id===id; }); }
34
+ function rtState(n){
35
  if(devHas(n.id)) return 'owned';
36
  if(!devAvail(n)) return 'locked';
37
+ return ((META.researchData||0)>=n.data&&matHas(n.cost))?'ready':'funding';
38
  }
39
+ function rtDepth(n,memo,stack){
40
+ if(memo[n.id]!=null) return memo[n.id];
41
+ if(stack[n.id]) return 0;
42
+ stack[n.id]=1;
43
+ var d=0;
44
+ n.req.forEach(function(id){ var p=rtNode(id); if(p) d=Math.max(d,rtDepth(p,memo,stack)+1); });
45
+ delete stack[n.id]; memo[n.id]=d; return d;
46
  }
47
+ function rtLayout(){
48
+ var memo={},rows=[],bands=[],top=18,maxDepth=0;
49
+ RT_BRANCHES.forEach(function(br){
50
+ var nodes=DEVTREE.filter(function(n){ return n.br===br; });
51
+ var layers={};
52
+ nodes.forEach(function(n){
53
+ var d=rtDepth(n,memo,{}); maxDepth=Math.max(maxDepth,d);
54
+ (layers[d]||(layers[d]=[])).push(n);
55
+ });
56
+ var stackMax=1;
57
+ Object.keys(layers).forEach(function(d){ stackMax=Math.max(stackMax,layers[d].length); });
58
+ var bandH=Math.max(144,58+stackMax*RT_GAP_Y);
59
+ var center=top+50+(bandH-62)*.5;
60
+ Object.keys(layers).forEach(function(k){
61
+ var d=+k,list=layers[k];
62
+ list.forEach(function(n,i){
63
+ rows.push({n:n,state:rtState(n),depth:d,
64
+ x:84+d*RT_COL,y:center+(i-(list.length-1)*.5)*RT_GAP_Y});
65
  });
66
  });
67
+ bands.push({br:br,top:top,height:bandH});
68
+ top+=bandH+14;
69
  });
70
+ return {nodes:rows,bands:bands,width:188+maxDepth*RT_COL,height:top+8};
71
  }
72
 
73
+ function rtQueue(save){
74
+ var before=Array.isArray(META.resQueue)?META.resQueue.slice():[];
75
+ var seen={};
76
+ var clean=before.filter(function(id){
77
+ if(seen[id]||!rtNode(id)||devHas(id)) return false;
78
+ seen[id]=1; return true;
79
+ }).slice(0,RT_QUEUE_SLOTS);
80
+ META.resQueue=clean;
81
+ if(save&&before.join('|')!==clean.join('|')&&typeof metaSave==='function') metaSave();
82
+ return clean;
83
  }
84
+ function rtQueuePath(id){
85
+ var out=[],seen={};
86
+ function walk(n){
87
+ if(!n||seen[n.id]||devHas(n.id)) return;
88
+ seen[n.id]=1;
89
+ n.req.forEach(function(r){ walk(rtNode(r)); });
90
+ out.push(n.id);
 
 
 
 
 
 
 
 
 
 
 
91
  }
92
+ walk(rtNode(id)); return out;
 
 
 
93
  }
94
+ function rtQueueAdd(id,path){
95
+ var n=rtNode(id); if(!n||devHas(id)) return;
96
+ var q=rtQueue(false).slice();
97
+ var add=path?rtQueuePath(id):[id];
98
+ add.forEach(function(x){ if(q.indexOf(x)<0&&q.length<RT_QUEUE_SLOTS) q.push(x); });
99
+ META.resQueue=q;
100
+ if(typeof metaSave==='function') metaSave();
101
+ if(add.some(function(x){ return q.indexOf(x)<0; })&&typeof toast==='function')
102
+ toast('Research queue is full — '+RT_QUEUE_SLOTS+' priorities maximum');
103
+ if(typeof sfx==='function') sfx('ui');
104
+ buildTree();
105
  }
106
+ function rtQueueRemove(at){
107
+ var q=rtQueue(false); if(at<0||at>=q.length) return;
108
+ q.splice(at,1); META.resQueue=q;
109
+ if(typeof metaSave==='function') metaSave();
110
+ if(typeof sfx==='function') sfx('ui');
111
+ buildTree();
112
+ }
113
+ function rtBuy(n){
114
+ if(!n) return;
115
+ var had=devHas(n.id);
116
+ devBuy(n);
117
+ if(!had&&devHas(n.id)){
118
+ META.resQueue=rtQueue(false).filter(function(id){ return id!==n.id; });
119
+ if(typeof metaSave==='function') metaSave();
120
+ /* devBuy redraws before control returns to us. Redraw once more after
121
+ queue cleanup so the first slot cannot keep showing a purchased node. */
122
+ buildTree();
123
+ }
124
  }
 
 
125
 
126
+ function rtCostHtml(n){
127
+ var b=matBag();
128
+ var h='<div class="rt3d-costGrid">';
129
+ Object.keys(n.cost).forEach(function(k){
130
+ var have=Math.floor(b[k]||0),need=n.cost[k],ok=have>=need;
131
+ h+='<div class="rt3d-cost '+(ok?'ok':'need')+'">'
132
+ +itemArt('mat_'+k,MATS[k].em,18)+'<span>'+rtEsc(MATS[k].nm)+'</span>'
133
+ +'<b>'+have+' / '+need+'</b></div>';
134
+ });
135
+ var dh=Math.floor(META.researchData||0),dok=dh>=n.data;
136
+ h+='<div class="rt3d-cost data '+(dok?'ok':'need')+'"><i>◆</i><span>Research Data</span>'
137
+ +'<b>'+dh+' / '+n.data+'</b></div></div>';
138
+ return h;
139
+ }
140
+ function rtDetailHtml(){
141
+ var n=RT.sel&&rtNode(RT.sel);
142
+ if(!n){
143
+ return '<section class="rt3d-detail empty" id="rtDetail" data-test="research-detail">'
144
+ +'<div class="rt3d-emptyIcon">⌬</div><div><b>SELECT A RESEARCH NODE</b>'
145
+ +'<span>Inspect its unlock, resource cost, and prerequisite path.</span></div></section>';
146
  }
147
+ var st=rtState(n),q=rtQueue(false),queued=q.indexOf(n.id)>=0;
148
+ var missing=n.req.filter(function(id){ return !devHas(id); });
149
+ var stateText={owned:'RESEARCHED',ready:'READY TO RESEARCH',funding:'RESOURCES REQUIRED',locked:'PREREQUISITES LOCKED'}[st];
150
+ var h='<section class="rt3d-detail '+st+'" id="rtDetail" data-test="research-detail" data-node="'+rtEsc(n.id)+'">'
151
+ +'<button class="rt3d-detailClose" id="rtDetailClose" type="button" aria-label="Close research details">×</button>'
152
+ +'<header><div class="rt3d-detailArt">'+itemArt(n.art||'res_'+n.id,'⌬',46)+'</div>'
153
+ +'<div><small>'+rtEsc(n.br)+' · TIER '+(rtDepth(n,{},{} )+1)+'</small><b>'+rtEsc(n.nm)+'</b>'
154
+ +'<span>'+rtEsc(n.ds)+'</span></div><em>'+stateText+'</em></header>';
155
+ if(n.req.length){
156
+ h+='<div class="rt3d-detailLabel">PREREQUISITES</div><div class="rt3d-prereqs">'
157
+ +n.req.map(function(id){
158
+ var p=rtNode(id),ok=devHas(id);
159
+ return '<button type="button" data-prereq="'+rtEsc(id)+'" class="'+(ok?'ok':'locked')+'">'
160
+ +(ok?'✓ ':'🔒 ')+rtEsc(p?p.nm:id)+'</button>';
161
+ }).join('')+'</div>';
 
 
 
162
  }
163
+ h+='<div class="rt3d-detailLabel">RESEARCH COST</div>'+rtCostHtml(n)+'<div class="rt3d-detailActions">';
164
+ if(st==='owned'){
165
+ h+='<button class="rt3d-primary done" type="button" disabled>✓ RESEARCH COMPLETE</button>';
166
+ }else if(st==='ready'){
167
+ h+='<button class="rt3d-primary" id="rtResearchNow" type="button">RESEARCH NOW</button>';
168
+ }else{
169
+ h+='<button class="rt3d-primary locked" type="button" disabled>'
170
+ +(st==='locked'?'COMPLETE '+missing.length+' PREREQUISITE'+(missing.length===1?'':'S'):'GATHER REQUIRED RESOURCES')+'</button>';
171
+ }
172
+ if(st!=='owned'){
173
+ h+='<button class="rt3d-secondary" id="rtQueueToggle" type="button">'
174
+ +(queued?'REMOVE FROM QUEUE':'ADD TO QUEUE')+'</button>';
175
+ if(st==='locked') h+='<button class="rt3d-secondary path" id="rtQueuePath" type="button">QUEUE PREREQUISITE PATH</button>';
 
 
 
 
176
  }
177
+ return h+'</div></section>';
 
 
 
178
  }
179
 
180
+ function rtQueueHtml(){
181
+ var q=rtQueue(true),next=q.length?rtNode(q[0]):null;
182
+ var h='<section class="rt3d-queue" data-test="research-queue"><header><div><b>RESEARCH QUEUE</b>'
183
+ +'<span>Plan the next unlocks without spending resources</span></div><strong>'+q.length+'/'+RT_QUEUE_SLOTS+'</strong></header>'
184
+ +'<div class="rt3d-qslots">';
185
+ for(var i=0;i<RT_QUEUE_SLOTS;i++){
186
+ var n=i<q.length?rtNode(q[i]):null;
187
+ if(n){
188
+ h+='<div class="rt3d-qslot filled '+rtState(n)+'" data-queue-slot="'+i+'">'
189
+ +'<button class="rt3d-qinspect" type="button" data-qid="'+rtEsc(n.id)+'" aria-label="Inspect '+rtEsc(n.nm)+'">'
190
+ +'<small>'+(i+1)+'</small>'+itemArt(n.art||'res_'+n.id,'⌬',24)+'<span>'+rtEsc(n.nm)+'</span></button>'
191
+ +'<button class="rt3d-qremove" type="button" data-qremove="'+i+'" aria-label="Remove '+rtEsc(n.nm)+' from queue">×</button></div>';
192
+ }else{
193
+ h+='<div class="rt3d-qslot empty" data-queue-slot="'+i+'"><small>'+(i+1)+'</small><span>EMPTY</span></div>';
194
+ }
195
+ }
196
+ h+='</div>';
197
+ if(next){
198
+ var st=rtState(next);
199
+ h+='<button class="rt3d-next '+(st==='ready'?'ready':'')+'" id="rtResearchNext" type="button" '
200
+ +(st==='ready'?'':'disabled')+'>'+(st==='ready'?'RESEARCH NEXT · '+rtEsc(next.nm):'NEXT · '+rtEsc(next.nm)+' · '+(st==='locked'?'LOCKED':'NEEDS RESOURCES'))+'</button>';
201
+ }
202
+ return h+'</section>';
203
+ }
204
 
205
+ function rtGraphHtml(layout){
206
+ var done=DEVTREE.filter(function(n){ return devHas(n.id); }).length;
207
+ var ready=DEVTREE.filter(function(n){ return rtState(n)==='ready'; }).length;
208
+ var h='<div class="rt3d-summary"><div><small>RESEARCH NETWORK</small><b>'+done+' / '+DEVTREE.length+' NODES</b></div>'
209
+ +'<div class="rt3d-summaryStats"><span><i class="ready"></i>'+ready+' READY</span><span>◆ '+Math.floor(META.researchData||0)+' DATA</span></div></div>'
210
+ +rtQueueHtml()
211
+ +'<nav class="rt3d-branchNav" aria-label="Research branches">'
212
+ +RT_BRANCHES.map(function(br){
213
+ var ns=DEVTREE.filter(function(n){ return n.br===br; });
214
+ var count=ns.filter(function(n){ return devHas(n.id); }).length;
215
+ return '<button type="button" data-rtbranch="'+br+'" class="'+(RT.branch===br?'on':'')+'">'
216
+ +'<span>'+({FABRICATION:'⚙',DOCTRINE:'⌖',XENOLOGY:'◈'}[br])+'</span>'+br+'<small>'+count+'/'+ns.length+'</small></button>';
217
+ }).join('')+'</nav>'
218
+ +'<div class="rt3d-stage" id="rtStage" data-test="research-graph" tabindex="0" aria-label="Scrollable research prerequisite graph">'
219
+ +'<div class="rt3d-world" id="rtWorld" style="width:'+layout.width+'px;height:'+layout.height+'px">';
220
+ layout.bands.forEach(function(b){
221
+ h+='<div class="rt3d-band '+b.br.toLowerCase()+'" data-band="'+b.br+'" style="top:'+b.top+'px;height:'+b.height+'px">'
222
+ +'<span>'+({FABRICATION:'⚙ FABRICATION',DOCTRINE:'⌖ DOCTRINE',XENOLOGY:'◈ XENOLOGY'}[b.br])+'</span></div>';
223
+ });
224
+ h+='<svg class="rt3d-links" width="'+layout.width+'" height="'+layout.height+'" viewBox="0 0 '+layout.width+' '+layout.height+'" aria-hidden="true">';
225
+ layout.nodes.forEach(function(row){
226
  row.n.req.forEach(function(rid){
227
+ var pr=layout.nodes.find(function(x){ return x.n.id===rid; }); if(!pr) return;
228
+ var x1=pr.x+RT_NODE_W*.5,y1=pr.y,x2=row.x-RT_NODE_W*.5,y2=row.y;
229
+ var bend=Math.max(28,(x2-x1)*.48);
230
+ var cls=(devHas(row.n.id)?'owned':devHas(rid)?'open':'locked')
231
+ +(RT.sel===rid||RT.sel===row.n.id?' focus':'');
232
+ h+='<path data-from="'+rtEsc(rid)+'" data-to="'+rtEsc(row.n.id)+'" class="'+cls+'" d="M '+x1+' '+y1+' C '+(x1+bend)+' '+y1+', '+(x2-bend)+' '+y2+', '+x2+' '+y2+'"></path>';
233
  });
234
  });
235
+ h+='</svg>';
236
+ layout.nodes.forEach(function(row){
237
+ var n=row.n,st=row.state,missing=n.req.filter(function(id){ return !devHas(id); });
238
+ var sub=st==='owned'?'RESEARCHED':st==='ready'?'READY':st==='funding'?'NEEDS RESOURCES':'NEEDS '+missing.length;
239
+ h+='<button type="button" class="rt3d-node '+st+(RT.sel===n.id?' selected':'')+'" data-research-id="'+rtEsc(n.id)+'" '
240
+ +'data-state="'+st+'" data-prerequisites="'+rtEsc(n.req.join(','))+'" '
241
+ +'style="left:'+(row.x-RT_NODE_W*.5)+'px;top:'+(row.y-RT_NODE_H*.5)+'px" aria-label="'+rtEsc(n.nm)+', '+sub+'">'
242
+ +'<span class="rt3d-tier">T'+(row.depth+1)+'</span><span class="rt3d-nodeArt">'+itemArt(n.art||'res_'+n.id,'⌬',30)+'</span>'
243
+ +'<b>'+rtEsc(n.nm)+'</b><small>'+sub+'</small></button>';
244
+ });
245
+ return h+'</div></div>'+rtDetailHtml();
246
+ }
247
 
248
+ function rtWire(){
249
+ var stage=document.getElementById('rtStage');
250
+ if(stage){
251
+ requestAnimationFrame(function(){ stage.scrollLeft=RT.scrollX; stage.scrollTop=RT.scrollY; });
252
+ stage.addEventListener('scroll',function(){ RT.scrollX=stage.scrollLeft; RT.scrollY=stage.scrollTop; },{passive:true});
253
+ }
254
+ document.querySelectorAll('[data-research-id]').forEach(function(el){
255
+ if(typeof mfBindTap==='function') mfBindTap(el,function(ev){ ev.stopPropagation(); rtSelect(el.dataset.researchId); });
256
+ else el.addEventListener('click',function(){ rtSelect(el.dataset.researchId); });
257
+ });
258
+ document.querySelectorAll('[data-rtbranch]').forEach(function(el){
259
+ el.addEventListener('click',function(){
260
+ RT.branch=el.dataset.rtbranch;
261
+ document.querySelectorAll('[data-rtbranch]').forEach(function(x){ x.classList.toggle('on',x===el); });
262
+ var band=document.querySelector('[data-band="'+RT.branch+'"]');
263
+ if(stage&&band){ RT.scrollY=Math.max(0,parseFloat(band.style.top)-8); stage.scrollTo({top:RT.scrollY,behavior:'smooth'}); }
264
+ if(typeof sfx==='function') sfx('ui');
265
+ });
266
  });
267
+ rtWireDetail();
268
+ document.querySelectorAll('[data-qid]').forEach(function(el){ el.addEventListener('click',function(){ rtSelect(el.dataset.qid); }); });
269
+ document.querySelectorAll('[data-qremove]').forEach(function(el){ el.addEventListener('click',function(ev){ ev.stopPropagation(); rtQueueRemove(+el.dataset.qremove); }); });
270
+ var next=document.getElementById('rtResearchNext');
271
+ if(next) next.addEventListener('click',function(){ var q=rtQueue(false),n=q.length&&rtNode(q[0]); if(n) rtBuy(n); });
272
+ }
273
+ function rtWireDetail(){
274
+ var close=document.getElementById('rtDetailClose');
275
+ if(close) close.addEventListener('click',function(){ RT.sel=null; buildTree(); });
276
+ document.querySelectorAll('[data-prereq]').forEach(function(el){ el.addEventListener('click',function(){ rtSelect(el.dataset.prereq); }); });
277
+ var buy=document.getElementById('rtResearchNow');
278
+ if(buy) buy.addEventListener('click',function(){ rtBuy(rtNode(RT.sel)); });
279
+ var toggle=document.getElementById('rtQueueToggle');
280
+ if(toggle) toggle.addEventListener('click',function(){
281
+ var q=rtQueue(false),at=q.indexOf(RT.sel);
282
+ if(at>=0) rtQueueRemove(at); else rtQueueAdd(RT.sel,false);
283
+ });
284
+ var path=document.getElementById('rtQueuePath');
285
+ if(path) path.addEventListener('click',function(){ rtQueueAdd(RT.sel,true); });
286
+ }
287
+ function rtSelect(id){
288
+ if(!rtNode(id)) return;
289
+ RT.sel=id;
290
+ document.querySelectorAll('[data-research-id]').forEach(function(el){ el.classList.toggle('selected',el.dataset.researchId===id); });
291
+ document.querySelectorAll('.rt3d-links path').forEach(function(p){ p.classList.toggle('focus',p.dataset.from===id||p.dataset.to===id); });
292
+ var detail=document.getElementById('rtDetail');
293
+ if(detail){ detail.outerHTML=rtDetailHtml(); rtWireDetail(); }
294
+ if(typeof sfx==='function') sfx('ui');
295
+ }
296
 
297
+ function buildTree(){
298
+ var body=document.getElementById('devBody'); if(!body) return;
299
+ var layout=rtLayout();
300
+ if(RT.sel&&!rtNode(RT.sel)) RT.sel=null;
301
+ body.innerHTML='<div class="rt3d-wrap" data-test="research-tree">'+rtGraphHtml(layout)+'</div>';
302
+ RT.renderCount++;
303
+ rtWire();
304
+ }
305
 
306
+ function rtSnapshot(){
307
+ var layout=rtLayout(),edges=0;
308
+ DEVTREE.forEach(function(n){ edges+=n.req.length; });
309
+ return {
310
+ version:'2.0',nodeCount:DEVTREE.length,edgeCount:edges,
311
+ renderedNodes:document.querySelectorAll('[data-research-id]').length,
312
+ renderedEdges:document.querySelectorAll('.rt3d-links path').length,
313
+ branches:RT_BRANCHES.slice(),queueSlots:RT_QUEUE_SLOTS,queue:rtQueue(false).slice(),
314
+ selected:RT.sel,renderCount:RT.renderCount,width:layout.width,height:layout.height,
315
+ states:DEVTREE.reduce(function(o,n){ o[n.id]=rtState(n); return o; },{})
316
+ };
317
  }
318
 
 
319
  function initResTree3D(){
320
+ if(typeof renderDevelop!=='function'||typeof DEVTREE==='undefined') return;
321
  var flatRender=renderDevelop;
322
  renderDevelop=function(){
323
  flatRender();
324
+ if(typeof devTab!=='undefined'&&devTab==='research') buildTree();
325
+ };
326
+ window.__MF_RESEARCH_TREE__={
327
+ version:'2.0',build:buildTree,snapshot:rtSnapshot,select:rtSelect,
328
+ queueAdd:function(id){ rtQueueAdd(id,false); },queuePath:function(id){ rtQueueAdd(id,true); },
329
+ queueRemove:rtQueueRemove,clearQueue:function(){ META.resQueue=[]; if(typeof metaSave==='function') metaSave(); buildTree(); },
330
+ layout:function(){ return rtLayout(); }
331
  };
332
  }
333
 
src/storeui.js CHANGED
@@ -9,8 +9,10 @@
9
  pattern src/audio.js uses for sfx — and rebuilds it as tabbed categories,
10
  sorted cheapest-first, with the concrete before/after numbers spelled out.
11
 
12
- Purchase rules and costs are untouched: this is presentation only. STORE,
13
- COLORS, META.owned and META.cores all still come from src/game/meta.js.
 
 
14
  ============================================================================ */
15
 
16
  /* ---- category map ---------------------------------------------------------
@@ -21,11 +23,12 @@ const ARM_CATS=[
21
  {id:'operations', em:'💰', nm:'OPERATIONS', ds:'Permanent economy and recovery advantages.', items:['cache','trade','salvage','droppod','reactor']},
22
  {id:'battlefield',em:'⚔', nm:'BATTLEFIELD', ds:'Permanent combat and fortification improvements.', items:['targeting','armor','bastion']},
23
  {id:'commander', em:'★', nm:'COMMANDER', ds:'Commander growth, abilities, and cooldowns.', items:['neural','capacitor','orbital']},
24
- {id:'gear', em:'', nm:'GEAR', ds:'Persistent mission gear. Equip one item in each slot.', items:[]},
25
- {id:'consumables',em:'', nm:'SUPPLIES', ds:'Stackable supplies. Ready up to two for the next mission.', items:[]},
26
  {id:'identity', em:'🎨', nm:'IDENTITY', ds:'Visual identity only — no combat advantage.', items:[]},
27
  ];
28
  let armTab='operations';
 
29
 
30
  /* ---- concrete effect text -------------------------------------------------
31
  applyMetaPerks() in meta.js applies every one of these as a flat multiple
@@ -93,8 +96,8 @@ function armBodyHTML(){
93
  const cat=ARM_CATS.find(c=>c.id===armTab)||ARM_CATS[0];
94
  const intro='<div class="armCatIntro"><b>'+cat.nm+'</b><span>'+cat.ds+'</span></div>';
95
  if(cat.id==='identity') return intro+armColorsHTML();
96
- if(cat.id==='gear') return intro+invGearHTML();
97
- if(cat.id==='consumables') return intro+invConsumableHTML();
98
  const items=cat.items.map(id=>STORE.find(s=>s.id===id)).filter(Boolean)
99
  .sort((a,b)=>a.cost[0]-b.cost[0]); // cheap-first = early-buy-first
100
  if(!items.length) return '<div class="devNone">Nothing in this category yet.</div>';
@@ -104,31 +107,157 @@ function invRarityLegend(){
104
  return '<div class="invLegend">'+INV_RARITIES.map(r=>
105
  '<span style="--rar:'+r.col+'"><i></i>'+r.nm+'</span>').join('')+'</div>';
106
  }
107
- function invGearHTML(){
108
- const b=invBag(), rank=Object.fromEntries(INV_RARITIES.map((r,i)=>[r.id,i]));
109
- const gear=[...INV_GEAR].sort((a,z)=>{
110
- const ao=(b.gear[a.id]||0)>0?0:1,zo=(b.gear[z.id]||0)>0?0:1;
111
- return ao-zo||rank[z.rarity]-rank[a.rarity]||a.slot.localeCompare(z.slot);
112
- });
113
- return invRarityLegend()+'<div class="invSlotStrip">'+['weapon','armor','utility'].map(s=>{
114
- const g=INV_GEAR.find(x=>x.id===b.equipped[s]);
115
- return '<div><span>'+s.toUpperCase()+'</span><b>'+(g?g.nm:'EMPTY')+'</b></div>';
116
- }).join('')+'</div>'+gear.map(g=>{
117
- const n=b.gear[g.id]||0, on=b.equipped[g.slot]===g.id, r=invRarity(g.rarity);
118
- return '<div class="invItem invGear'+(on?' equipped':'')+(n?'':' locked')+'" data-gear="'+g.id+'" data-owned="'+(n?1:0)+'" style="--rar:'+r.col+'">'
119
- +'<div class="invEm">'+g.em+'</div><div class="invTx"><i>'+r.nm+' · '+g.slot.toUpperCase()+'</i><b>'+g.nm+(n>1?' ×'+n:'')+'</b><span>'+g.ds+'</span></div>'
120
- +'<div class="invAct">'+(n?(on?'EQUIPPED':'EQUIP'):'UNDISCOVERED')+'</div></div>';
121
- }).join('');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  }
123
- function invConsumableHTML(){
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  const b=invBag();
125
- return invRarityLegend()+'<div class="invReadyNote">READY '+b.ready.length+' / 2 · consumed when the next mission is launched</div>'
126
- +INV_CONSUMABLES.map(c=>{
127
- const n=b.consumables[c.id]||0, on=b.ready.indexOf(c.id)>=0, r=invRarity(c.rarity);
128
- return '<div class="invItem invCons'+(on?' equipped':'')+(n?'':' locked')+'" data-cons="'+c.id+'" data-owned="'+(n?1:0)+'" style="--rar:'+r.col+'">'
129
- +'<div class="invEm">'+c.em+'</div><div class="invTx"><i>'+r.nm+' · CONSUMABLE</i><b>'+c.nm+'</b><span>'+c.ds+'</span></div>'
130
- +'<div class="invAct">'+(n?(on?'READY':'×'+n):'EMPTY')+'</div></div>';
131
- }).join('');
132
  }
133
  function armColorsHTML(){
134
  let h='<div class="sHead">COMMANDER COLORS</div><div id="colorRow">';
@@ -147,14 +276,13 @@ function armTabsHTML(){
147
  return '<div class="armTabWrap"><div class="tabRow" id="armTabs">'
148
  +ARM_CATS.map(c=>{
149
  const bag=invBag();
150
- const done=c.id==='gear'?INV_GEAR.filter(g=>(bag.gear[g.id]||0)>0).length
151
- :c.id==='consumables'?Object.values(bag.consumables).reduce((a,n)=>a+(n|0),0)
 
152
  :c.id==='identity'?0:c.items.filter(id=>{ const it=STORE.find(s=>s.id===id); return it&&(META.owned[it.id]||0)>=it.max; }).length;
153
- const tot=c.items.length;
154
  return '<button class="tabBtn'+(armTab===c.id?' on':'')+'" data-k="'+c.id+'">'
155
- +'<span class="tEm">'+c.em+'</span>'+c.nm+(c.id==='gear'?' <span class="tabCt">'+done+'/'+INV_GEAR.length+'</span>'
156
- :c.id==='consumables'?' <span class="tabCt">×'+done+'</span>'
157
- :tot?' <span class="tabCt">'+done+'/'+tot+'</span>':'')+'</button>';
158
  }).join('')
159
  +'</div><div class="armTabHint">SWIPE CATEGORIES →</div></div>';
160
  }
@@ -192,24 +320,59 @@ renderArmory=function(){
192
  list.querySelectorAll('.tabBtn').forEach(btn=>{
193
  mfBindTap(btn,ev=>{
194
  ev.stopPropagation();
195
- armTab=btn.dataset.k; sfx('ui'); renderArmory();
196
  });
197
  });
198
 
199
- list.querySelectorAll('.invGear[data-owned="1"]').forEach(el=>{
 
 
 
 
 
 
 
 
200
  mfBindTap(el,ev=>{
201
  ev.stopPropagation();
202
- const g=INV_GEAR.find(x=>x.id===el.dataset.gear);
203
- if(g&&invEquipGear(g.id)){ sfx('ui'); toast((invBag().equipped[g.slot]===g.id?'Equipped ':'Unequipped ')+g.nm); renderArmory(); }
 
204
  });
205
  });
206
- list.querySelectorAll('.invCons[data-owned="1"]').forEach(el=>{
207
  mfBindTap(el,ev=>{
208
  ev.stopPropagation();
209
- const c=INV_CONSUMABLES.find(x=>x.id===el.dataset.cons);
210
- if(c&&invReadyConsumable(c.id)){ sfx('ui'); renderArmory(); }
 
 
 
 
 
 
 
 
 
 
 
211
  });
212
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
 
214
  list.querySelectorAll('.armIt').forEach(el=>{
215
  el.addEventListener('pointerdown',()=>{
 
9
  pattern src/audio.js uses for sfx — and rebuilds it as tabbed categories,
10
  sorted cheapest-first, with the concrete before/after numbers spelled out.
11
 
12
+ Purchase rules and costs are untouched. The inventory half deliberately
13
+ keeps the legacy META.inventory shape from src/game/meta.js: the Account
14
+ Armory is the storage layer, while equipped/ready remain the five-slot
15
+ Session Loadout consumed by the existing match-start code.
16
  ============================================================================ */
17
 
18
  /* ---- category map ---------------------------------------------------------
 
23
  {id:'operations', em:'💰', nm:'OPERATIONS', ds:'Permanent economy and recovery advantages.', items:['cache','trade','salvage','droppod','reactor']},
24
  {id:'battlefield',em:'⚔', nm:'BATTLEFIELD', ds:'Permanent combat and fortification improvements.', items:['targeting','armor','bastion']},
25
  {id:'commander', em:'★', nm:'COMMANDER', ds:'Commander growth, abilities, and cooldowns.', items:['neural','capacitor','orbital']},
26
+ {id:'inventory', em:'', nm:'ARMORY', ds:'Account storage for every recovered gear item and mission supply.', items:[]},
27
+ {id:'loadout', em:'', nm:'LOADOUT', ds:'Five limited deployment slots: three gear categories and two supplies.', items:[]},
28
  {id:'identity', em:'🎨', nm:'IDENTITY', ds:'Visual identity only — no combat advantage.', items:[]},
29
  ];
30
  let armTab='operations';
31
+ let armInvFilter='all',armInvSelectedKind='',armInvSelectedId='';
32
 
33
  /* ---- concrete effect text -------------------------------------------------
34
  applyMetaPerks() in meta.js applies every one of these as a flat multiple
 
96
  const cat=ARM_CATS.find(c=>c.id===armTab)||ARM_CATS[0];
97
  const intro='<div class="armCatIntro"><b>'+cat.nm+'</b><span>'+cat.ds+'</span></div>';
98
  if(cat.id==='identity') return intro+armColorsHTML();
99
+ if(cat.id==='inventory') return intro+armInventoryHTML();
100
+ if(cat.id==='loadout') return intro+armSessionLoadoutHTML();
101
  const items=cat.items.map(id=>STORE.find(s=>s.id===id)).filter(Boolean)
102
  .sort((a,b)=>a.cost[0]-b.cost[0]); // cheap-first = early-buy-first
103
  if(!items.length) return '<div class="devNone">Nothing in this category yet.</div>';
 
107
  return '<div class="invLegend">'+INV_RARITIES.map(r=>
108
  '<span style="--rar:'+r.col+'"><i></i>'+r.nm+'</span>').join('')+'</div>';
109
  }
110
+
111
+ /* ---- two-layer inventory --------------------------------------------------
112
+ Account storage and a match loadout are deliberately different views of the
113
+ SAME legacy save fields. No migration or second source of truth: gear/counts
114
+ remain in inventory.gear / inventory.consumables, and the five slots remain
115
+ equipped.{weapon,armor,utility} plus ready[0..1]. */
116
+ const ARM_INV_FILTERS=[
117
+ {id:'all',nm:'ALL'}, {id:'weapon',nm:'WEAPONS'}, {id:'armor',nm:'ARMOR'},
118
+ {id:'utility',nm:'UTILITY'}, {id:'supply',nm:'SUPPLIES'},
119
+ ];
120
+ const ARM_INV_ART={
121
+ w_rangefinder:'mod_optic',w_pulseoptic:'res_optics',w_gaussdir:'st_targeting',w_voidlens:'mod_range',w_relicscope:'res_relictech',
122
+ a_fieldplate:'mod_plate',a_reinforced:'st_armor',a_phaseweave:'res_metallurgy',a_aegis:'st_bastion',a_starforged:'res_slot3',
123
+ u_toolkit:'mod_tempo',u_fluxcell:'mod_core',u_salvage:'mod_recl',u_chronorig:'st_capacitor',u_commandcore:'res_ability',
124
+ c_supply:'st_cache',c_power:'bst_energy',c_nanites:'bst_build',c_overdrive:'st_targeting',c_command:'res_slot3',
125
+ };
126
+ const ARM_INV_EFFECTS={
127
+ w_rangefinder:{stat:'UNIT DAMAGE',value:'+3%',score:3}, w_pulseoptic:{stat:'UNIT DAMAGE',value:'+5%',score:5},
128
+ w_gaussdir:{stat:'UNIT DAMAGE',value:'+8%',score:8}, w_voidlens:{stat:'UNIT DAMAGE',value:'+12%',score:12},
129
+ w_relicscope:{stat:'UNIT DAMAGE',value:'+16%',score:16},
130
+ a_fieldplate:{stat:'UNIT HEALTH',value:'+3%',score:3}, a_reinforced:{stat:'UNIT HEALTH',value:'+5%',score:5},
131
+ a_phaseweave:{stat:'UNIT HEALTH',value:'+8%',score:8}, a_aegis:{stat:'UNIT HEALTH',value:'+12%',score:12},
132
+ a_starforged:{stat:'UNIT HEALTH',value:'+16%',score:16},
133
+ u_toolkit:{stat:'BUILD SPEED',value:'+5%',score:5}, u_fluxcell:{stat:'ENERGY INCOME',value:'+7%',score:7},
134
+ u_salvage:{stat:'SALVAGE RECOVERY',value:'+15%',score:15}, u_chronorig:{stat:'ABILITY COOLDOWN',value:'-10%',score:10},
135
+ u_commandcore:{stat:'DAMAGE + HEALTH',value:'+8% / +8%',score:8},
136
+ c_supply:{stat:'STARTING MASS',value:'+220',score:220}, c_power:{stat:'STARTING ENERGY',value:'+900',score:900},
137
+ c_nanites:{stat:'UNIT HEALTH',value:'+8%',score:8}, c_overdrive:{stat:'UNIT DAMAGE',value:'+10%',score:10},
138
+ c_command:{stat:'DAMAGE + HEALTH',value:'+10% / +10%',score:10},
139
+ };
140
+ function armInvEffect(id){ return ARM_INV_EFFECTS[id]||{stat:'MISSION EFFECT',value:'ACTIVE',score:0}; }
141
+ function armInvIcon(it,size){
142
+ return typeof itemArt==='function'?itemArt(ARM_INV_ART[it.id]||('inv_'+it.id),it.em,size):'<span>'+it.em+'</span>';
143
+ }
144
+ function armInvEntries(){
145
+ return INV_GEAR.map(it=>({kind:'gear',it})).concat(INV_CONSUMABLES.map(it=>({kind:'supply',it})));
146
+ }
147
+ function armInvOwned(e,b){ return e.kind==='gear'?(b.gear[e.it.id]||0):(b.consumables[e.it.id]||0); }
148
+ function armInvEquipped(e,b){ return e.kind==='gear'?b.equipped[e.it.slot]===e.it.id:b.ready.indexOf(e.it.id)>=0; }
149
+ function armInvStats(b){
150
+ const all=armInvEntries(),stored=all.reduce((n,e)=>n+armInvOwned(e,b),0);
151
+ const found=all.reduce((n,e)=>n+(armInvOwned(e,b)>0?1:0),0);
152
+ const gear=['weapon','armor','utility'].reduce((n,s)=>n+(b.equipped[s]?1:0),0);
153
+ return {stored,found,total:all.length,used:gear+b.ready.length,gear,supplies:b.ready.length};
154
  }
155
+ function armInvLayerStatsHTML(b,layer){
156
+ const s=armInvStats(b);
157
+ return '<div class="armInvLayerHead '+layer+'"><div><i>'+(layer==='account'?'ACCOUNT LAYER':'DEPLOYMENT LAYER')+'</i>'
158
+ +'<b>'+(layer==='account'?'ACCOUNT ARMORY':'SESSION LOADOUT')+'</b>'
159
+ +'<span>'+(layer==='account'?'Items stay in this career after every operation.':'Only fitted gear and readied supplies enter the next operation.')+'</span></div>'
160
+ +'<div class="armInvCapacity"><b>'+(layer==='account'?s.stored:(s.used+' / 5'))+'</b><span>'+(layer==='account'?'ITEMS STORED':'SLOT CAPACITY')+'</span></div></div>'
161
+ +'<div class="armInvMeters"><div><b>'+s.found+' / '+s.total+'</b><span>DISCOVERED</span></div>'
162
+ +'<div><b>'+s.gear+' / 3</b><span>GEAR SLOTS</span></div><div><b>'+s.supplies+' / 2</b><span>MISSION SLOTS</span></div></div>';
163
+ }
164
+ function armInvFilteredEntries(b){
165
+ const rank=Object.fromEntries(INV_RARITIES.map((r,i)=>[r.id,i]));
166
+ return armInvEntries().filter(e=>armInvFilter==='all'||(armInvFilter==='supply'?e.kind==='supply':e.kind==='gear'&&e.it.slot===armInvFilter))
167
+ .sort((a,z)=>(armInvOwned(z,b)>0?1:0)-(armInvOwned(a,b)>0?1:0)
168
+ ||(armInvEquipped(z,b)?1:0)-(armInvEquipped(a,b)?1:0)
169
+ ||rank[z.it.rarity]-rank[a.it.rarity]||a.it.nm.localeCompare(z.it.nm));
170
+ }
171
+ function armInvEnsureSelection(entries,b){
172
+ let e=entries.find(x=>x.kind===armInvSelectedKind&&x.it.id===armInvSelectedId);
173
+ if(!e) e=entries.find(x=>armInvEquipped(x,b))||entries.find(x=>armInvOwned(x,b)>0)||entries[0];
174
+ armInvSelectedKind=e?e.kind:''; armInvSelectedId=e?e.it.id:'';
175
+ return e;
176
+ }
177
+ function armInvFilterHTML(){
178
+ return '<div class="armInvFilters" aria-label="Armory item categories">'+ARM_INV_FILTERS.map(f=>
179
+ '<button type="button" class="armInvFilter'+(armInvFilter===f.id?' on':'')+'" data-inv-filter="'+f.id+'">'+f.nm+'</button>').join('')+'</div>';
180
+ }
181
+ function armInvComparison(e,b){
182
+ const fx=armInvEffect(e.it.id);
183
+ if(e.kind==='supply'){
184
+ const at=b.ready.indexOf(e.it.id);
185
+ if(at>=0) return 'MISSION SLOT '+(at+1)+' · one charge will be consumed when the operation launches';
186
+ if(b.ready.length>=2) return 'MISSION SLOTS FULL · remove or replace one supply first';
187
+ return 'EMPTY MISSION SLOT AVAILABLE · one charge is consumed at deployment';
188
+ }
189
+ const cur=INV_GEAR.find(g=>g.id===b.equipped[e.it.slot]);
190
+ if(!cur) return 'EMPTY '+e.it.slot.toUpperCase()+' SLOT · this full effect will be added';
191
+ if(cur.id===e.it.id) return 'CURRENT '+e.it.slot.toUpperCase()+' SLOT · persistent until unequipped';
192
+ const old=armInvEffect(cur.id);
193
+ if(old.stat===fx.stat&&Number.isFinite(old.score)&&Number.isFinite(fx.score)){
194
+ const d=fx.score-old.score;
195
+ return (d>=0?'+':'')+d+' POINT'+(Math.abs(d)===1?'':'S')+' VS '+cur.nm.toUpperCase();
196
+ }
197
+ return 'REPLACES '+cur.nm.toUpperCase()+' · changes '+old.stat.toLowerCase()+' to '+fx.stat.toLowerCase();
198
+ }
199
+ function armInvPreviewHTML(e,b){
200
+ if(!e) return '<div class="armInvEmpty"><b>NO ITEMS IN THIS CATEGORY</b><span>Complete operations to recover gear and mission supplies.</span></div>';
201
+ const it=e.it,n=armInvOwned(e,b),on=armInvEquipped(e,b),r=invRarity(it.rarity),fx=armInvEffect(it.id);
202
+ const full=e.kind==='supply'&&!on&&b.ready.length>=2;
203
+ const action=!n?'RECOVER IN OPERATIONS':on?(e.kind==='gear'?'UNEQUIP FROM SESSION':'REMOVE FROM MISSION')
204
+ :full?'MISSION SLOTS FULL':(e.kind==='gear'?'EQUIP '+it.slot.toUpperCase():'READY FOR MISSION');
205
+ return '<section class="armInvPreview" style="--rar:'+r.col+'" aria-label="Selected item effect preview">'
206
+ +'<div class="armInvPreviewArt">'+armInvIcon(it,54)+'</div><div class="armInvPreviewInfo"><i>'+r.nm+' · '+(e.kind==='gear'?it.slot.toUpperCase()+' GEAR':'MISSION CONSUMABLE')+'</i>'
207
+ +'<b>'+it.nm+'</b><span>ACCOUNT STOCK · '+n+'</span></div>'
208
+ +'<div class="armInvExact"><span>EXACT EFFECT</span><b>'+fx.value+'</b><i>'+fx.stat+'</i></div>'
209
+ +'<p>'+it.ds+'</p><div class="armInvCompare">'+armInvComparison(e,b)+'</div>'
210
+ +'<button type="button" class="armInvCommit'+((!n||full)?' disabled':'')+'" data-inv-action="'+e.kind+'" '+((!n||full)?'disabled':'')+'>'+action+'</button>'
211
+ +'<small>'+(e.kind==='gear'?'Gear is never consumed. It remains fitted until you change this slot.':'Readied supplies remain in Account Armory until the operation begins, then consume one charge.')+'</small></section>';
212
+ }
213
+ function armInvItemHTML(e,b){
214
+ const it=e.it,n=armInvOwned(e,b),on=armInvEquipped(e,b),sel=e.kind===armInvSelectedKind&&it.id===armInvSelectedId;
215
+ const r=invRarity(it.rarity),fx=armInvEffect(it.id),slot=e.kind==='gear'?it.slot.toUpperCase():'SUPPLY';
216
+ return '<button type="button" class="armVaultItem'+(sel?' selected':'')+(on?' equipped':'')+(n?'':' locked')+'" data-inv-kind="'+e.kind+'" data-inv-id="'+it.id+'" style="--rar:'+r.col+'" aria-pressed="'+(sel?'true':'false')+'">'
217
+ +'<span class="armVaultArt">'+armInvIcon(it,38)+'</span><span class="armVaultCopy"><i>'+r.nm+' · '+slot+'</i><b>'+it.nm+'</b><em>'+fx.value+' '+fx.stat+'</em></span>'
218
+ +'<span class="armVaultState">'+(n?(on?'IN LOADOUT':'×'+n):'LOCKED')+'</span></button>';
219
+ }
220
+ function armInventoryHTML(){
221
+ const b=invBag(),entries=armInvFilteredEntries(b),selected=armInvEnsureSelection(entries,b);
222
+ return armInvLayerStatsHTML(b,'account')+invRarityLegend()+armInvFilterHTML()+armInvPreviewHTML(selected,b)
223
+ +'<div class="armInvSectionLabel"><b>STORED ITEMS</b><span>'+entries.filter(e=>armInvOwned(e,b)>0).length+' OWNED IN VIEW</span></div>'
224
+ +'<div class="armVaultList">'+entries.map(e=>armInvItemHTML(e,b)).join('')+'</div>';
225
+ }
226
+ function armLoadGearSlotHTML(slot,b){
227
+ const g=INV_GEAR.find(x=>x.id===b.equipped[slot]);
228
+ if(!g) return '<div class="armLoadSlot empty"><div class="armLoadSlotTop"><i>'+slot.toUpperCase()+'</i><span>GEAR SLOT</span></div>'
229
+ +'<b>EMPTY '+slot.toUpperCase()+'</b><small>No account gear is assigned.</small><button type="button" data-pick-slot="'+slot+'">CHOOSE '+slot.toUpperCase()+'</button></div>';
230
+ const r=invRarity(g.rarity),fx=armInvEffect(g.id);
231
+ return '<div class="armLoadSlot filled" style="--rar:'+r.col+'"><div class="armLoadSlotTop"><i>'+slot.toUpperCase()+'</i><span>'+r.nm+'</span></div>'
232
+ +'<div class="armLoadEquipped"><span>'+armInvIcon(g,36)+'</span><div><b>'+g.nm+'</b><em>'+fx.value+' '+fx.stat+'</em></div></div>'
233
+ +'<button type="button" data-remove-gear="'+g.id+'">UNEQUIP</button></div>';
234
+ }
235
+ function armLoadSupplySlotHTML(n,b){
236
+ const c=INV_CONSUMABLES.find(x=>x.id===b.ready[n]);
237
+ if(!c) return '<div class="armLoadSlot supply empty"><div class="armLoadSlotTop"><i>MISSION '+(n+1)+'</i><span>CONSUMABLE</span></div>'
238
+ +'<b>EMPTY SUPPLY SLOT</b><small>Choose one charge from Account Armory.</small><button type="button" data-pick-slot="supply">CHOOSE SUPPLY</button></div>';
239
+ const r=invRarity(c.rarity),fx=armInvEffect(c.id),stock=b.consumables[c.id]||0;
240
+ return '<div class="armLoadSlot supply filled" style="--rar:'+r.col+'"><div class="armLoadSlotTop"><i>MISSION '+(n+1)+'</i><span>'+r.nm+'</span></div>'
241
+ +'<div class="armLoadEquipped"><span>'+armInvIcon(c,36)+'</span><div><b>'+c.nm+'</b><em>'+fx.value+' '+fx.stat+'</em></div></div>'
242
+ +'<small>'+stock+' in Account Armory · consumes 1 at launch</small><button type="button" data-remove-cons="'+c.id+'">REMOVE</button></div>';
243
+ }
244
+ function armSessionEffectsHTML(b){
245
+ const active=[];
246
+ for(const s of ['weapon','armor','utility']){
247
+ const g=INV_GEAR.find(x=>x.id===b.equipped[s]); if(g) active.push({it:g,kind:'GEAR · '+s.toUpperCase()});
248
+ }
249
+ for(const id of b.ready){ const c=INV_CONSUMABLES.find(x=>x.id===id); if(c) active.push({it:c,kind:'ONE-MISSION SUPPLY'}); }
250
+ return '<section class="armMissionPackage"><div class="armInvSectionLabel"><b>OPERATION EFFECT PACKAGE</b><span>'+active.length+' / 5 ACTIVE</span></div>'
251
+ +(active.length?active.map(e=>{const r=invRarity(e.it.rarity),fx=armInvEffect(e.it.id);return '<div class="armMissionFx" style="--rar:'+r.col+'"><i></i><div><b>'+e.it.nm+'</b><span>'+e.kind+'</span></div><strong>'+fx.value+' '+fx.stat+'</strong></div>';}).join('')
252
+ :'<div class="armInvEmpty"><b>BASELINE DEPLOYMENT</b><span>No account equipment or mission supplies selected.</span></div>')
253
+ +'<p>Gear effects persist every operation. Mission supplies apply once and are consumed only when deployment begins.</p></section>';
254
+ }
255
+ function armSessionLoadoutHTML(){
256
  const b=invBag();
257
+ return armInvLayerStatsHTML(b,'session')+'<div class="armLoadoutRule"><b>5 HARD SLOTS</b><span>One weapon · one armor · one utility · two mission supplies</span></div>'
258
+ +'<div class="armLoadoutGrid">'+['weapon','armor','utility'].map(s=>armLoadGearSlotHTML(s,b)).join('')
259
+ +armLoadSupplySlotHTML(0,b)+armLoadSupplySlotHTML(1,b)+'</div>'+armSessionEffectsHTML(b)
260
+ +'<button type="button" class="armReturnVault" data-open-vault="1">▦ OPEN ACCOUNT ARMORY</button>';
 
 
 
261
  }
262
  function armColorsHTML(){
263
  let h='<div class="sHead">COMMANDER COLORS</div><div id="colorRow">';
 
276
  return '<div class="armTabWrap"><div class="tabRow" id="armTabs">'
277
  +ARM_CATS.map(c=>{
278
  const bag=invBag();
279
+ const invStats=armInvStats(bag);
280
+ const done=c.id==='inventory'?invStats.found
281
+ :c.id==='loadout'?invStats.used
282
  :c.id==='identity'?0:c.items.filter(id=>{ const it=STORE.find(s=>s.id===id); return it&&(META.owned[it.id]||0)>=it.max; }).length;
283
+ const tot=c.id==='inventory'?invStats.total:c.id==='loadout'?5:c.items.length;
284
  return '<button class="tabBtn'+(armTab===c.id?' on':'')+'" data-k="'+c.id+'">'
285
+ +'<span class="tEm">'+c.em+'</span>'+c.nm+(tot?' <span class="tabCt">'+done+'/'+tot+'</span>':'')+'</button>';
 
 
286
  }).join('')
287
  +'</div><div class="armTabHint">SWIPE CATEGORIES →</div></div>';
288
  }
 
320
  list.querySelectorAll('.tabBtn').forEach(btn=>{
321
  mfBindTap(btn,ev=>{
322
  ev.stopPropagation();
323
+ armTab=btn.dataset.k; sfx('ui'); renderArmory(); list.scrollTop=0;
324
  });
325
  });
326
 
327
+ list.querySelectorAll('.armInvFilter').forEach(el=>{
328
+ mfBindTap(el,ev=>{
329
+ ev.stopPropagation();
330
+ armInvFilter=el.dataset.invFilter||'all';
331
+ armInvSelectedKind=''; armInvSelectedId='';
332
+ sfx('ui'); renderArmory(); list.scrollTop=0;
333
+ });
334
+ });
335
+ list.querySelectorAll('.armVaultItem').forEach(el=>{
336
  mfBindTap(el,ev=>{
337
  ev.stopPropagation();
338
+ armInvSelectedKind=el.dataset.invKind||''; armInvSelectedId=el.dataset.invId||'';
339
+ sfx('ui'); renderArmory();
340
+ requestAnimationFrame(()=>{ const p=list.querySelector('.armInvPreview'); if(p) list.scrollTop=Math.max(0,p.offsetTop-112); });
341
  });
342
  });
343
+ list.querySelectorAll('.armInvCommit:not([disabled])').forEach(el=>{
344
  mfBindTap(el,ev=>{
345
  ev.stopPropagation();
346
+ if(el.dataset.invAction==='gear'){
347
+ const g=INV_GEAR.find(x=>x.id===armInvSelectedId);
348
+ if(g&&invEquipGear(g.id)){
349
+ const on=invBag().equipped[g.slot]===g.id;
350
+ toast((on?'Fitted ':'Removed ')+g.nm+(on?' to the '+g.slot+' slot':'')); sfx('ui'); renderArmory();
351
+ }
352
+ }else{
353
+ const c=INV_CONSUMABLES.find(x=>x.id===armInvSelectedId);
354
+ if(c&&invReadyConsumable(c.id)){
355
+ const on=invBag().ready.indexOf(c.id)>=0;
356
+ toast((on?'Readied ':'Removed ')+c.nm+(on?' for the next mission':'')); sfx('ui'); renderArmory();
357
+ }
358
+ }
359
  });
360
  });
361
+ list.querySelectorAll('[data-pick-slot]').forEach(el=>mfBindTap(el,ev=>{
362
+ ev.stopPropagation(); armTab='inventory'; armInvFilter=el.dataset.pickSlot||'all';
363
+ armInvSelectedKind=''; armInvSelectedId=''; sfx('ui'); renderArmory(); list.scrollTop=0;
364
+ }));
365
+ list.querySelectorAll('[data-remove-gear]').forEach(el=>mfBindTap(el,ev=>{
366
+ ev.stopPropagation(); const g=INV_GEAR.find(x=>x.id===el.dataset.removeGear);
367
+ if(g&&invEquipGear(g.id)){ toast('Removed '+g.nm+' from session loadout'); sfx('ui'); renderArmory(); }
368
+ }));
369
+ list.querySelectorAll('[data-remove-cons]').forEach(el=>mfBindTap(el,ev=>{
370
+ ev.stopPropagation(); const c=INV_CONSUMABLES.find(x=>x.id===el.dataset.removeCons);
371
+ if(c&&invReadyConsumable(c.id)){ toast('Returned '+c.nm+' to Account Armory'); sfx('ui'); renderArmory(); }
372
+ }));
373
+ list.querySelectorAll('[data-open-vault]').forEach(el=>mfBindTap(el,ev=>{
374
+ ev.stopPropagation(); armTab='inventory'; sfx('ui'); renderArmory(); list.scrollTop=0;
375
+ }));
376
 
377
  list.querySelectorAll('.armIt').forEach(el=>{
378
  el.addEventListener('pointerdown',()=>{
src/story.js CHANGED
@@ -326,3 +326,211 @@ function renderDossier(){
326
  });
327
  storyRefreshBadge();
328
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  });
327
  storyRefreshBadge();
328
  }
329
+
330
+ /* ============================================================================
331
+ CAMPAIGN PROLOGUE — a playable front door for first-time commanders
332
+ ----------------------------------------------------------------------------
333
+ This is deliberately a takeover of Operations rather than a second mission
334
+ menu. The prologue launches the proven Training Operation or pre-fills the
335
+ real Battle Setup screen, so every BEGIN button reaches playable rules and
336
+ none of the PRE-ALPHA story cards pretend that an unbuilt scripted mission
337
+ exists. Faction keys stay identical to ai.js/save data.
338
+ ============================================================================ */
339
+ const STORY_CAMPAIGN_PROLOGUE=[
340
+ {id:'orientation',code:'P-00',ttl:'FIELD ORIENTATION',fac:'nova',map:'vanguard',theme:'verdant',
341
+ threat:'COMMAND SIMULATION',goal:'training',diff:0,time:0,inf:false,defense:1,scale:'standard',
342
+ objective:'Deploy the Commander, establish power and production, build a defensive line, then lead a formation through its first attack order.',
343
+ reward:'150 CORES · COMMAND CERTIFICATION',unlock:'start',complete:'training'},
344
+ {id:'first-breach',code:'P-01',ttl:'THE FIRST BREACH',fac:'ascendancy',map:'vanguard',theme:'verdant',
345
+ threat:'CRIMSON DOMINION',goal:'annihilate',diff:0,time:0,inf:false,defense:1,scale:'standard',
346
+ objective:'Fortify the valley approach, preserve the Commander, and destroy the Dominion command unit before its armour column breaks your line.',
347
+ reward:'MATCH XP · CORES · CRIMSON SERVICE RECORD',unlock:'training',complete:'legion'},
348
+ {id:'coalition-intercept',code:'P-02',ttl:'COALITION INTERCEPT',fac:'syndicate',map:'highland',theme:'arctic',
349
+ threat:'SYNDICATE COALITION',goal:'domination',diff:1,time:600,inf:false,defense:0,scale:'standard',
350
+ objective:'Take and hold the Highland Scar resource lanes while Coalition energy cadres probe the flanks and contest the ruined arcology.',
351
+ reward:'MATCH XP · CORES · SYNDICATE SERVICE RECORD',unlock:'legion',complete:'syndicate'},
352
+ {id:'ground-remembers',code:'P-03',ttl:'THE GROUND REMEMBERS',fac:'horde',map:'crater',theme:'ashland',
353
+ threat:'BROOD SWARM',goal:'purge',diff:1,time:900,inf:true,defense:1,scale:'large',
354
+ objective:'Establish overlapping fields of fire in Relic Basin, burn out every living nest, and survive the Brood organisms defending the infestation.',
355
+ reward:'MATCH XP · CORES · BROOD SERVICE RECORD',unlock:'syndicate',complete:'horde'},
356
+ ];
357
+
358
+ function storyCampaignComplete(m){
359
+ if(m.complete==='training') return !!(META.tutorial&&META.tutorial.done);
360
+ return !!((META.facWins||{})[m.complete]>0);
361
+ }
362
+ function storyCampaignUnlocked(m,i){
363
+ if(i===0||storyCampaignComplete(m)) return true;
364
+ return storyCampaignComplete(STORY_CAMPAIGN_PROLOGUE[i-1]);
365
+ }
366
+ function storyCampaignProgress(){
367
+ const states=STORY_CAMPAIGN_PROLOGUE.map((m,i)=>({
368
+ done:storyCampaignComplete(m),open:storyCampaignUnlocked(m,i)
369
+ }));
370
+ let current=states.findIndex(s=>s.open&&!s.done);
371
+ if(current<0) current=STORY_CAMPAIGN_PROLOGUE.length-1;
372
+ return {states,current,done:states.filter(s=>s.done).length};
373
+ }
374
+ function storyCampaignEnsureOps(){
375
+ const root=document.getElementById('opsScr'),tabs=document.getElementById('opsTabs');
376
+ if(!root||!tabs) return;
377
+ if(!document.getElementById('opsTab-campaign')){
378
+ const tab=document.createElement('button');
379
+ tab.className='screenTabBtn'; tab.id='opsTab-campaign'; tab.type='button';
380
+ tab.setAttribute('role','tab'); tab.dataset.mfTab='campaign';
381
+ tab.setAttribute('aria-controls','opsPane-campaign');
382
+ tab.innerHTML='<span class="tabGlyph">◇</span><span>CAMPAIGN</span>';
383
+ tabs.insertBefore(tab,tabs.firstElementChild);
384
+ }
385
+ if(!document.getElementById('opsPane-campaign')){
386
+ const pane=document.createElement('section');
387
+ pane.className='setupCard mfTabPanel storyCampaignPane';
388
+ pane.id='opsPane-campaign'; pane.setAttribute('role','tabpanel');
389
+ pane.setAttribute('aria-labelledby','opsTab-campaign'); pane.dataset.mfPanel='campaign';
390
+ const scroll=root.querySelector('.opsScroll'),first=scroll&&scroll.firstElementChild;
391
+ if(scroll) scroll.insertBefore(pane,first||null);
392
+ }
393
+ /* A new commander sees the authored starting path first. Returning players
394
+ keep whichever Operations category they last selected. */
395
+ if(typeof MF_TAB_STATE!=='undefined'&&!MF_TAB_STATE.opsScr&&!(META.matches||0))
396
+ MF_TAB_STATE.opsScr='campaign';
397
+ }
398
+ function storyCampaignFac(m){
399
+ return (typeof facArt==='function'&&facArt(m.fac))||FACART.nova;
400
+ }
401
+ function storyCampaignPortrait(a){
402
+ return '<img src="./assets/factions/'+a.id+'_192.jpg" alt="'+a.cdr+', '+a.nm+' commander" '
403
+ +'onerror="this.style.visibility=\'hidden\'">';
404
+ }
405
+ const STORY_CAMPAIGN_KEY_ART={
406
+ nova:'./assets/factions/cinematic/terran-frontline-command-v1.png',
407
+ ascendancy:'./assets/factions/cinematic/crimson-dominion-v1.png',
408
+ syndicate:'./assets/factions/cinematic/syndicate-coalition-v1.png',
409
+ horde:'./assets/factions/cinematic/brood-swarm-v1.png'
410
+ };
411
+ function storyCampaignKeyArt(fac){
412
+ return STORY_CAMPAIGN_KEY_ART[fac]||STORY_CAMPAIGN_KEY_ART.nova;
413
+ }
414
+ function storyCampaignRender(){
415
+ const pane=document.getElementById('opsPane-campaign'); if(!pane) return;
416
+ const progress=storyCampaignProgress();
417
+ let missions='';
418
+ STORY_CAMPAIGN_PROLOGUE.forEach((m,i)=>{
419
+ const S=progress.states[i],A=storyCampaignFac(m),map=MAPDEFS[m.map]||{nm:m.map,ds:''},
420
+ haz=(typeof MAPHAZ!=='undefined'&&MAPHAZ[m.map])||{em:'◇',nm:'FIELD CONDITIONS'},
421
+ state=S.done?'COMPLETE':S.open?(i===progress.current?'CURRENT':'AVAILABLE'):'LOCKED',
422
+ unlock=i?('Complete '+STORY_CAMPAIGN_PROLOGUE[i-1].code+' · '+STORY_CAMPAIGN_PROLOGUE[i-1].ttl):'';
423
+ missions+='<article class="storyCampaignMission '+state.toLowerCase()+'" style="--cf:'+A.col+';--cf2:'+A.col2+'">'
424
+ +'<div class="storyCampaignKeyArt"><img src="'+storyCampaignKeyArt(m.fac)+'" alt="'+A.nm+' cinematic battlefield">'
425
+ +'<span>'+(typeof facIcon==='function'?facIcon(m.fac,30):'◇')+'</span><small>'+A.nm+'</small></div>'
426
+ +'<div class="storyCampaignMissionBody"><header><div><small>'+m.code+' · PROLOGUE</small><b>'+m.ttl+'</b></div>'
427
+ +'<i class="storyCampaignState">'+state+'</i></header>'
428
+ +'<div class="storyCampaignIntel"><span><em>OBJECTIVE</em>'+m.objective+'</span>'
429
+ +'<span><em>BATTLEFIELD</em>'+map.nm+' · '+haz.em+' '+haz.nm+'</span>'
430
+ +'<span><em>FACTION THREAT</em>'+A.nm+' · '+A.strong+'</span>'
431
+ +'<span class="storyCampaignReward"><em>REWARDS</em>'+m.reward+'</span></div>'
432
+ +(S.open?'<button class="storyCampaignAction" type="button" data-campaign-mission="'+m.id+'">'
433
+ +(S.done?'↻ REPLAY MISSION':'▶ '+(i===0?'BEGIN TRAINING':'BEGIN MISSION'))+'</button>'
434
+ :'<div class="storyCampaignLock">▣ '+unlock+'</div>')
435
+ +'</div></article>';
436
+ });
437
+ const arcs=Object.keys(FACART).map(k=>{
438
+ const A=FACART[k];
439
+ return '<article class="storyCampaignArc" style="--cf:'+A.col+';--cf2:'+A.col2+'">'
440
+ +'<div class="storyCampaignArcHead">'+storyCampaignPortrait(A)
441
+ +'<div><small>'+A.motto+'</small><b>'+A.nm+'</b><span>'+A.cdr+'</span></div></div>'
442
+ +'<div class="storyCampaignArcPath">'+A.arc+'</div><p>'+A.endgoal+'</p></article>';
443
+ }).join('');
444
+ const heroMission=STORY_CAMPAIGN_PROLOGUE[progress.current]||STORY_CAMPAIGN_PROLOGUE[0];
445
+ pane.innerHTML='<section class="storyCampaignHero" style="--campaign-hero:url(&quot;'+storyCampaignKeyArt(heroMission.fac)+'&quot;)"><div><span>PRE-ALPHA · PLAYABLE PROLOGUE</span>'
446
+ +'<h3>HALCYON REACH: FIRST CONTACT</h3><p>Learn the command loop, then meet all three enemy doctrines through live battle rules. '
447
+ +'Acts I–IV below are the campaign direction; only the Prologue ladder is presented as playable today.</p></div>'
448
+ +'<div class="storyCampaignCount"><b>'+progress.done+'</b><span>/ '+STORY_CAMPAIGN_PROLOGUE.length+' CLEARED</span></div></section>'
449
+ +'<div class="storyCampaignSection"><span>PROLOGUE MISSION LADDER</span><small>Each deployment uses the real training or battle setup flow.</small></div>'
450
+ +'<div class="storyCampaignLadder">'+missions+'</div>'
451
+ +'<div class="storyCampaignSection storyCampaignArcLabel"><span>FOUR FACTION ENDGAMES · CAMPAIGN PREVIEW</span>'
452
+ +'<small>Swipe to inspect the long-form arc promised by each faction.</small></div>'
453
+ +'<div class="storyCampaignArcs">'+arcs+'</div>';
454
+ pane.querySelectorAll('.storyCampaignAction').forEach(b=>{
455
+ const go=()=>storyCampaignOpenMission(b.dataset.campaignMission);
456
+ if(typeof mfBindTap==='function') mfBindTap(b,go); else b.addEventListener('click',go);
457
+ });
458
+ }
459
+ function storyCampaignToggle(sel,match){
460
+ document.querySelectorAll(sel).forEach(b=>b.classList.toggle('on',match(b)));
461
+ }
462
+ function storyCampaignSyncSetup(){
463
+ const setup=document.getElementById('setupScr');
464
+ if(setup){
465
+ setup.dataset.tab='map'; setup.classList.remove('setupAll');
466
+ setup.querySelectorAll('.setupTabBtn').forEach(b=>b.classList.toggle('on',b.dataset.tab==='map'));
467
+ const adv=document.getElementById('advToggle'); if(adv){adv.classList.remove('on');adv.textContent='SHOW ALL';}
468
+ }
469
+ if(typeof renderFacRow==='function') renderFacRow();
470
+ storyCampaignToggle('.glbtn',b=>b.dataset.g===goalSel);
471
+ storyCampaignToggle('.tmbtn',b=>+b.dataset.t===timeLimit);
472
+ storyCampaignToggle('.pcbtn',b=>+b.dataset.p===resPace);
473
+ storyCampaignToggle('.crbtn',b=>+b.dataset.c===crateRate);
474
+ storyCampaignToggle('.bsbtn',b=>b.dataset.bs===battlefieldPreset);
475
+ storyCampaignToggle('.ifbtn',b=>!!+b.dataset.i===infestationOn);
476
+ storyCampaignToggle('.globalDiff',b=>+b.dataset.d===difficulty);
477
+ storyCampaignToggle('.tbtn',b=>b.dataset.t===curTheme);
478
+ storyCampaignToggle('.fbtn',b=>b.dataset.f===aiFactionSel);
479
+ storyCampaignToggle('.dfbtn',b=>+b.dataset.df===defenseFocus);
480
+ storyCampaignToggle('.wbtn',b=>+b.dataset.w===wcChoice);
481
+ const goal=document.getElementById('goalHint'); if(goal) goal.textContent=goalDef().ds;
482
+ const scale=document.getElementById('battleScaleHint'),scaleDef=battlefieldPresetDef();
483
+ if(scale) scale.textContent=scaleDef.km+' — '+scaleDef.ds;
484
+ const infestation=document.getElementById('infestHint'); if(infestation) infestation.textContent=infestationOn
485
+ ?'Neutral nests spread and erupt during the battle.'
486
+ :'No neutral nests, infestation guards, eruptions, spread, or map-wide tides.';
487
+ const defence=document.getElementById('defFocusHint'); if(defence) defence.textContent=defenseFocus
488
+ ?'Tower defence: stronger, longer-ranged fortifications against faster enemy pressure.'
489
+ :'Classic RTS balance between mobile armies and static defences.';
490
+ if(typeof renderMapRow==='function') renderMapRow();
491
+ if(typeof renderSpawnPlanner==='function') renderSpawnPlanner();
492
+ }
493
+ function storyCampaignOpenMission(missionId){
494
+ const i=STORY_CAMPAIGN_PROLOGUE.findIndex(m=>m.id===missionId),m=STORY_CAMPAIGN_PROLOGUE[i];
495
+ if(!m||!storyCampaignUnlocked(m,i)) return;
496
+ if(m.goal==='training'){
497
+ const debug=window.__tutDebug&&window.__tutDebug();
498
+ if(debug&&typeof debug.startTraining==='function'){
499
+ if(typeof initAudio==='function') initAudio();
500
+ debug.startTraining(); return;
501
+ }
502
+ if(typeof toast==='function') toast('Training systems are still loading — try again in a moment.');
503
+ return;
504
+ }
505
+ difficulty=m.diff; defenseFocus=m.defense?1:0; infestationOn=!!m.inf; wcChoice=0;
506
+ goalSel=m.goal; timeLimit=m.time; resPace=1; crateRate=crateRateBase=1;
507
+ curMap=m.map; curTheme=m.theme; battlefieldPreset=m.scale;
508
+ aiFactionSel=storyCampaignFac(m).key; playerStartZone='sw'; spawnPick='player';
509
+ aiSlots.forEach((A,n)=>{A.on=n===0;A.diff=m.diff;A.zone=n===0?'ne':n===1?'nw':'se';});
510
+ storyCampaignSyncSetup();
511
+ if(typeof renderOps==='function') renderOps();
512
+ if(typeof initAudio==='function') initAudio();
513
+ if(typeof sfx==='function') sfx('confirm');
514
+ if(typeof showFrontScreen==='function') showFrontScreen('setupScr');
515
+ const scroll=document.querySelector('#setupScr .setupScroll'); if(scroll) scroll.scrollTop=0;
516
+ if(typeof toast==='function') toast(m.code+' · '+m.ttl+' — deployment preset loaded');
517
+ }
518
+
519
+ /* story.js loads after endgame.js, so the wrapper is installed before initOps
520
+ binds its tab listener. tutorial.js later wraps this wrapper in turn; both
521
+ additions therefore survive without either feature editing the other. */
522
+ const STORY_CAMPAIGN_RENDER_OPS_BASE=renderOps;
523
+ renderOps=function(){
524
+ storyCampaignEnsureOps();
525
+ STORY_CAMPAIGN_RENDER_OPS_BASE();
526
+ storyCampaignRender();
527
+ };
528
+ const STORY_CAMPAIGN_RENDER_BRIEF_BASE=renderOpsBrief;
529
+ renderOpsBrief=function(mode){
530
+ STORY_CAMPAIGN_RENDER_BRIEF_BASE(mode);
531
+ if(mode!=='campaign') return;
532
+ const p=storyCampaignProgress(),set=(id,v)=>{const e=document.getElementById(id);if(e)e.textContent=v;};
533
+ set('opsBriefThreat','P0'); set('opsBriefThreatNm','PLAYABLE PROLOGUE');
534
+ set('opsBriefMods',p.done+'/'+STORY_CAMPAIGN_PROLOGUE.length); set('opsBriefModsNm','MISSIONS CLEARED');
535
+ set('opsBriefPayout','LIVE'); set('opsBriefState','PRE-ALPHA');
536
+ };
src/styles/intro.css ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Logo-only PRE-ALPHA opening. Faction dossier/contact-sheet art is
2
+ intentionally restricted to the codex and never used as a splash. */
3
+ .mfTitleReveal{position:fixed;inset:0;z-index:950;display:grid;grid-template-rows:auto 1fr auto;overflow:hidden;isolation:isolate;
4
+ padding:max(14px,calc(var(--sat) + 12px)) max(16px,calc(var(--sar) + 14px)) max(16px,calc(var(--sab) + 12px)) max(16px,calc(var(--sal) + 14px));
5
+ color:#edf8ff;background:#02060c;opacity:0;transform:scale(1.025);transition:opacity .28s ease,transform .7s cubic-bezier(.16,.8,.2,1);touch-action:manipulation}
6
+ .mfTitleReveal[hidden]{display:none!important}.mfTitleReveal.open{opacity:1;transform:none}
7
+ .mfTitleBackdrop{position:absolute;inset:0;z-index:-1;overflow:hidden;background:
8
+ radial-gradient(circle at 50% 47%,rgba(41,141,213,.16),transparent 23%),
9
+ radial-gradient(ellipse at 50% 105%,rgba(255,199,83,.08),transparent 43%),
10
+ linear-gradient(180deg,#02050a,#06101b 52%,#02050a)}
11
+ .mfTitleBackdrop::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,rgba(0,0,0,.36),transparent 30%,transparent 70%,rgba(0,0,0,.36));box-shadow:inset 0 0 120px rgba(0,0,0,.75)}
12
+ .mfTitleGrid{position:absolute;inset:-10%;opacity:.2;background-image:
13
+ linear-gradient(rgba(92,190,255,.08) 1px,transparent 1px),linear-gradient(90deg,rgba(92,190,255,.07) 1px,transparent 1px);
14
+ background-size:42px 42px;transform:perspective(520px) rotateX(63deg) translateY(29%);mask-image:linear-gradient(180deg,transparent 16%,#000 64%,transparent)}
15
+ .mfTitleSweep{position:absolute;inset:-35% -20%;background:linear-gradient(103deg,transparent 45%,rgba(118,213,255,.11) 50%,transparent 55%);animation:mfTitleSweep 3.4s ease-in-out infinite}
16
+ @keyframes mfTitleSweep{from{transform:translateX(-28%)}to{transform:translateX(28%)}}
17
+ .mfTitleTop{display:flex;justify-content:space-between;align-items:center;width:min(100%,920px);margin:0 auto}
18
+ .mfTitleBuild{display:flex;align-items:center;gap:9px;color:#a9bfd1;font:800 10px/1 var(--fT);letter-spacing:.12em}
19
+ .mfTitleBuild b{padding:8px 11px;color:#160e00;background:linear-gradient(180deg,#ffeaa2,#d5a13b);clip-path:polygon(8px 0,100% 0,100% calc(100% - 8px),calc(100% - 8px) 100%,0 100%,0 8px)}
20
+ .mfTitleSkip{min-width:54px;min-height:48px;padding:0 12px;border:1px solid rgba(158,200,226,.34);border-radius:11px;color:#dcedf8;background:rgba(5,13,23,.78);font:800 10px/1 var(--fT);letter-spacing:.12em}
21
+ .mfTitleSkip span{color:#ffd46e;margin-left:4px}.mfTitleSkip:active{transform:scale(.96)}
22
+ .mfTitleCenter{align-self:center;justify-self:center;width:min(94vw,900px);text-align:center;animation:mfTitleCenterIn .9s cubic-bezier(.16,.8,.2,1) both}
23
+ @keyframes mfTitleCenterIn{from{opacity:0;transform:translateY(18px) scale(.96);filter:blur(8px)}to{opacity:1;transform:none;filter:none}}
24
+ .mfTitleInsignia{position:relative;width:clamp(76px,16vw,128px);height:clamp(76px,16vw,128px);margin:0 auto 18px}
25
+ .mfTitleInsignia img{position:relative;z-index:2;width:100%;height:100%;object-fit:contain;filter:drop-shadow(0 0 22px rgba(88,194,255,.46))}
26
+ .mfTitleInsignia i{position:absolute;inset:8%;border:1px solid rgba(100,203,255,.35);transform:rotate(45deg);box-shadow:0 0 30px rgba(75,181,240,.15);animation:mfTitleInsignia 2.8s linear infinite}
27
+ @keyframes mfTitleInsignia{to{transform:rotate(405deg)}}
28
+ .mfTitleEyebrow{margin-bottom:10px;color:#ffd46e;font:800 clamp(8px,1.8vw,12px)/1 var(--fU);letter-spacing:.26em;text-shadow:0 0 14px rgba(255,205,92,.32)}
29
+ .mfTitleCenter h1{margin:0;font:900 clamp(52px,13vw,118px)/.9 var(--fT);letter-spacing:.09em;background:linear-gradient(180deg,#fff 2%,#dbe6ed 36%,#6e8191 51%,#edf7fb 66%,#536b7d 100%);
30
+ -webkit-background-clip:text;background-clip:text;color:transparent;filter:drop-shadow(0 13px 24px rgba(0,0,0,.85));animation:mfTitleWordmark 1.4s ease both}
31
+ @keyframes mfTitleWordmark{0%{opacity:0;letter-spacing:.19em;filter:blur(7px)}55%{opacity:1}100%{letter-spacing:.09em;filter:drop-shadow(0 13px 24px rgba(0,0,0,.85))}}
32
+ .mfTitleSubtitle{display:flex;align-items:center;justify-content:center;gap:13px;margin:18px auto 0;color:#f1c969;font:900 clamp(10px,2.6vw,17px)/1 var(--fT);letter-spacing:.25em}
33
+ .mfTitleSubtitle i{width:min(16vw,130px);height:1px;background:linear-gradient(90deg,transparent,#d9ae50)}.mfTitleSubtitle i:last-child{transform:scaleX(-1)}
34
+ .mfTitleStatus{display:inline-flex;align-items:center;gap:8px;margin-top:25px;color:#91aabd;font:700 9px/1 var(--fU);letter-spacing:.19em}
35
+ .mfTitleStatus span{width:7px;height:7px;border-radius:50%;background:#6ed6ff;box-shadow:0 0 12px #6ed6ff;animation:mfTitlePulse 1.25s ease-in-out infinite}
36
+ @keyframes mfTitlePulse{50%{opacity:.3;transform:scale(.72)}}
37
+ .mfTitleBottom{width:min(100%,460px);margin:0 auto}.mfTitleStart{width:100%;min-height:56px;border:1px solid #fff1bb;border-radius:12px;color:#1b1101;background:linear-gradient(180deg,#fff0af,#e9bb52 47%,#bd8730 53%,#f2cd70);box-shadow:0 10px 34px rgba(0,0,0,.55),0 0 22px rgba(255,212,110,.2);font:900 14px/1 var(--fT);letter-spacing:.14em}
38
+ .mfTitleStart span{font-size:12px;margin-right:8px}.mfTitleStart:active{transform:scale(.98);filter:brightness(1.08)}
39
+ .mfIntroReplay{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:8px;width:min(88vw,340px);min-height:44px;margin-top:8px;padding:7px 11px;color:#bed7e8;text-align:left;border:1px solid rgba(102,164,205,.28);border-radius:10px;background:rgba(7,15,26,.72);font-family:var(--fT)}
40
+ .mfIntroReplay>span{color:#ffe39a;text-shadow:0 0 10px rgba(255,212,110,.55)}.mfIntroReplay b{font-size:9px;letter-spacing:.12em}.mfIntroReplay small{font-size:7px;letter-spacing:.09em;color:#6f9dbb}.mfIntroReplay:active{transform:scale(.98)}
41
+ body.mfIntroOpen{overflow:hidden}
42
+ @media(max-width:640px){.mfTitleCenter h1{font-size:clamp(46px,15vw,78px)}.mfTitleSubtitle{letter-spacing:.14em}.mfTitleSubtitle i{width:9vw}.mfTitleEyebrow{letter-spacing:.18em}.mfTitleStatus{font-size:8px;letter-spacing:.12em}.mfTitleInsignia{margin-bottom:13px}}
43
+ @media(max-height:520px){.mfTitleInsignia{width:66px;height:66px;margin-bottom:7px}.mfTitleCenter h1{font-size:48px}.mfTitleSubtitle{margin-top:9px}.mfTitleStatus{margin-top:12px}.mfTitleStart{min-height:48px}}
44
+ @media(prefers-reduced-motion:reduce){.mfTitleReveal,.mfTitleCenter,.mfTitleInsignia i,.mfTitleCenter h1,.mfTitleSweep,.mfTitleStatus span{animation:none!important;transition:none!important;transform:none!important;filter:none!important}.mfTitleReveal.open{opacity:1}}
src/styles/restree.css CHANGED
@@ -1,108 +1,86 @@
1
- /* ============================================================================
2
- RESEARCH TREE — 3D spatial replacement for the flat research list
3
- ----------------------------------------------------------------------------
4
- Owned by restree3d.js. Follows the visual language already established in
5
- ui.css (--cy/--gold/--green/--red, --fT/--fU, panelG/steelB gradients)
6
- rather than inventing a second one. CSS custom properties cascade from the
7
- document root regardless of <link> order, so referencing var(--cy) etc.
8
- here is safe even though ui.css is a separate stylesheet.
9
- ============================================================================ */
10
- .rt3d-wrap{display:flex;flex-direction:column;gap:6px;width:min(96vw,410px);margin:0 auto}
11
- .rt3d-legend{display:flex;gap:10px;flex-wrap:wrap;justify-content:center;font-family:var(--fU);
12
- font-size:9px;font-weight:700;letter-spacing:.05em;color:#8fb3cc;padding:2px 2px 0}
13
- .rt3d-legend span{display:inline-flex;align-items:center;gap:4px}
14
- .rt3d-legend i{width:8px;height:8px;border-radius:2px;display:inline-block}
15
- .rt3d-legend i.own{background:#5dff9a;box-shadow:0 0 5px #5dff9a}
16
- .rt3d-legend i.aff{background:#41c8ff;box-shadow:0 0 5px #41c8ff}
17
- .rt3d-legend i.ava{background:#ffd257}
18
- .rt3d-legend i.lok{background:#5a7186}
19
 
20
- .rt3d-stage{position:relative;height:min(62vh,470px);min-height:340px;border-radius:14px;
21
- overflow:hidden;touch-action:none;user-select:none;-webkit-user-select:none;
22
- border:1px solid rgba(120,170,205,.3);background:radial-gradient(ellipse at 50% 42%,rgba(34,62,90,.6),rgba(4,9,16,.94) 74%) padding-box,
23
- linear-gradient(160deg,rgba(120,170,205,.28),rgba(30,60,88,.16)) border-box;
24
- perspective:1000px;perspective-origin:50% 44%}
25
- .rt3d-stage.flat{perspective:none}
26
- .rt3d-scene{position:absolute;left:50%;top:50%;width:1px;height:1px;transform-style:preserve-3d;
27
- will-change:transform}
28
- .rt3d-stage.flat .rt3d-scene{transform-style:flat}
 
 
 
 
 
 
 
 
29
 
30
- .rt3d-links{position:absolute;left:0;top:0;overflow:visible;pointer-events:none}
31
- .rt3d-links line{stroke-linecap:round}
32
- .rt3d-links line.on{stroke:#5dff9a;stroke-width:2.4;filter:drop-shadow(0 0 3px rgba(93,255,154,.6))}
33
- .rt3d-links line.next{stroke:#41c8ff;stroke-width:2.2;filter:drop-shadow(0 0 3px rgba(65,200,255,.55))}
34
- .rt3d-links line.off{stroke:#3a4a5c;stroke-width:1.4;stroke-dasharray:3 4}
 
35
 
36
- .rt3d-node{position:absolute;left:0;top:0;width:76px;margin:-34px 0 0 -38px;padding:6px 5px 7px;
37
- border-radius:11px;text-align:center;cursor:pointer;
38
- border:1px solid transparent;background:var(--panelG) padding-box,var(--steelB) border-box;
39
- transition:box-shadow .2s}
40
- .rt3d-node .rt-ic{width:30px;height:30px;margin:0 auto 3px;display:flex;align-items:center;justify-content:center;
41
- border-radius:8px;background:rgba(4,10,18,.7);border:1px solid rgba(120,170,205,.22)}
42
- .rt3d-node .rt-nm{display:block;font-family:var(--fT);font-size:9px;font-weight:800;color:#eaf7ff;
43
- letter-spacing:.02em;line-height:1.2;white-space:normal}
44
- .rt3d-node .rt-sub{display:block;margin-top:3px;font-family:var(--fU);font-size:7.5px;font-weight:700;
45
- color:#7fa8c6;line-height:1.25}
46
- .rt3d-node .rt-badge{position:absolute;top:-6px;right:-6px;width:18px;height:18px;border-radius:50%;
47
- display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:900;
48
- border:1px solid rgba(0,0,0,.4)}
49
- .rt3d-node.owned{background:linear-gradient(180deg,rgba(14,34,24,.94),rgba(7,20,14,.95)) padding-box,
50
- linear-gradient(160deg,#bff6d8,#3fdc8f 45%,#0f5a34 75%,#9be9c3) border-box;
51
- box-shadow:0 6px 18px rgba(93,255,154,.22)}
52
- .rt3d-node.owned .rt-badge{background:#2bd672;color:#062;}
53
- .rt3d-node.afford{background:linear-gradient(180deg,rgba(15,34,50,.95),rgba(8,18,30,.96)) padding-box,
54
- linear-gradient(160deg,#c6f2ff,#41c8ff 40%,#10465f 70%,#8fe0ff) border-box;
55
- box-shadow:0 6px 18px rgba(65,200,255,.28);animation:rtPulse 2.2s ease-in-out infinite}
56
- .rt3d-node.afford .rt-badge{background:#41c8ff;color:#04222f}
57
- .rt3d-node.avail{opacity:.88}
58
- .rt3d-node.avail .rt-badge{background:#ffd257;color:#3a2900}
59
- .rt3d-node.locked{opacity:.5}
60
- .rt3d-node.locked .rt-nm{color:#b7cfe2}
61
- .rt3d-node.locked .rt-sub{color:#ff9a86}
62
- .rt3d-node.locked .rt-badge{background:#3a4a5c;color:#cfe0ee}
63
- .rt3d-node.sel{box-shadow:0 0 0 2px #fff,0 6px 18px rgba(255,255,255,.35)}
64
- @keyframes rtPulse{0%,100%{filter:brightness(1)}50%{filter:brightness(1.22)}}
65
 
66
- .rt3d-hint{text-align:center;font-family:var(--fU);font-size:9px;letter-spacing:.06em;color:#6f92ac;
67
- font-weight:700;padding:2px 4px}
68
- .rt3d-reset{position:absolute;right:8px;top:8px;z-index:4;width:44px;height:44px;border-radius:10px;
69
- display:flex;align-items:center;justify-content:center;font-size:14px;
70
- border:1px solid transparent;background:var(--panelG2) padding-box,var(--steelB) border-box;color:#cfe8ff}
71
- .rt3d-reset:active{transform:scale(.92)}
 
 
 
 
72
 
73
- /* ---- detail sheet ---------------------------------------------------------- */
74
- .rt3d-back{position:absolute;inset:0;z-index:5;background:rgba(2,5,10,.55);opacity:0;pointer-events:none;
75
- transition:opacity .2s}
76
- .rt3d-back.show{opacity:1;pointer-events:auto}
77
- .rt3d-detail{position:absolute;left:0;right:0;bottom:0;z-index:6;padding:14px 15px calc(14px + var(--sab,0px));
78
- border-radius:16px 16px 0 0;transform:translateY(105%);transition:transform .28s cubic-bezier(.2,.8,.3,1);
79
- border:1px solid transparent;background:var(--panelG2) padding-box,var(--steelB) border-box;
80
- border-bottom:none;max-height:78%;overflow-y:auto}
81
- .rt3d-detail.show{transform:translateY(0)}
82
- .rt3d-detail-x{position:absolute;right:10px;top:10px;width:44px;height:44px;border-radius:50%;
83
- display:flex;align-items:center;justify-content:center;background:rgba(4,10,18,.7);color:#cfe8ff;font-size:13px}
84
- .rt3d-detail-hd{display:flex;align-items:center;gap:10px;padding-right:30px}
85
- .rt3d-detail-hd .rt-ic{width:44px;height:44px;flex:0 0 auto;border-radius:10px;background:rgba(4,10,18,.7);
86
- border:1px solid rgba(120,170,205,.22);display:flex;align-items:center;justify-content:center}
87
- .rt3d-detail-hd b{display:block;font-family:var(--fT);font-size:14px;color:#eaf7ff;letter-spacing:.03em}
88
- .rt3d-detail-hd span{display:block;margin-top:2px;font-size:10.5px;color:#8fb3cc;font-weight:600;line-height:1.4}
89
- .rt3d-detail-row{margin-top:11px}
90
- .rt3d-detail-lbl{font-family:var(--fT);font-size:8.5px;letter-spacing:.18em;color:#7fb4d6;font-weight:800;
91
- margin-bottom:5px}
92
- .rt3d-reqs{display:flex;flex-wrap:wrap;gap:6px}
93
- .rt3d-reqChip{font-family:var(--fU);font-size:10px;font-weight:700;padding:4px 9px;border-radius:8px;
94
- border:1px solid transparent;background:rgba(4,10,18,.6) padding-box,var(--steelB) border-box;color:#cfe8ff}
95
- .rt3d-reqChip.ok{color:#0a2417;background:linear-gradient(180deg,#7ef0b4,#2bd672) padding-box,
96
- linear-gradient(160deg,#d8ffe9,#1f9c62) border-box}
97
- .rt3d-reqChip.no{color:#ffcfc4;background:rgba(60,20,16,.6) padding-box,var(--redB) border-box}
98
- .rt3d-detail-buy{margin-top:13px;width:100%;padding:12px;border-radius:11px;text-align:center;
99
- font-family:var(--fT);font-size:12px;font-weight:900;letter-spacing:.08em;color:#7fa8c6;
100
- border:1px solid transparent;background:var(--panelG2) padding-box,var(--steelB) border-box}
101
- .rt3d-detail-buy.go{color:#fff;background:linear-gradient(180deg,rgba(46,110,152,.96),rgba(20,52,80,.97)) padding-box,var(--goldB) border-box}
102
- .rt3d-detail-buy.done{color:#0a2417;background:linear-gradient(180deg,#7ef0b4,#2bd672) padding-box,
103
- linear-gradient(160deg,#d8ffe9,#1f9c62) border-box}
104
 
105
  @media (max-width:360px){
106
- .rt3d-node{width:68px;margin:-32px 0 0 -34px}
107
- .rt3d-stage{height:min(56vh,400px)}
108
  }
 
 
 
 
 
1
+ /* RESEARCH NETWORK — dedicated touch graph and node-detail presentation. */
2
+ .rt3d-wrap{width:min(96vw,430px);margin:0 auto 10px;display:flex;flex-direction:column;gap:8px;color:#dff4ff}
3
+ .rt3d-summary{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:11px 12px;border-radius:12px;
4
+ border:1px solid rgba(105,186,235,.3);background:linear-gradient(135deg,rgba(14,35,56,.96),rgba(5,13,24,.98));
5
+ box-shadow:inset 0 1px rgba(255,255,255,.04)}
6
+ .rt3d-summary small,.rt3d-summary b{display:block;font-family:var(--fT)}
7
+ .rt3d-summary small{font-size:7px;letter-spacing:.2em;color:#77badf}.rt3d-summary b{margin-top:2px;font-size:13px;letter-spacing:.06em}
8
+ .rt3d-summaryStats{display:flex;align-items:flex-end;flex-direction:column;gap:3px;font:800 8px var(--fT);color:#8db5ce}
9
+ .rt3d-summaryStats span{white-space:nowrap}.rt3d-summaryStats i{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:4px}
10
+ .rt3d-summaryStats i.ready{background:#45d5ff;box-shadow:0 0 8px #45d5ff}
 
 
 
 
 
 
 
 
11
 
12
+ .rt3d-queue{padding:10px;border-radius:12px;border:1px solid rgba(110,166,205,.25);
13
+ background:linear-gradient(180deg,rgba(9,23,38,.96),rgba(4,11,20,.98))}
14
+ .rt3d-queue>header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px}
15
+ .rt3d-queue>header b,.rt3d-queue>header span{display:block}.rt3d-queue>header b{font:900 9px var(--fT);letter-spacing:.15em;color:#a7e8ff}
16
+ .rt3d-queue>header span{margin-top:2px;font-size:8px;color:#7799b1;font-weight:700}.rt3d-queue>header strong{font:900 11px var(--fT);color:#ffd875}
17
+ .rt3d-qslots{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:5px}
18
+ .rt3d-qslot{position:relative;min-width:0;height:58px;border-radius:9px;border:1px dashed rgba(111,151,181,.28);background:rgba(3,9,17,.64)}
19
+ .rt3d-qslot.empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;color:#47647a}
20
+ .rt3d-qslot.empty small{font:900 8px var(--fT)}.rt3d-qslot.empty span{font:800 6px var(--fT);letter-spacing:.08em}
21
+ .rt3d-qslot.filled{border-style:solid;border-color:#637d92;background:linear-gradient(180deg,rgba(21,38,55,.95),rgba(7,16,27,.98))}
22
+ .rt3d-qslot.filled.ready{border-color:#43cfff;box-shadow:inset 0 0 12px rgba(67,207,255,.12)}
23
+ .rt3d-qslot.filled.locked{border-color:#725665}.rt3d-qslot.filled.funding{border-color:#9a783b}
24
+ .rt3d-qinspect{width:100%;height:100%;padding:4px 15px 3px 3px;display:flex;flex-direction:column;align-items:center;justify-content:center;color:#dff4ff;background:transparent;border:0}
25
+ .rt3d-qinspect small{position:absolute;left:4px;top:3px;font:900 7px var(--fT);color:#6990aa}.rt3d-qinspect span{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:800 6.5px var(--fT)}
26
+ .rt3d-qremove{position:absolute;right:1px;top:1px;z-index:2;width:24px;height:24px;border-radius:7px;color:#ffb4a7;font-size:14px;background:rgba(56,13,16,.72)}
27
+ .rt3d-next{width:100%;min-height:44px;margin-top:7px;border-radius:9px;border:1px solid rgba(110,150,180,.25);font:900 8px var(--fT);letter-spacing:.05em;color:#5c7a90;background:rgba(7,16,27,.8)}
28
+ .rt3d-next.ready{color:#ecfbff;border-color:#40caff;background:linear-gradient(180deg,#236e98,#123a5d);box-shadow:0 0 15px rgba(64,202,255,.16)}
29
 
30
+ .rt3d-branchNav{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:5px}
31
+ .rt3d-branchNav button{position:relative;min-width:0;min-height:52px;padding:6px 2px;border-radius:9px;border:1px solid rgba(95,144,180,.3);background:linear-gradient(180deg,rgba(18,35,51,.95),rgba(7,16,27,.98));
32
+ color:#7498b2;font:800 6.7px var(--fT);letter-spacing:.03em}
33
+ .rt3d-branchNav button>span{display:block;font-size:15px;color:#85cce9}.rt3d-branchNav button small{display:block;margin-top:2px;font-size:7px;color:#55758d}
34
+ .rt3d-branchNav button.on{color:#effcff;border-color:#48cfff;background:linear-gradient(180deg,rgba(33,97,134,.96),rgba(13,42,66,.98));box-shadow:0 0 14px rgba(72,207,255,.16)}
35
+ .rt3d-branchNav button.on small{color:#b7ecff}
36
 
37
+ .rt3d-stage{position:relative;height:min(55vh,500px);min-height:390px;overflow:auto;border-radius:14px;overscroll-behavior:contain;
38
+ touch-action:pan-x pan-y;-webkit-overflow-scrolling:touch;scrollbar-width:thin;scrollbar-color:#296c91 #07111d;
39
+ border:1px solid rgba(89,154,197,.34);background-color:#06101c;
40
+ background-image:linear-gradient(rgba(88,151,193,.045) 1px,transparent 1px),linear-gradient(90deg,rgba(88,151,193,.045) 1px,transparent 1px),radial-gradient(circle at 50% 28%,rgba(20,67,96,.5),rgba(4,10,18,.98) 70%);
41
+ background-size:22px 22px,22px 22px,100% 100%;box-shadow:inset 0 0 30px rgba(0,0,0,.48)}
42
+ .rt3d-world{position:relative;min-width:100%;isolation:isolate}
43
+ .rt3d-band{position:absolute;left:8px;right:8px;border:1px solid rgba(92,151,190,.14);border-radius:12px;background:rgba(9,22,35,.26);pointer-events:none}
44
+ .rt3d-band>span{position:sticky;left:12px;top:5px;display:inline-block;padding:5px 8px;border-radius:7px;background:#081522;border:1px solid rgba(103,160,198,.2);font:900 7px var(--fT);letter-spacing:.14em;color:#62a9d0}
45
+ .rt3d-band.fabrication{border-color:rgba(63,192,255,.18)}.rt3d-band.doctrine{border-color:rgba(255,190,82,.15)}.rt3d-band.xenology{border-color:rgba(170,101,255,.2)}
46
+ .rt3d-links{position:absolute;inset:0;z-index:1;overflow:visible;pointer-events:none}
47
+ .rt3d-links path{fill:none;stroke:#384d60;stroke-width:2;stroke-dasharray:5 5;opacity:.7;transition:stroke .18s,opacity .18s,stroke-width .18s}
48
+ .rt3d-links path.open{stroke:#40bde8;stroke-dasharray:none;filter:drop-shadow(0 0 3px rgba(64,189,232,.5))}
49
+ .rt3d-links path.owned{stroke:#55dc96;stroke-width:2.5;stroke-dasharray:none;filter:drop-shadow(0 0 3px rgba(85,220,150,.55))}
50
+ .rt3d-links path.focus{stroke:#fff2a4;stroke-width:4;opacity:1;filter:drop-shadow(0 0 5px rgba(255,226,110,.8))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
+ .rt3d-node{position:absolute;z-index:2;width:126px;height:76px;padding:6px 7px 5px;border-radius:11px;display:grid;grid-template-columns:34px 1fr;grid-template-rows:1fr auto;gap:1px 5px;text-align:left;
53
+ border:1px solid #536d82;background:linear-gradient(155deg,rgba(26,44,61,.98),rgba(7,16,28,.99));box-shadow:0 6px 15px rgba(0,0,0,.3);color:#dceefa}
54
+ .rt3d-nodeArt{grid-row:1/3;align-self:center;width:34px;height:42px;display:flex;align-items:center;justify-content:center;border-radius:8px;background:rgba(2,8,15,.7);border:1px solid rgba(116,163,194,.2)}
55
+ .rt3d-node>b{align-self:end;display:block;max-height:25px;overflow:hidden;font:900 8px/1.22 var(--fT);letter-spacing:.01em}.rt3d-node>small{align-self:start;font:800 6.5px var(--fT);letter-spacing:.07em;color:#7799b0}
56
+ .rt3d-tier{position:absolute;left:-5px;top:-5px;min-width:22px;height:18px;padding:0 3px;border-radius:6px;display:flex;align-items:center;justify-content:center;background:#203244;border:1px solid #657d91;font:900 7px var(--fT);color:#bcd4e5}
57
+ .rt3d-node.owned{border-color:#50dc93;background:linear-gradient(155deg,rgba(17,67,44,.98),rgba(5,24,16,.99));box-shadow:0 5px 16px rgba(62,221,143,.14)}
58
+ .rt3d-node.owned>small{color:#68e1a5}.rt3d-node.ready{border-color:#42cdff;background:linear-gradient(155deg,rgba(24,77,106,.98),rgba(7,27,43,.99));box-shadow:0 5px 18px rgba(66,205,255,.2)}
59
+ .rt3d-node.ready>small{color:#7ce2ff}.rt3d-node.funding{border-color:#a17a36}.rt3d-node.funding>small{color:#e0b15c}.rt3d-node.locked{opacity:.62;filter:saturate(.55)}
60
+ .rt3d-node.locked>small{color:#e18c82}.rt3d-node.selected{z-index:3;border-color:#fff6bd;box-shadow:0 0 0 2px rgba(255,239,143,.55),0 0 22px rgba(255,216,75,.32);opacity:1;filter:none}
61
+ .rt3d-node:active{transform:scale(.97)}
62
 
63
+ .rt3d-detail{position:sticky;bottom:0;z-index:8;padding:12px;border-radius:13px;border:1px solid rgba(103,172,215,.35);background:linear-gradient(160deg,rgba(17,38,59,.985),rgba(5,13,24,.995));box-shadow:0 -8px 24px rgba(0,0,0,.44)}
64
+ .rt3d-detailClose{position:absolute;right:8px;top:8px;z-index:2;width:44px;height:44px;border-radius:10px;color:#d6ebf8;border:1px solid #48677d;background:#0a1825;font-size:20px}
65
+ .rt3d-detail.empty{min-height:66px;display:flex;align-items:center;justify-content:center;gap:10px;color:#7295ad}.rt3d-emptyIcon{font-size:28px;color:#4c91b6}
66
+ .rt3d-detail.empty b,.rt3d-detail.empty span{display:block}.rt3d-detail.empty b{font:900 9px var(--fT);letter-spacing:.1em}.rt3d-detail.empty span{margin-top:3px;font-size:9px;font-weight:600}
67
+ .rt3d-detail>header{position:relative;display:flex;align-items:center;gap:9px;padding-right:128px}.rt3d-detailArt{width:50px;height:50px;flex:0 0 auto;display:flex;align-items:center;justify-content:center;border-radius:10px;background:rgba(2,8,15,.72);border:1px solid rgba(110,165,201,.24)}
68
+ .rt3d-detail header small,.rt3d-detail header b,.rt3d-detail header span{display:block}.rt3d-detail header small{font:800 7px var(--fT);letter-spacing:.12em;color:#65a9cf}.rt3d-detail header b{margin-top:2px;font:900 12px var(--fT);color:#effaff}.rt3d-detail header span{margin-top:2px;font-size:9px;line-height:1.3;color:#8eafc4;font-weight:600}
69
+ .rt3d-detail header em{position:absolute;right:48px;top:0;max-width:78px;padding:5px;border-radius:7px;text-align:center;font:900 6.5px var(--fT);font-style:normal;line-height:1.3;color:#8faabd;border:1px solid #4b6478;background:#0a1724}
70
+ .rt3d-detail.ready header em{color:#caf7ff;border-color:#3dc9ff;background:#10384f}.rt3d-detail.owned header em{color:#caffdf;border-color:#45d98c;background:#103a27}.rt3d-detail.locked header em{color:#ffc2b7;border-color:#8a4d48;background:#32191b}.rt3d-detail.funding header em{color:#ffe0a5;border-color:#9a7432;background:#302512}
71
+ .rt3d-detailLabel{margin:10px 0 5px;font:900 7px var(--fT);letter-spacing:.16em;color:#6e9ebc}.rt3d-prereqs{display:flex;gap:5px;flex-wrap:wrap}.rt3d-prereqs button{min-height:36px;padding:6px 9px;border-radius:8px;font:800 8px var(--fT);color:#ffc0b4;border:1px solid #6f3e3f;background:rgba(45,16,18,.62)}
72
+ .rt3d-prereqs button.ok{color:#a7f1c9;border-color:#347a58;background:rgba(12,46,31,.7)}
73
+ .rt3d-costGrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:5px}.rt3d-cost{min-width:0;min-height:35px;padding:5px 6px;border-radius:8px;display:grid;grid-template-columns:20px 1fr;grid-template-rows:auto auto;border:1px solid rgba(102,146,177,.22);background:rgba(3,10,18,.56)}
74
+ .rt3d-cost img,.rt3d-cost>i{grid-row:1/3;align-self:center}.rt3d-cost>i{font-style:normal;color:#77d9ff;text-align:center}.rt3d-cost span{font-size:7px;font-weight:700;color:#7598ae}.rt3d-cost b{font:900 8px var(--fT);color:#e8f7ff}.rt3d-cost.need b{color:#ff9c8e}.rt3d-cost.ok b{color:#80e3ad}
75
+ .rt3d-detailActions{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:9px}.rt3d-detailActions button{min-height:46px;padding:8px 6px;border-radius:9px;font:900 8px var(--fT);letter-spacing:.04em}
76
+ .rt3d-primary{color:#fff;border:1px solid #54d8ff;background:linear-gradient(180deg,#2878a4,#123b5e);box-shadow:0 0 15px rgba(58,196,246,.16)}.rt3d-primary.locked{color:#6e899d;border-color:#3a5265;background:#101e2b}.rt3d-primary.done{color:#a7f2c8;border-color:#3ca774;background:#133526}
77
+ .rt3d-secondary{color:#b7dbef;border:1px solid #507994;background:linear-gradient(180deg,#172d40,#0b1b2a)}.rt3d-secondary.path{grid-column:1/-1;color:#ffe2a0;border-color:#8c6a2e;background:linear-gradient(180deg,#342a17,#1c170d)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
 
79
  @media (max-width:360px){
80
+ .rt3d-wrap{width:97vw}.rt3d-qslots{gap:3px}.rt3d-qslot{height:54px}.rt3d-qinspect span{font-size:5.8px}.rt3d-stage{height:min(51vh,445px);min-height:350px}
81
+ .rt3d-detail>header{padding-right:112px}.rt3d-detail header em{max-width:61px}.rt3d-detail header b{font-size:10.5px}.rt3d-detail header span{font-size:8.5px}
82
  }
83
+ @media (min-width:700px) and (orientation:landscape){
84
+ .rt3d-wrap{width:min(94vw,760px)}.rt3d-stage{height:min(62vh,560px)}.rt3d-detailActions{grid-template-columns:1.25fr .75fr}.rt3d-costGrid{grid-template-columns:repeat(3,minmax(0,1fr))}
85
+ }
86
+ @media (prefers-reduced-motion:reduce){.rt3d-stage{scroll-behavior:auto}.rt3d-links path,.rt3d-node{transition:none}}
src/styles/store.css CHANGED
@@ -86,3 +86,100 @@
86
  .invAct{flex:0 0 66px;min-height:40px;padding:5px;display:flex;align-items:center;justify-content:center;text-align:center;border-radius:8px;
87
  color:var(--rar);background:rgba(5,11,18,.68);border:1px solid var(--rar);font:800 7.5px var(--fT);letter-spacing:.04em}
88
  .invItem.locked .invAct{border-color:#607486;color:#8495a3;font-size:6.3px}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  .invAct{flex:0 0 66px;min-height:40px;padding:5px;display:flex;align-items:center;justify-content:center;text-align:center;border-radius:8px;
87
  color:var(--rar);background:rgba(5,11,18,.68);border:1px solid var(--rar);font:800 7.5px var(--fT);letter-spacing:.04em}
88
  .invItem.locked .invAct{border-color:#607486;color:#8495a3;font-size:6.3px}
89
+
90
+ /* ---- TWO-LAYER ACCOUNT ARMORY / SESSION LOADOUT ---------------------------
91
+ Isolated `armInv*` names keep this denser interaction model from changing
92
+ perk rows, Development modules, or the in-match inventory presentation. */
93
+ .armInvLayerHead{display:flex;align-items:stretch;gap:8px;padding:10px;border-radius:12px;text-align:left;
94
+ border:1px solid rgba(91,177,226,.3);background:linear-gradient(135deg,rgba(21,56,81,.94),rgba(7,16,27,.98));
95
+ box-shadow:0 5px 16px rgba(0,0,0,.4),inset 0 1px 0 rgba(255,255,255,.07)}
96
+ .armInvLayerHead.session{border-color:rgba(255,205,91,.34);background:linear-gradient(135deg,rgba(67,49,19,.58),rgba(8,17,29,.98))}
97
+ .armInvLayerHead>div:first-child{flex:1;min-width:0}
98
+ .armInvLayerHead i{display:block;color:#70d9ff;font:800 7px var(--fT);font-style:normal;letter-spacing:.17em}
99
+ .armInvLayerHead.session i{color:#ffd86d}
100
+ .armInvLayerHead>div>b{display:block;margin-top:3px;color:#e8f8ff;font:900 15px var(--fT);letter-spacing:.09em}
101
+ .armInvLayerHead>div>span{display:block;margin-top:4px;color:#9abbd1;font-size:10px;font-weight:600;line-height:1.3}
102
+ .armInvCapacity{flex:0 0 88px;display:flex!important;flex-direction:column;align-items:center;justify-content:center;padding:6px;
103
+ border-radius:9px;text-align:center;background:rgba(3,10,17,.6);border:1px solid rgba(117,196,238,.22)}
104
+ .armInvCapacity b{margin:0!important;color:#8ce7ff!important;font-size:18px!important;letter-spacing:.02em!important;font-variant-numeric:tabular-nums}
105
+ .armInvLayerHead.session .armInvCapacity b{color:#ffe083!important}
106
+ .armInvCapacity span{margin-top:2px!important;color:#779ab1!important;font:800 6.4px var(--fT)!important;letter-spacing:.1em!important}
107
+ .armInvMeters{display:grid;grid-template-columns:repeat(3,1fr);gap:5px}
108
+ .armInvMeters div{min-width:0;padding:7px 4px;border-radius:8px;text-align:center;background:rgba(10,23,36,.86);border:1px solid rgba(90,147,182,.18)}
109
+ .armInvMeters b{display:block;color:#d9f4ff;font:800 10px var(--fT);font-variant-numeric:tabular-nums}
110
+ .armInvMeters span{display:block;margin-top:2px;color:#6389a2;font:800 6.4px var(--fT);letter-spacing:.08em;white-space:nowrap}
111
+
112
+ .armInvFilters{display:flex;gap:5px;max-width:100%;overflow-x:auto;padding:1px 0 3px;scrollbar-width:none;overscroll-behavior-x:contain}
113
+ .armInvFilters::-webkit-scrollbar{display:none}
114
+ .armInvFilter{flex:0 0 auto;min-width:72px;min-height:46px;padding:0 10px;border-radius:9px;color:#789cb5;
115
+ border:1px solid rgba(99,155,190,.26);background:linear-gradient(180deg,rgba(23,39,56,.96),rgba(8,16,27,.97));
116
+ font:800 7.5px var(--fT);letter-spacing:.08em;touch-action:manipulation}
117
+ .armInvFilter.on{color:#d8f6ff;border-color:#52cfff;background:linear-gradient(180deg,rgba(37,112,151,.8),rgba(12,45,70,.96));box-shadow:0 0 10px rgba(65,200,255,.28)}
118
+
119
+ .armInvPreview{--rar:#aeb9c6;display:grid;grid-template-columns:58px minmax(0,1fr) 82px;gap:8px;align-items:center;padding:10px;border-radius:13px;text-align:left;
120
+ border:1px solid var(--rar);border-left:4px solid var(--rar);background:radial-gradient(circle at 8% 20%,color-mix(in srgb,var(--rar) 18%,transparent),transparent 36%),linear-gradient(145deg,rgba(27,42,58,.98),rgba(5,12,21,.99));
121
+ box-shadow:0 4px 15px rgba(0,0,0,.48),0 0 14px color-mix(in srgb,var(--rar) 16%,transparent)}
122
+ .armInvPreviewArt{width:54px;height:54px;display:flex;align-items:center;justify-content:center;border-radius:10px;
123
+ background:rgba(2,8,14,.55);box-shadow:inset 0 0 12px color-mix(in srgb,var(--rar) 22%,transparent)}
124
+ .armInvPreviewArt .itemArt{width:54px!important;height:54px!important;object-fit:contain}
125
+ .armInvPreviewInfo{min-width:0}.armInvPreviewInfo i{display:block;color:var(--rar);font:800 6.8px var(--fT);font-style:normal;letter-spacing:.1em}
126
+ .armInvPreviewInfo b{display:block;margin-top:3px;color:#f1faff;font:900 13px var(--fT);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
127
+ .armInvPreviewInfo span{display:block;margin-top:4px;color:#799bb2;font:700 7px var(--fT);letter-spacing:.07em}
128
+ .armInvExact{align-self:stretch;min-width:0;padding:6px 4px;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;border-radius:9px;
129
+ background:rgba(2,8,14,.67);border:1px solid color-mix(in srgb,var(--rar) 45%,transparent)}
130
+ .armInvExact span{color:#7697ad;font:800 5.8px var(--fT);letter-spacing:.08em}.armInvExact b{margin-top:2px;color:var(--rar);font:900 15px var(--fT)}
131
+ .armInvExact i{margin-top:2px;color:#bad2e1;font:800 5.8px var(--fT);font-style:normal;letter-spacing:.05em}
132
+ .armInvPreview p{grid-column:1/-1;margin:0;color:#acc7d8;font-size:10.5px;font-weight:600;line-height:1.35}
133
+ .armInvCompare{grid-column:1/-1;padding:6px 8px;border-radius:7px;color:#d0e9f5;background:rgba(51,102,132,.18);
134
+ border-left:2px solid var(--rar);font:800 7px var(--fT);letter-spacing:.055em;line-height:1.35}
135
+ .armInvCommit{grid-column:1/-1;min-height:48px;border-radius:9px;color:#081018;border:1px solid transparent;
136
+ background:linear-gradient(180deg,color-mix(in srgb,var(--rar) 74%,white),var(--rar));font:900 9px var(--fT);letter-spacing:.1em;touch-action:manipulation}
137
+ .armInvCommit.disabled{color:#637889;border-color:#526575;background:rgba(19,30,41,.85)}
138
+ .armInvPreview small{grid-column:1/-1;color:#6f90a6;font-size:9px;font-weight:600;line-height:1.35;text-align:center}
139
+ .armInvSectionLabel{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:3px 2px}
140
+ .armInvSectionLabel b{color:#93ddfa;font:800 8px var(--fT);letter-spacing:.14em}.armInvSectionLabel span{color:#607f94;font:800 6.5px var(--fT);letter-spacing:.07em}
141
+
142
+ .armVaultList{display:flex;flex-direction:column;gap:6px}
143
+ .armVaultItem{--rar:#aeb9c6;width:100%;min-height:68px;display:flex;align-items:center;gap:9px;padding:7px 8px;border-radius:10px;text-align:left;
144
+ color:inherit;border:1px solid rgba(105,149,178,.23);border-left:3px solid var(--rar);background:linear-gradient(180deg,rgba(25,39,54,.96),rgba(7,14,23,.98));
145
+ box-shadow:0 3px 8px rgba(0,0,0,.38);touch-action:manipulation}
146
+ .armVaultItem.selected{border-color:var(--rar);box-shadow:0 0 12px color-mix(in srgb,var(--rar) 34%,transparent),inset 0 1px 0 rgba(255,255,255,.08)}
147
+ .armVaultItem.equipped{background:linear-gradient(90deg,color-mix(in srgb,var(--rar) 13%,rgba(15,28,42,.98)),rgba(7,14,23,.98))}
148
+ .armVaultItem.locked{opacity:.42;filter:saturate(.35)}
149
+ .armVaultArt{flex:0 0 42px;height:44px;display:flex;align-items:center;justify-content:center}.armVaultArt .itemArt{width:40px!important;height:40px!important;object-fit:contain}
150
+ .armVaultCopy{flex:1;min-width:0}.armVaultCopy i{display:block;color:var(--rar);font:800 6.7px var(--fT);font-style:normal;letter-spacing:.1em}
151
+ .armVaultCopy b{display:block;margin-top:2px;color:#e9f7ff;font:800 10.5px var(--fT);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
152
+ .armVaultCopy em{display:block;margin-top:3px;color:#93b5ca;font-size:9px;font-weight:700;font-style:normal}
153
+ .armVaultState{flex:0 0 65px;min-height:34px;display:flex;align-items:center;justify-content:center;padding:3px;border-radius:7px;text-align:center;
154
+ color:var(--rar);background:rgba(2,8,14,.56);border:1px solid color-mix(in srgb,var(--rar) 60%,transparent);font:800 6.5px var(--fT);letter-spacing:.05em}
155
+ .armInvEmpty{padding:18px 12px;border-radius:10px;text-align:center;background:rgba(10,22,35,.74);border:1px dashed rgba(95,153,189,.3)}
156
+ .armInvEmpty b{display:block;color:#a6cadd;font:800 9px var(--fT);letter-spacing:.1em}.armInvEmpty span{display:block;margin-top:4px;color:#6d8da2;font-size:9.5px;font-weight:600}
157
+
158
+ .armLoadoutRule{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px;border-radius:9px;text-align:left;
159
+ background:linear-gradient(90deg,rgba(111,77,20,.35),rgba(11,24,38,.88));border-left:3px solid #ffd567}
160
+ .armLoadoutRule b{color:#ffe08a;font:900 8px var(--fT);letter-spacing:.1em;white-space:nowrap}.armLoadoutRule span{color:#a9bdc9;font-size:9px;font-weight:600;text-align:right}
161
+ .armLoadoutGrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}
162
+ .armLoadSlot{--rar:#758da0;min-width:0;min-height:126px;display:flex;flex-direction:column;padding:8px;border-radius:11px;text-align:left;
163
+ border:1px solid color-mix(in srgb,var(--rar) 48%,transparent);border-top:3px solid var(--rar);background:linear-gradient(150deg,color-mix(in srgb,var(--rar) 10%,rgba(20,34,48,.98)),rgba(5,12,20,.99));box-shadow:0 4px 10px rgba(0,0,0,.4)}
164
+ .armLoadSlot.empty{border-style:dashed;opacity:.86}.armLoadSlotTop{display:flex;justify-content:space-between;gap:5px}.armLoadSlotTop i{color:var(--rar);font:900 7px var(--fT);font-style:normal;letter-spacing:.1em}
165
+ .armLoadSlotTop span{color:#6c8da3;font:800 6px var(--fT);letter-spacing:.07em}.armLoadSlot>b{margin-top:14px;color:#99b6c8;font:800 9px var(--fT)}
166
+ .armLoadSlot>small{display:block;margin-top:4px;color:#6e8da1;font-size:8px;font-weight:600;line-height:1.25}
167
+ .armLoadEquipped{display:flex;align-items:center;gap:6px;margin-top:8px;min-width:0}.armLoadEquipped>span{flex:0 0 38px;height:38px;display:flex;align-items:center;justify-content:center}
168
+ .armLoadEquipped .itemArt{width:36px!important;height:36px!important;object-fit:contain}.armLoadEquipped>div{min-width:0}.armLoadEquipped b{display:block;color:#edf9ff;font:800 9px var(--fT);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
169
+ .armLoadEquipped em{display:block;margin-top:3px;color:var(--rar);font-size:7.8px;font-weight:800;font-style:normal;line-height:1.2}
170
+ .armLoadSlot button{width:100%;min-height:44px;margin-top:auto;border-radius:8px;color:var(--rar);border:1px solid color-mix(in srgb,var(--rar) 68%,transparent);
171
+ background:rgba(4,10,17,.7);font:900 7px var(--fT);letter-spacing:.09em;touch-action:manipulation}
172
+ .armLoadSlot.empty button{color:#9bdfff;border-color:#3aaedc;background:rgba(25,83,111,.25)}
173
+ .armMissionPackage{padding:9px;border-radius:12px;text-align:left;background:linear-gradient(160deg,rgba(18,35,51,.96),rgba(5,12,20,.99));border:1px solid rgba(91,153,190,.24)}
174
+ .armMissionFx{--rar:#aeb9c6;display:grid;grid-template-columns:8px minmax(0,1fr) auto;gap:7px;align-items:center;padding:7px 4px;border-bottom:1px solid rgba(99,142,168,.13)}
175
+ .armMissionFx>i{width:7px;height:7px;border-radius:50%;background:var(--rar);box-shadow:0 0 6px var(--rar)}.armMissionFx div{min-width:0}
176
+ .armMissionFx div b{display:block;color:#dceef7;font:800 8.5px var(--fT);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.armMissionFx div span{display:block;margin-top:2px;color:#698ba0;font:700 6px var(--fT);letter-spacing:.07em}
177
+ .armMissionFx strong{max-width:115px;color:var(--rar);font:800 7.5px var(--fT);text-align:right}.armMissionPackage>p{margin:8px 3px 1px;color:#738fa2;font-size:8.5px;font-weight:600;line-height:1.35;text-align:center}
178
+ .armReturnVault{min-height:48px;border-radius:9px;color:#b9ecff;border:1px solid #46bde9;background:linear-gradient(180deg,rgba(37,100,135,.7),rgba(12,39,61,.94));
179
+ font:900 8px var(--fT);letter-spacing:.11em;touch-action:manipulation}
180
+
181
+ @media(max-width:350px){
182
+ .armInvPreview{grid-template-columns:50px minmax(0,1fr) 72px;padding:8px;gap:6px}
183
+ .armInvPreviewArt,.armInvPreviewArt .itemArt{width:48px!important;height:48px!important}
184
+ .armInvCapacity{flex-basis:78px}.armLoadoutGrid{grid-template-columns:1fr}
185
+ }
src/styles/tutorial.css CHANGED
@@ -82,6 +82,15 @@
82
  #keelTxt{
83
  margin-top:2px; font-size:12.5px; line-height:1.36; font-weight:600; color:#eaf7ff;
84
  }
 
 
 
 
 
 
 
 
 
85
 
86
  #keelNext{
87
  display:none;align-items:center;justify-content:center;min-width:92px;min-height:44px;margin-top:8px;padding:8px 15px;
@@ -181,5 +190,6 @@
181
  #keelBar{ transition:none; }
182
  #keelBar.pulse{ animation:none; }
183
  #keelBar.pulse #keelBadge .kr1,#keelBar.pulse #keelBadge .kr2{ animation:none; }
 
184
  .keelFocus,#keelMapCue{animation:none!important}
185
  }
 
82
  #keelTxt{
83
  margin-top:2px; font-size:12.5px; line-height:1.36; font-weight:600; color:#eaf7ff;
84
  }
85
+ #keelProgress{
86
+ height:3px;margin-top:7px;border-radius:4px;overflow:hidden;background:rgba(4,12,20,.78);
87
+ box-shadow:inset 0 0 0 1px rgba(120,205,242,.16);
88
+ }
89
+ #keelProgress i{
90
+ display:block;width:0;height:100%;border-radius:inherit;
91
+ background:linear-gradient(90deg,#42c9ff,#ffe06b);box-shadow:0 0 7px rgba(88,205,255,.62);
92
+ transition:width .3s ease;
93
+ }
94
 
95
  #keelNext{
96
  display:none;align-items:center;justify-content:center;min-width:92px;min-height:44px;margin-top:8px;padding:8px 15px;
 
190
  #keelBar{ transition:none; }
191
  #keelBar.pulse{ animation:none; }
192
  #keelBar.pulse #keelBadge .kr1,#keelBar.pulse #keelBadge .kr2{ animation:none; }
193
+ #keelProgress i{transition:none}
194
  .keelFocus,#keelMapCue{animation:none!important}
195
  }
src/styles/ui.css CHANGED
@@ -52,18 +52,30 @@ canvas#gl{position:absolute;inset:0;width:100%;height:100%;display:block;touch-a
52
  border:1px solid transparent;background:var(--panelG) padding-box,var(--steelB) border-box;
53
  box-shadow:0 2px 8px rgba(0,0,0,.5),inset 0 1px 0 rgba(255,255,255,.1);
54
  clip-path:polygon(6px 0,100% 0,100% calc(100% - 6px),calc(100% - 6px) 100%,0 100%,0 6px)}
55
- .res .ic{width:15px;height:15px;border-radius:4px;border:1px solid rgba(255,255,255,.35)}
 
 
 
 
 
 
 
 
56
  .res b{font-variant-numeric:tabular-nums;font-size:15px;font-weight:700;letter-spacing:.02em}
57
  .res .rate{font-size:11px;opacity:.85;font-variant-numeric:tabular-nums;font-weight:600}
58
- #massIc{background:linear-gradient(160deg,#a8ffd0,#2bd672 60%,#0d7a3c);box-shadow:0 0 7px var(--green)}
59
- #enIc{background:linear-gradient(160deg,#ffefb0,#ffd257 60%,#a97d12);box-shadow:0 0 7px var(--gold)}
60
- #unitRes .ic{background:linear-gradient(160deg,#b8e9ff,#41c8ff 60%,#136a94);box-shadow:0 0 7px var(--cy)}
61
  #fps{margin-left:auto;padding:3px 9px;font-size:11px;opacity:.9;border-radius:8px;font-weight:600;
62
  border:1px solid transparent;background:var(--panelG) padding-box,var(--steelB) border-box}
63
  #menuBtn,#spdBtn{pointer-events:auto;color:#dfeeff;border-radius:9px;padding:4px 12px;font-size:15px;
 
64
  border:1px solid transparent;background:var(--panelG2) padding-box,var(--steelB) border-box;
65
- box-shadow:0 2px 8px rgba(0,0,0,.5),inset 0 1px 0 rgba(255,255,255,.12)}
66
  #spdBtn{font-size:12px;font-weight:800;font-family:var(--fT);padding:5px 8px}
 
 
 
67
 
68
  /* ---------- ALERT / TOAST ---------- */
69
  /* z-index 110, not 20. Overlays (menus, profile, settings, armory) sit at 50,
@@ -193,8 +205,11 @@ canvas#gl{position:absolute;inset:0;width:100%;height:100%;display:block;touch-a
193
  #wcRow{position:absolute;top:calc(var(--sat) + 36px);right:8px;z-index:9;display:none;gap:4px;align-items:center;
194
  border:1px solid transparent;background:var(--panelG) padding-box,var(--redB) border-box;
195
  border-radius:9px;padding:2px 8px;pointer-events:auto;box-shadow:0 2px 8px rgba(0,0,0,.5)}
196
- .wcChip{font-size:15px}
 
 
197
  .wcMult{font-size:11px;color:#ffb0a2;font-weight:700;font-family:var(--fT)}
 
198
 
199
  /* ---------- OBJECTIVE / CLOCK BAR ---------- */
200
  #goalBar{position:absolute;top:calc(var(--sat) + 36px);left:50%;transform:translateX(-50%);z-index:9;display:none;
@@ -290,6 +305,28 @@ canvas#gl{position:absolute;inset:0;width:100%;height:100%;display:block;touch-a
290
  width:max-content;max-width:min(96vw,398px);max-height:min(56vh,56dvh);overflow-y:auto;-webkit-overflow-scrolling:touch;
291
  clip-path:polygon(14px 0,calc(100% - 14px) 0,100% 14px,100% calc(100% - 14px),calc(100% - 14px) 100%,14px 100%,0 calc(100% - 14px),0 14px)}
292
  #buildMenu .grid,#prodMenu .grid{display:flex;gap:7px;flex-wrap:wrap;justify-content:center}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  .bcard{width:84px;border-radius:10px;padding:6px 3px 5px;display:flex;flex-direction:column;align-items:center;gap:2px;
294
  font-size:10.5px;pointer-events:auto;position:relative;overflow:hidden;
295
  border:1px solid transparent;background:linear-gradient(180deg,rgba(30,46,64,.95),rgba(13,21,33,.97)) padding-box,var(--steelB) border-box;
@@ -323,6 +360,10 @@ canvas#gl{position:absolute;inset:0;width:100%;height:100%;display:block;touch-a
323
  border:1px solid transparent;background:var(--panelG2) padding-box,var(--steelB) border-box;font-family:var(--fU);text-transform:uppercase}
324
  #upBtn{color:var(--gold);background:linear-gradient(180deg,rgba(64,48,14,.95),rgba(30,22,6,.97)) padding-box,var(--goldB) border-box}
325
  #repeatBtn.on{color:var(--green);box-shadow:0 0 9px rgba(93,255,154,.35)}
 
 
 
 
326
 
327
  /* ---------- PLACEMENT CONFIRM ---------- */
328
  #placeUI{position:absolute;left:50%;transform:translateX(-50%);bottom:calc(var(--sab) + 68px);z-index:12;display:none;gap:12px}
@@ -441,6 +482,12 @@ canvas#gl{position:absolute;inset:0;width:100%;height:100%;display:block;touch-a
441
  .goBreak span{padding:3px 6px;border-radius:6px;font-family:var(--fU);font-size:9px;letter-spacing:.04em;
442
  color:#9fc6e0;background:rgba(22,38,56,.8);border:1px solid rgba(110,165,205,.22)}
443
  .goBreak b{color:#ffe9ad}.goData{margin-top:7px;color:#9dffcf;font-size:12px;letter-spacing:.08em}
 
 
 
 
 
 
444
  .goData small{display:block;color:#7fb29c;font:600 9px var(--fU);letter-spacing:.03em}.goData.muted{color:#7f9aab;font-size:10px}
445
 
446
  /* ---------- META HEADER ---------- */
@@ -654,11 +701,11 @@ canvas#gl{position:absolute;inset:0;width:100%;height:100%;display:block;touch-a
654
  background:var(--panelG2) padding-box,var(--steelB) border-box}
655
  .setupAdv.on{color:#fff;background:linear-gradient(180deg,rgba(46,110,152,.96),rgba(20,52,80,.97)) padding-box,
656
  linear-gradient(160deg,#c6f2ff,#41c8ff 40%,#10465f 70%,#8fe0ff) border-box}
657
- .setupTabs{flex:0 0 auto;width:100%;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;
658
  padding:0 calc(var(--sar) + 14px) 10px calc(var(--sal) + 14px);
659
  background:rgba(6,12,22,.98);z-index:4}
660
- .setupTabBtn{min-width:0;min-height:48px;padding:9px 4px;border-radius:9px;font-family:var(--fT);
661
- font-size:9px;letter-spacing:.08em;font-weight:800;color:#789bb5;border:1px solid rgba(120,170,205,.24);
662
  background:linear-gradient(180deg,rgba(23,36,53,.96),rgba(9,17,28,.97))}
663
  .setupTabBtn.on{color:#f0fbff;border-color:#41c8ff;background:linear-gradient(180deg,rgba(43,113,154,.9),rgba(15,44,68,.96));
664
  box-shadow:0 0 12px rgba(65,200,255,.25)}
@@ -677,6 +724,14 @@ canvas#gl{position:absolute;inset:0;width:100%;height:100%;display:block;touch-a
677
  display:flex;align-items:center;justify-content:center;text-align:center;line-height:1.15}
678
  .optGrid{display:grid;grid-template-columns:1fr 1fr;gap:9px}
679
  .optGrid .dbtn{min-width:0}
 
 
 
 
 
 
 
 
680
  .secHint{margin-top:11px;font-size:11.5px;line-height:1.4;color:#8fb3cc;font-weight:600;text-align:left}
681
  .secHint.danger{color:#c98d80}
682
  .advWrap{display:contents!important}
@@ -684,7 +739,8 @@ canvas#gl{position:absolute;inset:0;width:100%;height:100%;display:block;touch-a
684
  #setupScr:not(.setupAll)[data-tab="map"] [data-setup-tab]:not([data-setup-tab="map"]),
685
  #setupScr:not(.setupAll)[data-tab="forces"] [data-setup-tab]:not([data-setup-tab="forces"]),
686
  #setupScr:not(.setupAll)[data-tab="rules"] [data-setup-tab]:not([data-setup-tab="rules"]),
687
- #setupScr:not(.setupAll)[data-tab="economy"] [data-setup-tab]:not([data-setup-tab="economy"]){display:none}
 
688
  .setupFoot{flex:0 0 auto;width:100%;display:flex;gap:10px;align-items:center;justify-content:center;
689
  /* Some Android WebViews report a zero bottom inset while still drawing a
690
  3-button/navigation shelf. The 54px floor is the physical escape hatch:
@@ -1276,18 +1332,41 @@ body.menuMode #fps,body.menuMode #atkAlert,body.menuMode #waveAlert{display:none
1276
  box-shadow:0 0 14px rgba(255,106,85,.35)}
1277
  .thBtn.on b{color:#fff} .thBtn.on span{color:#ffb0a2}
1278
  .thBtn.lock{opacity:.34}
1279
- #opModRow{display:flex;flex-direction:column;gap:7px}
1280
- .opMod{display:flex;align-items:center;gap:10px;padding:10px 11px;border-radius:11px;text-align:left;
 
1281
  border:1px solid transparent;
1282
  background:linear-gradient(180deg,rgba(20,30,46,.92),rgba(10,17,28,.94)) padding-box,
1283
  linear-gradient(160deg,rgba(120,170,205,.24),rgba(30,60,88,.14)) border-box}
 
1284
  .opMod.on{background:linear-gradient(180deg,rgba(58,20,16,.94),rgba(28,10,8,.96)) padding-box,
1285
  linear-gradient(160deg,rgba(255,150,130,.5),rgba(90,30,22,.3)) border-box}
 
 
 
 
 
 
 
 
 
 
 
1286
  .opTx{flex:1;min-width:0}
1287
  .opTx b{display:block;font-family:var(--fT);font-size:11.5px;color:#eaf7ff;letter-spacing:.04em}
1288
  .opTx span{display:block;font-size:10px;color:#8fb3cc;font-weight:600;margin-top:2px}
1289
- .opRw{flex:0 0 auto;font-family:var(--fT);font-size:11px;font-weight:900;color:#7fa8c6}
 
 
1290
  .opMod.on .opRw{color:#ffb0a2}
 
 
 
 
 
 
 
 
1291
  #opPayout{margin-top:11px;text-align:center;font-family:var(--fT);font-size:11px;letter-spacing:.14em;
1292
  color:#8fb3cc;font-weight:800}
1293
  #opPayout b{color:#ffe9ad;font-size:15px}
@@ -1486,6 +1565,16 @@ body.menuMode #fps,body.menuMode #atkAlert,body.menuMode #waveAlert{display:none
1486
  .facUnit b{display:block;font-family:var(--fT);font-size:10px;letter-spacing:.09em;color:#eaf4ff}
1487
  .facUnit span{display:block;margin-top:1px;font-size:8.5px;font-weight:800;letter-spacing:.05em;color:var(--fc)}
1488
  .facUnit i{display:block;margin-top:3px;font-size:9px;font-style:normal;line-height:1.4;color:#93b0c6;font-weight:600}
 
 
 
 
 
 
 
 
 
 
1489
  .facBonus{margin-top:9px;padding:8px 9px;border-radius:8px;font-size:10px;line-height:1.45;
1490
  color:#c3dcee;font-weight:600;background:color-mix(in srgb,var(--fc) 11%,rgba(4,9,16,.7));
1491
  border:1px solid color-mix(in srgb,var(--fc) 28%,transparent)}
@@ -1548,11 +1637,17 @@ body.godMode #godBadge{display:flex}
1548
  .setRow[data-set="godMode"] .sTx b{color:#ffe08a}
1549
 
1550
  @media (max-width:600px) and (orientation:portrait){
1551
- #topbar{left:var(--sal);right:var(--sar);gap:4px;padding:5px 6px;font-size:12px}
1552
- .res{gap:4px;padding:3px 6px 3px 4px;border-radius:8px}
1553
- .res b{font-size:13px}.res .rate{font-size:9px}.res .ic{width:14px;height:14px}
1554
- #menuBtn,#spdBtn{padding:7px 10px;min-width:44px;min-height:44px}#fps{display:none!important}
1555
- body.godMode #godBadge{position:fixed;top:calc(var(--sat) + 42px);right:calc(var(--sar) + 8px);z-index:12}
 
 
 
 
 
 
1556
 
1557
  #cmdbar{left:0;right:0;bottom:0;width:auto;gap:5px;align-items:flex-end;
1558
  padding:7px calc(var(--sar) + 8px) max(calc(var(--sab) + 8px),46px) calc(var(--sal) + 8px);
@@ -1580,7 +1675,87 @@ body.godMode #godBadge{display:flex}
1580
  #placeUI{bottom:max(calc(var(--sab) + 160px),200px);gap:7px}
1581
  #placeUI .cbtn{width:52px;height:52px;font-size:22px}
1582
  #placeRotL,#placeRotR{width:46px!important;height:46px!important}
1583
- #goalBar{max-width:calc(100vw - var(--sal) - var(--sar) - 16px);overflow:hidden;text-overflow:ellipsis}
1584
- #heroBar{left:calc(var(--sal) + 8px);right:auto;max-width:55vw}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1585
  #atkAlert,#waveAlert{max-width:calc(100vw - var(--sal) - var(--sar) - 20px);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
1586
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  border:1px solid transparent;background:var(--panelG) padding-box,var(--steelB) border-box;
53
  box-shadow:0 2px 8px rgba(0,0,0,.5),inset 0 1px 0 rgba(255,255,255,.1);
54
  clip-path:polygon(6px 0,100% 0,100% calc(100% - 6px),calc(100% - 6px) 100%,0 100%,0 6px)}
55
+ .res .ic{position:relative;isolation:isolate;flex:0 0 auto;width:18px;height:18px;overflow:hidden;border-radius:5px;
56
+ border:1px solid rgba(255,255,255,.62);box-shadow:inset 0 1px 2px rgba(255,255,255,.28),0 0 8px currentColor}
57
+ .res .ic::after{content:'';position:absolute;z-index:1;inset:3px;background:#f5fbff;
58
+ filter:drop-shadow(0 1px 1px rgba(0,0,0,.68)) drop-shadow(0 0 2px rgba(255,255,255,.72))}
59
+ #massIc::after{clip-path:polygon(50% 0,92% 25%,78% 88%,22% 88%,8% 25%)}
60
+ #enIc::after{clip-path:polygon(56% 0,25% 50%,48% 50%,35% 100%,78% 40%,55% 40%)}
61
+ #unitRes .ic::after{inset:2px;background:
62
+ radial-gradient(circle at 50% 26%,#f5fbff 0 18%,transparent 20%),
63
+ radial-gradient(ellipse at 50% 90%,#f5fbff 0 43%,transparent 45%)}
64
  .res b{font-variant-numeric:tabular-nums;font-size:15px;font-weight:700;letter-spacing:.02em}
65
  .res .rate{font-size:11px;opacity:.85;font-variant-numeric:tabular-nums;font-weight:600}
66
+ #massIc{color:#5dff9a;background:linear-gradient(160deg,#45f194,#159952 62%,#063d23)}
67
+ #enIc{color:#ffd257;background:linear-gradient(160deg,#ffe786,#d29d22 62%,#654607)}
68
+ #unitRes .ic{color:#41c8ff;background:linear-gradient(160deg,#7fe0ff,#248dbb 62%,#0a405e)}
69
  #fps{margin-left:auto;padding:3px 9px;font-size:11px;opacity:.9;border-radius:8px;font-weight:600;
70
  border:1px solid transparent;background:var(--panelG) padding-box,var(--steelB) border-box}
71
  #menuBtn,#spdBtn{pointer-events:auto;color:#dfeeff;border-radius:9px;padding:4px 12px;font-size:15px;
72
+ min-width:44px;min-height:44px;
73
  border:1px solid transparent;background:var(--panelG2) padding-box,var(--steelB) border-box;
74
+ box-shadow:0 2px 8px rgba(0,0,0,.5),inset 0 1px 0 rgba(255,255,255,.12);transition:transform .08s ease,filter .08s ease,box-shadow .08s ease}
75
  #spdBtn{font-size:12px;font-weight:800;font-family:var(--fT);padding:5px 8px}
76
+ #menuBtn:active,#spdBtn:active{transform:translateY(1px) scale(.96);filter:brightness(1.28);
77
+ box-shadow:0 1px 3px rgba(0,0,0,.62),inset 0 2px 7px rgba(0,0,0,.38)}
78
+ #menuBtn:focus-visible,#spdBtn:focus-visible{outline:2px solid #8ee5ff;outline-offset:2px}
79
 
80
  /* ---------- ALERT / TOAST ---------- */
81
  /* z-index 110, not 20. Overlays (menus, profile, settings, armory) sit at 50,
 
205
  #wcRow{position:absolute;top:calc(var(--sat) + 36px);right:8px;z-index:9;display:none;gap:4px;align-items:center;
206
  border:1px solid transparent;background:var(--panelG) padding-box,var(--redB) border-box;
207
  border-radius:9px;padding:2px 8px;pointer-events:auto;box-shadow:0 2px 8px rgba(0,0,0,.5)}
208
+ .wcChip{display:grid;place-items:center;flex:0 0 27px;width:27px;height:27px;border-radius:7px;font-size:15px;
209
+ color:#fff;border:1px solid rgba(255,190,171,.42);background:linear-gradient(180deg,rgba(98,45,39,.9),rgba(36,17,22,.94));
210
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.14),0 1px 4px rgba(0,0,0,.45);filter:saturate(1.18) contrast(1.08)}
211
  .wcMult{font-size:11px;color:#ffb0a2;font-weight:700;font-family:var(--fT)}
212
+ #wcRow:active{filter:brightness(1.2)}
213
 
214
  /* ---------- OBJECTIVE / CLOCK BAR ---------- */
215
  #goalBar{position:absolute;top:calc(var(--sat) + 36px);left:50%;transform:translateX(-50%);z-index:9;display:none;
 
305
  width:max-content;max-width:min(96vw,398px);max-height:min(56vh,56dvh);overflow-y:auto;-webkit-overflow-scrolling:touch;
306
  clip-path:polygon(14px 0,calc(100% - 14px) 0,100% 14px,100% calc(100% - 14px),calc(100% - 14px) 100%,14px 100%,0 calc(100% - 14px),0 14px)}
307
  #buildMenu .grid,#prodMenu .grid{display:flex;gap:7px;flex-wrap:wrap;justify-content:center}
308
+ .menuRoleBrief{position:relative;display:grid;grid-template-columns:112px minmax(0,1fr);min-height:116px;
309
+ margin:7px 0 9px;overflow:hidden;border-radius:11px;border:1px solid rgba(88,189,238,.27);
310
+ background:linear-gradient(115deg,rgba(15,39,60,.96),rgba(8,18,30,.98));
311
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.07),0 4px 13px rgba(0,0,0,.3)}
312
+ .menuRoleBrief::after{content:'';position:absolute;inset:0;pointer-events:none;background:
313
+ linear-gradient(105deg,transparent 48%,rgba(65,200,255,.04) 48.5%,transparent 49%),
314
+ radial-gradient(circle at 88% 10%,rgba(65,200,255,.13),transparent 45%)}
315
+ .menuRoleVisual{min-width:0;min-height:116px;border-right:1px solid rgba(88,189,238,.2);z-index:1}
316
+ .menuRoleVisual .mfIntelPreview{height:100%;min-height:116px;border:0;border-radius:0;background:transparent}
317
+ .menuRoleVisual .mfIntelPreviewTag{font-size:6px;top:5px;left:5px}
318
+ .menuRoleCopy{position:relative;z-index:2;min-width:0;padding:8px 9px 7px;display:flex;flex-direction:column;align-items:flex-start}
319
+ .menuRoleEyebrow{width:100%;display:flex;align-items:center;gap:5px;color:#7fdfff;
320
+ font:800 7px/1 var(--fT);letter-spacing:.1em;text-transform:uppercase}
321
+ .menuRoleEyebrow>b{margin-left:auto;color:#7c9bb0;font-size:6px;white-space:nowrap}
322
+ .menuRoleCopy>strong{margin-top:6px;color:#effaff;font:800 13px/1 var(--fT);letter-spacing:.08em;text-transform:uppercase}
323
+ .menuRoleCopy>p{margin:5px 0 0;color:#b9cedc;font-size:8.5px;line-height:1.3}
324
+ .menuRoleDoctrine{margin-top:5px;color:#ffd66f;font:800 7px/1.2 var(--fT);letter-spacing:.055em}
325
+ .menuRoleDoctrine>span{margin-right:5px;color:#6f9bb6}
326
+ .menuRoleCopy>small{margin-top:4px;color:#f0a99b;font-size:7px;line-height:1.2}
327
+ .menuRoleInspect{margin-top:auto;min-height:31px;padding:5px 9px;border-radius:6px;color:#9eeaff;
328
+ border:1px solid rgba(95,198,244,.32);background:rgba(24,66,91,.62);font:800 7px/1 var(--fT);letter-spacing:.08em}
329
+ .menuRoleInspect:active{background:rgba(45,114,151,.84);transform:scale(.97)}
330
  .bcard{width:84px;border-radius:10px;padding:6px 3px 5px;display:flex;flex-direction:column;align-items:center;gap:2px;
331
  font-size:10.5px;pointer-events:auto;position:relative;overflow:hidden;
332
  border:1px solid transparent;background:linear-gradient(180deg,rgba(30,46,64,.95),rgba(13,21,33,.97)) padding-box,var(--steelB) border-box;
 
360
  border:1px solid transparent;background:var(--panelG2) padding-box,var(--steelB) border-box;font-family:var(--fU);text-transform:uppercase}
361
  #upBtn{color:var(--gold);background:linear-gradient(180deg,rgba(64,48,14,.95),rgba(30,22,6,.97)) padding-box,var(--goldB) border-box}
362
  #repeatBtn.on{color:var(--green);box-shadow:0 0 9px rgba(93,255,154,.35)}
363
+ @media(max-width:365px){
364
+ .menuRoleBrief{grid-template-columns:96px minmax(0,1fr)}
365
+ .menuRoleCopy>p{font-size:8px}.menuRoleCopy>small{display:none}
366
+ }
367
 
368
  /* ---------- PLACEMENT CONFIRM ---------- */
369
  #placeUI{position:absolute;left:50%;transform:translateX(-50%);bottom:calc(var(--sab) + 68px);z-index:12;display:none;gap:12px}
 
482
  .goBreak span{padding:3px 6px;border-radius:6px;font-family:var(--fU);font-size:9px;letter-spacing:.04em;
483
  color:#9fc6e0;background:rgba(22,38,56,.8);border:1px solid rgba(110,165,205,.22)}
484
  .goBreak b{color:#ffe9ad}.goData{margin-top:7px;color:#9dffcf;font-size:12px;letter-spacing:.08em}
485
+ .goBreak.research{padding-top:7px;border-top:1px solid rgba(95,181,215,.18)}
486
+ .goBreak.research strong{flex:0 0 100%;text-align:center;color:#79d9ff;font:800 7px var(--fT);letter-spacing:.15em}
487
+ .goAccountSalvage{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:10px 1px 6px;
488
+ color:#9fffc4;font:800 8px var(--fT);letter-spacing:.13em}
489
+ .goAccountSalvage small{color:#789aae;font:600 7px var(--fU);letter-spacing:.02em;text-transform:none}
490
+ .goResourceGrid.account .goRes{border-color:rgba(95,225,156,.2);background:rgba(12,38,32,.64)}
491
  .goData small{display:block;color:#7fb29c;font:600 9px var(--fU);letter-spacing:.03em}.goData.muted{color:#7f9aab;font-size:10px}
492
 
493
  /* ---------- META HEADER ---------- */
 
701
  background:var(--panelG2) padding-box,var(--steelB) border-box}
702
  .setupAdv.on{color:#fff;background:linear-gradient(180deg,rgba(46,110,152,.96),rgba(20,52,80,.97)) padding-box,
703
  linear-gradient(160deg,#c6f2ff,#41c8ff 40%,#10465f 70%,#8fe0ff) border-box}
704
+ .setupTabs{flex:0 0 auto;width:100%;display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:5px;
705
  padding:0 calc(var(--sar) + 14px) 10px calc(var(--sal) + 14px);
706
  background:rgba(6,12,22,.98);z-index:4}
707
+ .setupTabBtn{min-width:0;min-height:48px;padding:9px 2px;border-radius:9px;font-family:var(--fT);
708
+ font-size:8px;letter-spacing:.045em;font-weight:800;color:#789bb5;border:1px solid rgba(120,170,205,.24);
709
  background:linear-gradient(180deg,rgba(23,36,53,.96),rgba(9,17,28,.97))}
710
  .setupTabBtn.on{color:#f0fbff;border-color:#41c8ff;background:linear-gradient(180deg,rgba(43,113,154,.9),rgba(15,44,68,.96));
711
  box-shadow:0 0 12px rgba(65,200,255,.25)}
 
724
  display:flex;align-items:center;justify-content:center;text-align:center;line-height:1.15}
725
  .optGrid{display:grid;grid-template-columns:1fr 1fr;gap:9px}
726
  .optGrid .dbtn{min-width:0}
727
+ #battleScaleRow{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px}
728
+ #battleScaleRow .bsbtn{min-width:0;min-height:68px;padding:8px 4px;flex-direction:column;gap:2px}
729
+ #battleScaleRow .bsbtn b{font-family:var(--fT);font-size:10px;letter-spacing:.07em;color:#e8f6ff}
730
+ #battleScaleRow .bsbtn span{font-family:var(--fT);font-size:9px;letter-spacing:.04em;color:#79b9df}
731
+ #battleScaleRow .bsbtn small{font-size:8px;font-weight:800;letter-spacing:.04em;color:#718ca0}
732
+ #battleScaleRow .bsbtn.on b{color:#fff}
733
+ #battleScaleRow .bsbtn.on span{color:#9deaff}
734
+ #battleScaleRow .bsbtn.on small{color:#c8f3ff}
735
  .secHint{margin-top:11px;font-size:11.5px;line-height:1.4;color:#8fb3cc;font-weight:600;text-align:left}
736
  .secHint.danger{color:#c98d80}
737
  .advWrap{display:contents!important}
 
739
  #setupScr:not(.setupAll)[data-tab="map"] [data-setup-tab]:not([data-setup-tab="map"]),
740
  #setupScr:not(.setupAll)[data-tab="forces"] [data-setup-tab]:not([data-setup-tab="forces"]),
741
  #setupScr:not(.setupAll)[data-tab="rules"] [data-setup-tab]:not([data-setup-tab="rules"]),
742
+ #setupScr:not(.setupAll)[data-tab="economy"] [data-setup-tab]:not([data-setup-tab="economy"]),
743
+ #setupScr:not(.setupAll)[data-tab="modifiers"] [data-setup-tab]:not([data-setup-tab="modifiers"]){display:none}
744
  .setupFoot{flex:0 0 auto;width:100%;display:flex;gap:10px;align-items:center;justify-content:center;
745
  /* Some Android WebViews report a zero bottom inset while still drawing a
746
  3-button/navigation shelf. The 54px floor is the physical escape hatch:
 
1332
  box-shadow:0 0 14px rgba(255,106,85,.35)}
1333
  .thBtn.on b{color:#fff} .thBtn.on span{color:#ffb0a2}
1334
  .thBtn.lock{opacity:.34}
1335
+ #opModRow{display:flex;flex-direction:column;gap:8px}
1336
+ .opMod{position:relative;display:grid;grid-template-columns:96px minmax(0,1fr) auto;align-items:center;gap:10px;min-height:96px;padding:7px 10px 7px 7px;border-radius:12px;text-align:left;overflow:hidden;
1337
+ cursor:pointer;touch-action:manipulation;
1338
  border:1px solid transparent;
1339
  background:linear-gradient(180deg,rgba(20,30,46,.92),rgba(10,17,28,.94)) padding-box,
1340
  linear-gradient(160deg,rgba(120,170,205,.24),rgba(30,60,88,.14)) border-box}
1341
+ .opMod::after{content:"";position:absolute;inset:0;pointer-events:none;background:linear-gradient(90deg,transparent 0 58%,rgba(112,184,224,.035) 58.5% 59%,transparent 59.5%)}
1342
  .opMod.on{background:linear-gradient(180deg,rgba(58,20,16,.94),rgba(28,10,8,.96)) padding-box,
1343
  linear-gradient(160deg,rgba(255,150,130,.5),rgba(90,30,22,.3)) border-box}
1344
+ .opModArt{position:relative;align-self:stretch;min-height:80px;overflow:hidden;border-radius:9px;border:1px solid rgba(126,181,215,.25);
1345
+ background-image:url('../../assets/modifiers/modifier-art-atlas-v1.png');background-size:500% 200%;background-position:var(--opx) var(--opy);background-repeat:no-repeat;
1346
+ box-shadow:inset 0 0 18px rgba(0,0,0,.28),0 4px 10px rgba(0,0,0,.28)}
1347
+ .opModArt::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent 45%,rgba(5,12,21,.18)),linear-gradient(180deg,rgba(82,180,228,.03),rgba(1,5,10,.25));pointer-events:none}
1348
+ .opModArt i{position:absolute;left:6px;bottom:6px;width:18px;height:3px;border-radius:2px;background:#69caef;box-shadow:0 0 8px #55c4ee;opacity:.8}
1349
+ .opMod.on .opModArt{border-color:rgba(255,139,110,.64);box-shadow:inset 0 0 20px rgba(92,17,8,.1),0 0 15px rgba(255,89,58,.2)}
1350
+ .opMod.on .opModArt i{background:#ff8a70;box-shadow:0 0 8px #ff6248}
1351
+ .opMod.lock{cursor:not-allowed;opacity:.68}
1352
+ .opMod.lock .opModArt{filter:saturate(.38) brightness(.62)}
1353
+ .opMod.lock .opModArt::before{content:"🔒";position:absolute;z-index:2;left:50%;top:50%;transform:translate(-50%,-50%);width:34px;height:34px;display:flex;align-items:center;justify-content:center;border-radius:50%;background:rgba(3,8,14,.82);border:1px solid rgba(190,211,224,.25);font-size:15px}
1354
+ .opMod.lock .opRw{color:#526b7d}
1355
  .opTx{flex:1;min-width:0}
1356
  .opTx b{display:block;font-family:var(--fT);font-size:11.5px;color:#eaf7ff;letter-spacing:.04em}
1357
  .opTx span{display:block;font-size:10px;color:#8fb3cc;font-weight:600;margin-top:2px}
1358
+ .opUnlock{display:block;margin-top:5px;color:#f0ad86;font:800 8px var(--fT);letter-spacing:.055em;line-height:1.25}
1359
+ .opUnlock.earned{color:#70d9a4}
1360
+ .opRw{position:relative;z-index:1;align-self:start;margin-top:3px;padding:5px 6px;border-radius:7px;border:1px solid rgba(102,151,182,.24);background:rgba(4,11,19,.7);font-family:var(--fT);font-size:10px;font-weight:900;color:#7fa8c6}
1361
  .opMod.on .opRw{color:#ffb0a2}
1362
+ @media (max-width:360px){
1363
+ .opMod{grid-template-columns:82px minmax(0,1fr) auto;gap:7px;min-height:90px;padding-right:7px}.opModArt{min-height:76px}.opTx b{font-size:10.5px}.opTx span{font-size:9px}.opRw{font-size:9px;padding:4px}
1364
+ }
1365
+ @media (orientation:landscape) and (min-width:760px){
1366
+ #opModRow{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.opMod{grid-template-columns:112px minmax(0,1fr) auto;min-height:102px}.opModArt{min-height:86px}
1367
+ }
1368
+ #setupScr[data-tab="modifiers"] .setupCard>.secHint:first-of-type{margin:-3px 0 12px}
1369
+ #setupScr .wbtn.lock{opacity:.38;filter:saturate(.35);cursor:not-allowed}
1370
  #opPayout{margin-top:11px;text-align:center;font-family:var(--fT);font-size:11px;letter-spacing:.14em;
1371
  color:#8fb3cc;font-weight:800}
1372
  #opPayout b{color:#ffe9ad;font-size:15px}
 
1565
  .facUnit b{display:block;font-family:var(--fT);font-size:10px;letter-spacing:.09em;color:#eaf4ff}
1566
  .facUnit span{display:block;margin-top:1px;font-size:8.5px;font-weight:800;letter-spacing:.05em;color:var(--fc)}
1567
  .facUnit i{display:block;margin-top:3px;font-size:9px;font-style:normal;line-height:1.4;color:#93b0c6;font-weight:600}
1568
+ .facDoctrine{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:9px}
1569
+ .facDoctrine>div{min-width:0;padding:7px 8px;border-radius:8px;font-size:8.5px;line-height:1.4;font-weight:650;
1570
+ background:rgba(4,9,16,.7);border:1px solid rgba(130,165,190,.19);color:#adc5d6}
1571
+ .facDoctrine b{display:block;margin-bottom:3px;font:800 8px/1 var(--fT);letter-spacing:.11em}
1572
+ .facStrong b{color:#76efaa}.facWeak b{color:#ff927f}
1573
+ .facEndgoal{margin-top:7px;padding:8px 9px;border-radius:8px;background:rgba(4,9,16,.72);
1574
+ border:1px solid color-mix(in srgb,var(--fc) 25%,transparent)}
1575
+ .facEndgoal>span{display:block;color:var(--fc);font:800 8px/1 var(--fT);letter-spacing:.13em}
1576
+ .facEndgoal>b{display:block;margin-top:5px;color:#f1f8ff;font:800 9px/1.25 var(--fT);letter-spacing:.05em}
1577
+ .facEndgoal>p{margin:5px 0 0;color:#9eb8ca;font-size:9px;line-height:1.45;font-weight:600}
1578
  .facBonus{margin-top:9px;padding:8px 9px;border-radius:8px;font-size:10px;line-height:1.45;
1579
  color:#c3dcee;font-weight:600;background:color-mix(in srgb,var(--fc) 11%,rgba(4,9,16,.7));
1580
  border:1px solid color-mix(in srgb,var(--fc) 28%,transparent)}
 
1637
  .setRow[data-set="godMode"] .sTx b{color:#ffe08a}
1638
 
1639
  @media (max-width:600px) and (orientation:portrait){
1640
+ /* Explicit portrait rows keep variable-length objectives and modifier sets
1641
+ from borrowing the same pixels. These offsets all share the same safe-area
1642
+ origin so a notched WebView cannot shift only one part of the stack. */
1643
+ #topbar{left:var(--sal);right:var(--sar);min-height:54px;display:grid;
1644
+ grid-template-columns:repeat(3,minmax(0,1fr)) 44px 44px;gap:4px;padding:5px 6px;font-size:12px}
1645
+ #topbar .res{min-width:0;min-height:36px;gap:4px;padding:3px 5px 3px 4px;border-radius:8px;overflow:hidden}
1646
+ #topbar .res b{min-width:0;overflow:hidden;text-overflow:ellipsis;font-size:13px;white-space:nowrap}
1647
+ #topbar .res .rate{flex:0 0 auto;font-size:9px}.res .ic{width:19px;height:19px}
1648
+ #menuBtn,#spdBtn{display:grid;place-items:center;width:44px;min-width:44px;height:44px;min-height:44px;padding:0}
1649
+ #menuBtn{font-size:19px}#fps{display:none!important}
1650
+ body.godMode #godBadge{position:fixed;top:calc(var(--sat) + 128px);right:calc(var(--sar) + 8px);z-index:12;max-width:38vw}
1651
 
1652
  #cmdbar{left:0;right:0;bottom:0;width:auto;gap:5px;align-items:flex-end;
1653
  padding:7px calc(var(--sar) + 8px) max(calc(var(--sab) + 8px),46px) calc(var(--sal) + 8px);
 
1675
  #placeUI{bottom:max(calc(var(--sab) + 160px),200px);gap:7px}
1676
  #placeUI .cbtn{width:52px;height:52px;font-size:22px}
1677
  #placeRotL,#placeRotR{width:46px!important;height:46px!important}
1678
+ #goalBar{top:calc(var(--sat) + 58px);min-height:30px;max-height:30px;
1679
+ max-width:calc(100vw - var(--sal) - var(--sar) - 16px);overflow:hidden;text-overflow:ellipsis}
1680
+ /* Modifier icons and live threat status share a reserved split row. Fixed
1681
+ columns plus local overflow mean neither panel can cover its neighbour. */
1682
+ #wcRow{top:calc(var(--sat) + 92px);left:calc(var(--sal) + 8px);right:auto;
1683
+ width:calc(58vw - var(--sal) - 12px);min-height:32px;max-height:32px;padding:2px 5px;
1684
+ overflow-x:auto;overflow-y:hidden;white-space:nowrap;scrollbar-width:none}
1685
+ #wcRow::-webkit-scrollbar{display:none}
1686
+ #infMeter{top:calc(var(--sat) + 92px);left:auto;right:calc(var(--sar) + 8px);
1687
+ width:calc(42vw - var(--sar) - 12px);min-height:32px;max-height:32px;justify-content:center;
1688
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:3px 6px}
1689
+ #heroBar{top:calc(var(--sat) + 128px);left:calc(var(--sal) + 8px);right:auto;
1690
+ width:clamp(168px,55vw,220px);max-width:55vw;min-height:34px}
1691
+ /* Transient panels begin below the complete live-HUD stack. Their base rules
1692
+ predate the split-row layout and used to open on top of modifiers, threat,
1693
+ and the Commander bars even though the resting HUD looked correct. */
1694
+ #toast,#radioAck{top:calc(var(--sat) + 176px)}
1695
+ #unitCard{top:calc(var(--sat) + 176px)}
1696
+ #abBarrage .cdring,#abClass .cdring{font-size:10px;letter-spacing:0}
1697
+ #abBarrage>span:not(.em):not(.lvl),#abClass>span:not(.em):not(.lvl){font-size:7px;letter-spacing:0}
1698
  #atkAlert,#waveAlert{max-width:calc(100vw - var(--sal) - var(--sar) - 20px);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
1699
  }
1700
+ @media (max-width:380px) and (orientation:portrait){
1701
+ /* On 320–360px phones the live rates are the only expendable text. Values,
1702
+ resource identities, and both 44px command targets always remain visible. */
1703
+ #topbar .res .rate{display:none}
1704
+ #topbar{grid-template-columns:repeat(3,minmax(0,1fr)) 44px 44px}
1705
+ }
1706
+
1707
+ /* Operations / Campaign takeover. Every selector is rooted to the injected
1708
+ pane so the normal Threat, Weekly and Mastery layouts keep their own rhythm. */
1709
+ #opsPane-campaign{padding:0;border:0;background:none;box-shadow:none;overflow:visible}
1710
+ .storyCampaignHero{display:flex;align-items:flex-start;gap:14px;margin:0 0 12px;padding:16px;
1711
+ border:1px solid rgba(101,202,255,.34);border-radius:14px;
1712
+ background:linear-gradient(90deg,rgba(5,14,25,.97) 0%,rgba(5,14,25,.87) 56%,rgba(5,14,25,.32)),
1713
+ var(--campaign-hero) center 42%/cover no-repeat,
1714
+ linear-gradient(145deg,rgba(13,32,52,.98),rgba(5,14,25,.97));box-shadow:0 12px 30px rgba(0,0,0,.28)}
1715
+ .storyCampaignHero>div:first-child{min-width:0;flex:1}.storyCampaignHero span,.storyCampaignSection>span{
1716
+ color:#7edcff;font-size:9px;letter-spacing:1.7px;font-weight:900}.storyCampaignHero h3{margin:5px 0 6px;
1717
+ color:#f4fbff;font-size:18px;line-height:1.08;letter-spacing:.5px}.storyCampaignHero p{margin:0;color:#adc0d2;
1718
+ font-size:11px;line-height:1.45}.storyCampaignCount{flex:0 0 64px;text-align:center;padding:9px 5px;border-radius:10px;
1719
+ border:1px solid rgba(126,220,255,.3);background:rgba(3,11,21,.62)}.storyCampaignCount b{display:block;
1720
+ color:#ffe089;font-size:25px;line-height:1}.storyCampaignCount span{display:block;margin-top:5px;color:#8ba7bd;
1721
+ font-size:7px;letter-spacing:.8px}
1722
+ .storyCampaignSection{display:flex;align-items:flex-end;justify-content:space-between;gap:10px;margin:14px 3px 8px}
1723
+ .storyCampaignSection small{color:#7890a5;font-size:9px;text-align:right}.storyCampaignLadder{display:grid;gap:9px}
1724
+ .storyCampaignMission{display:grid;grid-template-columns:132px minmax(0,1fr);gap:11px;padding:11px;border-radius:13px;
1725
+ border:1px solid color-mix(in srgb,var(--cf) 34%,#26384a);background:linear-gradient(125deg,var(--cf2),rgba(7,16,28,.98) 58%);
1726
+ box-shadow:inset 3px 0 0 color-mix(in srgb,var(--cf) 72%,transparent),0 7px 18px rgba(0,0,0,.22)}
1727
+ .storyCampaignMission.current{border-color:color-mix(in srgb,var(--cf) 78%,white);box-shadow:inset 3px 0 0 var(--cf),
1728
+ 0 0 18px color-mix(in srgb,var(--cf) 18%,transparent)}.storyCampaignMission.locked{filter:saturate(.55);opacity:.68}
1729
+ .storyCampaignKeyArt{position:relative;align-self:stretch;min-height:146px;border-radius:10px;overflow:hidden;
1730
+ border:1px solid color-mix(in srgb,var(--cf) 55%,#294158);background:#07101b}
1731
+ .storyCampaignKeyArt>img{width:100%;height:100%;display:block;object-fit:cover}.storyCampaignKeyArt::after{content:"";position:absolute;inset:0;background:linear-gradient(180deg,transparent 42%,rgba(2,7,13,.9))}
1732
+ .storyCampaignKeyArt>span{position:absolute;z-index:2;right:5px;top:5px;
1733
+ display:grid;place-items:center;width:30px;height:30px;padding:2px;border-radius:8px;background:rgba(3,9,17,.88);
1734
+ border:1px solid color-mix(in srgb,var(--cf) 60%,transparent)}.storyCampaignKeyArt>span img{width:100%;height:100%;object-fit:contain}
1735
+ .storyCampaignKeyArt>small{position:absolute;z-index:2;left:8px;right:8px;bottom:7px;color:#eff8ff;font:900 7px/1.25 var(--fT);letter-spacing:.08em;text-shadow:0 2px 6px #000}
1736
+ .storyCampaignMissionBody{min-width:0}.storyCampaignMission header{display:flex;align-items:flex-start;justify-content:space-between;
1737
+ gap:8px;margin-bottom:7px}.storyCampaignMission header small{display:block;color:var(--cf);font-size:8px;letter-spacing:1.2px;
1738
+ font-style:normal}.storyCampaignMission header b{display:block;margin-top:2px;color:#f5f9fc;font-size:13px;line-height:1.14}
1739
+ .storyCampaignState{flex:0 0 auto;padding:4px 6px;border:1px solid color-mix(in srgb,var(--cf) 54%,transparent);
1740
+ border-radius:6px;background:color-mix(in srgb,var(--cf2) 75%,black);color:var(--cf);font-size:7px;letter-spacing:.9px;font-style:normal}
1741
+ .storyCampaignIntel{display:grid;gap:5px}.storyCampaignIntel>span{color:#aabccc;font-size:9.5px;line-height:1.33}
1742
+ .storyCampaignIntel em{display:block;margin-bottom:1px;color:#6f8aa1;font-size:7px;font-style:normal;font-weight:900;letter-spacing:1px}
1743
+ .storyCampaignIntel .storyCampaignReward{color:#f2d47f}.storyCampaignAction{width:100%;min-height:48px;margin-top:9px;
1744
+ border:1px solid color-mix(in srgb,var(--cf) 75%,white);border-radius:9px;color:#06111b;font:900 10px var(--font);
1745
+ letter-spacing:1.1px;background:linear-gradient(180deg,color-mix(in srgb,var(--cf) 78%,white),var(--cf));
1746
+ box-shadow:0 5px 14px color-mix(in srgb,var(--cf) 18%,transparent)}.storyCampaignAction:active{transform:translateY(1px)}
1747
+ .storyCampaignLock{display:flex;align-items:center;min-height:44px;margin-top:8px;padding:0 9px;border-radius:8px;
1748
+ border:1px dashed rgba(135,154,172,.28);color:#8295a7;font-size:8.5px;line-height:1.3;letter-spacing:.4px}
1749
+ .storyCampaignArcLabel{margin-top:19px}.storyCampaignArcs{display:flex;gap:10px;padding:0 2px 8px;overflow-x:auto;
1750
+ scroll-snap-type:x mandatory;scrollbar-width:none}.storyCampaignArcs::-webkit-scrollbar{display:none}.storyCampaignArc{flex:0 0 270px;
1751
+ min-height:194px;padding:11px;border:1px solid color-mix(in srgb,var(--cf) 42%,#203245);border-radius:13px;
1752
+ background:radial-gradient(circle at 15% 0,color-mix(in srgb,var(--cf) 18%,transparent),transparent 46%),
1753
+ linear-gradient(145deg,var(--cf2),#07101b 72%);scroll-snap-align:start}.storyCampaignArcHead{display:flex;gap:10px;align-items:center}
1754
+ .storyCampaignArcHead>img{width:58px;height:70px;object-fit:cover;border-radius:8px;border:1px solid var(--cf)}
1755
+ .storyCampaignArcHead>div{min-width:0}.storyCampaignArcHead small{display:block;color:var(--cf);font-size:7px;letter-spacing:.7px}
1756
+ .storyCampaignArcHead b{display:block;margin:3px 0;color:#f1f7fb;font-size:12px;line-height:1.15}.storyCampaignArcHead span{color:#91a8b8;font-size:8px}
1757
+ .storyCampaignArcPath{margin:10px 0 6px;padding:7px;border-radius:7px;background:rgba(0,0,0,.28);color:var(--cf);
1758
+ font-size:8px;font-weight:900;line-height:1.35;letter-spacing:.55px;text-align:center}.storyCampaignArc p{margin:0;color:#9fb2c1;font-size:9px;line-height:1.4}
1759
+ @media(max-width:430px){.storyCampaignHero{padding:13px;min-height:142px;background-position:center 42%}.storyCampaignHero h3{font-size:16px}.storyCampaignHero p{font-size:10px}
1760
+ .storyCampaignMission{grid-template-columns:1fr;gap:9px;padding:9px}.storyCampaignKeyArt{height:112px;min-height:112px}
1761
+ .storyCampaignMission header{display:block}.storyCampaignState{display:inline-block;margin-top:5px}.storyCampaignArc{flex-basis:252px}}
src/tutorial.js CHANGED
@@ -41,15 +41,14 @@
41
  --------------------------------------------------------------------------- */
42
  var KEEL_NAME='KEEL';
43
  var KEEL_TAG='SHIP LIAISON · LANCE OF MORNING';
44
- var GUIDE_VERSION=2;
45
 
46
  var GREETING='KEEL online — ship liaison, Lance of Morning. Follow the gold signal: it marks the exact control for your next action. '+
47
  'Tap SKIP any time; replay this guide from Settings.';
48
 
49
  var SKIP_LINE='Understood. Find me again under Settings → Tutorial if you change your mind.';
50
 
51
- var GRADUATION='That’s the job: a Commander, mass, power, production, territory, defence, and orders. '+
52
- 'Everything after this is those same systems, faster, with worse odds. '+
53
  "I'll go quiet now — unless something's on fire.";
54
 
55
  /* Each step tests REAL state. `say` is shown the moment a step becomes
@@ -58,55 +57,100 @@ var GRADUATION='That’s the job: a Commander, mass, power, production, territor
58
  out of order gets fast-forwarded in silence instead of congratulated for
59
  a step they finished five minutes ago. */
60
  var STEPS=[
61
- { id:'deploy',
 
 
 
 
 
62
  say:'Tap open ground to fly the carrier there. When the landing signal turns green, tap DEPLOY BASE HERE.',
63
  done:"Hull's down, engines cold. That was the easy part.",
64
  test:function(){ return typeof matchLive!=='undefined' && !!matchLive; } },
65
 
66
- { id:'commander',
67
- say:'Your COMMANDER is your hero and a mobile builder. Tap POWERS, then CDR to select and center it.',
68
  done:'Commander identified. Keep it alive: losing every Commander loses the match.',
69
  test:function(){ return typeof heroIdx==='number'&&heroIdx>=0&&!!usel[heroIdx]; } },
70
 
71
- { id:'mex',
 
 
 
 
 
72
  say:'Mass buys units and structures. Tap BUILD → ECONOMY → Extractor, drag its hologram onto a ◆ deposit, then tap ✓.',
73
  done:"Extractor placed. That's your first income line.",
74
  test:function(){ return hasPlayerBld('mex'); } },
75
 
76
- { id:'power',
77
  say:'Production also needs energy. Tap BUILD → ECONOMY → Reactor, place it on open ground inside the blue grid, then tap ✓.',
78
  done:'Reactor placed. Mass and energy must both stay positive while building.',
79
  test:function(){ return hasPlayerBld('pgen')||hasPlayerBld('geo'); } },
80
 
81
- { id:'fac',
82
- say:'Now production. Tap BUILD → PRODUCTION Factory and place it inside your blue construction grid.',
83
- done:"Factory placed. It also expands your permanent construction territory.",
84
  test:function(){ return hasPlayerBld('fac'); } },
85
 
86
- { id:'territory', manual:true,
87
- say:'BLUE GRID = legal building territory. Move your Commander or Constructor to build farther out; Factories and Uplinks expand it. Locked cards name the level or structure they require. There is no general building cap. Tap GOT IT.',
88
- done:'Territory rules logged.',
89
  test:function(){ return MATCH.territoryAck; } },
90
 
91
- { id:'queue',
92
  say:'Tap the Factory on the map, then tap the Striker unit card. The queue line shows what is being built.',
93
- done:"Striker queued. It'll roll out on its own.",
94
  test:function(){ return MATCH.sawQueue; } },
95
 
96
- { id:'turret',
 
 
 
 
 
97
  say:'Protect the economy before the first push. Tap BUILD → DEFENCE → Sentinel and place it on the likely approach.',
98
  done:'Basic perimeter online. Towers buy your army time; they do not replace it.',
99
  test:function(){ return hasPlayerBld('turret'); } },
100
 
101
- { id:'select',
102
- say:'When the Striker finishes, tap ARMY to select your field units. You can also tap one unit directly or drag with SELECT.',
103
- done:'Unit selected. The command dock now applies to that selection.',
104
- test:function(){ return MATCH.sawCombatSel; } },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
 
106
- { id:'move',
107
- say:'With that unit selected, tap open ground to issue an attack-move. It will travel there and fight enemies it meets.',
108
- done:"Order confirmed. Select, point, adapt that's the core RTS loop.",
109
- test:function(){ return MATCH.sawMove; } }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  ];
111
 
112
  var BASE_ATTACK_LINES=[
@@ -136,8 +180,10 @@ var trainingPrev=null, trainingLaunched=false;
136
  /* Facts about THIS match, tracked whether or not the tutorial is running, so
137
  a mid-match "replay" (from Settings) can credit things the player already
138
  demonstrably knows how to do instead of waiting for them to happen again. */
139
- var MATCH={ sawSel:false, sawCombatSel:false, sawMove:false, sawQueue:false, territoryAck:false,
140
- usedAbility:false, lastAbCool:[0,0,0,0] };
 
 
141
  /* Reactive one-per-match latches. */
142
  var REACT={ baseAttack:false, lowPower:false, hazard:false, unitLost:false, wave:false,
143
  lastAlarmT:0, lastStallE:false, lastHazWarn:false, lastWarned:false };
@@ -148,7 +194,7 @@ function tutMeta(){
148
  META.tutorial=META.tutorial||{done:false,skipped:false,version:0};
149
  /* `progress` is additive save metadata: old profiles load exactly as before,
150
  while the briefing can distinguish new, interrupted and completed runs.
151
- It is a high-water mark only; the ten live-state gates below remain the
152
  authority whenever a mission is running. */
153
  if(typeof META.tutorial.progress!=='number') META.tutorial.progress=META.tutorial.done?STEPS.length:0;
154
  META.tutorial.progress=Math.max(0,Math.min(STEPS.length,META.tutorial.progress|0));
@@ -179,6 +225,10 @@ function hasCombatUnit(){
179
  for(var i=0;i<unitHigh;i++) if(isCombatUnit(i)) return true;
180
  return false;
181
  }
 
 
 
 
182
  function hasSelectedCombatUnit(){
183
  if(typeof unitHigh!=='number') return false;
184
  for(var i=0;i<unitHigh;i++) if(isCombatUnit(i)&&usel[i]) return true;
@@ -221,6 +271,7 @@ function buildDOM(){
221
  '<div id="keelBody">'+
222
  '<div id="keelHd"><span id="keelName" title="'+KEEL_TAG+'">'+KEEL_NAME+'</span><span id="keelStepTag"></span></div>'+
223
  '<div id="keelTxt"></div>'+
 
224
  '<button id="keelNext" type="button">GOT IT</button>'+
225
  '</div>'+
226
  '<button id="keelSkip" type="button" aria-label="Skip tutorial">SKIP</button>'+
@@ -234,8 +285,9 @@ function buildDOM(){
234
  var nextBtn=document.getElementById('keelNext');
235
  nextBtn.addEventListener('pointerdown',function(ev){
236
  ev.preventDefault(); ev.stopPropagation();
237
- if(!TUT.active||!STEPS[TUT.stepIdx]||STEPS[TUT.stepIdx].id!=='territory') return;
238
- MATCH.territoryAck=true;
 
239
  if(typeof sfx==='function'){ try{ sfx('ui'); }catch(e){} }
240
  });
241
  /* Tapping the body re-shows the full-strength card if it has settled into
@@ -250,6 +302,12 @@ function renderBubble(text,tag){
250
  if(!domReady) return;
251
  if(text!==lastText){ var t=document.getElementById('keelTxt'); if(t) t.textContent=text; lastText=text; }
252
  var tg=document.getElementById('keelStepTag'); if(tg) tg.textContent=tag||'';
 
 
 
 
 
 
253
  }
254
  function showWrap(fresh){
255
  if(!domReady) return;
@@ -337,8 +395,15 @@ function syncFocus(){
337
  clearFocus();
338
  if(!TUT.active||TUT.stepIdx>=STEPS.length) return;
339
  var S=STEPS[TUT.stepIdx],next=document.getElementById('keelNext');
340
- if(next) next.style.display=S.manual?'inline-flex':'none';
341
- if(S.id==='deploy'){
 
 
 
 
 
 
 
342
  var dep=document.getElementById('deployBtn');
343
  if(shown(dep)) focusEl(dep); else cueAt((typeof VW==='number'?VW:innerWidth)*.5,(typeof VH==='number'?VH:innerHeight)*.52,'TAP LANDING SITE');
344
  } else if(S.id==='commander'){
@@ -346,6 +411,9 @@ function syncFocus(){
346
  var cdr=document.getElementById('abHero');
347
  if(shown(cdr)) focusEl(cdr);
348
  else focusEl(document.querySelector('.hudDeckBtn[data-deck="powers"]'));
 
 
 
349
  } else if(S.id==='mex') buildFocus('ECONOMY','Extractor','mex');
350
  else if(S.id==='power') buildFocus('ECONOMY','Reactor','pgen');
351
  else if(S.id==='fac') buildFocus('PRODUCTION','Factory','fac');
@@ -357,15 +425,35 @@ function syncFocus(){
357
  var pm=document.getElementById('prodMenu');
358
  if(shown(pm)) focusEl(byText('prodGrid','.bcard','Striker')||pm.querySelector('.bcard'));
359
  else { var F=playerBld('fac'); if(F) cueWorld(F.x,F.y,'TAP FACTORY'); }
360
- } else if(S.id==='turret') buildFocus('DEFENCE','Sentinel','turret');
361
- else if(S.id==='select'){
362
  if(hasCombatUnit()) focusEl(document.getElementById('armyBtn'));
363
  else { var F2=playerBld('fac'); if(F2) cueWorld(F2.x,F2.y,'UNIT BUILDING'); }
364
- } else if(S.id==='move'){
 
 
 
 
 
 
 
 
 
 
 
365
  var sx=0,sy=0,n=0;
366
  if(typeof unitHigh==='number') for(var j=0;j<unitHigh;j++) if(isCombatUnit(j)&&usel[j]){sx+=ux[j];sy+=uy[j];n++;}
367
- if(n) cueWorld(sx/n+90,sy/n+70,'TAP TO MOVE');
368
  else focusEl(document.getElementById('armyBtn'));
 
 
 
 
 
 
 
 
 
 
369
  }
370
  }
371
  /* Capped so a burst of state changes (several buildings finishing within the
@@ -426,6 +514,11 @@ function speakVoice(text){
426
  --------------------------------------------------------------------------- */
427
  function trackMatchSignals(){
428
  if(sawQueuedUnit()) MATCH.sawQueue=true;
 
 
 
 
 
429
  if(typeof unitHigh==='number' && (!MATCH.sawSel||!MATCH.sawCombatSel)){
430
  for(var i=0;i<unitHigh;i++){
431
  if(!ualive[i]||uteam[i]!==0) continue;
@@ -434,6 +527,11 @@ function trackMatchSignals(){
434
  if(MATCH.sawSel&&MATCH.sawCombatSel) break;
435
  }
436
  }
 
 
 
 
 
437
  if(typeof abCool!=='undefined'){
438
  for(var k=0;k<abCool.length;k++){
439
  if(abCool[k]>(MATCH.lastAbCool[k]||0)+0.01) MATCH.usedAbility=true;
@@ -452,8 +550,12 @@ function beginRun(){
452
  TUT.stepIdx=0;
453
  TUT.shownStepIdx=-1;
454
  TUT.doneFlags=STEPS.map(function(){ return false; });
455
- MATCH.sawSel=false; MATCH.sawCombatSel=false; MATCH.sawMove=false; MATCH.sawQueue=false; MATCH.territoryAck=false;
456
- MATCH.usedAbility=false;
 
 
 
 
457
  queue.length=0;
458
  speak(GREETING,5.5,'greeting');
459
  }
@@ -489,7 +591,7 @@ function evalSteps(){
489
  }
490
  var cur=STEPS[TUT.stepIdx];
491
  restingText=cur.say;
492
- restingTag='STEP '+(TUT.stepIdx+1)+' / '+STEPS.length;
493
  if(TUT.shownStepIdx!==TUT.stepIdx){
494
  TUT.shownStepIdx=TUT.stepIdx;
495
  speak(cur.say,5.5,'step');
@@ -565,7 +667,11 @@ function onNewMatchBegin(){
565
  REACT.baseAttack=false; REACT.lowPower=false; REACT.hazard=false; REACT.unitLost=false; REACT.wave=false;
566
  REACT.lastAlarmT=(typeof alarmT!=='undefined')?alarmT:0;
567
  REACT.lastStallE=false; REACT.lastHazWarn=false; REACT.lastWarned=false;
568
- MATCH.sawSel=false; MATCH.sawCombatSel=false; MATCH.sawMove=false; MATCH.sawQueue=false; MATCH.territoryAck=false; MATCH.usedAbility=false;
 
 
 
 
569
  MATCH.lastAbCool=(typeof abCool!=='undefined')?abCool.slice():[0,0,0,0];
570
  if(TUT.trainingMode){ TUT.forceNext=false; beginRun(); }
571
  }
@@ -619,6 +725,27 @@ function restoreTrainingConfig(){
619
  META.opmods=trainingPrev.opmods; META.threatSel=trainingPrev.threatSel;
620
  trainingPrev=null;
621
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
622
  function trainingSafety(){
623
  if(!TUT.trainingMode||typeof running==='undefined'||!running||typeof matchLive==='undefined'||!matchLive) return;
624
  /* No scripted ambush while the player is reading. The enemy still builds a
@@ -632,6 +759,10 @@ function trainingSafety(){
632
  if(typeof resE!=='undefined'&&typeof RES_ECAP!=='undefined') resE[0]=Math.min(RES_ECAP[0],Math.max(resE[0],2200));
633
  if(typeof heroIdx==='number'&&heroIdx>=0&&ualive[heroIdx]) uhp[heroIdx]=Math.max(uhp[heroIdx],uhpm[heroIdx]*.72);
634
  var H=playerBld('hq'); if(H) H.hp=Math.max(H.hp,H.hpm*.75);
 
 
 
 
635
  }
636
  function startTrainingMission(){
637
  if(typeof running!=='undefined'&&running){ toast('Leave the current battle before starting training'); return; }
@@ -652,7 +783,7 @@ function startTrainingMission(){
652
  if(typeof hideFrontScreens==='function') hideFrontScreens();
653
  var load=document.getElementById('loadScr'); if(load) load.style.display='flex';
654
  requestAnimationFrame(function(){requestAnimationFrame(function(){
655
- applyTheme(); newSkirmish(); trainingLaunched=true;
656
  if(load) load.style.display='none';
657
  toast('TRAINING OPERATION — threat paused while KEEL guides you');
658
  });});
@@ -731,7 +862,7 @@ function resumeTrainingMission(){
731
  function ensureTrainingEntry(){
732
  var start=document.getElementById('startBtn'); if(!start||document.getElementById('keelTrainingCallout')) return;
733
  var d=document.createElement('div'); d.id='keelTrainingCallout';
734
- d.innerHTML='<div class="ktMiniIdentity"><img src="./assets/factions/nova_icon_96.png" alt="Nova Federation">'
735
  +'<div><span class="ktState">FIRST OPERATION</span><b>FIELD ORIENTATION</b><small class="ktProgress">0 / '+STEPS.length+' OBJECTIVES</small></div></div>'
736
  +'<button type="button">VIEW TRAINING</button>';
737
  start.parentNode.insertBefore(d,start);
@@ -754,14 +885,15 @@ function appendTrainingOperation(){
754
  +'<span>'+(typeof facIcon==='function'?facIcon('nova',38,'ktoCrestImg'):'✦')+'</span></div>'
755
  +'<div class="ktoIdentity"><small>TRAINING OPERATION 01</small><b>FIELD ORIENTATION</b>'
756
  +'<i>'+commander+' · KEEL tactical guidance</i>'
757
- +'<p>A protected live-fire drop that teaches the complete first-base loop without an early enemy rush.</p></div></div>'
758
- +'<div class="ktoTeach" aria-label="Training topics"><span>⬡ ECONOMY</span><span>▣ PRODUCTION</span>'
759
- +'<span>⛨ DEFENCE</span><span> TERRITORY</span><span> COMMANDER</span><span>➜ ORDERS</span></div>'
 
760
  +'<div class="ktoProgress"><div><span>OBJECTIVE PROGRESS</span><b>'+S.progress+' / '+STEPS.length+'</b></div>'
761
  +'<div class="ktoProgressTrack"><i style="width:'+progressPct+'%"></i></div>'
762
  +'<small>'+(S.done?'Orientation certified · replay does not repeat the first-clear reward':
763
  S.active?'Current lesson: '+(STEPS[Math.min(TUT.stepIdx,STEPS.length-1)].id||'field systems').toUpperCase():
764
- S.interrupted?'Previous best retained · a new drop restarts at deployment':'Ten guided objectives · progress is saved to this profile')+'</small></div>'
765
  +'<div class="ktoFacts"><div><b>'+STEPS.length+'</b><span>OBJECTIVES</span></div><div><b>EASY</b><span>FIXED OPPONENT</span></div>'
766
  +'<div><b>SAFE</b><span>NO INFESTATION</span></div></div>'
767
  +'<button class="ktoAction" type="button">'+S.action+'</button>';
@@ -859,11 +991,27 @@ function initTutorial(){
859
  window.__keelMoveWrapped=true;
860
  var _keelOrderMove=orderMove;
861
  orderMove=function(wx,wy,patrol){
862
- var combat=hasSelectedCombatUnit(),ok=_keelOrderMove(wx,wy,patrol);
863
- if(ok&&combat&&!patrol) MATCH.sawMove=true;
 
 
 
 
 
 
 
864
  return ok;
865
  };
866
  }
 
 
 
 
 
 
 
 
 
867
  setInterval(tutTick,350);
868
  }
869
  window.initTutorial=initTutorial;
 
41
  --------------------------------------------------------------------------- */
42
  var KEEL_NAME='KEEL';
43
  var KEEL_TAG='SHIP LIAISON · LANCE OF MORNING';
44
+ var GUIDE_VERSION=3;
45
 
46
  var GREETING='KEEL online — ship liaison, Lance of Morning. Follow the gold signal: it marks the exact control for your next action. '+
47
  'Tap SKIP any time; replay this guide from Settings.';
48
 
49
  var SKIP_LINE='Understood. Find me again under Settings → Tutorial if you change your mind.';
50
 
51
+ var GRADUATION='Field orientation complete. Camera, economy, logistics, formations, scouting, technology and extraction are certified. '+
 
52
  "I'll go quiet now — unless something's on fire.";
53
 
54
  /* Each step tests REAL state. `say` is shown the moment a step becomes
 
57
  out of order gets fast-forwarded in silence instead of congratulated for
58
  a step they finished five minutes ago. */
59
  var STEPS=[
60
+ { id:'camera', icon:'◇',
61
+ say:'DRAG the battlefield to pan. PINCH to zoom and twist, or use VIEW → ROT / TILT. Move the camera once to confirm control.',
62
+ done:'Command view responding. The minimap can jump across the whole battlefield.',
63
+ test:function(){ return MATCH.sawCamera; } },
64
+
65
+ { id:'deploy', icon:'⚓',
66
  say:'Tap open ground to fly the carrier there. When the landing signal turns green, tap DEPLOY BASE HERE.',
67
  done:"Hull's down, engines cold. That was the easy part.",
68
  test:function(){ return typeof matchLive!=='undefined' && !!matchLive; } },
69
 
70
+ { id:'commander', icon:'★',
71
+ say:'Your COMMANDER is the marked hero and a mobile builder. Tap POWERS, then CDR to select and center it.',
72
  done:'Commander identified. Keep it alive: losing every Commander loses the match.',
73
  test:function(){ return typeof heroIdx==='number'&&heroIdx>=0&&!!usel[heroIdx]; } },
74
 
75
+ { id:'pickup', icon:'◆',
76
+ say:'A gold SUPPLY CACHE is marked nearby. Select the Commander or Constructor, then tap beside the cache so the unit collects it.',
77
+ done:'Cache secured. Pickups can grant resources, repairs, scans, veterancy or rare strike codes.',
78
+ test:function(){ return MATCH.sawPickup; } },
79
+
80
+ { id:'mex', icon:'⛏',
81
  say:'Mass buys units and structures. Tap BUILD → ECONOMY → Extractor, drag its hologram onto a ◆ deposit, then tap ✓.',
82
  done:"Extractor placed. That's your first income line.",
83
  test:function(){ return hasPlayerBld('mex'); } },
84
 
85
+ { id:'power', icon:'⚡',
86
  say:'Production also needs energy. Tap BUILD → ECONOMY → Reactor, place it on open ground inside the blue grid, then tap ✓.',
87
  done:'Reactor placed. Mass and energy must both stay positive while building.',
88
  test:function(){ return hasPlayerBld('pgen')||hasPlayerBld('geo'); } },
89
 
90
+ { id:'fac', icon:'▣',
91
+ say:"Build a Factory under BUILD → PRODUCTION. It is this army's barracks and vehicle plant, and expands construction territory.",
92
+ done:'Factory placed. Open it to train the first field unit.',
93
  test:function(){ return hasPlayerBld('fac'); } },
94
 
95
+ { id:'territory', icon:'⌁', manual:'territoryAck', action:'GOT IT',
96
+ say:'LOGISTICS: blue grid is legal build territory. Builders extend a mobile zone; Factories and Uplinks anchor it. Silos prevent resource overflow. Tap GOT IT.',
97
+ done:'Logistics and territory rules logged.',
98
  test:function(){ return MATCH.territoryAck; } },
99
 
100
+ { id:'queue', icon:'▤',
101
  say:'Tap the Factory on the map, then tap the Striker unit card. The queue line shows what is being built.',
102
+ done:'Striker queued. Production consumes mass and energy over build time.',
103
  test:function(){ return MATCH.sawQueue; } },
104
 
105
+ { id:'train', icon:'⚔',
106
+ say:'Keep the Factory supplied until the Striker rolls out. Tap ARMY when it appears to select every field unit.',
107
+ done:'Combat unit trained and selected. Unit cards show role, range, armor and ammunition.',
108
+ test:function(){ return MATCH.sawCombatSel&&combatCount()>MATCH.startCombat; } },
109
+
110
+ { id:'turret', icon:'⛨',
111
  say:'Protect the economy before the first push. Tap BUILD → DEFENCE → Sentinel and place it on the likely approach.',
112
  done:'Basic perimeter online. Towers buy your army time; they do not replace it.',
113
  test:function(){ return hasPlayerBld('turret'); } },
114
 
115
+ { id:'platoon', icon:'Ⅳ',
116
+ say:'With combat units selected, open PLATOONS and HOLD P1 to save them. Tap P1 later to recall; double-tap focuses the camera.',
117
+ done:'Platoon P1 stored. Saved groups remember membership and formation.',
118
+ test:function(){ return MATCH.sawPlatoon; } },
119
+
120
+ { id:'formation', icon:'⌃',
121
+ say:'Open ORDERS, tap the formation button, then drag the hologram on the map and release. Every marker is one unit slot.',
122
+ done:'Formation placed. Spacing keeps mixed units readable and prevents stacking.',
123
+ test:function(){ return MATCH.sawFormation; } },
124
+
125
+ { id:'attack', icon:'➜',
126
+ say:'Leave A-MOVE active and tap unexplored ground. Units advance and engage contacts. Green matchup chips are strong; red chips warn of counters.',
127
+ done:'Attack-move confirmed. Long-press a unit or structure for its full counter card.',
128
+ test:function(){ return MATCH.sawAttackMove; } },
129
+
130
+ { id:'fog', icon:'⌾',
131
+ say:'Black fog hides enemies; grey terrain is remembered, not currently visible. Move the marked SCOUT toward the signal to reveal the sector.',
132
+ done:'Sensor contact expanded. Enemy positions remain hidden until a friendly sensor sees them.',
133
+ test:function(){ return MATCH.sawScout; } },
134
 
135
+ { id:'tech', icon:'⌬',
136
+ say:'Field rank 3 granted for training. Build BUILD TECH Research Complex; it unlocks studies and banks account-level Data after battle.',
137
+ done:'Research Complex online. Its shield buffer protects studies from a sudden rush.',
138
+ test:function(){ return hasPlayerBld('techlab'); } },
139
+
140
+ { id:'ability', icon:'✦',
141
+ say:'Select the Commander, open POWERS and use BLAST on the marked safe target. Abilities cost energy and have cooldowns.',
142
+ done:'Special ability confirmed. Research and Commander levels unlock additional powers.',
143
+ test:function(){ return MATCH.usedAbility; } },
144
+
145
+ { id:'objective', icon:'◎', manual:'objectiveAck', action:'CALL EXTRACTION',
146
+ say:'Normal operations end by their selected objective: destroy Commanders, hold territory, purge hives or survive. Training is secure—tap CALL EXTRACTION.',
147
+ done:'Extraction acknowledged. Match resources, rewards and research are summarized after every operation.',
148
+ test:function(){ return MATCH.objectiveAck; } },
149
+
150
+ { id:'cloud', icon:'☁', manual:'cloudAck', action:'FINISH TRAINING',
151
+ say:'Your career autosaves locally. After extraction, ACCOUNT can compare and sync a cloud save; PROFILE can export a portable .mfsave backup. Tap FINISH TRAINING.',
152
+ done:'Career storage confirmed.',
153
+ test:function(){ return MATCH.cloudAck; } }
154
  ];
155
 
156
  var BASE_ATTACK_LINES=[
 
180
  /* Facts about THIS match, tracked whether or not the tutorial is running, so
181
  a mid-match "replay" (from Settings) can credit things the player already
182
  demonstrably knows how to do instead of waiting for them to happen again. */
183
+ var MATCH={ sawSel:false, sawCombatSel:false, sawMove:false, sawQueue:false, sawCamera:false,
184
+ sawPickup:false, sawPlatoon:false, sawFormation:false, sawAttackMove:false,sawScout:false,
185
+ territoryAck:false,objectiveAck:false,cloudAck:false,usedAbility:false,
186
+ startCombat:0,cameraBase:null,pickup:null,scoutIdx:-1,scoutStart:null,lastAbCool:[0,0,0,0] };
187
  /* Reactive one-per-match latches. */
188
  var REACT={ baseAttack:false, lowPower:false, hazard:false, unitLost:false, wave:false,
189
  lastAlarmT:0, lastStallE:false, lastHazWarn:false, lastWarned:false };
 
194
  META.tutorial=META.tutorial||{done:false,skipped:false,version:0};
195
  /* `progress` is additive save metadata: old profiles load exactly as before,
196
  while the briefing can distinguish new, interrupted and completed runs.
197
+ It is a high-water mark only; the live-state gates below remain the
198
  authority whenever a mission is running. */
199
  if(typeof META.tutorial.progress!=='number') META.tutorial.progress=META.tutorial.done?STEPS.length:0;
200
  META.tutorial.progress=Math.max(0,Math.min(STEPS.length,META.tutorial.progress|0));
 
225
  for(var i=0;i<unitHigh;i++) if(isCombatUnit(i)) return true;
226
  return false;
227
  }
228
+ function combatCount(){
229
+ if(typeof unitHigh!=='number') return 0;
230
+ var n=0;for(var i=0;i<unitHigh;i++)if(isCombatUnit(i))n++;return n;
231
+ }
232
  function hasSelectedCombatUnit(){
233
  if(typeof unitHigh!=='number') return false;
234
  for(var i=0;i<unitHigh;i++) if(isCombatUnit(i)&&usel[i]) return true;
 
271
  '<div id="keelBody">'+
272
  '<div id="keelHd"><span id="keelName" title="'+KEEL_TAG+'">'+KEEL_NAME+'</span><span id="keelStepTag"></span></div>'+
273
  '<div id="keelTxt"></div>'+
274
+ '<div id="keelProgress" role="progressbar" aria-label="Training objective progress" aria-valuemin="0"><i></i></div>'+
275
  '<button id="keelNext" type="button">GOT IT</button>'+
276
  '</div>'+
277
  '<button id="keelSkip" type="button" aria-label="Skip tutorial">SKIP</button>'+
 
285
  var nextBtn=document.getElementById('keelNext');
286
  nextBtn.addEventListener('pointerdown',function(ev){
287
  ev.preventDefault(); ev.stopPropagation();
288
+ var S=TUT.active&&STEPS[TUT.stepIdx];
289
+ if(!S||!S.manual) return;
290
+ MATCH[S.manual]=true;
291
  if(typeof sfx==='function'){ try{ sfx('ui'); }catch(e){} }
292
  });
293
  /* Tapping the body re-shows the full-strength card if it has settled into
 
302
  if(!domReady) return;
303
  if(text!==lastText){ var t=document.getElementById('keelTxt'); if(t) t.textContent=text; lastText=text; }
304
  var tg=document.getElementById('keelStepTag'); if(tg) tg.textContent=tag||'';
305
+ var pg=document.getElementById('keelProgress');
306
+ if(pg){
307
+ var at=Math.min(STEPS.length,TUT.stepIdx),pct=STEPS.length?at/STEPS.length*100:0;
308
+ pg.setAttribute('aria-valuemax',STEPS.length);pg.setAttribute('aria-valuenow',at);
309
+ var fill=pg.querySelector('i');if(fill)fill.style.width=pct+'%';
310
+ }
311
  }
312
  function showWrap(fresh){
313
  if(!domReady) return;
 
395
  clearFocus();
396
  if(!TUT.active||TUT.stepIdx>=STEPS.length) return;
397
  var S=STEPS[TUT.stepIdx],next=document.getElementById('keelNext');
398
+ if(next){
399
+ next.style.display=S.manual?'inline-flex':'none';next.textContent=S.action||'GOT IT';
400
+ next.setAttribute('aria-label',(S.action||'Confirm')+' — '+S.id+' lesson');
401
+ }
402
+ if(S.id==='camera'){
403
+ focusEl(document.querySelector('.hudDeckBtn[data-deck="view"]'));
404
+ focusEl(document.getElementById('rotL'));focusEl(document.getElementById('tiltBtn'));focusEl(document.getElementById('rotR'));
405
+ cueAt((typeof VW==='number'?VW:innerWidth)*.5,(typeof VH==='number'?VH:innerHeight)*.52,'DRAG / PINCH');
406
+ } else if(S.id==='deploy'){
407
  var dep=document.getElementById('deployBtn');
408
  if(shown(dep)) focusEl(dep); else cueAt((typeof VW==='number'?VW:innerWidth)*.5,(typeof VH==='number'?VH:innerHeight)*.52,'TAP LANDING SITE');
409
  } else if(S.id==='commander'){
 
411
  var cdr=document.getElementById('abHero');
412
  if(shown(cdr)) focusEl(cdr);
413
  else focusEl(document.querySelector('.hudDeckBtn[data-deck="powers"]'));
414
+ } else if(S.id==='pickup'){
415
+ if(MATCH.pickup&&crates.indexOf(MATCH.pickup)>=0)cueWorld(MATCH.pickup.x,MATCH.pickup.y,'◆ COLLECT CACHE');
416
+ focusEl(document.getElementById('heroBar'));
417
  } else if(S.id==='mex') buildFocus('ECONOMY','Extractor','mex');
418
  else if(S.id==='power') buildFocus('ECONOMY','Reactor','pgen');
419
  else if(S.id==='fac') buildFocus('PRODUCTION','Factory','fac');
 
425
  var pm=document.getElementById('prodMenu');
426
  if(shown(pm)) focusEl(byText('prodGrid','.bcard','Striker')||pm.querySelector('.bcard'));
427
  else { var F=playerBld('fac'); if(F) cueWorld(F.x,F.y,'TAP FACTORY'); }
428
+ } else if(S.id==='train'){
 
429
  if(hasCombatUnit()) focusEl(document.getElementById('armyBtn'));
430
  else { var F2=playerBld('fac'); if(F2) cueWorld(F2.x,F2.y,'UNIT BUILDING'); }
431
+ } else if(S.id==='turret') buildFocus('DEFENCE','Sentinel','turret');
432
+ else if(S.id==='platoon'){
433
+ if(shown(document.getElementById('grpRow')))focusEl(document.getElementById('grpBtn1'));
434
+ else focusEl(document.querySelector('.hudDeckBtn[data-deck="platoons"]'));
435
+ } else if(S.id==='formation'){
436
+ if(shown(document.getElementById('tacRow'))){
437
+ focusEl(document.getElementById('formBtn'));
438
+ if(orderPreview)cueWorld(orderPreview.x,orderPreview.y,'DRAG FORMATION');
439
+ } else focusEl(document.querySelector('.hudDeckBtn[data-deck="orders"]'));
440
+ } else if(S.id==='attack'){
441
+ if(shown(document.getElementById('tacRow')))focusEl(document.getElementById('moveBtn'));
442
+ else focusEl(document.querySelector('.hudDeckBtn[data-deck="orders"]'));
443
  var sx=0,sy=0,n=0;
444
  if(typeof unitHigh==='number') for(var j=0;j<unitHigh;j++) if(isCombatUnit(j)&&usel[j]){sx+=ux[j];sy+=uy[j];n++;}
445
+ if(n) cueWorld(sx/n+150,sy/n-110,'ATTACK-MOVE');
446
  else focusEl(document.getElementById('armyBtn'));
447
+ } else if(S.id==='fog'){
448
+ focusEl(document.getElementById('minimapWrap'));
449
+ if(MATCH.scoutIdx>=0&&ualive[MATCH.scoutIdx])cueWorld(ux[MATCH.scoutIdx]+190,uy[MATCH.scoutIdx]-150,'SCOUT THIS SECTOR');
450
+ } else if(S.id==='tech') buildFocus('TECH','Research Complex','techlab');
451
+ else if(S.id==='ability'){
452
+ var blast=document.getElementById('abOver');
453
+ if(shown(blast))focusEl(blast);else focusEl(document.querySelector('.hudDeckBtn[data-deck="powers"]'));
454
+ if(typeof heroIdx==='number'&&heroIdx>=0&&ualive[heroIdx])cueWorld(ux[heroIdx]+105,uy[heroIdx]-70,'BLAST TARGET');
455
+ } else if(S.id==='objective'||S.id==='cloud'){
456
+ focusEl(next);
457
  }
458
  }
459
  /* Capped so a burst of state changes (several buildings finishing within the
 
514
  --------------------------------------------------------------------------- */
515
  function trackMatchSignals(){
516
  if(sawQueuedUnit()) MATCH.sawQueue=true;
517
+ if(MATCH.cameraBase){
518
+ var C=MATCH.cameraBase;
519
+ if((typeof camUserT==='number'&&camUserT>0)||Math.abs(yawTarget-C.yaw)>.06||
520
+ Math.abs(pitchTarget-C.pitch)>.035||Math.abs(orthoSpan-C.span)>24)MATCH.sawCamera=true;
521
+ }
522
  if(typeof unitHigh==='number' && (!MATCH.sawSel||!MATCH.sawCombatSel)){
523
  for(var i=0;i<unitHigh;i++){
524
  if(!ualive[i]||uteam[i]!==0) continue;
 
527
  if(MATCH.sawSel&&MATCH.sawCombatSel) break;
528
  }
529
  }
530
+ if(typeof activePlatoon==='number'&&activePlatoon>=0&&ctrlGroups[activePlatoon]&&groupLive(ctrlGroups[activePlatoon]).length)
531
+ MATCH.sawPlatoon=true;
532
+ if(typeof orderConfirm!=='undefined'&&orderConfirm)MATCH.sawFormation=true;
533
+ if(MATCH.scoutIdx>=0&&MATCH.scoutStart&&ualive[MATCH.scoutIdx]&&
534
+ Math.hypot(ux[MATCH.scoutIdx]-MATCH.scoutStart.x,uy[MATCH.scoutIdx]-MATCH.scoutStart.y)>115)MATCH.sawScout=true;
535
  if(typeof abCool!=='undefined'){
536
  for(var k=0;k<abCool.length;k++){
537
  if(abCool[k]>(MATCH.lastAbCool[k]||0)+0.01) MATCH.usedAbility=true;
 
550
  TUT.stepIdx=0;
551
  TUT.shownStepIdx=-1;
552
  TUT.doneFlags=STEPS.map(function(){ return false; });
553
+ MATCH.sawSel=false;MATCH.sawCombatSel=false;MATCH.sawMove=false;MATCH.sawQueue=false;MATCH.sawCamera=false;
554
+ MATCH.sawPickup=false;MATCH.sawPlatoon=false;MATCH.sawFormation=false;MATCH.sawAttackMove=false;MATCH.sawScout=false;
555
+ MATCH.territoryAck=false;MATCH.objectiveAck=false;MATCH.cloudAck=false;MATCH.usedAbility=false;
556
+ MATCH.startCombat=combatCount();MATCH.pickup=null;MATCH.scoutIdx=-1;MATCH.scoutStart=null;
557
+ MATCH.cameraBase={yaw:yawTarget,pitch:pitchTarget,span:orthoSpan};
558
+ MATCH.lastAbCool=(typeof abCool!=='undefined')?abCool.slice():[0,0,0,0];
559
  queue.length=0;
560
  speak(GREETING,5.5,'greeting');
561
  }
 
591
  }
592
  var cur=STEPS[TUT.stepIdx];
593
  restingText=cur.say;
594
+ restingTag=(cur.icon||'◇')+' STEP '+(TUT.stepIdx+1)+' / '+STEPS.length;
595
  if(TUT.shownStepIdx!==TUT.stepIdx){
596
  TUT.shownStepIdx=TUT.stepIdx;
597
  speak(cur.say,5.5,'step');
 
667
  REACT.baseAttack=false; REACT.lowPower=false; REACT.hazard=false; REACT.unitLost=false; REACT.wave=false;
668
  REACT.lastAlarmT=(typeof alarmT!=='undefined')?alarmT:0;
669
  REACT.lastStallE=false; REACT.lastHazWarn=false; REACT.lastWarned=false;
670
+ MATCH.sawSel=false;MATCH.sawCombatSel=false;MATCH.sawMove=false;MATCH.sawQueue=false;MATCH.sawCamera=false;
671
+ MATCH.sawPickup=false;MATCH.sawPlatoon=false;MATCH.sawFormation=false;MATCH.sawAttackMove=false;MATCH.sawScout=false;
672
+ MATCH.territoryAck=false;MATCH.objectiveAck=false;MATCH.cloudAck=false;MATCH.usedAbility=false;
673
+ MATCH.startCombat=combatCount();MATCH.pickup=null;MATCH.scoutIdx=-1;MATCH.scoutStart=null;
674
+ MATCH.cameraBase={yaw:yawTarget,pitch:pitchTarget,span:orthoSpan};
675
  MATCH.lastAbCool=(typeof abCool!=='undefined')?abCool.slice():[0,0,0,0];
676
  if(TUT.trainingMode){ TUT.forceNext=false; beginRun(); }
677
  }
 
725
  META.opmods=trainingPrev.opmods; META.threatSel=trainingPrev.threatSel;
726
  trainingPrev=null;
727
  }
728
+ function trainingStepId(){
729
+ return TUT.active&&STEPS[TUT.stepIdx]?STEPS[TUT.stepIdx].id:'';
730
+ }
731
+ function ensureTrainingPickup(){
732
+ if(MATCH.sawPickup||(MATCH.pickup&&crates.indexOf(MATCH.pickup)>=0))return;
733
+ var H=playerBld('hq');if(!H||typeof spawnCrate!=='function')return;
734
+ var C=spawnCrate(H.x+155,H.y-55,'supply');
735
+ C.alt=0;C.seen=true;C.t=0;C.site=true;C.siteName='TRAINING CACHE';C.announced=true;
736
+ MATCH.pickup=C;
737
+ if(typeof mmPing==='function')mmPing(C.x,C.y);
738
+ }
739
+ function ensureTrainingScout(){
740
+ if(MATCH.scoutIdx>=0&&ualive[MATCH.scoutIdx])return;
741
+ var H=playerBld('hq'),ty=-1;if(!H)return;
742
+ for(var i=0;i<TYPES.length;i++)if(TYPES[i]&&TYPES[i].scout){ty=i;break;}
743
+ if(ty<0)return;
744
+ var u=spawnUnit(ty,0,H.x+95,H.y+55);if(u<0)return;
745
+ clearSel();usel[u]=1;MATCH.scoutIdx=u;MATCH.scoutStart={x:ux[u],y:uy[u]};
746
+ updateSelInfo();cam.x=ux[u];cam.y=uy[u];clampCam();camUpdateMatrices();
747
+ toast('⌾ TRAINING SCOUT DEPLOYED — move it into black fog');
748
+ }
749
  function trainingSafety(){
750
  if(!TUT.trainingMode||typeof running==='undefined'||!running||typeof matchLive==='undefined'||!matchLive) return;
751
  /* No scripted ambush while the player is reading. The enemy still builds a
 
759
  if(typeof resE!=='undefined'&&typeof RES_ECAP!=='undefined') resE[0]=Math.min(RES_ECAP[0],Math.max(resE[0],2200));
760
  if(typeof heroIdx==='number'&&heroIdx>=0&&ualive[heroIdx]) uhp[heroIdx]=Math.max(uhp[heroIdx],uhpm[heroIdx]*.72);
761
  var H=playerBld('hq'); if(H) H.hp=Math.max(H.hp,H.hpm*.75);
762
+ var step=trainingStepId();
763
+ if(step==='pickup')ensureTrainingPickup();
764
+ if(step==='fog')ensureTrainingScout();
765
+ if(step==='tech'&&typeof heroLvl==='number')heroLvl=Math.max(heroLvl,3);
766
  }
767
  function startTrainingMission(){
768
  if(typeof running!=='undefined'&&running){ toast('Leave the current battle before starting training'); return; }
 
783
  if(typeof hideFrontScreens==='function') hideFrontScreens();
784
  var load=document.getElementById('loadScr'); if(load) load.style.display='flex';
785
  requestAnimationFrame(function(){requestAnimationFrame(function(){
786
+ applyTheme(); newSkirmish();fogOn=true;updateFog();trainingLaunched=true;
787
  if(load) load.style.display='none';
788
  toast('TRAINING OPERATION — threat paused while KEEL guides you');
789
  });});
 
862
  function ensureTrainingEntry(){
863
  var start=document.getElementById('startBtn'); if(!start||document.getElementById('keelTrainingCallout')) return;
864
  var d=document.createElement('div'); d.id='keelTrainingCallout';
865
+ d.innerHTML='<div class="ktMiniIdentity"><img src="./assets/factions/nova_icon_96.png" alt="Terran Frontline Command">'
866
  +'<div><span class="ktState">FIRST OPERATION</span><b>FIELD ORIENTATION</b><small class="ktProgress">0 / '+STEPS.length+' OBJECTIVES</small></div></div>'
867
  +'<button type="button">VIEW TRAINING</button>';
868
  start.parentNode.insertBefore(d,start);
 
885
  +'<span>'+(typeof facIcon==='function'?facIcon('nova',38,'ktoCrestImg'):'✦')+'</span></div>'
886
  +'<div class="ktoIdentity"><small>TRAINING OPERATION 01</small><b>FIELD ORIENTATION</b>'
887
  +'<i>'+commander+' · KEEL tactical guidance</i>'
888
+ +'<p>A protected live-fire drop covering the complete command loop without an early enemy rush.</p></div></div>'
889
+ +'<div class="ktoTeach" aria-label="Training topics"><span>◇ CAMERA</span><span>⬡ ECONOMY</span><span>▣ PRODUCTION</span>'
890
+ +'<span>⛨ DEFENCE</span><span> PLATOONS</span><span> SCOUTING</span>'
891
+ +'<span>⌬ TECH</span><span>✦ POWERS</span><span>☁ SAVES</span></div>'
892
  +'<div class="ktoProgress"><div><span>OBJECTIVE PROGRESS</span><b>'+S.progress+' / '+STEPS.length+'</b></div>'
893
  +'<div class="ktoProgressTrack"><i style="width:'+progressPct+'%"></i></div>'
894
  +'<small>'+(S.done?'Orientation certified · replay does not repeat the first-clear reward':
895
  S.active?'Current lesson: '+(STEPS[Math.min(TUT.stepIdx,STEPS.length-1)].id||'field systems').toUpperCase():
896
+ S.interrupted?'Previous best retained · a new drop restarts at camera control':STEPS.length+' guided objectives · progress is saved to this profile')+'</small></div>'
897
  +'<div class="ktoFacts"><div><b>'+STEPS.length+'</b><span>OBJECTIVES</span></div><div><b>EASY</b><span>FIXED OPPONENT</span></div>'
898
  +'<div><b>SAFE</b><span>NO INFESTATION</span></div></div>'
899
  +'<button class="ktoAction" type="button">'+S.action+'</button>';
 
991
  window.__keelMoveWrapped=true;
992
  var _keelOrderMove=orderMove;
993
  orderMove=function(wx,wy,patrol){
994
+ var combat=hasSelectedCombatUnit(),formationDone=MATCH.sawFormation,attackMode=typeof moveMode==='undefined'||!moveMode,
995
+ ok=_keelOrderMove(wx,wy,patrol);
996
+ if(ok&&combat&&!patrol){
997
+ MATCH.sawMove=true;
998
+ /* The formation release itself also calls orderMove. Requiring a
999
+ formation observed on an earlier poll ensures the next deliberate
1000
+ map tap teaches attack-move instead of silently auto-completing. */
1001
+ if(formationDone&&attackMode)MATCH.sawAttackMove=true;
1002
+ }
1003
  return ok;
1004
  };
1005
  }
1006
+ if(typeof applyCrate==='function'&&!window.__keelPickupWrapped){
1007
+ window.__keelPickupWrapped=true;
1008
+ var _keelApplyCrate=applyCrate;
1009
+ applyCrate=function(k,x,y){
1010
+ var out=_keelApplyCrate(k,x,y);
1011
+ if(TUT.trainingMode)MATCH.sawPickup=true;
1012
+ return out;
1013
+ };
1014
+ }
1015
  setInterval(tutTick,350);
1016
  }
1017
  window.initTutorial=initTutorial;
src/ui/hud.js CHANGED
@@ -259,9 +259,23 @@ function renderLegacySprites(dtDraw){
259
  for(const r of rocks){ if(r.x<x0||r.x>x1||r.y<y0||r.y>y1) continue;
260
  batN.add(sRock,r.x,r.y,r.s,r.s,r.a,255,255,255,255); }
261
  for(const d of deposits){
262
- if(d.taken||d.x<x0||d.x>x1||d.y<y0||d.y>y1) continue;
263
- if(d.rich) batN.add(sprites.depR,d.x,d.y,35,35,0,255,255,255,255);
264
- else batN.add(sDep,d.x,d.y,30,30,0,255,255,255,255);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  }
266
  for(const gz of geysers){
267
  if(gz.x<x0||gz.x>x1||gz.y<y0||gz.y>y1) continue;
@@ -270,7 +284,13 @@ function renderLegacySprites(dtDraw){
270
  addParticle(1,gz.x+rr(-5,5),gz.y+rr(-4,4),rr(-2,2),rr(-17,-10),1.3,9, 165,225,250);
271
  }
272
  for(const cst of crystals){ if(cst.x<x0||cst.x>x1||cst.y<y0||cst.y>y1) continue;
273
- batN.add(sCry,cst.x,cst.y,cst.s,cst.s,cst.a,255,255,255,255); }
 
 
 
 
 
 
274
  // ---- contact shadows for standing relief (drawn flat, on the ground) ----
275
  for(const M of relief){
276
  if(M.x<x0-60||M.x>x1+60||M.y<y0-60||M.y>y1+60) continue;
@@ -597,6 +617,18 @@ function renderLegacySprites(dtDraw){
597
  const tc=TEAMC[uteam[i]];
598
  if(usel[i]) batA.add(selRing,X,Y,T.size*1.6,T.size*1.6,0,60,220,140,170);
599
  if(ubuff[i]>0) batA.add(glow,X,Y,T.size*1.9,T.size*1.9,0,255,210,60,40);
 
 
 
 
 
 
 
 
 
 
 
 
600
  // organic sway for walkers & beasts
601
  let bodyRot=uang[i];
602
  if(T.wk==='m'||utype[i]===0||utype[i]===8||utype[i]===11) bodyRot+=Math.sin(t*8+i*1.7)*0.045;
@@ -676,6 +708,11 @@ function renderLegacySprites(dtDraw){
676
  const ha=(T.tur?uturr[i]:uang[i])-Math.PI/2;
677
  batA.add(glow,X+Math.cos(ha)*T.size*0.8,Y+Math.sin(ha)*T.size*0.8,
678
  T.size*1.3,T.size*1.3,0, 255,190,110, 54*dep);
 
 
 
 
 
679
  }
680
  }
681
  }
@@ -684,6 +721,10 @@ function renderLegacySprites(dtDraw){
684
  const i=airTmp[k], T=TYPES[utype[i]], tc=TEAMC[uteam[i]];
685
  const bob=Math.sin(t*3+i)*1.5;
686
  if(usel[i]) batA.add(selRing,ux[i],uy[i],T.size*1.6,T.size*1.6,0,60,220,140,170);
 
 
 
 
687
  if(use3D&&UNIT_UV[T.spr]){
688
  batU.add(UNIT_UV[T.spr][angFrame(uang[i]+camYaw)],ux[i],uy[i]-7+bob,T.size*S3D,T.size*S3D,0,tc[0],tc[1],tc[2],255,52);
689
  } else {
@@ -1239,8 +1280,10 @@ function renderMinimap(){
1239
  mm.drawImage(mmBg,0,0);
1240
  mm.fillStyle='#3dd68a';
1241
  for(const d of deposits){
1242
- if(d.taken||(fogOn&&!demoMode&&!fogExploredAt(d.x,d.y))) continue;
1243
- mm.fillRect(d.x*k-1.5,d.y*k-1.5,3,3);
 
 
1244
  }
1245
  for(const B of blds){
1246
  if(!B.alive) continue;
@@ -1384,7 +1427,7 @@ function updateSelInfo(){
1384
  if(mr){
1385
  mr.style.display=deck==='platoons'&&modeable?'flex':'none';
1386
  if(modeable){
1387
- const M=MODES[mixed?0:Math.max(0,curMode)];
1388
  $('modeEm').textContent=mixed?'⁇':M.em;
1389
  $('modeNm').textContent=mixed?'Mixed':M.nm;
1390
  }
@@ -1426,10 +1469,10 @@ function cycleSelectedModes(){
1426
  const cur=list.indexOf(umode[i]);
1427
  decided[ty]=list[(cur+1)%list.length];
1428
  }
1429
- if(setMode(i,decided[ty])){ changed++; label=MODES[decided[ty]].nm; }
1430
  }
1431
  if(changed){
1432
- const M=MODES[decided[Object.keys(decided)[0]]];
1433
  toast(M.em+' '+changed+' units → '+M.nm+' — '+M.ds);
1434
  sfx('ui');
1435
  } else toast('Selected units have no alternate stance');
@@ -1471,6 +1514,7 @@ function updateHUD(fps){
1471
  }
1472
  if(aiming===0) $('abOver').classList.add('on'); else $('abOver').classList.remove('on');
1473
  if(typeof artBarrageButtonState==='function') artBarrageButtonState();
 
1474
  // carrier deployment prompt
1475
  const db=$('deployBtn');
1476
  if(db){
@@ -1567,6 +1611,32 @@ const INTEL_BUILD_COPY={
1567
  tech:'Unlocks and strengthens advanced systems', sup:'Extends and sustains the base',
1568
  sup2:'Strategic weapon for decisive strikes'
1569
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1570
  const INTEL_BLD_WEAPONS={
1571
  turret:{wk:'b',rng:()=>TURRET_RNG,tg:'a'}, bunker:{wk:'e',rng:()=>BUNKER.rng,tg:'g'},
1572
  aatower:{wk:'e',rng:()=>AA.rng,tg:'air'}, bastion:{wk:'e',rng:()=>BASTION.rng,min:()=>BASTION.minRng,tg:'g'},
@@ -1586,6 +1656,8 @@ function intelRangeBand(r){
1586
  return '—';
1587
  }
1588
  function intelUnitPurpose(T){
 
 
1589
  if(T.builder) return 'Mobile engineer that establishes forward bases and repairs structures.';
1590
  if(T.name==='Bulwark') return 'Mobile shield projector that reduces damage to nearby allied units.';
1591
  if(T.scout) return 'High-speed reconnaissance aircraft for finding threats and flanking exposed targets.';
@@ -1809,6 +1881,33 @@ function mfIntelPreviewSet(host,kind,id){
1809
  if(V&&V.setSubject(kind,id)) return;
1810
  if(host){host.innerHTML='';host.appendChild(mfIntelPreviewWindow(kind,id,'LIVE 3D'));}
1811
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1812
  let mfIntelMenuTimer=0;
1813
  function mfIntelMenuInit(tryN){
1814
  const host=$('menuIntelModel');if(!host||host.dataset.ready) return;
@@ -1849,6 +1948,16 @@ function showUnitTypeCard(tIdx,pinned){
1849
  ?'☄ ACTIVE: CHARGED BARRAGE · select artillery, open POWERS, then target the map'
1850
  :'🔒 CHARGED BARRAGE · research Fire Mission Protocol in Development > Doctrine';
1851
  card.appendChild(info);
 
 
 
 
 
 
 
 
 
 
1852
  }
1853
  mfIntelAttachPreview('unit',tIdx);
1854
  }
@@ -2046,7 +2155,7 @@ function unitIconEl(tIdx,size){
2046
  13:'unit_alpha',18:'unit_scorcher',19:'unit_constructor',
2047
  20:'unit_reaper',21:'unit_cinder',22:'unit_lancer',23:'unit_resonator',
2048
  24:'unit_warden',25:'unit_kestrel',26:'unit_basilisk',27:'unit_harbinger',
2049
- 28:'unit_praetor',29:'unit_archon',30:'unit_brood'
2050
  }[tIdx];
2051
  if(special&&typeof itemArt==='function'){
2052
  w.innerHTML=itemArt(special,T.name,size);
@@ -2098,8 +2207,8 @@ function renderProdMenu(){
2098
  if(B.type==='tgate') list=[8,26];
2099
  else if(B.type==='harbor') list=[14,15];
2100
  else if(B.type==='airfield') list=[5,17,25];
2101
- else list = B.tier===2? [0,1,9,18,10,2,3,6,7,11,16,19,20,21,22,23,24,27]
2102
- : [0,1,9,10,19,24];
2103
  /* ROLE TABS. A flat grid of eighteen cards is a wall; one tap per role lets
2104
  the player find the answer to whatever is killing them without reading
2105
  every stat block on the way there. */
@@ -2118,6 +2227,7 @@ function renderProdMenu(){
2118
  b.addEventListener('pointerdown',ev=>{ ev.stopPropagation(); prodTab=c; sfx('ui'); renderProdMenu(); });
2119
  tr.appendChild(b);
2120
  }
 
2121
  (groups[prodTab]||[]).forEach(tIdx=>{
2122
  const T=TYPES[tIdx];
2123
  const d=document.createElement('div');
@@ -2134,6 +2244,9 @@ function renderProdMenu(){
2134
  if(openBld<0) return;
2135
  const Bb=blds[openBld];
2136
  if(tIdx===8 && titanCount[0]+Bb.queue.filter(q=>q===8).length>=3){ toast('Max 3 TITANs'); return; }
 
 
 
2137
  if(Bb.queue.length<30){ Bb.queue.push(tIdx); sfx('ui'); renderQueue(); }
2138
  if(tIdx!==8){ // hold to queue a batch of 5
2139
  const hold=setTimeout(()=>{
@@ -2204,6 +2317,7 @@ function renderBuildMenu(){
2204
  b.addEventListener('pointerdown',ev=>{ ev.stopPropagation(); bldTab=c; sfx('ui'); renderBuildMenu(); });
2205
  tr.appendChild(b);
2206
  }
 
2207
  for(const key of (grp[bldTab]||[])){
2208
  const T=BT[key];
2209
  const d=document.createElement('div');
 
259
  for(const r of rocks){ if(r.x<x0||r.x>x1||r.y<y0||r.y>y1) continue;
260
  batN.add(sRock,r.x,r.y,r.s,r.s,r.a,255,255,255,255); }
261
  for(const d of deposits){
262
+ if(d.x<x0||d.x>x1||d.y<y0||d.y>y1) continue;
263
+ const tier=depositTier(d),frac=d.capacity?d.remaining/d.capacity:0;
264
+ const col=tier===3?[188,82,255]:tier===2?[58,225,150]:tier===1?[58,190,255]:[72,76,88];
265
+ /* Corrupted terrain is a world feature, not a UI pin. Layered fractured
266
+ decals, subsurface bloom and counter-rotating rings remain under an
267
+ Extractor and desaturate as the finite field collapses. */
268
+ const fieldR=72+(d.initialTier||1)*20;
269
+ batN.add(sCrater,d.x,d.y,fieldR*1.8,fieldR*1.32,(d.pulse||0)+.35,col[0]*.62|0,col[1]*.58|0,col[2]*.68|0,tier?72:42);
270
+ batN.add(sCrater,d.x+8,d.y-5,fieldR*1.35,fieldR*.95,-(d.pulse||0)-.7,col[0]*.86|0,col[1]*.78|0,col[2]*.92|0,tier?46:28);
271
+ batN.add(glow,d.x,d.y,fieldR*2.05,fieldR*2.05,0,col[0],col[1],col[2],tier?(42+24*frac)|0:8);
272
+ batN.add(selRing,d.x,d.y,fieldR*1.45,fieldR*1.45,t*.10+(d.pulse||0),col[0],col[1],col[2],tier?76:20);
273
+ if(tier>0&&!d.taken){
274
+ if(tier===3) batN.add(sprites.depR,d.x,d.y,39,39,0,col[0],col[1],col[2],255);
275
+ else batN.add(sDep,d.x,d.y,30+tier*3,30+tier*3,0,col[0],col[1],col[2],255);
276
+ }
277
+ if(tier>0&&perfScale>.45&&Math.random()<.018*tier)
278
+ addParticle(0,d.x+rr(-fieldR*.55,fieldR*.55),d.y+rr(-fieldR*.42,fieldR*.42),rr(-3,3),rr(-12,-4),.55,3.5,col[0],col[1],col[2]);
279
  }
280
  for(const gz of geysers){
281
  if(gz.x<x0||gz.x>x1||gz.y<y0||gz.y>y1) continue;
 
284
  addParticle(1,gz.x+rr(-5,5),gz.y+rr(-4,4),rr(-2,2),rr(-17,-10),1.3,9, 165,225,250);
285
  }
286
  for(const cst of crystals){ if(cst.x<x0||cst.x>x1||cst.y<y0||cst.y>y1) continue;
287
+ const D=deposits[cst.dep],tier=depositTier(D);if(!D||cst.band>tier)continue;
288
+ const bandFill=clamp((D.remaining-(tier-1)*DEPOSIT_BAND)/DEPOSIT_BAND,0,1);
289
+ const edge=cst.band===tier?(.42+.58*bandFill):1,pulse=.94+.08*Math.sin(t*1.45+(cst.phase||0));
290
+ const s=cst.s*edge*pulse*(cst.core?1.42:1.18),col=cst.band===3?[220,112,255]:cst.band===2?[78,255,170]:[78,225,255];
291
+ batN.add(glow,cst.x,cst.y,s*1.8,s*1.8,0,col[0],col[1],col[2],cst.core?78:55);
292
+ batN.add(sCry,cst.x,cst.y,s,s*1.22,cst.a+Math.sin(t*.24+(cst.phase||0))*.035,col[0],col[1],col[2],255);
293
+ }
294
  // ---- contact shadows for standing relief (drawn flat, on the ground) ----
295
  for(const M of relief){
296
  if(M.x<x0-60||M.x>x1+60||M.y<y0-60||M.y>y1+60) continue;
 
617
  const tc=TEAMC[uteam[i]];
618
  if(usel[i]) batA.add(selRing,X,Y,T.size*1.6,T.size*1.6,0,60,220,140,170);
619
  if(ubuff[i]>0) batA.add(glow,X,Y,T.size*1.9,T.size*1.9,0,255,210,60,40);
620
+ if(typeof uclassBuffT!=='undefined'&&uclassBuffT[i]>0){
621
+ const cc=uclassBuff[i]===1?[255,112,54]:uclassBuff[i]===2?[75,225,255]:[82,255,164];
622
+ batA.add(selRing,X,Y,T.size*2.05,T.size*2.05,t*(uclassBuff[i]===2?1.8:.65),cc[0],cc[1],cc[2],120);
623
+ batA.add(glow,X,Y,T.size*1.65,T.size*1.65,0,cc[0],cc[1],cc[2],34);
624
+ }
625
+ if(typeof ubroodLed!=='undefined'&&ubroodLed[i]>0){
626
+ batA.add(selRing,X,Y,T.size*1.65,T.size*1.65,t*.72+i*.17,176,92,255,T.caster?155:54);
627
+ if(T.caster){
628
+ batA.add(selRing,X,Y,T.size*2.75,T.size*2.75,-t*.38,116,255,105,128);
629
+ batA.add(glow,X,Y-T.size*.24,T.size*2.25,T.size*2.25,0,178,82,255,64+Math.sin(t*4+i)*24);
630
+ }
631
+ }
632
  // organic sway for walkers & beasts
633
  let bodyRot=uang[i];
634
  if(T.wk==='m'||utype[i]===0||utype[i]===8||utype[i]===11) bodyRot+=Math.sin(t*8+i*1.7)*0.045;
 
708
  const ha=(T.tur?uturr[i]:uang[i])-Math.PI/2;
709
  batA.add(glow,X+Math.cos(ha)*T.size*0.8,Y+Math.sin(ha)*T.size*0.8,
710
  T.size*1.3,T.size*1.3,0, 255,190,110, 54*dep);
711
+ } else if(mo===6){ // ASSIST: logistics lock
712
+ batA.add(selRing,X,Y,T.size*2.1,T.size*2.1,t*.7,72,225,255,120*dep);
713
+ } else if(mo===7){ // SURVEY: scanning wave
714
+ const scan=1+((t*.55+i*.13)%1)*1.8;
715
+ batA.add(selRing,X,Y,T.size*scan,T.size*scan,-t*.35,100,255,185,(125/scan)*dep);
716
  }
717
  }
718
  }
 
721
  const i=airTmp[k], T=TYPES[utype[i]], tc=TEAMC[uteam[i]];
722
  const bob=Math.sin(t*3+i)*1.5;
723
  if(usel[i]) batA.add(selRing,ux[i],uy[i],T.size*1.6,T.size*1.6,0,60,220,140,170);
724
+ if(typeof uclassBuffT!=='undefined'&&uclassBuffT[i]>0){
725
+ const cc=uclassBuff[i]===2?[75,225,255]:[255,112,54];
726
+ batA.add(selRing,ux[i],uy[i],T.size*2.25,T.size*2.25,t*1.8,cc[0],cc[1],cc[2],135);
727
+ }
728
  if(use3D&&UNIT_UV[T.spr]){
729
  batU.add(UNIT_UV[T.spr][angFrame(uang[i]+camYaw)],ux[i],uy[i]-7+bob,T.size*S3D,T.size*S3D,0,tc[0],tc[1],tc[2],255,52);
730
  } else {
 
1280
  mm.drawImage(mmBg,0,0);
1281
  mm.fillStyle='#3dd68a';
1282
  for(const d of deposits){
1283
+ const tier=depositTier(d);
1284
+ if(tier<=0||(fogOn&&!demoMode&&!fogExploredAt(d.x,d.y))) continue;
1285
+ mm.fillStyle=tier===3?'#d06bff':tier===2?'#55eea3':'#4edcff';
1286
+ const rs=tier+2;mm.fillRect(d.x*k-rs/2,d.y*k-rs/2,rs,rs);
1287
  }
1288
  for(const B of blds){
1289
  if(!B.alive) continue;
 
1427
  if(mr){
1428
  mr.style.display=deck==='platoons'&&modeable?'flex':'none';
1429
  if(modeable){
1430
+ const M=unitModeDef(utype[first],mixed?0:Math.max(0,curMode));
1431
  $('modeEm').textContent=mixed?'⁇':M.em;
1432
  $('modeNm').textContent=mixed?'Mixed':M.nm;
1433
  }
 
1469
  const cur=list.indexOf(umode[i]);
1470
  decided[ty]=list[(cur+1)%list.length];
1471
  }
1472
+ if(setMode(i,decided[ty])){ changed++; label=unitModeDef(ty,decided[ty]).nm; }
1473
  }
1474
  if(changed){
1475
+ const firstTy=+Object.keys(decided)[0],M=unitModeDef(firstTy,decided[firstTy]);
1476
  toast(M.em+' '+changed+' units → '+M.nm+' — '+M.ds);
1477
  sfx('ui');
1478
  } else toast('Selected units have no alternate stance');
 
1514
  }
1515
  if(aiming===0) $('abOver').classList.add('on'); else $('abOver').classList.remove('on');
1516
  if(typeof artBarrageButtonState==='function') artBarrageButtonState();
1517
+ if(typeof classAbilityButtonState==='function') classAbilityButtonState();
1518
  // carrier deployment prompt
1519
  const db=$('deployBtn');
1520
  if(db){
 
1611
  tech:'Unlocks and strengthens advanced systems', sup:'Extends and sustains the base',
1612
  sup2:'Strategic weapon for decisive strikes'
1613
  };
1614
+ /* A category tab must answer more than “which cards are hidden underneath
1615
+ it?”. These short tactical briefs make the taxonomy useful to a newcomer:
1616
+ what the role accomplishes, which formation supports it, and the mistake
1617
+ that normally gets it killed. They deliberately sit above the card grid so
1618
+ the player can understand the decision before spending resources. */
1619
+ const INTEL_ROLE_GUIDE={
1620
+ inf:{tag:'LINE TROOPS',form:'WEDGE / SPREAD',use:'Capture ground, screen expensive weapons, and pressure exposed economy.',avoid:'Do not mass into splash or flame.'},
1621
+ veh:{tag:'ARMOURED CORE',form:'BATTLE LINE',use:'Hold the centre and trade efficiently under sustained fire.',avoid:'Screen against gauss and dedicated anti-tank.'},
1622
+ at :{tag:'HEAVY HUNTERS',form:'LINE / ARC',use:'Delete plated targets with focused, deliberate volleys.',avoid:'Keep light swarms outside minimum range.'},
1623
+ aoe:{tag:'CROWD BREAKERS',form:'SPREAD',use:'Punish dense infantry and biological waves with splash damage.',avoid:'Protect them from long-range armour hunters.'},
1624
+ art:{tag:'SIEGE BATTERY',form:'ARC / LINE',use:'Break towers and formations from beyond normal weapon range.',avoid:'Scout first; artillery folds at close range.'},
1625
+ aa :{tag:'AIR DENIAL',form:'SPREAD / BOX',use:'Escort valuable columns and deny bomber approaches.',avoid:'Specialists lose value against ground-only armies.'},
1626
+ air:{tag:'RAPID RESPONSE',form:'WEDGE',use:'Scout, flank, and strike weak points without following ground paths.',avoid:'Never loiter inside layered anti-air.'},
1627
+ nav:{tag:'SEA CONTROL',form:'LINE',use:'Own coastlines and project heavy fire onto nearby land.',avoid:'Water-only movement makes positioning a commitment.'},
1628
+ sup:{tag:'FORCE MULTIPLIER',form:'BOX / COLUMN',use:'Repair, shield, build, and keep the fighting line operational.',avoid:'Unarmed support needs an escort.'},
1629
+ exp:{tag:'DECISIVE ASSET',form:'BOX',use:'Anchor a late-game push and force the enemy to answer one threat.',avoid:'High cost makes isolation and focus fire disastrous.'}
1630
+ };
1631
+ const INTEL_BUILD_GUIDE={
1632
+ eco :{tag:'RESOURCE GRID',use:'Secure income and storage before expanding production.',chain:'DEPOSIT → EXTRACTOR → STORAGE'},
1633
+ prod:{tag:'ARMY PIPELINE',use:'Set rally points, maintain queues, and diversify unit counters.',chain:'POWER → FACTORY → RALLY'},
1634
+ def :{tag:'DEFENCE LAYER',use:'Overlap ranges so one tower covers another tower’s weakness.',chain:'SCOUT → SCREEN → KILL ZONE'},
1635
+ wall:{tag:'PATH CONTROL',use:'Shape enemy movement without trapping your own reinforcements.',chain:'WALL → GATE → CROSS-FIRE'},
1636
+ tech:{tag:'ACCOUNT RESEARCH',use:'Protect the lab network; completed studies persist beyond the match.',chain:'LAB → STUDY → ACCOUNT DATA'},
1637
+ sup :{tag:'BATTLE NETWORK',use:'Extend territory, shielding, repairs, and targeting coverage.',chain:'UPLINK → COVERAGE → ADVANCE'},
1638
+ sup2:{tag:'STRATEGIC STRIKE',use:'Scout the target and force movement before committing the long cooldown.',chain:'VISION → LOCK → LAUNCH'}
1639
+ };
1640
  const INTEL_BLD_WEAPONS={
1641
  turret:{wk:'b',rng:()=>TURRET_RNG,tg:'a'}, bunker:{wk:'e',rng:()=>BUNKER.rng,tg:'g'},
1642
  aatower:{wk:'e',rng:()=>AA.rng,tg:'air'}, bastion:{wk:'e',rng:()=>BASTION.rng,min:()=>BASTION.minRng,tg:'g'},
 
1656
  return '—';
1657
  }
1658
  function intelUnitPurpose(T){
1659
+ if(T.miner) return 'Mobile phase-ore miner. Cycle MINE, ASSIST and SURVEY orders to gather or accelerate production.';
1660
+ if(T.caster) return 'Critical-mass Brood leader. Its aura turns nearby creatures into a faster coordinated tide.';
1661
  if(T.builder) return 'Mobile engineer that establishes forward bases and repairs structures.';
1662
  if(T.name==='Bulwark') return 'Mobile shield projector that reduces damage to nearby allied units.';
1663
  if(T.scout) return 'High-speed reconnaissance aircraft for finding threats and flanking exposed targets.';
 
1881
  if(V&&V.setSubject(kind,id)) return;
1882
  if(host){host.innerHTML='';host.appendChild(mfIntelPreviewWindow(kind,id,'LIVE 3D'));}
1883
  }
1884
+ function mfMenuRoleHost(menuId,gridId){
1885
+ const menu=$(menuId),grid=$(gridId);if(!menu||!grid)return null;
1886
+ let host=menu.querySelector('.menuRoleBrief');
1887
+ if(!host){host=document.createElement('section');host.className='menuRoleBrief';grid.insertAdjacentElement('beforebegin',host);}
1888
+ return host;
1889
+ }
1890
+ function renderMenuRoleBrief(kind,key,ids){
1891
+ const isUnit=kind==='unit',host=mfMenuRoleHost(isUnit?'prodMenu':'buildMenu',isUnit?'prodGrid':'buildGrid');
1892
+ if(!host||!ids||!ids.length)return;
1893
+ const C=isUnit?(UCAT[key]||UCAT.veh):(BCAT[key]||BCAT.sup);
1894
+ const G=isUnit?(INTEL_ROLE_GUIDE[key]||INTEL_ROLE_GUIDE.veh):(INTEL_BUILD_GUIDE[key]||INTEL_BUILD_GUIDE.sup);
1895
+ const first=ids[0],T=isUnit?TYPES[first]:BT[first];
1896
+ host.innerHTML='<div class="menuRoleVisual"></div><div class="menuRoleCopy">'
1897
+ +'<div class="menuRoleEyebrow"><span>'+C.em+'</span>'+G.tag+'<b>'+ids.length+' AVAILABLE</b></div>'
1898
+ +'<strong>'+C.nm+'</strong><p>'+G.use+'</p>'
1899
+ +'<div class="menuRoleDoctrine"><span>'+(isUnit?'FORMATION':'BUILD CHAIN')+'</span>'+(isUnit?G.form:G.chain)+'</div>'
1900
+ +(isUnit?'<small>⚠ '+G.avoid+'</small>':'')
1901
+ +'<button type="button" class="menuRoleInspect">INSPECT '+T.name.toUpperCase()+'</button></div>';
1902
+ const visual=host.querySelector('.menuRoleVisual');
1903
+ visual.appendChild(mfIntelPreviewWindow(isUnit?'unit':'building',first,'TACTICAL PREVIEW'));
1904
+ const inspect=host.querySelector('.menuRoleInspect');
1905
+ inspect.addEventListener('pointerdown',ev=>{
1906
+ ev.preventDefault();ev.stopPropagation();
1907
+ if(isUnit)showUnitTypeCard(first,true);else showBuildingTypeCard(first,-1,true);
1908
+ sfx('ui');
1909
+ });
1910
+ }
1911
  let mfIntelMenuTimer=0;
1912
  function mfIntelMenuInit(tryN){
1913
  const host=$('menuIntelModel');if(!host||host.dataset.ready) return;
 
1948
  ?'☄ ACTIVE: CHARGED BARRAGE · select artillery, open POWERS, then target the map'
1949
  :'🔒 CHARGED BARRAGE · research Fire Mission Protocol in Development > Doctrine';
1950
  card.appendChild(info);
1951
+ }else if(typeof CLASS_AB!=='undefined'){
1952
+ const k=Object.keys(CLASS_AB).find(id=>CLASS_AB[id].cats.indexOf(T.cat)>=0);
1953
+ if(k){
1954
+ const A=CLASS_AB[k],card=$('unitCard'),info=document.createElement('div'),open=classAbilityUnlocked(A);
1955
+ info.className='ucCounter'+(open?'':' caution');
1956
+ info.textContent=open
1957
+ ?A.em+' ACTIVE: '+A.nm+' · select this class and open POWERS'
1958
+ :'🔒 '+A.nm+' · research '+((typeof DEVTREE!=='undefined'&&DEVTREE.find(x=>x.id===A.req)||{}).nm||A.req)+' in Development > Doctrine';
1959
+ card.appendChild(info);
1960
+ }
1961
  }
1962
  mfIntelAttachPreview('unit',tIdx);
1963
  }
 
2155
  13:'unit_alpha',18:'unit_scorcher',19:'unit_constructor',
2156
  20:'unit_reaper',21:'unit_cinder',22:'unit_lancer',23:'unit_resonator',
2157
  24:'unit_warden',25:'unit_kestrel',26:'unit_basilisk',27:'unit_harbinger',
2158
+ 28:'unit_praetor',29:'unit_archon',30:'unit_brood',31:'unit_brood'
2159
  }[tIdx];
2160
  if(special&&typeof itemArt==='function'){
2161
  w.innerHTML=itemArt(special,T.name,size);
 
2207
  if(B.type==='tgate') list=[8,26];
2208
  else if(B.type==='harbor') list=[14,15];
2209
  else if(B.type==='airfield') list=[5,17,25];
2210
+ else list = B.tier===2? [0,1,9,18,10,2,3,6,7,11,16,19,20,21,22,23,24,27,32]
2211
+ : [0,1,9,10,19,24,32];
2212
  /* ROLE TABS. A flat grid of eighteen cards is a wall; one tap per role lets
2213
  the player find the answer to whatever is killing them without reading
2214
  every stat block on the way there. */
 
2227
  b.addEventListener('pointerdown',ev=>{ ev.stopPropagation(); prodTab=c; sfx('ui'); renderProdMenu(); });
2228
  tr.appendChild(b);
2229
  }
2230
+ renderMenuRoleBrief('unit',prodTab,groups[prodTab]||[]);
2231
  (groups[prodTab]||[]).forEach(tIdx=>{
2232
  const T=TYPES[tIdx];
2233
  const d=document.createElement('div');
 
2244
  if(openBld<0) return;
2245
  const Bb=blds[openBld];
2246
  if(tIdx===8 && titanCount[0]+Bb.queue.filter(q=>q===8).length>=3){ toast('Max 3 TITANs'); return; }
2247
+ if((tIdx===UT_ENGINEER||tIdx===UT_MINER)&&supportUnitCount(0,true)>=supportUnitCap(0)){
2248
+ toast('⚙ SUPPORT CAP '+supportUnitCap(0)+' — raise Commander level or operate a Research Lab');return;
2249
+ }
2250
  if(Bb.queue.length<30){ Bb.queue.push(tIdx); sfx('ui'); renderQueue(); }
2251
  if(tIdx!==8){ // hold to queue a batch of 5
2252
  const hold=setTimeout(()=>{
 
2317
  b.addEventListener('pointerdown',ev=>{ ev.stopPropagation(); bldTab=c; sfx('ui'); renderBuildMenu(); });
2318
  tr.appendChild(b);
2319
  }
2320
+ renderMenuRoleBrief('building',bldTab,grp[bldTab]||[]);
2321
  for(const key of (grp[bldTab]||[])){
2322
  const T=BT[key];
2323
  const d=document.createElement('div');
src/ui/input.js CHANGED
@@ -146,8 +146,11 @@ function formationTargets(sel,wx,wy,form,angle){
146
  angle=Math.atan2(wy-cy2/sel.length,wx-cx2/sel.length);
147
  }
148
  const ct=Math.cos(angle),st=Math.sin(angle),offs=formationOffsets(sel.length,form,formationSpacing(sel));
149
- return offs.map(o=>({x:clamp(wx-o[1]*ct-o[0]*st,15,MAP-15),
150
- y:clamp(wy-o[1]*st+o[0]*ct,15,MAP-15)}));
 
 
 
151
  }
152
  function setFormation(n,quiet){
153
  selFormation=((n%FORMS.length)+FORMS.length)%FORMS.length;
@@ -182,6 +185,7 @@ function toggleMoveMode(){
182
  function orderMove(wx,wy,patrol){
183
  const sel=formationMembers();
184
  if(!sel.length) return false;
 
185
  const fld=requestField(wx,wy); // flow field routes the army around lakes/cliffs
186
  const form=FORMS[selFormation].id;
187
  const targets=formationTargets(sel,wx,wy,form);
@@ -215,7 +219,9 @@ function beginPatrolDraft(){
215
  }
216
  function addPatrolWaypoint(wx,wy){
217
  if(!patrolDraft)return;
218
- const p={x:clamp(wx,20,MAP-20),y:clamp(wy,20,MAP-20)};
 
 
219
  const last=patrolDraft.pts[patrolDraft.pts.length-1];
220
  if(Math.hypot(p.x-last.x,p.y-last.y)<35){toast('Place the next waypoint farther along the route');return;}
221
  if(patrolDraft.pts.length>=6){toast('Route has 5 waypoints — tap START');return;}
@@ -409,7 +415,9 @@ function onTap(sx,sy){
409
  if(armRally>=0){
410
  const bi2=armRally; armRally=-1;
411
  if(blds[bi2]&&blds[bi2].alive){
412
- blds[bi2].rally={x:clamp(wx,20,MAP-20),y:clamp(wy,20,MAP-20)};
 
 
413
  addParticle(3,wx,wy,0,0,.5,30, 120,255,170);
414
  toast('⚑ Rally point set — new units will gather there');
415
  sfx('ui');
@@ -418,7 +426,9 @@ function onTap(sx,sy){
418
  }
419
  // pre-deployment: taps steer the carrier to your chosen landing ground
420
  if(carrier.active&&carrier.phase===1){
421
- carrier.tx=clamp(wx,60,MAP-60); carrier.ty=clamp(wy,60,MAP-60);
 
 
422
  addParticle(3,carrier.tx,carrier.ty,0,0,.5,32, 120,220,255);
423
  uiCommandAck('deploy',1,carrier.tx,carrier.ty);
424
  return;
@@ -475,8 +485,9 @@ cv.addEventListener('pointerdown',e=>{
475
  }
476
  if(ptrs.size===1&&armFormation&&!placing&&!boxMode&&running&&selCount()){
477
  const [wx,wy]=s2w(e.clientX,e.clientY);
 
478
  orderConfirm=null;
479
- orderPreview={pid:e.pointerId,x:clamp(wx,15,MAP-15),y:clamp(wy,15,MAP-15),
480
  members:formationMembers(),form:selFormation};
481
  clearTimeout(holdTimer);return;
482
  }
@@ -518,7 +529,8 @@ cv.addEventListener('pointermove',e=>{
518
  }
519
  if(orderPreview&&orderPreview.pid===e.pointerId){
520
  const [wx,wy]=s2w(e.clientX,e.clientY);
521
- orderPreview.x=clamp(wx,15,MAP-15);orderPreview.y=clamp(wy,15,MAP-15);return;
 
522
  }
523
  if(ptrs.size===1){
524
  if(placing&&dragGhost){
 
146
  angle=Math.atan2(wy-cy2/sel.length,wx-cx2/sel.length);
147
  }
148
  const ct=Math.cos(angle),st=Math.sin(angle),offs=formationOffsets(sel.length,form,formationSpacing(sel));
149
+ return offs.map(o=>{
150
+ let x=clamp(wx-o[1]*ct-o[0]*st,15,MAP-15),y=clamp(wy-o[1]*st+o[0]*ct,15,MAP-15);
151
+ if(typeof battlefieldClampPoint==='function'){const p=battlefieldClampPoint(x,y,15);x=p[0];y=p[1];}
152
+ return {x,y};
153
+ });
154
  }
155
  function setFormation(n,quiet){
156
  selFormation=((n%FORMS.length)+FORMS.length)%FORMS.length;
 
185
  function orderMove(wx,wy,patrol){
186
  const sel=formationMembers();
187
  if(!sel.length) return false;
188
+ if(typeof battlefieldClampPoint==='function'){const p=battlefieldClampPoint(wx,wy,24);wx=p[0];wy=p[1];}
189
  const fld=requestField(wx,wy); // flow field routes the army around lakes/cliffs
190
  const form=FORMS[selFormation].id;
191
  const targets=formationTargets(sel,wx,wy,form);
 
219
  }
220
  function addPatrolWaypoint(wx,wy){
221
  if(!patrolDraft)return;
222
+ let px=clamp(wx,20,MAP-20),py=clamp(wy,20,MAP-20);
223
+ if(typeof battlefieldClampPoint==='function'){const q=battlefieldClampPoint(px,py,24);px=q[0];py=q[1];}
224
+ const p={x:px,y:py};
225
  const last=patrolDraft.pts[patrolDraft.pts.length-1];
226
  if(Math.hypot(p.x-last.x,p.y-last.y)<35){toast('Place the next waypoint farther along the route');return;}
227
  if(patrolDraft.pts.length>=6){toast('Route has 5 waypoints — tap START');return;}
 
415
  if(armRally>=0){
416
  const bi2=armRally; armRally=-1;
417
  if(blds[bi2]&&blds[bi2].alive){
418
+ let rx=clamp(wx,20,MAP-20),ry=clamp(wy,20,MAP-20);
419
+ if(typeof battlefieldClampPoint==='function'){const p=battlefieldClampPoint(rx,ry,24);rx=p[0];ry=p[1];}
420
+ blds[bi2].rally={x:rx,y:ry};
421
  addParticle(3,wx,wy,0,0,.5,30, 120,255,170);
422
  toast('⚑ Rally point set — new units will gather there');
423
  sfx('ui');
 
426
  }
427
  // pre-deployment: taps steer the carrier to your chosen landing ground
428
  if(carrier.active&&carrier.phase===1){
429
+ let cx=clamp(wx,60,MAP-60),cy=clamp(wy,60,MAP-60);
430
+ if(typeof battlefieldClampPoint==='function'){const p=battlefieldClampPoint(cx,cy,80);cx=p[0];cy=p[1];}
431
+ carrier.tx=cx; carrier.ty=cy;
432
  addParticle(3,carrier.tx,carrier.ty,0,0,.5,32, 120,220,255);
433
  uiCommandAck('deploy',1,carrier.tx,carrier.ty);
434
  return;
 
485
  }
486
  if(ptrs.size===1&&armFormation&&!placing&&!boxMode&&running&&selCount()){
487
  const [wx,wy]=s2w(e.clientX,e.clientY);
488
+ const pp=typeof battlefieldClampPoint==='function'?battlefieldClampPoint(wx,wy,24):[clamp(wx,15,MAP-15),clamp(wy,15,MAP-15)];
489
  orderConfirm=null;
490
+ orderPreview={pid:e.pointerId,x:pp[0],y:pp[1],
491
  members:formationMembers(),form:selFormation};
492
  clearTimeout(holdTimer);return;
493
  }
 
529
  }
530
  if(orderPreview&&orderPreview.pid===e.pointerId){
531
  const [wx,wy]=s2w(e.clientX,e.clientY);
532
+ const pp=typeof battlefieldClampPoint==='function'?battlefieldClampPoint(wx,wy,24):[clamp(wx,15,MAP-15),clamp(wy,15,MAP-15)];
533
+ orderPreview.x=pp[0];orderPreview.y=pp[1];return;
534
  }
535
  if(ptrs.size===1){
536
  if(placing&&dragGhost){
src/ui/render3d.js CHANGED
@@ -94,7 +94,10 @@ function drawShadows(S){
94
  as a decal painted on the grass. */
95
  for(const o of rocks) if(vis(o.x,o.y,60)) put(o.x,o.y,o.s*0.62,o.s*0.75);
96
  for(const o of trees) if(vis(o.x,o.y,60)) put(o.x,o.y,o.s*0.52,o.s*1.15);
97
- for(const o of crystals) if(vis(o.x,o.y,60)) put(o.x,o.y,o.s*0.45,o.s*1.0);
 
 
 
98
  if(typeof carrier!=='undefined'&&carrier.active&&carrierEffectiveAlt()<260)
99
  put(carrier.x,carrier.y,54,Math.max(2,carrierEffectiveAlt()*0.6));
100
  if(!FX.shadow.n) return;
@@ -137,6 +140,65 @@ function begin3D(nA){
137
  }
138
  function setEmis(v){ gl.uniform1f(U3.uEmis,v); }
139
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  /* A beam is a camera-facing textured ribbon, not a row of glow dots. Project
141
  both endpoints first so the rectangle follows the beam on screen even while
142
  the player rotates and tilts the camera. Long paths are split into a handful
@@ -342,7 +404,16 @@ function render(dtDraw){
342
  the active unit, so it cannot alias a material or AO sampler. */
343
  gl.activeTexture(gl.TEXTURE7); gl.bindTexture(gl.TEXTURE_2D,fogTex||terrainTex);
344
  gl.uniform1f(UT.uFogActive,fogOn&&!demoMode&&fogTex?1:0);
 
 
 
 
 
 
 
 
345
  gl.activeTexture(gl.TEXTURE0);
 
346
  drawTerrain();
347
  drawShadows(Sun); // ground shadows go on before anything stands on them
348
  begin3D(S_nA); // back to the lit model program
@@ -351,9 +422,16 @@ function render(dtDraw){
351
  for(const r of rocks){ if(!vis(r.x,r.y,40)) continue; FX.rock.add(r.x,r.y,gh(r.x,r.y),r.s*0.035,r.a,190,186,178,255); }
352
  const tt=THEMES[curTheme].treeTint;
353
  for(const tr of trees){ if(!vis(tr.x,tr.y,50)) continue; FX.tree.add(tr.x,tr.y,gh(tr.x,tr.y),tr.s*0.030,tr.a,tt[0],tt[1],tt[2],255); }
354
- for(const cs of crystals){ if(!vis(cs.x,cs.y,40)) continue; FX.crystal.add(cs.x,cs.y,gh(cs.x,cs.y),cs.s*0.038,cs.a,220,240,255,255); }
 
 
 
 
 
 
355
  for(const D of deposits){ if(!vis(D.x,D.y,60)) continue;
356
- FX.dep.add(D.x,D.y,gh(D.x,D.y),D.rich?1.25:0.95,0, D.rich?255:200, D.rich?214:230, D.rich?120:255,255); }
 
357
  for(const G of geysers){ if(!vis(G.x,G.y,60)) continue; FX.geyser.add(G.x,G.y,gh(G.x,G.y),1,0,210,220,225,255); }
358
  // crater berms — real mounds standing on the deformed ground
359
  for(const M of relief){ if(!vis(M.x,M.y,60)) continue;
@@ -882,6 +960,7 @@ function render(dtDraw){
882
  gl.blendFunc(gl.SRC_ALPHA,gl.ONE);
883
  gl.depthMask(false);
884
  gl.disable(gl.CULL_FACE);
 
885
  /* At the strategic overview, thousands of individually correct glows placed
886
  on regimented formation rows merge into solid stripes. Sample the effects
887
  there; the full billboard stack returns automatically as the player zooms
 
94
  as a decal painted on the grass. */
95
  for(const o of rocks) if(vis(o.x,o.y,60)) put(o.x,o.y,o.s*0.62,o.s*0.75);
96
  for(const o of trees) if(vis(o.x,o.y,60)) put(o.x,o.y,o.s*0.52,o.s*1.15);
97
+ for(const o of crystals){
98
+ const D=deposits[o.dep],tier=depositTier(D);if(!D||o.band>tier||!vis(o.x,o.y,60))continue;
99
+ put(o.x,o.y,o.s*(o.core?.15:.11),o.s*(o.core?.34:.24));
100
+ }
101
  if(typeof carrier!=='undefined'&&carrier.active&&carrierEffectiveAlt()<260)
102
  put(carrier.x,carrier.y,54,Math.max(2,carrierEffectiveAlt()*0.6));
103
  if(!FX.shadow.n) return;
 
140
  }
141
  function setEmis(v){ gl.uniform1f(U3.uEmis,v); }
142
 
143
+ /* A battlefield limit should read as command infrastructure, not the renderer
144
+ running out of world. The red core and EVERY grid rung sit on the safe side;
145
+ procedural exclusion art starts beyond them in the terrain shader. It is
146
+ flushed by the unlit additive pass, so sensor fog cannot turn it black and
147
+ the physical-edge haze cannot erase it. */
148
+ function terrainExclusionStyle(mapKey,themeKey){
149
+ const TH=THEMES[themeKey]||THEMES.verdant;
150
+ const style=mapKey==='isles'?1:(themeKey==='ashland'||mapKey==='crater'?2:
151
+ (themeKey==='arctic'||mapKey==='highland'?3:0));
152
+ const tint=style===1?TH.wDeep:(style===2?(TH.cliff||[64,48,42]):
153
+ (style===3?(themeKey==='arctic'?[108,128,150]:[76,88,104]):(TH.g0||[62,82,54])));
154
+ return {style,tint};
155
+ }
156
+ let mapBoundaryDrawCount=0,mapBoundaryOutsideCount=0;
157
+ function queueBattlefieldEdgeGrid(t,vis){
158
+ mapBoundaryDrawCount=0;mapBoundaryOutsideCount=0;
159
+ const B=typeof battlefieldPlayBounds==='function'?battlefieldPlayBounds(0):{lo:0,hi:MAP,span:MAP};
160
+ const d=typeof battlefieldSignedDistance==='function'?Math.abs(battlefieldSignedDistance(cam.x,cam.y,0)):
161
+ Math.min(Math.abs(cam.x-B.lo),Math.abs(cam.x-B.hi),Math.abs(cam.y-B.lo),Math.abs(cam.y-B.hi));
162
+ const reach=Math.max(480,orthoSpan*.42);
163
+ if(d>=reach) return 0;
164
+ const reveal=clamp((reach-d)/(reach*.64),0,1),span=B.hi-B.lo;
165
+ const seg=Math.max(64,Math.round(span/28)),inner=Math.min(112,span*.07),safe=9;
166
+ const pulse=.78+.22*Math.sin(t*3.2), coreA=(180+55*pulse)*reveal, gridA=(58+38*pulse)*reveal;
167
+ const inside=(x,y,pad)=>typeof battlefieldContains==='function'?battlefieldContains(x,y,pad||0):
168
+ x>=B.lo+(pad||0)&&x<=B.hi-(pad||0)&&y>=B.lo+(pad||0)&&y<=B.hi-(pad||0);
169
+ const put=(x,y,len,ang,r,g,b,a,w,endpoints)=>{
170
+ if(a<5||!vis(x,y,len*.55+55)) return;
171
+ FX.line.add(x,y,terrainH(x,y)+7.5,len,ang,r,g,b,a,w);
172
+ if(!inside(x,y,0)||(endpoints&&endpoints.some(P=>!inside(P[0],P[1],0))))mapBoundaryOutsideCount++;
173
+ mapBoundaryDrawCount++;
174
+ };
175
+ const point=(a,pad)=>typeof battlefieldBoundaryPoint==='function'?battlefieldBoundaryPoint(a,pad):[
176
+ (B.lo+B.hi)*.5+Math.cos(a)*span*.48,(B.lo+B.hi)*.5+Math.sin(a)*span*.48];
177
+ /* Sample the authored silhouette itself. Coastal inlets become a soft,
178
+ rounded perimeter; scorched maps keep sharper chamfers; storm fronts bow
179
+ asymmetrically. No map is forced through the old four-sided stamp. */
180
+ for(let k=0;k<seg;k++){
181
+ const a0=k/seg*TAU,a1=(k+1)/seg*TAU;
182
+ const A=point(a0,safe),C=point(a1,safe),dx=C[0]-A[0],dy=C[1]-A[1],len=Math.hypot(dx,dy);
183
+ put((A[0]+C[0])*.5,(A[1]+C[1])*.5,len,Math.atan2(dy,dx),255,42,30,coreA,3.8,[A,C]);
184
+ const D=point(a0,safe+inner*.42),E=point(a1,safe+inner*.42),ex=E[0]-D[0],ey=E[1]-D[1];
185
+ put((D[0]+E[0])*.5,(D[1]+E[1])*.5,Math.hypot(ex,ey),Math.atan2(ey,ex),255,72,48,gridA,2.0,[D,E]);
186
+ if((k&3)===0){
187
+ const O=point((a0+a1)*.5,safe+2),I=point((a0+a1)*.5,safe+inner),rx=I[0]-O[0],ry=I[1]-O[1];
188
+ put((O[0]+I[0])*.5,(O[1]+I[1])*.5,Math.hypot(rx,ry),Math.atan2(ry,rx),255,58,42,gridA*1.15,1.8,[O,I]);
189
+ }
190
+ }
191
+ const nodeR=5.2+1.0*pulse;
192
+ for(let k=0;k<8;k++){
193
+ const P=point(k/8*TAU,safe+nodeR+1),x=P[0],y=P[1];
194
+ if(!vis(x,y,90))continue;
195
+ FX.ring.add(x,y,terrainH(x,y)+9,nodeR,-t*.55,255,58,38,205*reveal);
196
+ if(!inside(x,y,nodeR*.15))mapBoundaryOutsideCount++;
197
+ mapBoundaryDrawCount++;
198
+ }
199
+ return mapBoundaryDrawCount;
200
+ }
201
+
202
  /* A beam is a camera-facing textured ribbon, not a row of glow dots. Project
203
  both endpoints first so the rectangle follows the beam on screen even while
204
  the player rotates and tilts the camera. Long paths are split into a handful
 
404
  the active unit, so it cannot alias a material or AO sampler. */
405
  gl.activeTexture(gl.TEXTURE7); gl.bindTexture(gl.TEXTURE_2D,fogTex||terrainTex);
406
  gl.uniform1f(UT.uFogActive,fogOn&&!demoMode&&fogTex?1:0);
407
+ {
408
+ const E=typeof battlefieldPlayBounds==='function'?battlefieldPlayBounds(0):{lo:0,hi:MAP};
409
+ const ES=terrainExclusionStyle(curMap,curTheme),style=ES.style,ec=ES.tint;
410
+ const lc=_lin([ec[0]/255,ec[1]/255,ec[2]/255]);
411
+ gl.uniform2f(UT.uPlayBounds,E.lo,E.hi);
412
+ gl.uniform1f(UT.uEdgeStyle,style);gl.uniform1f(UT.uEdgeTime,t);
413
+ gl.uniform3f(UT.uEdgeTint,lc[0],lc[1],lc[2]);
414
+ }
415
  gl.activeTexture(gl.TEXTURE0);
416
+ drawTerrainEdge();
417
  drawTerrain();
418
  drawShadows(Sun); // ground shadows go on before anything stands on them
419
  begin3D(S_nA); // back to the lit model program
 
422
  for(const r of rocks){ if(!vis(r.x,r.y,40)) continue; FX.rock.add(r.x,r.y,gh(r.x,r.y),r.s*0.035,r.a,190,186,178,255); }
423
  const tt=THEMES[curTheme].treeTint;
424
  for(const tr of trees){ if(!vis(tr.x,tr.y,50)) continue; FX.tree.add(tr.x,tr.y,gh(tr.x,tr.y),tr.s*0.030,tr.a,tt[0],tt[1],tt[2],255); }
425
+ for(const cs of crystals){
426
+ const D=deposits[cs.dep],tier=depositTier(D);if(!D||cs.band>tier||!vis(cs.x,cs.y,52))continue;
427
+ const fill=clamp((D.remaining-(tier-1)*DEPOSIT_BAND)/DEPOSIT_BAND,0,1),edge=cs.band===tier?(.46+.54*fill):1;
428
+ const col=cs.band===3?[255,120,255]:cs.band===2?[105,255,176]:[105,226,255];
429
+ const sc=cs.s*(cs.core?.073:.058)*edge;
430
+ FX.crystal.add(cs.x,cs.y,gh(cs.x,cs.y),sc,cs.a,col[0],col[1],col[2],255);
431
+ }
432
  for(const D of deposits){ if(!vis(D.x,D.y,60)) continue;
433
+ const tier=depositTier(D),col=tier===3?[255,122,255]:tier===2?[110,255,180]:tier===1?[112,228,255]:[88,92,100];
434
+ FX.dep.add(D.x,D.y,gh(D.x,D.y),.9+tier*.14,0,col[0],col[1],col[2],tier?255:145); }
435
  for(const G of geysers){ if(!vis(G.x,G.y,60)) continue; FX.geyser.add(G.x,G.y,gh(G.x,G.y),1,0,210,220,225,255); }
436
  // crater berms — real mounds standing on the deformed ground
437
  for(const M of relief){ if(!vis(M.x,M.y,60)) continue;
 
960
  gl.blendFunc(gl.SRC_ALPHA,gl.ONE);
961
  gl.depthMask(false);
962
  gl.disable(gl.CULL_FACE);
963
+ queueBattlefieldEdgeGrid(t,vis);
964
  /* At the strategic overview, thousands of individually correct glows placed
965
  on regimented formation rows merge into solid stripes. Sample the effects
966
  there; the full billboard stack returns automatically as the player zooms
src/updater.js CHANGED
@@ -29,7 +29,7 @@
29
  ============================================================================ */
30
 
31
  /* Bumped by the release script. Compared against the manifest's `version`. */
32
- const APP_VERSION = '1.31.0';
33
 
34
  /* The channel URL in update-config.json remains publisher-configurable, but a
35
  production checker also needs one known-good recovery path. More importantly,
 
29
  ============================================================================ */
30
 
31
  /* Bumped by the release script. Compared against the manifest's `version`. */
32
+ const APP_VERSION = '1.32.0';
33
 
34
  /* The channel URL in update-config.json remains publisher-configurable, but a
35
  production checker also needs one known-good recovery path. More importantly,