File size: 52,456 Bytes
b51e03a
3597f5a
fcce7e3
 
 
 
 
b51e03a
eabf9d4
 
181ede2
 
 
fcce7e3
 
 
b51e03a
 
 
 
 
fcce7e3
 
 
b51e03a
 
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b51e03a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
b51e03a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
b51e03a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
b51e03a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
 
 
 
 
 
 
 
 
 
 
b51e03a
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
b51e03a
3597f5a
b51e03a
 
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
 
b51e03a
3597f5a
 
b51e03a
 
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
 
b51e03a
 
 
 
3597f5a
b51e03a
 
 
3597f5a
b51e03a
3597f5a
 
b51e03a
 
3597f5a
b51e03a
3597f5a
 
 
b51e03a
3597f5a
 
b51e03a
3597f5a
 
b51e03a
 
 
3597f5a
 
 
 
b51e03a
3597f5a
b51e03a
3597f5a
 
 
 
b51e03a
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
b51e03a
3597f5a
 
b51e03a
 
3597f5a
 
 
b51e03a
 
 
 
 
 
 
 
 
3597f5a
b51e03a
 
fcce7e3
369b236
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bebd577
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45d8535
 
 
 
 
 
 
 
 
 
 
 
 
bebd577
 
 
45d8535
bebd577
 
 
 
45d8535
 
bebd577
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45d8535
bebd577
 
 
3597f5a
 
 
 
 
 
 
bebd577
 
 
 
 
45d8535
bebd577
 
45d8535
bebd577
 
 
fcce7e3
 
 
 
 
 
 
 
 
 
 
 
 
 
45d8535
 
 
 
 
fcce7e3
 
 
acce472
 
 
 
fcce7e3
 
 
45d8535
fcce7e3
 
 
eabf9d4
 
 
 
 
 
 
 
 
fcce7e3
 
 
 
 
 
 
 
 
 
 
 
 
 
45d8535
 
 
 
 
 
fcce7e3
45d8535
 
 
 
 
fcce7e3
 
45d8535
fcce7e3
 
 
 
 
 
 
 
 
 
 
 
 
 
369b236
b51e03a
 
fcce7e3
 
 
eabf9d4
fcce7e3
 
 
 
3597f5a
 
 
 
 
 
369b236
 
 
fcce7e3
3597f5a
fcce7e3
 
 
 
 
 
369b236
fcce7e3
eabf9d4
 
 
 
 
 
 
 
 
3597f5a
eabf9d4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
 
eabf9d4
 
 
 
 
fcce7e3
3597f5a
fcce7e3
 
 
 
 
369b236
fcce7e3
 
181ede2
 
 
3597f5a
 
181ede2
3597f5a
181ede2
 
3597f5a
181ede2
 
 
 
 
 
 
 
 
 
 
 
 
8b385f3
 
 
 
 
 
 
 
181ede2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
181ede2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
181ede2
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
181ede2
fcce7e3
 
3597f5a
fcce7e3
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fcce7e3
3597f5a
 
369b236
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fcce7e3
 
 
b51e03a
 
 
181ede2
bebd577
369b236
3597f5a
fcce7e3
 
 
3597f5a
fcce7e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
369b236
fcce7e3
 
 
 
 
 
 
 
 
3597f5a
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
;
;
/* ============================================================
   RPG β€” hero commander: XP, levels, upgrades, active abilities
   ============================================================ */
let heroLvl=1, heroXp=0, heroXpNext=100;
let heroDmgMult=1, heroRegen=14;
let commanderHpMult=1;
let abCool=[0,0,0,0,0];                // blast, repair, surge, orbital lance, EMP
const AB_CD=[26,20,30,70,45];
let heroJumpCool=0;
const HERO_JUMP={range:275,cool:28,energy:170};
let heroStuckFor=0,heroRescueAt=0;
/* Index 4 is the craftable EMP: unlocked by a module rather than by rank or by
   the store, so it is the one ability that can be LOST when its module wears
   out. */
/* No universal Blast button. The selected commander grants their authored
   baseline power in applyCommanderChoice(); rank, Armory and modules add the
   others later. This makes the action rail describe the commander rather than
   exposing a generic legacy power menu. */
let abUnlock=[false,false,false,false,false];
let salvageMult=1, bldHpMult=1;
let aiming=-1;                         // ability awaiting a target tap
let pendingLevels=0;
let commanderActiveCool=0;
let commanderWeaponCool=[0,0];
/* Voss βˆ’14% is a live multiplier at ASSIGN time, not a rewrite of AB_CD.
   applyMetaPerks rebuilds AB_CD from AB_BASE (capacitor / Chrono Rig), and
   Rapid Systems mutates that table mid-match. Folding Voss into AB_CD missed
   signature, jump and commander weapons, and would double-apply if the table
   was rebuilt. Class doctrine and barrage stay army unlocks, not commander CD. */
let commanderCdMult=1;
function commanderCool(t){return (t||0)*commanderCdMult;}
/* Seat identity. main.js stores AI.bases[].commanderId (and the same field
   on AI.allies) so Large 1v3 is not three copies of the faction default.
   Mesh and weapon overlays key off that id β€” never TYPES, never playerCommanderId
   for an enemy. applyCommanderChoice stays the player perk stack. */
function commanderIdForUnit(i){
  if(i==null||i<0) return null;
  if(typeof heroIdx!=='undefined'&&i===heroIdx)
    return (typeof playerCommanderId!=='undefined'&&playerCommanderId)||null;
  if(typeof AI==='undefined'||!AI) return null;
  const hit=S=>{
    if(!S||S.commander!==i) return null;
    if(S.commanderGen!=null&&typeof ugen!=='undefined'&&ugen[i]!==S.commanderGen) return null;
    return S.commanderId||null;
  };
  for(const S of AI.bases||[]){ const id=hit(S); if(id) return id; }
  for(const S of AI.allies||[]){ const id=hit(S); if(id) return id; }
  return null;
}
function commanderWeaponProfileFor(id){
  if(typeof COMMANDER_WEAPON_PROFILES==='undefined'||!id) return null;
  return COMMANDER_WEAPON_PROFILES[id]||null;
}
function commanderStampAiSeats(){
  if(typeof AI==='undefined'||!AI) return;
  const stamp=seats=>{
    if(!seats) return;
    for(const S of seats){
      if(!S||!S.commanderId) continue;
      const P=commanderWeaponProfileFor(S.commanderId)||
        (S.fac&&typeof COMMANDER_WEAPON_PROFILES!=='undefined'&&COMMANDER_WEAPON_PROFILES[S.fac])||null;
      if(!P) continue;
      S.primary=P.primary; S.secondary=P.secondary;
      if(S.activeCool==null) S.activeCool=8+Math.random()*6;
      if(!S.weaponCool) S.weaponCool=[0,4+Math.random()*6];
    }
  };
  stamp(AI.bases); stamp(AI.allies);
}
function commanderDefForUnit(i){
  const id=commanderIdForUnit(i);
  if(id&&typeof commanderById==='function'){
    const C=commanderById(id);
    if(C) return C;
  }
  if(typeof heroIdx!=='undefined'&&i===heroIdx&&typeof playerCommanderDef==='function')
    return playerCommanderDef();
  return null;
}
function commanderSeatForUnit(i){
  if(typeof heroIdx!=='undefined'&&i===heroIdx) return {team:0,slot:-1,seat:null};
  if(typeof AI!=='undefined'&&AI){
    const hit=S=>{
      if(!S||S.commander!==i) return null;
      if(S.commanderGen!=null&&ugen[i]!==S.commanderGen) return null;
      return S;
    };
    for(const S of AI.bases||[]){ const s=hit(S); if(s) return {team:uteam[i],slot:s.slot,seat:s}; }
    for(const S of AI.allies||[]){ const s=hit(S); if(s) return {team:uteam[i],slot:s.slot,seat:s}; }
  }
  return {team:uteam[i],slot:typeof uCmd!=='undefined'?uCmd[i]:-1,seat:null};
}

/* ---------- PRIMARY / SECONDARY FIRE -------------------------------------
   Auto-attack keeps the Commander useful when the player is managing an army.
   These two manual weapons provide the missing deliberate shot: primary is a
   quick focus-fire command, secondary spends grid energy on faction-specific
   ordnance. They are selection-bound through hotslots.js, not global menus. */
function commanderWeaponDef(slot){
  try{const C=typeof playerCommanderDef==='function'?playerCommanderDef():null;return C?(slot?C.secondary:C.primary):null;}
  catch(e){return null;}
}
function commanderWeaponButton(slot){
  const id=slot?'abSecondary':'abPrimary';let b=document.getElementById(id);if(b)return b;
  const row=document.getElementById('heroRow');if(!row)return null;
  b=document.createElement('button');b.type='button';b.className='cbtn abtn';b.id=id;
  b.innerHTML='<span class="em"></span><span class="wnm"></span><div class="cdring"></div>';
  const before=document.getElementById('abCommander')||row.firstChild;row.insertBefore(b,before);
  b.addEventListener('pointerdown',e=>{e.stopPropagation();tryCommanderWeapon(slot);});
  return b;
}
function commanderWeaponRefresh(reset){
  if(reset)commanderWeaponCool=[0,0];
  for(let slot=0;slot<2;slot++){
    const W=commanderWeaponDef(slot),b=commanderWeaponButton(slot);if(!b)continue;
    b.style.display=W?'flex':'none';if(!W)continue;
    b.querySelector('.em').textContent=W.em||'β€’';b.querySelector('.wnm').textContent=(W.nm||(slot?'SECONDARY':'PRIMARY')).toUpperCase();
  }
  commanderWeaponButtonState();
}
function tryCommanderWeapon(slot){
  const W=commanderWeaponDef(slot);if(!W||heroIdx<0||!ualive[heroIdx]){toast('Commander weapon unavailable');return;}
  if(commanderWeaponCool[slot]>0){toast(W.nm+' REARMING β€” '+Math.ceil(commanderWeaponCool[slot])+'s');return;}
  if((W.energy||0)>resE[0]){toast(W.nm+' NEEDS '+W.energy+' ENERGY');return;}
  if(aiming===7+slot){aiming=-1;toast(W.nm.toUpperCase()+' TARGETING CANCELLED');return;}
  aiming=7+slot;
  toast((W.em||'β€’')+' '+W.nm.toUpperCase()+' β€” tap within '+W.range+'m');
  if(typeof radioAck==='function')radioAck('ability',1,ux[heroIdx],uy[heroIdx]);
}
function fireCommanderWeapon(slot,wx,wy){
  const W=commanderWeaponDef(slot);if(!W||heroIdx<0||!ualive[heroIdx]){aiming=-1;return false;}
  const hx=ux[heroIdx],hy=uy[heroIdx],d=Math.hypot(wx-hx,wy-hy);
  if(d>W.range){toast(W.nm.toUpperCase()+' TARGET OUT OF RANGE');return false;}
  if((W.energy||0)>resE[0]){toast(W.nm+' NEEDS '+W.energy+' ENERGY');return false;}
  /* Direct primary rounds need an actual contact. Passing the Commander as
     the projectile target made an otherwise valid shot curl back toward its
     owner; passing no target made zero-splash rounds land harmlessly. Snap the
     reticle to a nearby hostile, while authored splash secondaries may still
     bombard clear ground to deny an approach. */
  const snap=Math.max(22,Math.min(48,(W.aoe||0)*.55));
  let tgt=findEnemy(wx,wy,0,snap),tx=wx,ty=wy;
  if(tgt>=0){tx=ux[tgt];ty=uy[tgt];}
  else {
    const b=findEnemyBld(wx,wy,0,snap+14);
    if(b>=0){tgt=-2-b;tx=blds[b].x;ty=blds[b].y;}
    else if(!slot&&!W.aoe){toast('NO HOSTILE UNDER RETICLE');return false;}
  }
  if(W.energy)pay(0,0,W.energy);
  commanderWeaponCool[slot]=commanderCool(W.cool);aiming=-1;
  const pk=fireProj(W.ptype,0,hx,hy,tx,ty,W.speed,W.damage*heroDmgMult,W.aoe||0,tgt);
  if(pk>=0){
    pwk[pk]=W.wk||'p';
    if(slot){pCannon[pk]=1;if(W.ptype===2){pBarrage[pk]=1;pArc[pk]=90;}}
    projectileFireFX(pk,hx,hy,tx-hx,ty-hy);
  }
  const C=playerCommanderDef(),col=C&&C.active&&C.active.col||[110,215,255];
  addParticle(0,hx,hy,0,0,.22,slot?22:13,col[0],col[1],col[2]);
  sfx(W.sfx||'shot',hx,hy,slot?1.45:1.05);shake=Math.max(shake,slot?2.4:.65);
  if(typeof radioAck==='function')radioAck('ability',1,tx,ty);
  return true;
}
function commanderWeaponButtonState(){
  for(let slot=0;slot<2;slot++){
    const W=commanderWeaponDef(slot),b=document.getElementById(slot?'abSecondary':'abPrimary');if(!b||!W)continue;
    const cover=commanderWeaponCool[slot]>0?Math.ceil(commanderWeaponCool[slot]):((W.energy||0)>resE[0]?'E':'');
    const cd=b.querySelector('.cdring');b.classList.toggle('cd',!!cover);b.classList.toggle('on',aiming===7+slot);
    cd.style.display=cover?'flex':'none';cd.textContent=cover;
    const tip=(slot?'Secondary':'Primary')+' fire β€” '+W.nm+(W.energy?' Β· '+W.energy+' energy':'')+' Β· '+Math.round(commanderCool(W.cool))+'s. '+W.ds;
    b.title=tip;b.setAttribute('aria-label',tip);
  }
}

/* ---------- COMMANDER SIGNATURE ACTIVES -----------------------------------
   Baseline powers remain baseline powers. The selected commander adds one
   signature command through its own source button, so identity changes play
   without taking Orbital Blast, Repair Pulse, Surge, Lance or EMP away. */
function commanderActiveDef(){
  try{ const C=typeof playerCommanderDef==='function'?playerCommanderDef():null;return C&&C.active?C.active:null; }
  catch(e){ return null; }
}
function commanderActiveButton(){
  let b=document.getElementById('abCommander');
  if(b) return b;
  const row=document.getElementById('heroRow');if(!row)return null;
  b=document.createElement('button');b.type='button';b.className='cbtn abtn';b.id='abCommander';
  b.innerHTML='<span class="em" id="cmdAbEm">✦</span><span id="cmdAbNm">SIGNATURE</span><div class="cdring"></div>';
  const jump=document.getElementById('abJump');row.insertBefore(b,jump||row.firstChild);
  b.addEventListener('pointerdown',e=>{e.stopPropagation();tryCommanderActive();});
  return b;
}
function commanderActiveRefresh(reset){
  const b=commanderActiveButton(),A=commanderActiveDef();if(!b)return;
  if(reset) commanderActiveCool=0;
  b.style.display=A?'flex':'none';if(!A)return;
  const em=document.getElementById('cmdAbEm'),nm=document.getElementById('cmdAbNm');
  if(em)em.textContent=A.em||'✦';if(nm)nm.textContent=A.nm||'SIGNATURE';
  commanderActiveButtonState();
}
function commanderActiveReset(){
  commanderActiveCool=0;commanderWeaponCool=[0,0];
  if(aiming===6||aiming===7||aiming===8)aiming=-1;
  commanderActiveRefresh(false);commanderWeaponRefresh(false);
}
function commanderActiveFx(A,x,y,size){
  const c=A.col||[110,210,255],s=size||A.range||150;
  addParticle(3,x,y,0,0,.72,s,c[0],c[1],c[2]);
  addParticle(0,x,y,0,-10,.5,Math.max(9,s*.08),c[0],c[1],c[2]);
}
function commanderActivePayAt(i,A,quiet){
  const m=A.mass||0,e=A.energy||0,seat=commanderSeatForUnit(i);
  if(!canAfford(seat.team,m,e,seat.slot)){
    if(!quiet) toast(A.nm+' NEEDS '+(m?m+' MASS'+(e?' + ':''):'')+(e?e+' ENERGY':''));
    return false;
  }
  pay(seat.team,m,e,seat.slot);
  const cd=commanderCool(A.cool||30);
  if(typeof heroIdx!=='undefined'&&i===heroIdx) commanderActiveCool=cd;
  else if(seat.seat) seat.seat.activeCool=cd;
  return true;
}
function commanderActivePay(A){ return commanderActivePayAt(heroIdx,A,false); }
function commanderActiveRefundAt(i,A){
  const seat=commanderSeatForUnit(i);
  if(typeof heroIdx!=='undefined'&&i===heroIdx) commanderActiveCool=0;
  else if(seat.seat) seat.seat.activeCool=0;
  if(seat.team===1&&seat.seat){
    seat.seat.mass=(seat.seat.mass||0)+(A.mass||0);
    seat.seat.energy=(seat.seat.energy||0)+(A.energy||0);
    return;
  }
  const t=seat.team||0;
  resM[t]=Math.min(RES_MCAP[t],resM[t]+(A.mass||0));
  resE[t]=Math.min(RES_ECAP[t],resE[t]+(A.energy||0));
}
function commanderActiveRefund(A){ commanderActiveRefundAt(heroIdx,A); }
function tryCommanderActive(){
  const A=commanderActiveDef();
  if(!A){toast('Commander signature unavailable β€” baseline powers remain operational');sfx('ui');return;}
  if(heroIdx<0||!ualive[heroIdx]){toast('Commander is down');return;}
  if(commanderActiveCool>0){toast(A.nm+' REARMING β€” '+Math.ceil(commanderActiveCool)+'s');return;}
  if(!canAfford(0,A.mass||0,A.energy||0)){commanderActivePay(A);return;}
  if(aiming===7||aiming===8)aiming=-1;
  if(A.target){
    if(aiming===6){aiming=-1;toast(A.nm.toUpperCase()+' TARGETING CANCELLED');return;}
    aiming=6;toast((A.em||'✦')+' '+A.nm.toUpperCase()+' β€” tap within '+A.range+'m');
    if(typeof radioAck==='function')radioAck('ability',1,ux[heroIdx],uy[heroIdx]);return;
  }
  fireCommanderActive(ux[heroIdx],uy[heroIdx]);
}
function commanderActiveDamageCircle(x,y,r,unitDmg,bldDmg,team){
  if(team==null) team=0;
  let n=0;
  forUnitsIn(x,y,r,j=>{if(uteam[j]===team)return;dealDamage(j,unitDmg,team,-1);n++;});
  for(let bi=0;bi<blds.length;bi++){const B=blds[bi];if(B.alive&&B.team!==team&&dist2(x,y,B.x,B.y)<=r*r){damageBld(bi,bldDmg,team);n++;}}
  return n;
}
function fireCommanderActive(wx,wy){ return fireCommanderActiveAt(heroIdx,wx,wy,false); }
function fireCommanderActiveAt(idx,wx,wy,quiet){
  const C=commanderDefForUnit(idx),A=C&&C.active;
  if(!A||idx<0||!ualive[idx]){if(!quiet)aiming=-1;return false;}
  const hx=ux[idx],hy=uy[idx],team=uteam[idx],player=typeof heroIdx!=='undefined'&&idx===heroIdx;
  const dmgM=player?heroDmgMult:1;
  if(A.target&&Math.hypot(wx-hx,wy-hy)>(A.range||0)){
    if(!quiet)toast(A.nm.toUpperCase()+' TARGET OUT OF RANGE');return false;
  }
  if(A.id==='phasebreach'&&!jumpLandingClear(wx,wy,idx)){
    if(!quiet)toast('PHASE BREACH NEEDS CLEAR GROUND');return false;
  }
  if(!commanderActivePayAt(idx,A,quiet))return false;
  if(player)aiming=-1;let affected=0;
  if(A.id==='skybreaker'){
    const pts=[[0,0],[36,-24],[-31,28]];
    for(let q=0;q<pts.length;q++){
      const x=wx+pts[q][0],y=wy+pts[q][1];
      affected+=commanderActiveDamageCircle(x,y,72,190*dmgM,130*dmgM,team);
      addBeam(x,y-680,x,y,10,A.col[0],A.col[1],A.col[2],.25,'orbital');
      spawnExplosion(x,y,34,1);addCrater(x,y,28);
    }
    if(player)shake=Math.max(shake,7);
  }else if(A.id==='fieldworkshop'){
    forUnitsIn(hx,hy,A.range,j=>{if(uteam[j]!==team)return;affected++;uhp[j]=Math.min(uhpm[j],uhp[j]+uhpm[j]*.34+55);uclassBuff[j]=3;uclassBuffT[j]=Math.max(uclassBuffT[j],8);});
    for(const B of blds)if(B.alive&&B.team===team&&dist2(hx,hy,B.x,B.y)<=A.range*A.range){B.hp=Math.min(B.hpm,B.hp+B.hpm*.24+90);B.shieldT=Math.max(B.shieldT||0,8);affected++;}
  }else if(A.id==='ghostnet'){
    if(typeof fogStartScan==='function')fogStartScan(wx,wy,22,18);
    forUnitsIn(wx,wy,225,j=>{affected++;if(uteam[j]===team){uclassBuff[j]=2;uclassBuffT[j]=Math.max(uclassBuffT[j],9);}else{ustun[j]=Math.max(ustun[j],1.6);ucool[j]=Math.max(ucool[j],2.2);}});
  }else if(A.id==='seismicdecree'){
    affected=commanderActiveDamageCircle(wx,wy,125,420*dmgM,920*dmgM,team);
    damageScenery(wx,wy,145,1300);spawnExplosion(wx,wy,36,team);addCrater(wx,wy,105);deformTerrain(wx,wy,125,.09);
    if(player)shake=Math.max(shake,11);
  }else if(A.id==='crimsonadvance'){
    forUnitsIn(hx,hy,A.range,j=>{if(uteam[j]!==team||j===idx)return;affected++;uhp[j]=Math.min(uhpm[j],uhp[j]+uhpm[j]*.15);uclassBuff[j]=1;uclassBuffT[j]=Math.max(uclassBuffT[j],11);});
    affected+=commanderActiveDamageCircle(hx,hy,95,120*dmgM,70*dmgM,team);
  }else if(A.id==='ironredoubt'){
    for(const B of blds)if(B.alive&&B.team===team&&dist2(hx,hy,B.x,B.y)<=A.range*A.range){affected++;B.hp=Math.min(B.hpm,B.hp+B.hpm*.17+100);B.shieldT=Math.max(B.shieldT||0,12);commanderActiveFx(A,B.x,B.y,B.r*2.1);}
    if(!affected){commanderActiveRefundAt(idx,A);if(!quiet)toast('IRON REDOUBT NEEDS A FRIENDLY STRUCTURE IN RANGE β€” RESOURCES REFUNDED');return false;}
  }else if(A.id==='liquidation'){
    let claimed=0;
    for(let w=wrecks.length-1;w>=0&&claimed<4;w--){const W=wrecks[w];if(dist2(hx,hy,W.x,W.y)>A.range*A.range)continue;
      if(team===1){const seat=commanderSeatForUnit(idx);if(seat.seat){seat.seat.mass=(seat.seat.mass||0)+W.mass;seat.seat.energy=(seat.seat.energy||0)+W.en;}}
      else{resM[team]=Math.min(RES_MCAP[team],resM[team]+W.mass);resE[team]=Math.min(RES_ECAP[team],resE[team]+W.en);}
      commanderActiveFx(A,W.x,W.y,38);wrecks.splice(w,1);claimed++;}
    for(const B of blds)if(B.alive&&B.team===team&&['fac','tgate','harbor','airfield'].includes(B.type)&&dist2(hx,hy,B.x,B.y)<=A.range*A.range){B.boost=Math.max(B.boost,16);affected++;}
    affected+=claimed;
    if(!affected){commanderActiveRefundAt(idx,A);if(!quiet)toast('COMBAT LIQUIDATION NEEDS A WRECK OR FACTORY β€” RESOURCES REFUNDED');return false;}
  }else if(A.id==='phasebreach'){
    commanderActiveFx(A,hx,hy,65);ux[idx]=wx;uy[idx]=wy;utx[idx]=wx;uty[idx]=wy;utgt[idx]=-1;ustate[idx]=0;
    forUnitsIn(wx,wy,165,j=>{if(uteam[j]===team)return;ustun[j]=Math.max(ustun[j],3.5);ucool[j]=Math.max(ucool[j],4);affected++;});
    for(const B of blds)if(B.alive&&B.team!==team&&dist2(wx,wy,B.x,B.y)<=165*165){B.cool=Math.max(B.cool||0,4);affected++;}
    commanderCrushScenery(idx,true);
  }else if(A.id==='naniterecall'){
    const chosen=[];
    if(player){for(let n=0;n<unitHigh&&chosen.length<8;n++)if(ualive[n]&&uteam[n]===team&&usel[n]&&n!==idx)chosen.push(n);}
    else{
      const near=[];
      for(let n=0;n<unitHigh;n++)if(ualive[n]&&uteam[n]===team&&n!==idx&&dist2(ux[n],uy[n],hx,hy)<720*720)near.push(n);
      near.sort((a,b)=>dist2(ux[a],uy[a],hx,hy)-dist2(ux[b],uy[b],hx,hy));
      for(let n=0;n<near.length&&chosen.length<8;n++)chosen.push(near[n]);
    }
    for(let n=0;n<chosen.length;n++){const u=chosen[n],ang=n/Math.max(1,chosen.length)*TAU,rad=52+22*(n>>2);let x=hx+Math.cos(ang)*rad,y=hy+Math.sin(ang)*rad;
      if(typeof battlefieldClampPoint==='function'){const p=battlefieldClampPoint(x,y,TYPES[utype[u]].r+5);x=p[0];y=p[1];}
      if(!TYPES[utype[u]].air&&typeof isWalkable==='function'&&!isWalkable(x,y))continue;
      commanderActiveFx(A,ux[u],uy[u],30);ux[u]=x;uy[u]=y;utx[u]=x;uty[u]=y;utgt[u]=-1;ustate[u]=0;uhp[u]=Math.min(uhpm[u],uhp[u]+uhpm[u]*.18);uclassBuff[u]=3;uclassBuffT[u]=7;affected++;commanderActiveFx(A,x,y,34);}
    if(!affected){commanderActiveRefundAt(idx,A);if(!quiet)toast('NANITE RECALL NEEDS SELECTED ALLIED UNITS β€” RESOURCES REFUNDED');return false;}
  }else{
    commanderActiveRefundAt(idx,A);
    if(!quiet)toast('Signature command unavailable β€” resources refunded');return false;
  }
  commanderActiveFx(A,wx,wy,Math.max(70,Math.min(260,A.range||150)));
  sfx(A.sfx||'surge',wx,wy,1.2);
  if(typeof radioAck==='function'&&player)radioAck('ability',Math.max(1,affected),wx,wy);
  if(!quiet)toast((A.em||'✦')+' '+A.nm.toUpperCase()+' β€” '+affected+' '+(affected===1?'ASSET':'ASSETS')+' AFFECTED');
  return true;
}
function commanderActiveButtonState(){
  const b=commanderActiveButton(),A=commanderActiveDef();if(!b)return;
  b.style.display=A?'flex':'none';if(!A)return;
  const cd=b.querySelector('.cdring'),m=A.mass||0,e=A.energy||0;
  let cover=commanderActiveCool>0?Math.ceil(commanderActiveCool):(!canAfford(0,m,e)?(m&&resM[0]<m?'M':'E'):'');
  b.classList.toggle('cd',!!cover);b.classList.toggle('on',aiming===6);cd.style.display=cover?'flex':'none';cd.textContent=cover;
  const cost=(m?m+' mass'+(e?' + ':''):'')+(e?e+' energy':'');
  const tip=(A.em||'✦')+' '+A.nm+' β€” '+cost+', '+Math.round(commanderCool(A.cool))+'s cooldown. '+A.ds;
  b.title=tip;b.setAttribute('aria-label',tip);
}

/* ---------- CHARGED ARTILLERY BARRAGE --------------------------------------
   A Development unlock grants a new COMMAND, never a passive stat. Selected
   artillery must visibly brace for the shot and can be interrupted by orders,
   displacement or heavy incoming damage. All timing is simulation time: pause,
   game speed and reset remain deterministic, unlike a setTimeout salvo. */
const ART_BARRAGE={energy:240,cool:52,charge:2.8,range:720,shells:6,
                   cadence:.28,speed:115,damage:155,aoe:48,spread:104,arc:620};
const ART_BARRAGE_OFF=[[-.68,-.42],[.08,-.78],[.76,-.25],[.58,.57],[-.16,.80],[-.80,.18]];
let artBarrageCool=0, artBarrageAim=null, artBarrageCharge=null, artBarrageQueue=[];
function artBarrageUnlocked(){ return typeof devHas==='function'&&devHas('firemission'); }
function artBarragePattern(x,y){
  return ART_BARRAGE_OFF.slice(0,ART_BARRAGE.shells).map(o=>({
    x:clamp(x+o[0]*ART_BARRAGE.spread,15,MAP-15),
    y:clamp(y+o[1]*ART_BARRAGE.spread,15,MAP-15)
  }));
}
function artBarrageSelected(tx,ty,limit){
  const out=[];
  for(let i=0;i<unitHigh;i++) if(ualive[i]&&uteam[i]===0&&usel[i]&&TYPES[utype[i]].cat==='art'){
    if(tx!==undefined&&dist2(ux[i],uy[i],tx,ty)>ART_BARRAGE.range*ART_BARRAGE.range) continue;
    out.push(i);
  }
  if(tx!==undefined) out.sort((a,b)=>dist2(ux[a],uy[a],tx,ty)-dist2(ux[b],uy[b],tx,ty));
  return limit?out.slice(0,limit):out;
}
function setArtBarragePreview(x,y){
  artBarrageAim={x:clamp(x,15,MAP-15),y:clamp(y,15,MAP-15)};
}
function cancelArtilleryBarrageAim(quiet){
  if(aiming===5) aiming=-1;
  artBarrageAim=null;
  if(!quiet){toast('BARRAGE TARGETING CANCELLED');sfx('ui');}
}
function artBarrageRemember(i){
  return {i:i,g:ugen[i],x:ux[i],y:uy[i],hp:uhp[i],state:ustate[i],hold:uhold[i],
    tx:utx[i],ty:uty[i],tgt:utgt[i],tgtg:utgtg[i],field:ufield[i],
    route:uPatrolRoute[i],step:uPatrolStep[i],slot:uPatrolSlot[i],cohort:uMoveCohort[i]};
}
function artBarrageRestore(m){
  const i=m.i;if(!ualive[i]||ugen[i]!==m.g||uteam[i]!==0||ustate[i]!==6)return;
  ustate[i]=m.state;uhold[i]=m.hold;utx[i]=m.tx;uty[i]=m.ty;utgt[i]=m.tgt;utgtg[i]=m.tgtg;
  ufield[i]=m.field;uPatrolRoute[i]=m.route;uPatrolStep[i]=m.step;uPatrolSlot[i]=m.slot;uMoveCohort[i]=m.cohort;
}
function artBarrageMemberLive(m){
  const i=m.i;
  return !!(ualive[i]&&ugen[i]===m.g&&uteam[i]===0&&TYPES[utype[i]].cat==='art'&&
    ustate[i]===6&&dist2(ux[i],uy[i],m.x,m.y)<=28*28&&uhp[i]>m.hp-uhpm[i]*.08);
}
function tryArtilleryBarrage(){
  if(artBarrageCharge){
    for(const m of artBarrageCharge.members)artBarrageRestore(m);
    artBarrageCharge=null;toast('BARRAGE CHARGE INTERRUPTED');sfx('ui');return;
  }
  if(aiming===5){cancelArtilleryBarrageAim(false);return;}
  if(!artBarrageUnlocked()){
    toast('LOCKED β€” research Fire Mission Protocol in DEVELOPMENT > DOCTRINE');sfx('ui');return;
  }
  if(artBarrageCool>0){toast('BARRAGE REARMING β€” '+Math.ceil(artBarrageCool)+'s');return;}
  if(artBarrageQueue.length){toast('BARRAGE VOLLEY IS STILL IN FLIGHT');return;}
  const src=artBarrageSelected();
  if(!src.length){toast('Select a Thumper or Bombard artillery unit first');sfx('ui');return;}
  if(!canAfford(0,0,ART_BARRAGE.energy)){toast('BARRAGE NEEDS '+ART_BARRAGE.energy+' ENERGY');return;}
  let cx=0,cy=0;for(const i of src){cx+=ux[i];cy+=uy[i];}cx/=src.length;cy/=src.length;
  let dx=cam.x-cx,dy=cam.y-cy,d=Math.hypot(dx,dy);
  if(d<60){dx=0;dy=-1;d=1;}
  const step=Math.min(310,Math.max(180,d));
  setArtBarragePreview(cx+dx/d*step,cy+dy/d*step);
  aiming=5;
  toast('CHARGED BARRAGE β€” '+ART_BARRAGE.energy+' energy Β· tap or drag a target area');
  if(typeof radioAck==='function')radioAck('ability',src.length,cx,cy);else sfx('radio',cx,cy,.9);
}
function beginArtilleryBarrage(x,y){
  if(!artBarrageUnlocked()||artBarrageCool>0||artBarrageCharge||artBarrageQueue.length){cancelArtilleryBarrageAim(true);return false;}
  const src=artBarrageSelected(x,y,4);
  if(!src.length){setArtBarragePreview(x,y);toast('TARGET OUT OF RANGE β€” move selected artillery within '+ART_BARRAGE.range+'m');return false;}
  if(!canAfford(0,0,ART_BARRAGE.energy)){cancelArtilleryBarrageAim(true);toast('BARRAGE NEEDS '+ART_BARRAGE.energy+' ENERGY');return false;}
  pay(0,0,ART_BARRAGE.energy);artBarrageCool=ART_BARRAGE.cool;
  const members=src.map(artBarrageRemember);
  for(const m of members){
    const i=m.i;ustate[i]=6;uhold[i]=1;utgt[i]=-1;utgtg[i]=-1;utx[i]=ux[i];uty[i]=uy[i];
    uPatrolRoute[i]=-1;uMoveCohort[i]=-1;
  }
  artBarrageCharge={x:clamp(x,15,MAP-15),y:clamp(y,15,MAP-15),t:0,total:ART_BARRAGE.charge,
    members:members,pattern:artBarragePattern(x,y),fx:0};
  aiming=-1;artBarrageAim=null;
  const c0=members[0],sx=ux[c0.i],sy=uy[c0.i];
  addParticle(3,sx,sy,0,0,.55,34,255,190,80);
  if(typeof fogFxVisible!=='function'||fogFxVisible(sx,sy,0))sfx('surge',sx,sy,1.05);
  toast('FIRE MISSION CHARGING β€” moving or heavy damage interrupts');
  return true;
}
function artBarrageLaunch(m,P){
  const i=m.i;if(!ualive[i]||ugen[i]!==m.g||uteam[i]!==0||TYPES[utype[i]].cat!=='art')return false;
  const T=TYPES[utype[i]],a=Math.atan2(P.y-uy[i],P.x-ux[i]);
  uturr[i]=a+Math.PI/2;
  const sx=ux[i]+Math.cos(a)*T.size*.72,sy=uy[i]+Math.sin(a)*T.size*.72;
  const k=fireProj(2,0,sx,sy,P.x,P.y,ART_BARRAGE.speed,ART_BARRAGE.damage,ART_BARRAGE.aoe,-1);
  if(k<0)return false;
  pwk[k]='e';pmu0[k]=1;pBarrage[k]=1;
  pArc[k]=ART_BARRAGE.arc+Math.min(180,Math.sqrt(dist2(sx,sy,P.x,P.y))*.22);
  addParticle(0,sx,sy,0,0,.16,18,255,238,188);
  addParticle(3,sx,sy,0,0,.28,29,255,170,62);
  addParticle(1,sx,sy,rr(-5,5),rr(-14,-5),.9,10,65,60,55);
  if(typeof fogFxVisible!=='function'||fogFxVisible(sx,sy,0)){
    sfx('cannon',sx,sy,1.22);
    if(typeof artilleryWorldAudio==='function')artilleryWorldAudio('launch',sx,sy,0,1.18);
  }
  return true;
}
function artBarrageTick(dt){
  if(artBarrageCool>0)artBarrageCool=Math.max(0,artBarrageCool-dt);
  const C=artBarrageCharge;
  if(C){
    const live=[];
    for(const m of C.members){if(artBarrageMemberLive(m))live.push(m);else artBarrageRestore(m);}
    C.members=live;
    if(!live.length){artBarrageCharge=null;toast('BARRAGE INTERRUPTED β€” battery lost the firing solution');sfx('ui');}
    else{
      C.t+=dt;C.fx-=dt;
      const p=clamp(C.t/C.total,0,1);
      for(const m of live){
        const i=m.i,a=Math.atan2(C.y-uy[i],C.x-ux[i])+Math.PI/2,d=a-uturr[i];
        uturr[i]+=clamp(Math.atan2(Math.sin(d),Math.cos(d)),-2.8*dt,2.8*dt);
      }
      if(C.fx<=0){
        C.fx=.17;
        for(const m of live){const i=m.i;addParticle(0,ux[i],uy[i],rr(-3,3),rr(-4,1),.22,5+8*p,255,185,68);}
      }
      if(C.t>=C.total){
        for(const m of live)artBarrageRestore(m);
        for(let n=0;n<C.pattern.length;n++)artBarrageQueue.push({t:n*ART_BARRAGE.cadence,m:live[n%live.length],p:C.pattern[n]});
        artBarrageCharge=null;toast('BARRAGE AWAY β€” '+C.pattern.length+' shells');
      }
    }
  }
  for(let q=artBarrageQueue.length-1;q>=0;q--){
    const Q=artBarrageQueue[q];Q.t-=dt;
    if(Q.t<=0){artBarrageLaunch(Q.m,Q.p);artBarrageQueue.splice(q,1);}
  }
}
function artBarrageReset(){
  artBarrageCool=0;artBarrageAim=null;artBarrageCharge=null;artBarrageQueue.length=0;
}
function artBarrageButtonState(){
  const b=document.getElementById('abBarrage');if(!b)return;
  const cd=b.querySelector('.cdring'),locked=!artBarrageUnlocked(),n=artBarrageSelected().length;
  let cover='',desc='Charged Barrage β€” '+ART_BARRAGE.energy+' energy, '+ART_BARRAGE.cool+'s cooldown. '+
    'Selected artillery braces for '+ART_BARRAGE.charge+'s; movement or heavy damage interrupts.';
  if(locked){cover='LOCK';desc='Locked β€” research Fire Mission Protocol in Development > Doctrine.';}
  else if(artBarrageCharge){cover=Math.round(clamp(artBarrageCharge.t/artBarrageCharge.total,0,1)*100)+'%';}
  else if(artBarrageQueue.length){cover='FIRE';}
  else if(artBarrageCool>0){cover=String(Math.ceil(artBarrageCool));}
  else if(resE[0]<ART_BARRAGE.energy){cover='E';}
  else if(!n){cover='ARTY';}
  b.classList.toggle('cd',!!cover);b.classList.toggle('on',aiming===5||!!artBarrageCharge);
  cd.style.display=cover?'flex':'none';cd.textContent=cover;
  b.title=desc;b.setAttribute('aria-label',desc);
}

/* ---------- CONTEXTUAL CLASS DOCTRINES ------------------------------------
   One adaptive button keeps the phone HUD readable while still giving three
   major unit families a real unlocked active. The selected composition decides
   which command is presented; mixed selections choose the family with the most
   eligible units. Artillery retains its authored target-and-charge Barrage. */
const CLASS_AB={
  assault:{nm:'BREAK',em:'➀',req:'breakthrough',energy:130,cool:44,dur:9,
    cats:['inf','veh','at','aoe','exp'],
    ds:'+28% damage, +22% speed and faster fire for 9s; units take +12% damage.'},
  intercept:{nm:'INTERCEPT',em:'⌁',req:'intercept',energy:95,cool:38,dur:8,
    cats:['air','aa'],
    ds:'+58% speed, +22% range and faster tracking for 8s.'},
  service:{nm:'SERVICE',em:'✚',req:'fieldservice',energy:115,cool:34,dur:7,
    cats:['sup'],
    ds:'Support units restore nearby allies and project a 28% damage screen for 7s.'}
};
const classAbCool={assault:0,intercept:0,service:0};
function classAbilityEligible(A){
  const out=[];
  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);
  return out;
}
function classAbilityChoice(){
  let best=null,bestN=0;
  for(const k of ['service','intercept','assault']){
    const n=classAbilityEligible(CLASS_AB[k]).length;
    if(n>bestN){best=k;bestN=n;}
  }
  return best?{key:best,A:CLASS_AB[best],units:classAbilityEligible(CLASS_AB[best])}:null;
}
function classAbilityUnlocked(A){return typeof devHas==='function'&&devHas(A.req);}
/* THE ARMY'S OWN WORD FOR ITS OWN DOCTRINE. CLASS_AB holds the rules β€” cost,
   cooldown, duration, eligible categories β€” and those are identical for every
   faction. Only the label, glyph and sentence move, resolved once here so the
   button, its aria-label, the hot-slot chip and every toast cannot disagree
   about what the player just pressed. */
function classAbWords(key){
  const A=CLASS_AB[key]||{};
  if(typeof factionClassAbName!=='function') return {nm:A.nm||'',em:A.em||'',ds:A.ds||''};
  const kit=(typeof factionTextKit==='function')?factionTextKit(0):undefined;
  return {nm:factionClassAbName(key,kit)||A.nm||'',
          em:factionClassAbEm(key,kit)||A.em||'',
          ds:factionClassAbDesc(key,kit)||A.ds||''};
}
function tryClassAbility(){
  const C=classAbilityChoice();
  if(!C){toast('Select assault, interceptor, anti-air, or support units first');sfx('ui');return;}
  const A=C.A,W=classAbWords(C.key);
  if(!classAbilityUnlocked(A)){
    const n=(typeof DEVTREE!=='undefined'&&DEVTREE.find(x=>x.id===A.req));
    toast('LOCKED β€” research '+(n?n.nm:A.req)+' in DEVELOPMENT > DOCTRINE');sfx('ui');return;
  }
  if(classAbCool[C.key]>0){toast(W.nm+' REARMING β€” '+Math.ceil(classAbCool[C.key])+'s');return;}
  if(!canAfford(0,0,A.energy)){toast(W.nm+' NEEDS '+A.energy+' ENERGY');return;}
  pay(0,0,A.energy);classAbCool[C.key]=A.cool;
  const touched=new Set(),centres=C.units;
  if(C.key==='service'){
    for(const i of centres){
      uclassBuff[i]=3;uclassBuffT[i]=A.dur;
      forUnitsIn(ux[i],uy[i],125,j=>{
        if(uteam[j]!==0)return;touched.add(j);uclassBuff[j]=3;uclassBuffT[j]=Math.max(uclassBuffT[j],A.dur);
        uhp[j]=Math.min(uhpm[j],uhp[j]+uhpm[j]*.26+55);
        addParticle(0,ux[j],uy[j],0,-8,.42,6,82,255,164);
      });
      for(const B of blds)if(B.alive&&B.team===0&&dist2(ux[i],uy[i],B.x,B.y)<=145*145){
        B.hp=Math.min(B.hpm,B.hp+B.hpm*.18+80);touched.add(B);
      }
      addParticle(3,ux[i],uy[i],0,0,.75,250,82,255,164);
    }
  }else{
    const kind=C.key==='intercept'?2:1;
    for(const i of centres){uclassBuff[i]=kind;uclassBuffT[i]=A.dur;touched.add(i);
      const col=kind===2?[75,225,255]:[255,112,54];
      addParticle(3,ux[i],uy[i],0,0,.5,TYPES[utype[i]].size*2.5,col[0],col[1],col[2]);
    }
  }
  let cx=0,cy=0;for(const i of centres){cx+=ux[i];cy+=uy[i];}cx/=centres.length;cy/=centres.length;
  toast(W.em+' '+W.nm+' β€” '+touched.size+' '+(touched.size===1?'ASSET':'ASSETS')+' AFFECTED');
  sfx(C.key==='service'?'heal':'surge',cx,cy,1.05);
  if(typeof radioAck==='function')radioAck('ability',touched.size,cx,cy);
}
function classAbilityReset(){
  for(const k in classAbCool)classAbCool[k]=0;
  /* resetWorld already calls this after wiping the pop ledger. airlift.js
     loads before main.js so it cannot wrap resetWorld; this is the world-reset
     hook we own. mfAirliftResetHolds is idempotent with the ledger wrap. */
  if(typeof mfAirliftResetHolds==='function') mfAirliftResetHolds();
}
function classAbilityTick(dt){for(const k in classAbCool)if(classAbCool[k]>0)classAbCool[k]=Math.max(0,classAbCool[k]-dt);}
function classAbilityButtonState(){
  const b=document.getElementById('abClass');if(!b)return;
  const C=classAbilityChoice(),cd=b.querySelector('.cdring'),em=document.getElementById('classAbEm'),nm=document.getElementById('classAbNm');
  b.style.display=C?'flex':'none';if(!C)return;
  const A=C.A,W=classAbWords(C.key),locked=!classAbilityUnlocked(A);em.textContent=W.em;nm.textContent=W.nm;
  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';
  b.classList.toggle('cd',!!cover);cd.style.display=cover?'flex':'none';cd.textContent=cover;
  const desc=W.nm+' β€” '+A.energy+' energy, '+A.cool+'s cooldown. '+W.ds;
  b.title=desc;b.setAttribute('aria-label',desc);
}

function heroXP(x){
  if(heroIdx<0) return;
  heroXp+=x;
  while(heroXp>=heroXpNext){
    heroXp-=heroXpNext; heroLvl++;
    heroXpNext=Math.round(heroXpNext*1.45);
    pendingLevels++;
    if(heroLvl===2){ abUnlock[1]=true; toast('πŸ›  REPAIR PULSE unlocked'); }
    if(heroLvl===3){ abUnlock[2]=true; toast('⚑ COMBAT SURGE unlocked'); }
    sfx('level');
  }
  if(pendingLevels>0 && document.getElementById('levelUp').style.display!=='flex') showLevelUp();
}

/* The eight level-up cards. Nova is the base roster's own voice so these ARE
   its words; the other three armies overlay `upgrade` in src/factext.js and a
   Brood commander is never offered "Nano Plating". Names stay <= 16 characters
   because .upCard is 136px wide on a 380px phone β€” "Assembly Protocols" was 18
   and wrapped. Whatever the words, `fn` is the effect and is never overridden. */
const UPGRADES=[
 {em:'πŸ—‘', nm:'Weapon Overload',  ds:'+25% Commander damage',        fn:()=>{ heroDmgMult*=1.25; }},
 {em:'πŸ›‘', nm:'Nano Plating',     ds:'+20% Commander max HP (heals)',fn:()=>{ if(heroIdx>=0){ uhpm[heroIdx]*=1.2; uhp[heroIdx]=Math.min(uhpm[heroIdx],uhp[heroIdx]+uhpm[heroIdx]*0.25);} }},
 /* Was *=1.8 uncapped: three picks took regen from 14 to 82/s and the
    commander stopped being killable. Capped compounding keeps the card
    worth taking without ever out-regenerating a defended base's damage. */
 {em:'β™»', nm:'Regen Matrix',     ds:'+45% Commander regeneration',   fn:()=>{ heroRegen=Math.min(heroRegen*1.45,70); }},
 {em:'⏱', nm:'Rapid Systems',    ds:'Ability cooldowns βˆ’20%',        fn:()=>{ for(let i=0;i<3;i++) AB_CD[i]*=0.8; }},
 {em:'πŸ“¦', nm:'Logistics Core',   ds:'+2.5 mass & +8 energy income',  fn:()=>{ bonusMass+=2.5; bonusEnergy+=8; }},
 {em:'πŸŽ–', nm:'Veteran Doctrine', ds:'All your units +10% damage',    fn:()=>{ armyDmgMult+=0.10; }},
 {em:'🏭', nm:'Assembly Lines', ds:'Factories build 25% faster',      fn:()=>{ playerBuildMult+=0.25; }},
 {em:'πŸ’₯', nm:'Wider Blast',      ds:'Orbital Blast radius +35%',     fn:()=>{ blastRadius*=1.35; }},
];
let bonusMass=0, bonusEnergy=0, armyDmgMult=1, blastRadius=110;
/* Combat Stims are a TEMPORARY multiplier and must be kept out of armyDmgMult.
   armyDmgMult is written ADDITIVELY by thirteen different sources β€” research,
   the Veteran Doctrine level-up card, five armory weapon items, consumables,
   develop modules and operation modifiers. Folding a *=1.45 into it and later
   doing /=1.45 is not an inverse once any of those has landed in between:
   (1.00*1.45 + 0.12)/1.45 = 1.083, not 1.12. A player who completed Ballistics I
   during a stim permanently kept 0.083 of the 0.12 they paid 500 mass and 2500
   energy for, and level-ups are MORE likely during a stim because kills spike. */
let stimDmgMult=1;
function flashScreen(){
  const f=document.getElementById('flash');
  if(!f) return;
  f.style.transition='none'; f.style.opacity=0.85;
  requestAnimationFrame(()=>{ f.style.transition='opacity .9s'; f.style.opacity=0; });
}

function showLevelUp(){
  if(pendingLevels<=0) return;
  paused=true;
  const el=document.getElementById('levelUp');
  document.getElementById('luLvl').textContent='Commander reached level '+(heroLvl-pendingLevels+1+0)+' β€” choose an upgrade';
  const cards=document.getElementById('luCards');
  cards.innerHTML='';
  /* Draw INDICES, not the card objects: the words on a card are resolved
     through the player's faction kit (src/factext.js) and the index is what
     names them. A Brood commander is not offered "Nano Plating". The `fn` is
     never overridden, so the effect can never drift from the promise. */
  const pool=UPGRADES.map((u,i)=>i);
  const upKit=(typeof factionTextKit==='function')?factionTextKit(0):undefined;
  for(let k=0;k<2;k++){
    const gi=pool.splice(Math.floor(Math.random()*pool.length),1)[0];
    const pick=UPGRADES[gi];
    const upEm=(typeof factionUpgradeEm==='function')?factionUpgradeEm(gi,upKit)||pick.em:pick.em;
    const upNm=(typeof factionUpgradeName==='function')?factionUpgradeName(gi,upKit)||pick.nm:pick.nm;
    const upDs=(typeof factionUpgradeDesc==='function')?factionUpgradeDesc(gi,upKit)||pick.ds:pick.ds;
    const d=document.createElement('div');
    d.className='upCard';
    d.innerHTML='<div class="em">'+upEm+'</div><b>'+upNm+'</b>'+upDs;
    d.addEventListener('pointerdown',ev=>{
      ev.stopPropagation();
      pick.fn(); sfx('ui');
      pendingLevels--;
      if(pendingLevels>0){ showLevelUp(); }
      else { el.style.display='none'; paused=false; }
    });
    cards.appendChild(d);
  }
  el.style.display='flex';
}

// ---------- abilities ----------
function tryAbility(k){
  if(aiming===5) cancelArtilleryBarrageAim(true);
  if(aiming===6) aiming=-1;
  if(aiming===7||aiming===8)aiming=-1;
  if(heroIdx<0){ toast('Commander is down'); return; }
  if(!abUnlock[k]){
    toast(k===3?'πŸ›° Buy the Orbital Uplink in the Armory to unlock this'
        :k===4?'⚑ Craft the EMP Charge module in Development to unlock this'
                :'Unlocks at Commander level '+(k===1?2:3));
    return;
  }
  if(abCool[k]>0) return;
  if(k===0){
    aiming=0;
    const fac=typeof mfCombatFactionTeam==='function'?mfCombatFactionTeam(0):'nova';
    toast(fac==='legion'||fac==='syndicate'
      ? '◐ Tap a location to open a GRAVITY WELL'
      : '✹ Tap a location to fire CLUSTER BOMB');
    if(typeof radioAck==='function')radioAck('ability',1,ux[heroIdx],uy[heroIdx]); return; }
  if(k===3){ aiming=3; toast('πŸ›° Tap a location to call the ORBITAL LANCE');
    if(typeof radioAck==='function')radioAck('ability',1,ux[heroIdx],uy[heroIdx]); return; }
  if(k===1){ // repair pulse
    abCool[1]=commanderCool(AB_CD[1]);
    const hx=ux[heroIdx], hy=uy[heroIdx];
    forUnitsIn(hx,hy,190,j=>{
      if(uteam[j]===0){ uhp[j]=Math.min(uhpm[j],uhp[j]+uhpm[j]*0.45+40);
        addParticle(0,ux[j],uy[j],0,-10,.5,7, 120,255,170); }
    });
    addParticle(3,hx,hy,0,0,.7,190, 90,255,160);
    sfx('heal'); if(typeof radioAck==='function')radioAck('ability',1,hx,hy); return;
  }
  if(k===4){ // EMP charge β€” the craftable module from Development
    /* This module has been on sale in Development for 5 Relic Cores + 30 Isotope
       since it was added, described as "stun everything in a wide radius", with
       no implementation anywhere in the codebase and a flag that a four-entry
       reset array threw away every match. Both are fixed; this is the ability.

       Centred on the Commander rather than aimed: it is a panic button for when
       the Commander is caught, and a self-centred blast needs no aiming mode β€”
       one less state machine to get wrong. Friendlies are untouched. */
    abCool[4]=commanderCool(AB_CD[4]);
    const hx=ux[heroIdx], hy=uy[heroIdx], R=260;
    let n=0;
    forUnitsIn(hx,hy,R,j=>{
      if(!ualive[j]||uteam[j]===0||j===heroIdx) return;
      ustun[j]=4; ucool[j]=Math.max(ucool[j],4); n++;
      addParticle(0,ux[j],uy[j],0,-12,.5,7,150,220,255);
    });
    /* Enemy defensive structures are on the same grid and are just as much
       "everything in a wide radius" as the units are. */
    let b=0;
    for(const B of blds){
      if(!B.alive||B.team===0) continue;
      if(Math.hypot(B.x-hx,B.y-hy)>R) continue;
      B.cool=Math.max(B.cool||0,4); b++;
    }
    addParticle(3,hx,hy,0,0,.8,R,150,220,255);
    if(typeof gpfxEnergyBlast==='function')
      gpfxEnergyBlast(hx,hy,16,52,[150,220,255],{speed:180,up:0.12,life:0.85,size:3.4,min:8,jit:10});
    toast('⚑ EMP β€” '+n+' unit'+(n===1?'':'s')+(b?' and '+b+' structure'+(b===1?'':'s'):'')+' disabled for 4s');
    sfx('surge',hx,hy,1.2);
    if(typeof radioAck==='function')radioAck('ability',n,hx,hy);
    return;
  }
  if(k===2){ // combat surge
    abCool[2]=commanderCool(AB_CD[2]);
    const hx=ux[heroIdx], hy=uy[heroIdx];
    let n=0;
    forUnitsIn(hx,hy,230,j=>{ if(uteam[j]===0){ ubuff[j]=8; n++; } });
    addParticle(3,hx,hy,0,0,.7,230, 255,220,90);
    toast('⚑ '+n+' units surging (+dmg +speed)');
    sfx('surge'); if(typeof radioAck==='function')radioAck('ability',n,hx,hy); return;
  }
}
/* Emergency mobility is deliberately short-range and terrain-aware. It gets
   the heavy commander out of a blocked street or a surround, without turning
   it into a free cross-map teleport. */
function jumpLandingClear(x,y,i){
  if(i==null) i=heroIdx;
  if(typeof isWalkable==='function'&&!isWalkable(x,y)) return false;
  const rad=(TYPES[utype[i]]&&TYPES[utype[i]].r)||12;
  for(let b=0;b<blds.length;b++){
    const B=blds[b]; if(!B.alive) continue;
    if(dist2(x,y,B.x,B.y)<(B.r+rad+12)*(B.r+rad+12)) return false;
  }
  return true;
}
/* Craters alter the passability grid after they are drawn. A commander that
   was already standing in the new flooded cell otherwise has no legal first
   step, so normal pathfinding can never escape. Search outward in rings and
   use a tiny automatic jet-assist only when the terrain (or a real stall) has
   trapped the player. This does not spend energy or consume the chosen Jump
   Jets ability. */
function nearestCommanderGround(x,y,range){
  const T=TYPES[utype[heroIdx]];
  for(let d=18;d<=range;d+=18){
    for(let n=0;n<16;n++){
      /* `let`, not `const`: the clamp below reassigns these. battlefieldClampPoint
         is always defined (main.js), so that branch ALWAYS ran and always threw
         "Assignment to constant variable" β€” from inside unitTick, which is the
         first call in frame(). Every later sim step and the entire render were
         skipped, so a Commander trapped by crater terrain froze the whole game
         on a still picture. The one function whose job is to un-stick the
         Commander could not complete even once. */
      let a=n/16*TAU+(d/18&1)*.19,px=clamp(x+Math.cos(a)*d,10,MAP-10),py=clamp(y+Math.sin(a)*d,10,MAP-10);
      if(typeof battlefieldClampPoint==='function'){const p=battlefieldClampPoint(px,py,T.r+8);px=p[0];py=p[1];}
      if(jumpLandingClear(px,py)) return [px,py];
    }
  }
  return null;
}
function commanderTerrainRecovery(i,travel,dt){
  if(i!==heroIdx||!ualive[i]||!TYPES[utype[i]]||TYPES[utype[i]].cat!=='hero') return;
  const ordered=Math.hypot(utx[i]-ux[i],uty[i]-uy[i])>34;
  const trapped=(typeof isWalkable==='function'&&!isWalkable(ux[i],uy[i]));
  heroStuckFor=(trapped||(ordered&&travel<.08&&umode[i]!==1&&umode[i]!==5))?heroStuckFor+dt:0;
  if(heroStuckFor<0.9) return;
  const P=nearestCommanderGround(ux[i],uy[i],trapped?220:120);
  heroStuckFor=0;
  if(!P) return;
  const ox=ux[i],oy=uy[i];ux[i]=P[0];uy[i]=P[1];utgt[i]=-1;utgtg[i]=-1;ufield[i]=-1;
  utx[i]=P[0];uty[i]=P[1];ustate[i]=0;
  addParticle(3,ox,oy,0,0,.32,42,110,205,255);addParticle(3,P[0],P[1],0,0,.42,50,110,205,255);
  if(performance.now()>heroRescueAt){heroRescueAt=performance.now()+2800;toast('β†— JET ASSIST β€” Commander cleared crater terrain');sfx('surge',P[0],P[1],.72);}
}
function tryCommanderJump(){
  if(heroIdx<0||!ualive[heroIdx]){ toast('Commander is down'); return; }
  if(heroJumpCool>0){ toast('JUMP JETS REARMING β€” '+Math.ceil(heroJumpCool)+'s'); return; }
  if(!canAfford(0,0,HERO_JUMP.energy)){ toast('JUMP JETS NEED '+HERO_JUMP.energy+' ENERGY'); return; }
  if(aiming===4){ aiming=-1; toast('JUMP TARGETING CANCELLED'); return; }
  aiming=4;
  toast('JUMP JETS β€” tap clear ground within '+HERO_JUMP.range+'m');
  if(typeof radioAck==='function') radioAck('ability',1,ux[heroIdx],uy[heroIdx]);
}
function commanderCrushScenery(i,impact){
  if(i<0||!ualive[i]||!TYPES[utype[i]]||TYPES[utype[i]].cat!=='hero') return;
  const T=TYPES[utype[i]],rad=T.r+(impact?36:16),r2=rad*rad;
  for(const R of relics){
    /* Cities and large derelicts remain terrain decisions. Cottages, houses,
       props and small blocks are destructible under a commander's weight. */
    if(!R.alive||R.s>56||!(R.kind===2||R.kind===3)||dist2(ux[i],uy[i],R.x,R.y)>r2) continue;
    R.hp-=impact?9999:Math.max(95,T.size*4);
    R.burn=Math.min(1,(R.burn||0)+.16);
    if(R.hp<=0) collapseBlock(R,0);
  }
}
function fireCommanderJump(wx,wy){
  if(heroIdx<0||!ualive[heroIdx]){ aiming=-1; return; }
  const hx=ux[heroIdx],hy=uy[heroIdx],d=Math.hypot(wx-hx,wy-hy);
  if(d>HERO_JUMP.range){ toast('JUMP TARGET OUT OF RANGE'); return; }
  if(!jumpLandingClear(wx,wy)){ toast('JUMP NEEDS CLEAR LANDING GROUND'); return; }
  pay(0,0,HERO_JUMP.energy); heroJumpCool=commanderCool(HERO_JUMP.cool); aiming=-1;
  addParticle(3,hx,hy,0,0,.42,58,110,215,255); addParticle(1,hx,hy,0,-16,.72,18,85,80,72);
  ux[heroIdx]=wx; uy[heroIdx]=wy; utx[heroIdx]=wx; uty[heroIdx]=wy; utgt[heroIdx]=-1; ustate[heroIdx]=0;
  commanderCrushScenery(heroIdx,true);
  addParticle(3,wx,wy,0,0,.66,72,130,225,255); addParticle(1,wx,wy,0,-18,.86,24,95,88,76);
  shake=Math.max(shake,3.2); sfx('surge',wx,wy,1.25);
  toast('β†— COMMANDER JUMP β€” landing zone secured');
}
function commanderJumpTick(dt){ if(heroJumpCool>0) heroJumpCool=Math.max(0,heroJumpCool-dt); }
function commanderJumpButtonState(){
  const b=document.getElementById('abJump'); if(!b) return;
  const cd=b.querySelector('.cdring'),cover=heroJumpCool>0?Math.ceil(heroJumpCool):(resE[0]<HERO_JUMP.energy?'E':'');
  b.classList.toggle('cd',!!cover); b.classList.toggle('on',aiming===4);
  cd.style.display=cover?'flex':'none'; cd.textContent=cover;
  b.title='Jump Jets β€” '+HERO_JUMP.energy+' energy, '+Math.round(commanderCool(HERO_JUMP.cool))+'s cooldown. Emergency move through blocked terrain.';
}
function fireBlast(wx,wy){
  buzz(30);
  abCool[0]=commanderCool(AB_CD[0]);
  aiming=-1;
  const fac=typeof mfCombatFactionTeam==='function'?mfCombatFactionTeam(0):'nova';
  /* Ability 0 used spawnExplosion(46, victimTeam) β€” size>=40 is the
     superweapon handoff, and victim-team made Nova's blast inherit the
     enemy's singularity. Legion/Syndicate own the well; Nova is bomblets. */
  if(fac==='legion'||fac==='syndicate'){
    spawnSingularity(wx,wy,1.05,0);
    if(typeof radioAck==='function')radioAck('ability',1,wx,wy);
    shake=Math.max(shake,6);
    return;
  }
  const R=blastRadius, dmgM=heroDmgMult, spread=R*(72/110);
  const pts=[[0,0]];
  for(let k=0;k<7;k++){
    const a=k/7*TAU+0.18, d=spread*(0.42+((k*3)&1)*0.28);
    pts.push([Math.cos(a)*d,Math.sin(a)*d]);
  }
  let n=0;
  for(let q=0;q<pts.length;q++){
    const x=wx+pts[q][0], y=wy+pts[q][1];
    const r=q?40:48, ud=(q?115:170)*dmgM, bd=(q?85:120)*dmgM;
    n+=commanderActiveDamageCircle(x,y,r,ud,bd,0);
    spawnExplosion(x,y,q?22:28,0);
    if(typeof gpfxEnergyBlast==='function')
      gpfxEnergyBlast(x,y,14,q?18:28,[255,210,120],{speed:140,up:0.5,life:0.62,size:3.1,min:5});
    addCrater(x,y,q?16:22);
  }
  damageScenery(wx,wy,R,700);
  deformTerrain(wx,wy,70,0.035);
  sfx('boom',wx,wy,2.2);
  if(typeof radioAck==='function')radioAck('ability',1,wx,wy);
  shake=8;
  toast('✹ CLUSTER BOMB β€” '+n+' '+(n===1?'ASSET':'ASSETS')+' HIT');
}
function commanderAiAimPoint(i,A){
  const hx=ux[i],hy=uy[i],team=uteam[i],R=A.range||180;
  if(!A.target) return {x:hx,y:hy,n:1};
  let cx=0,cy=0,n=0;
  forUnitsIn(hx,hy,R,j=>{if(uteam[j]===team)return;cx+=ux[j];cy+=uy[j];n++;});
  if(n>=2) return {x:cx/n,y:cy/n,n};
  const e=findEnemy(hx,hy,team,R);
  if(e>=0) return {x:ux[e],y:uy[e],n:1};
  const b=findEnemyBld(hx,hy,team,Math.min(R,420));
  if(b>=0) return {x:blds[b].x,y:blds[b].y,n:1};
  return null;
}
function commanderAiShouldCast(i,A,aim){
  const hx=ux[i],hy=uy[i],team=uteam[i];
  if(A.id==='ironredoubt'){
    for(const B of blds)if(B.alive&&B.team===team&&dist2(hx,hy,B.x,B.y)<=A.range*A.range) return true;
    return false;
  }
  if(A.id==='liquidation'){
    for(let w=0;w<wrecks.length;w++) if(dist2(hx,hy,wrecks[w].x,wrecks[w].y)<=A.range*A.range) return true;
    for(const B of blds)if(B.alive&&B.team===team&&['fac','tgate','harbor','airfield'].includes(B.type)&&dist2(hx,hy,B.x,B.y)<=A.range*A.range) return true;
    return false;
  }
  if(A.id==='naniterecall'){
    for(let n=0;n<unitHigh;n++) if(ualive[n]&&uteam[n]===team&&n!==i&&dist2(ux[n],uy[n],hx,hy)<720*720) return true;
    return false;
  }
  if(A.id==='fieldworkshop'||A.id==='crimsonadvance') return true;
  return !!(aim&&aim.n);
}
function commanderAiFireSecondary(i,S){
  const C=commanderDefForUnit(i),W=C&&C.secondary;
  if(!W||!S.weaponCool) return;
  if(S.weaponCool[1]>0) return;
  const e=findEnemy(ux[i],uy[i],uteam[i],W.range||280);
  if(e<0) return;
  const seat=commanderSeatForUnit(i);
  if((W.energy||0)&&!canAfford(seat.team,0,W.energy,seat.slot)) return;
  if(W.energy) pay(seat.team,0,W.energy,seat.slot);
  S.weaponCool[1]=W.cool||16;
  const pk=fireProj(W.ptype,uteam[i],ux[i],uy[i],ux[e],uy[e],W.speed,W.damage,W.aoe||0,e);
  if(pk>=0){pwk[pk]=W.wk||'p';pCannon[pk]=1;if(W.ptype===2){pBarrage[pk]=1;pArc[pk]=90;}projectileFireFX(pk,ux[i],uy[i],ux[e]-ux[i],uy[e]-uy[i]);}
  sfx(W.sfx||'cannon',ux[i],uy[i],1.2);
}
function commanderAiTick(dt){
  if(typeof AI==='undefined'||!AI) return;
  const seats=[].concat(AI.bases||[],AI.allies||[]);
  for(const S of seats){
    if(!S||S.commander==null||S.commander<0) continue;
    const i=S.commander;
    if(!ualive[i]||(S.commanderGen!=null&&ugen[i]!==S.commanderGen)) continue;
    if(typeof heroIdx!=='undefined'&&i===heroIdx) continue;
    S.activeCool=Math.max(0,(S.activeCool||0)-dt);
    if(!S.weaponCool) S.weaponCool=[0,0];
    S.weaponCool[0]=Math.max(0,S.weaponCool[0]-dt);
    S.weaponCool[1]=Math.max(0,S.weaponCool[1]-dt);
    commanderAiFireSecondary(i,S);
    if(S.activeCool>0) continue;
    const C=commanderDefForUnit(i),A=C&&C.active; if(!A) continue;
    const aim=commanderAiAimPoint(i,A);
    if(!commanderAiShouldCast(i,A,aim)||!aim) continue;
    fireCommanderActiveAt(i,aim.x,aim.y,true);
  }
}
function abilTick(dt){
  for(let i=0;i<abCool.length;i++) if(abCool[i]>0) abCool[i]-=dt;
  if(commanderActiveCool>0)commanderActiveCool=Math.max(0,commanderActiveCool-dt);
  for(let i=0;i<commanderWeaponCool.length;i++)if(commanderWeaponCool[i]>0)commanderWeaponCool[i]=Math.max(0,commanderWeaponCool[i]-dt);
  commanderWeaponButtonState();
  commanderJumpTick(dt);
  classAbilityTick(dt);
  artBarrageTick(dt);
  commanderAiTick(dt);
}
/* ---------- ORBITAL LANCE β€” a sweeping beam bought in the Armory ---------- */
function fireLance(wx,wy){
  abCool[3]=commanderCool(AB_CD[3]);
  aiming=-1;
  const ang=Math.random()*TAU, LEN=560, W=64;
  const dxl=Math.cos(ang), dyl=Math.sin(ang);
  for(let k=0;k<9;k++){
    const px2=wx+dxl*(k-4)*(LEN/9), py2=wy+dyl*(k-4)*(LEN/9);
    setTimeout(()=>{
      if(!running) return;
      forUnitsIn(px2,py2,W,j=>{ if(uteam[j]!==0) dealDamage(j,760*heroDmgMult,0,-1); });
      const nb=findEnemyBld(px2,py2,0,W);
      if(nb>=0) damageBld(nb,620,0);
      damageScenery(px2,py2,W,500);
      spawnExplosion(px2,py2,34,1);
      addParticle(3,px2,py2,0,0,.6,W*2.2, 190,225,255);
      addBeam(px2,py2-700,px2,py2,16,200,235,255,0.28,'orbital');
      deformTerrain(px2,py2,W*0.8,0.03);
      shake=Math.max(shake,7);
      sfx('boom',px2,py2,1.7);
    },k*85);
  }
  toast('πŸ›° ORBITAL LANCE β€” cutting the surface');
  sfx('laser',wx,wy,2.4);
  if(typeof radioAck==='function')radioAck('ability',1,wx,wy);
}
// hook bonus income into economy
const _econTick=econTick;
econTick=function(dt){
  _econTick(dt);
  resM[0]=Math.min(RES_MCAP[0],resM[0]+bonusMass*dt);
  resE[0]=Math.min(RES_ECAP[0],resE[0]+bonusEnergy*dt);
  mRate+=bonusMass; eRate+=bonusEnergy;
};