File size: 26,122 Bytes
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
;
;
/* ============================================================================

   ENDGAME β€” why you play match fifty

   ----------------------------------------------------------------------------

   The career runs out at Warmaster and the store runs out at the Orbital Lance,

   which lands somewhere around match ten. After that the game had nothing left

   to ask of the player except percentages. Four systems replace that, and each

   one answers a different question:



     THREAT LEVEL   "is there anything harder?"    a permanent ladder

     OPERATIONS     "can I choose my own fight?"   priced, stackable modifiers

     THE WEEKLY     "how do I compare?"            one fixed contract per week

     MASTERY        "what have I not done yet?"    the long tail, visible



   The design rule shared by all four: difficulty the player CHOOSES must always

   pay, and the game must never hand out difficulty they did not choose. That is

   the difference between a challenge and a punishment.

   ============================================================================ */

/* ---- THREAT LEVEL ----------------------------------------------------------

   A ladder that ratchets. Beating the current level unlocks the next and banks

   it forever, so progress is never lost to a bad run β€” a loss costs the attempt

   and nothing else. Rewards climb faster than the difficulty does at the low

   end and slower at the top, which makes the early rungs feel generous and the

   last ones feel earned. */
const THREAT_MAX=12;
function threatUnlocked(){ META.threat=META.threat||1; return META.threat; }
function threatSel(){ META.threatSel=Math.min(META.threatSel||1,threatUnlocked()); return META.threatSel; }
function setThreat(n){ META.threatSel=Math.max(1,Math.min(n,threatUnlocked())); metaSave(); renderOps(); }
/* Applied on top of the difficulty setting, so Hard T7 is genuinely harder than

   Hard T1 rather than being the same match with a bigger number on the screen. */
function threatEcon(){ return 1+(threatSel()-1)*0.16; }
function threatHp()  { return 1+(threatSel()-1)*0.07; }
function threatDmg() { return 1+(threatSel()-1)*0.06; }
function threatTech(){ return 1+(threatSel()-1)*0.13; }   // how fast the AI tiers up
function threatReward(){ const t=threatSel(); return 1+(t-1)*0.22+Math.max(0,t-6)*0.10; }
/* T5 used to be named CAMPAIGN. That is also the locked War Room mode, so a

   rung on the threat ladder read as if authored story missions were live. */
const THREAT_NM=['','SKIRMISH','PROBE','RAID','OFFENSIVE','THEATRE','WAR',
                 'ATTRITION','SIEGE','ANNIHILATION','EXTINCTION','APEX','ABSOLUTE'];

/* ---- OPERATION MODIFIERS ---------------------------------------------------

   Wildcards, but chosen and individually priced. The old system let the player

   pick a COUNT and then rolled the dice, paying the same +35% whether they drew

   something cosmetic or something crippling. Choosing which knives to hold is

   the interesting decision; the dice were taking it away. */
const OPMODS=[
 {id:'iron',   nm:'Iron Enemy',      ds:'Enemy units +25% health',         rw:0.30,gate:{kind:'rank',n:0}},
 {id:'fogb',   nm:'Fog Bank',        ds:'Vision reduced by 40%',           rw:0.25,gate:{kind:'rank',n:1}},
 {id:'veins',  nm:'Scarce Veins',    ds:'Your mass income -30%',           rw:0.40,gate:{kind:'wins',n:1}},
 {id:'swarm',  nm:'Hiveworld',       ds:'Wildlife escalates twice as fast',rw:0.35,gate:{kind:'tutorial',n:1}},
 {id:'blitz',  nm:'Blitz Doctrine',  ds:'Enemy waves arrive 40% sooner',   rw:0.35,gate:{kind:'rank',n:2}},
 {id:'volatile',nm:'Volatile Cores', ds:'Every death detonates',           rw:0.15,gate:{kind:'matches',n:3}},
 {id:'titan',  nm:'Early Titans',    ds:'The enemy fields TITANs early',   rw:0.45,gate:{kind:'threat',n:4}},
 {id:'brittle',nm:'Brittle Frames',  ds:'Your units -20% health',          rw:0.40,gate:{kind:'rank',n:3}},
 {id:'nofab',  nm:'No Salvage',      ds:'Wrecks cannot be reclaimed',      rw:0.20,gate:{kind:'mastery',n:3}},
 {id:'dark',   nm:'Blood Moon',      ds:'Permanent night',                 rw:0.10,gate:{kind:'wins',n:5}},
];
function opModsOn(){ META.opmods=META.opmods||{}; return META.opmods; }
function opModUnlockState(mod){
  const g=mod&&mod.gate||{kind:'rank',n:0}; let value=0,label='Available';
  if(g.kind==='rank'){
    value=typeof metaRankIdx==='function'?metaRankIdx():0;
    const rank=(typeof RANKS!=='undefined'&&RANKS[g.n])||{nm:'Commander Rank '+(g.n+1)};
    label=g.n===0?'Recruit access':'Reach '+rank.nm+' rank';
  }else if(g.kind==='wins'){
    value=META.wins||0; label='Win '+g.n+' operation'+(g.n===1?'':'s');
  }else if(g.kind==='matches'){
    value=META.matches||0; label='Complete '+g.n+' operations';
  }else if(g.kind==='tutorial'){
    value=META.tutorial&&META.tutorial.done?1:0; label='Complete First Command training';
  }else if(g.kind==='threat'){
    value=typeof threatUnlocked==='function'?threatUnlocked():1; label='Unlock Threat T'+g.n;
  }else if(g.kind==='mastery'){
    value=typeof masteryTotal==='function'?masteryTotal().done:0; label='Earn '+g.n+' battlefield masteries';
  }
  return {open:value>=g.n,label,progress:Math.min(value,g.n)+' / '+g.n};
}
function opModUnlocked(mod){ return opModUnlockState(typeof mod==='string'?OPMODS.find(x=>x.id===mod):mod).open; }
function opModActive(id){ return opModUnlocked(id)&&!!opModsOn()[id]; }
function toggleOpMod(id){
  const mod=OPMODS.find(x=>x.id===id),unlock=opModUnlockState(mod);
  if(!mod||!unlock.open){ toast('πŸ”’ '+(mod?mod.nm:'Modifier')+' β€” '+unlock.label+' ('+unlock.progress+')'); sfx('reject'); return; }
  const o=opModsOn();
  if(o[id]) delete o[id]; else o[id]=1;
  /* Hand-picked rules and the random quick-pick are two alternatives. Keeping

     both selected made the random buttons appear broken because chosen rules

     intentionally win at match start. */
  wcChoice=0; META.wcPref=0;
  metaSave(); renderOps(); sfx('ui');
}
function opModMult(){
  let m=1; for(const k of OPMODS) if(opModActive(k.id)) m+=k.rw;
  return m;
}
/* The total the player is playing for, shown before they commit. */
function payoutMult(){
  const chosen=OPMODS.some(k=>opModActive(k.id));
  return threatReward()*(chosen?opModMult():(1+0.35*Math.max(0,wcChoice|0)));
}

/* ---- SCORE -----------------------------------------------------------------

   One number so runs are comparable β€” to your own past runs, and to the weekly.

   Weighted toward things that reflect play rather than patience: a win, speed,

   what you destroyed, and the difficulty you chose to carry. */
function matchScore(res){
  const speed=Math.max(0.4, Math.min(2.2, 900/Math.max(120,res.seconds)));
  const base=1200+res.kills*0.55+res.built*18;
  const s=Math.round(base*speed*payoutMult()*(1+res.difficulty*0.25));
  /* A loss scored exactly zero, so the debrief's headline number told a player

     who had just fought for nine minutes that they had done nothing β€” sitting

     directly above a cores payout that had correctly credited their kills,

     their fortification and their research. It also meant META.bestScore could

     never move on a loss and the weekly banked a 0. Losses score now; they

     simply cannot out-score the same run won. */
  return res.win?s:Math.round(s*0.25);
}

/* ---- THE WEEKLY OPERATION --------------------------------------------------

   One fixed contract for everybody, every week: same map, same faction, same

   goal, same modifiers, derived from the ISO week number so no server is

   needed to agree on it. The player's own best is what they chase, and the

   save-code format already carries it between devices. */
function isoWeek(d){
  d=new Date(d||Date.now());
  const t=new Date(Date.UTC(d.getFullYear(),d.getMonth(),d.getDate()));
  const day=t.getUTCDay()||7;
  t.setUTCDate(t.getUTCDate()+4-day);
  const y0=new Date(Date.UTC(t.getUTCFullYear(),0,1));
  return t.getUTCFullYear()*100+Math.ceil(((t-y0)/86400000+1)/7);
}
/* Opponent banners for weekly + mastery. Same pool as the battle-setup

   picker: The Four, Nova included as a legal enemy. Brood stays AI-only as

   a player seat. Campaign/MMO/Co-op are locked modes, not missing banners. */
function endgameEnemyFactions(){
  const facs=(typeof enemyFactions==='function'?enemyFactions():['nova','legion','syndicate','horde']);
  return facs.length?facs:['nova','legion','syndicate','horde'];
}
function weeklyDef(){
  const w=isoWeek();
  let s=w>>>0;
  const r=()=>{ s=(s*1664525+1013904223)>>>0; return s/4294967296; };
  const maps=(typeof homeworldMapIds==='function'?homeworldMapIds():Object.keys(MAPDEFS||{})).filter(k=>MAPDEFS[k]);
  const facs=endgameEnemyFactions();
  const goals=['annihilate','domination','purge','survival'];
  const mods=[];
  const pool=OPMODS.slice();
  const n=2+((r()*2)|0);
  for(let i=0;i<n&&pool.length;i++) mods.push(pool.splice((r()*pool.length)|0,1)[0].id);
  return {week:w,
          map:maps[(r()*maps.length)|0]||'aelos_north_small',
          fac:facs[(r()*facs.length)|0]||'legion',
          goal:goals[(r()*4)|0],
          threat:3+((r()*5)|0),
          mods};
}
function weeklyBest(){
  META.weekly=META.weekly||{};
  const w=isoWeek();
  if(META.weekly.week!==w) META.weekly={week:w,best:0,runs:0};
  return META.weekly;
}
function weeklyRecord(score){
  const wk=weeklyBest();
  wk.runs=(wk.runs||0)+1;
  if(score>(wk.best||0)){ wk.best=score; metaSave(); return true; }
  metaSave(); return false;
}
/* A forecast, not a second reward formula. These are the exact minimum and

   maximum core-ledger totals a victory can produce before boosters: objective,

   challenge and completion at the floor; every capped performance line and a

   possible first-win award at the ceiling. The operation multiplier is the

   same threat/modifier product endgameRecord uses after the match. */
function weeklyRewardForecast(d,playThreat){
  const diff=clamp(typeof difficulty!=='undefined'?difficulty|0:1,0,2);
  const challenge=[0,14,30][diff];
  const threatMul=1+(playThreat-1)*0.22+Math.max(0,playThreat-6)*0.10;
  let modMul=1;
  for(const id of d.mods){ const m=OPMODS.find(o=>o.id===id); if(m) modMul+=m.rw; }
  const mult=threatMul*modMul;
  return {mult,min:Math.round((6+50+challenge)*mult),
               max:Math.round((18+24+36+24+18+50+challenge+25)*mult)};
}
let weeklyMode=false;
/* A WEEKLY RUN IS A LOAN, NOT A REWRITE.

   startWeekly() overwrote META.threatSel and META.opmods with the authored

   weekly plan and metaSave()'d it, and endgameRecord only ever cleared the

   weeklyMode flag β€” so a single Weekly attempt permanently replaced whatever

   threat level and modifiers the player had chosen for their own skirmishes,

   with nothing on screen saying so. That is a large part of why this screen

   "doesn't make sense": you set up a match, played one Weekly, and your setup

   was someone else's. Same borrow/return shape tutorial.js already uses for

   training (saveTrainingConfig / restoreTrainingConfig). */
let weeklyPrev=null;
function saveWeeklyConfig(){
  weeklyPrev={ threatSel:META.threatSel, opmods:Object.assign({},META.opmods||{}),
               curMap:curMap, curTheme:curTheme, battlefieldPreset:battlefieldPreset,
               aiFactionSel:aiFactionSel, goalSel:goalSel };
}
function restoreWeeklyConfig(){
  if(!weeklyPrev) return false;
  META.threatSel=weeklyPrev.threatSel; META.opmods=weeklyPrev.opmods;
  curMap=weeklyPrev.curMap; aiFactionSel=weeklyPrev.aiFactionSel; goalSel=weeklyPrev.goalSel;
  if(weeklyPrev.curTheme) curTheme=weeklyPrev.curTheme;
  if(weeklyPrev.battlefieldPreset) battlefieldPreset=weeklyPrev.battlefieldPreset;
  weeklyPrev=null;
  if(typeof metaSave==='function') metaSave();
  return true;
}
function startWeekly(){
  const d=weeklyDef();
  saveWeeklyConfig();          // borrow the plan; hand it back when the run ends
  weeklyMode=true;
  curMap=d.map; aiFactionSel=d.fac; goalSel=d.goal;
  const def=MAPDEFS[d.map];
  if(def&&def.theme&&typeof THEMES!=='undefined'&&THEMES[def.theme]) curTheme=def.theme;
  if(def&&def.size&&typeof battlefieldPresetKey==='function') battlefieldPreset=battlefieldPresetKey(def.size);
  META.threatSel=Math.min(d.threat,threatUnlocked());
  const o={}; for(const id of d.mods) o[id]=1;
  META.opmods=o; metaSave();
  document.getElementById('opsScr').style.display='none';
  applyTheme(); newSkirmish();
  toast('πŸ“… WEEKLY OPERATION β€” best score this week: '+(weeklyBest().best||0).toLocaleString());
}

/* ---- MASTERY ---------------------------------------------------------------

   The long tail, and the only part of the endgame that is a checklist rather

   than a ladder. Four enemy banners β€” The Four, Nova included as a legal

   opponent β€” across 48 homeworld sites is 192 boxes; each box remembers the

   highest threat level you beat it at, so it keeps meaning something after

   it is first ticked. */
function masteryKey(map,fac){ return map+':'+fac; }
function masteryGet(map,fac){ META.mastery=META.mastery||{}; return META.mastery[masteryKey(map,fac)]||0; }
function masterySet(map,fac,t){
  META.mastery=META.mastery||{};
  const k=masteryKey(map,fac);
  if(t>(META.mastery[k]||0)){ META.mastery[k]=t; metaSave(); return true; }
  return false;
}
function masteryTotal(){
  let n=0,sum=0;
  const maps=(typeof homeworldMapIds==='function'?homeworldMapIds():Object.keys(MAPDEFS||{}));
  const facs=endgameEnemyFactions();
  for(const m of maps) for(const f of facs){
    n++; sum+=masteryGet(m,f)>0?1:0;
  }
  return {done:sum,total:n};
}

/* ---- MATCH RESULT ----------------------------------------------------------

   Everything the endgame needs to know, applied once. */
function endgameCareerNote(){
  const lines=[];
  const t=threatSel();
  lines.push('T'+t+' '+THREAT_NM[t]+' Β· Γ—'+payoutMult().toFixed(2)+' payout');
  const daily=typeof dailyLast==='function'?dailyLast():null;
  if(daily&&daily.granted&&daily.granted.length){
    for(const o of daily.granted) lines.push('πŸ“‹ '+o.nm+' β€” order paid');
  }
  const story=typeof storyMatchNote==='function'?storyMatchNote():null;
  if(story) lines.push('πŸ“‘ NEW DISPATCH β€” '+story.ttl);
  const hz=typeof mapHazardDef==='function'?mapHazardDef(typeof curMap!=='undefined'?curMap:''):null;
  const n=(typeof HAZ!=='undefined'&&HAZ.count)|0;
  if(hz&&hz.nm&&hz.mode!=='calm')
    lines.push((hz.em||'⚠')+' '+hz.nm+(n?' β€” '+n+' event'+(n===1?'':'s'):' β€” theatre weather'));
  return lines;
}
function endgameRecord(res){
  const score=matchScore(res);
  let msgs=[];
  if(res.win){
    /* The ladder only advances when you win AT the level you are sitting on,

       so a player cannot climb by farming a rung they have already beaten. */
    if(threatSel()>=threatUnlocked() && threatUnlocked()<THREAT_MAX){
      META.threat=threatUnlocked()+1;
      META.threatSel=META.threat;
      msgs.push('βš” THREAT '+META.threat+' UNLOCKED β€” '+THREAT_NM[META.threat]);
    }
    if(masterySet(curMap,(AI&&AI.fac)||'legion',threatSel()))
      msgs.push('β˜… Mastery raised: '+((MAPDEFS[curMap]&&MAPDEFS[curMap].nm)||curMap));
  }
  /* A finished Weekly is an attempt even on a loss β€” otherwise the dispatch

     keyed to "run a Weekly" and the personal-best card both stayed blank. */
  if(weeklyMode){
    if(weeklyRecord(score)&&res.win)
      msgs.push('πŸ“… New weekly best: '+score.toLocaleString());
  }
  META.bestScore=Math.max(META.bestScore||0,score);
  metaSave();
  /* Give the player's own plan back before the flag clears, so the next visit

     to Battle Setup shows what THEY chose rather than this week's brief. */
  if(weeklyMode&&restoreWeeklyConfig()) msgs.push('↩ Your own battle plan restored');
  weeklyMode=false;
  msgs=msgs.concat(endgameCareerNote());
  return {score,msgs};
}

/* ---- UI --------------------------------------------------------------------- */
function renderOpsBrief(mode){
  /* Weekly is a complete authored plan, not merely another view of the custom

     setup. Showing custom T1 above a T5 weekly briefing gave the player two

     contradictory answers about the operation they were inspecting. */
  let t=threatSel(), activeMods=OPMODS.filter(k=>opModActive(k.id)), mult=payoutMult(), state='READY';
  if(mode==='weekly'){
    const d=weeklyDef();
    t=Math.min(d.threat,threatUnlocked());
    activeMods=d.mods.map(id=>OPMODS.find(o=>o.id===id)).filter(Boolean);
    mult=weeklyRewardForecast(d,t).mult;
    state='WEEKLY';
  }else if(activeMods.length||t>1) state='CUSTOM';
  const setBrief=(id,v)=>{ const e=document.getElementById(id); if(e) e.textContent=v; };
  setBrief('opsBriefThreat','T'+t);
  setBrief('opsBriefThreatNm',mode==='weekly'?'WEEKLY TARGET':THREAT_NM[t]);
  setBrief('opsBriefMods',String(activeMods.length));
  setBrief('opsBriefModsNm',activeMods.length?(activeMods.length===1?'RULE ACTIVE':'RULES ACTIVE'):'STANDARD RULES');
  setBrief('opsBriefPayout','Γ—'+mult.toFixed(2));
  setBrief('opsBriefState',state);
}
/* WHICH OPERATION IS THE PLAYER LOOKING AT.

   'threat' and 'mastery' are no longer tabs here β€” the ladder moved to Battle

   Setup and the record moved to Profile β€” so a stale saved tab name has to

   fall back to something that still exists, or the screen opens on nothing. */
function opsTab(){
  const t=(typeof MF_TAB_STATE!=='undefined'&&MF_TAB_STATE&&MF_TAB_STATE.opsScr)||'';
  /* Campaign is advertised, not shipped. A stale tab name must not hide Weekly. */
  if(t==='campaign'&&!document.getElementById('opsTab-campaign')) return 'weekly';
  return (t==='campaign'||t==='weekly')?t:
    (document.getElementById('opsTab-campaign')?'campaign':'weekly');
}
/* The footer start button is the SAME control for every tab β€” that is what

   makes "every tab ends in a button that starts a match" true without burying

   one at the bottom of each pane. It just has to say, and do, the right thing. */
function opsSyncGo(){
  const go=document.getElementById('weeklyGo'); if(!go) return;
  const campaign=opsTab()==='campaign';
  go.textContent=campaign?'β–Ά START SELECTED MISSION':'β–Ά START WEEKLY OPERATION';
  go.dataset.opsMode=campaign?'campaign':'weekly';
}
function renderOps(){
  const el=document.getElementById('opsScr'); if(!el) return;
  /* The plan strip lives on Battle Setup now; it still reflects whichever

     operation is selected here, because that is what it will be launched at. */
  renderOpsBrief(opsTab());
  opsSyncGo();
  /* Threat ladder */
  const tl=document.getElementById('threatRow');
  if(tl){
    let h='';
    for(let i=1;i<=THREAT_MAX;i++){
      const open=i<=threatUnlocked(), sel=i===threatSel();
      h+='<button class="thBtn'+(sel?' on':'')+(open?'':' lock')+'" data-t="'+i+'">'
        +'<b>T'+i+'</b><span>'+(open?THREAT_NM[i]:'πŸ”’')+'</span></button>';
    }
    tl.innerHTML=h;
    tl.querySelectorAll('.thBtn').forEach(b=>b.addEventListener('pointerdown',ev=>{
      ev.stopPropagation();
      const t=+b.dataset.t;
      if(t>threatUnlocked()){ toast('πŸ”’ Win at Threat '+threatUnlocked()+' to unlock this'); return; }
      setThreat(t); sfx('ui');
    }));
  }
  const ti=document.getElementById('threatInfo');
  if(ti) ti.innerHTML='<b>T'+threatSel()+' Β· '+THREAT_NM[threatSel()]+'</b> β€” enemy economy +'
    +Math.round((threatEcon()-1)*100)+'%, health +'+Math.round((threatHp()-1)*100)
    +'%, tech +'+Math.round((threatTech()-1)*100)+'%';
  /* Modifiers */
  const mr=document.getElementById('opModRow');
  if(mr){
    mr.innerHTML=OPMODS.map((k,mi)=>{ const u=opModUnlockState(k),active=opModActive(k.id),
      /* The atlas is row-major 5x2. CSS percentage positions are relative to

         the remaining overflow, so five columns land at 0/25/50/75/100. */
      artX=(mi%5)*25,artY=mi<5?0:100; return '<div class="opMod'
      +(active?' on':'')+(u.open?'':' lock')+'" data-id="'+k.id+'" role="button" tabindex="0" aria-disabled="'+(!u.open)+'">'
      +'<div class="opModArt" aria-hidden="true" style="--opx:'+artX+'%;--opy:'+artY+'%"><i></i></div>'
      +'<div class="opTx"><b>'+k.nm+'</b><span>'+k.ds+'</span><small class="opUnlock '+(u.open?'earned':'')+'">'
      +(u.open?'βœ“ ':'πŸ”’ ')+u.label+(u.open?'':' Β· '+u.progress)+'</small></div>'
      +'<div class="opRw">+'+Math.round(k.rw*100)+'%</div></div>'; }).join('');
    mr.querySelectorAll('.opMod').forEach(d=>{
      d.addEventListener('pointerdown',()=>toggleOpMod(d.dataset.id));
      d.addEventListener('keydown',ev=>{
        if(ev.key!=='Enter'&&ev.key!==' ') return;
        ev.preventDefault(); toggleOpMod(d.dataset.id);
      });
    });
  }
  const pm=document.getElementById('opPayout');
  if(pm) pm.innerHTML=(wcChoice?'RANDOM Γ—'+wcChoice+' Β· ':'PAYOUT  ')+'<b>Γ—'+payoutMult().toFixed(2)+'</b>';
  const available=OPMODS.filter(opModUnlocked).length;
  document.querySelectorAll('.wbtn').forEach(b=>{
    const n=+b.dataset.w,blocked=n>available;
    b.disabled=blocked; b.classList.toggle('lock',blocked);
    b.title=blocked?'Unlock '+n+' modifiers first ('+available+' available)':'';
    b.classList.toggle('on',!blocked&&n===wcChoice);
  });
  /* Weekly */
  const wd=weeklyDef(), wb=weeklyBest();
  const wk=document.getElementById('weeklyBox');
  if(wk){
    const map=MAPDEFS[wd.map]||{nm:wd.map,ds:''}, fac=FACTIONS[wd.fac]||{nm:wd.fac,em:'β—†',heroNm:'Unknown Commander'},
          art=typeof facArt==='function'?facArt(wd.fac):null,
          haz=(typeof mapHazardDef==='function'?mapHazardDef(wd.map):null)||{em:'β—‡',nm:'NO HAZARD',ds:''},
          goal=GOALS.find(g=>g.id===wd.goal)||GOALS[0], playThreat=Math.min(wd.threat,threatUnlocked()),
          forecast=weeklyRewardForecast(wd,playThreat), mins=timeLimit>0?Math.max(1,Math.round(timeLimit/60)):0,
          commander=(art&&art.cdr)||fac.heroNm||'Unknown Commander', artId=(art&&art.id)||fac.art||wd.fac,
          facNm=(art&&art.nm)||fac.nm, facCol=(art&&art.col)||'#8be8ff', facCol2=(art&&art.col2)||'#071523',
          modNames=wd.mods.map(id=>(OPMODS.find(o=>o.id===id)||{nm:id}).nm);
    wk.innerHTML='<article class="wkBriefCard" data-map="'+wd.map+'" data-faction="'+wd.fac+'" style="--wkf:'+facCol+';--wkf2:'+facCol2+'">'
      +'<header class="wkBriefHead"><div><span>ROTATION '+String(wd.week).slice(-2)+'</span><b>WEEKLY OPERATION</b></div>'
      +'<i>HIGH VALUE CONTRACT</i></header>'
      +'<div class="wkVisualRow">'
        +'<div class="wkMapPreview" data-map="'+wd.map+'"><div class="wkMapGrid"></div>'
          +'<div class="wkMapName"><span>COMBAT ZONE</span><b>'+map.nm+'</b><small>'+map.ds+'</small></div>'
          +'<div class="wkHazard">'+haz.em+' '+haz.nm+'</div></div>'
        +'<div class="wkCommander"><img class="wkCommanderPortrait" src="./assets/factions/'+artId+'_192.jpg" '
          +'alt="'+commander+', opposing commander" onerror="this.style.display=\'none\'">'
          +'<div class="wkCommanderCrest">'+(typeof facIcon==='function'?facIcon(wd.fac,34):fac.em)+'</div>'
          +'<div class="wkCommanderName"><span>OPPOSING COMMANDER</span><b>'+commander+'</b><small>'+facNm+'</small></div></div>'
      +'</div>'
      +'<div class="wkGoal"><span class="wkGoalEm">'+goal.em+'</span><div><small>PRIMARY OBJECTIVE</small><b>'+goal.nm+'</b><p>'+goal.ds+'</p></div></div>'
      +'<div class="wkIntelGrid">'
        +'<div><span>EST. DURATION</span><b>'+(mins?'β‰ˆ '+mins+' MIN':'OPEN ENDED')+'</b></div>'
        +'<div><span>THREAT</span><b>T'+playThreat+(playThreat<wd.threat?' / T'+wd.threat:'')+'</b></div>'
        +'<div><span>ENVIRONMENT</span><b>'+haz.em+' '+haz.nm+'</b></div></div>'
      +'<div class="wkModChips">'+modNames.map(n=>'<span>'+n+'</span>').join('')+'</div>'
      +'<div class="wkReward"><div><span>PROJECTED CORE RANGE</span><b>+'+forecast.min+'–'+forecast.max+'</b></div>'
        +'<small>Γ—'+forecast.mult.toFixed(2)+' XP &amp; CORE PAYOUT<br>Performance determines final recovery</small></div>'
      +(playThreat<wd.threat?'<div class="wkScaleNote">Contract target T'+wd.threat+' Β· currently scales to unlocked T'+playThreat+'</div>':'')
      +'<div class="wkRecord"><span>PERSONAL BEST</span><b>'+(wb.best||0).toLocaleString()+'</b>'
        +'<small>'+(wb.runs?wb.runs+' completed run'+(wb.runs>1?'s':''):'No attempts this rotation')+'</small></div>'
      +'</article>';
  }
  /* Mastery */
  const mg=document.getElementById('masteryGrid');
  if(mg){
    const facs=endgameEnemyFactions();
    let h='<div class="mRow mHdr"><div></div>'+facs.map(f=>'<div>'+((FACTIONS[f]&&FACTIONS[f].em)||'β—†')+'</div>').join('')+'</div>';
    const maps=(typeof homeworldMapIds==='function'?homeworldMapIds():Object.keys(MAPDEFS||{}));
    for(const m of maps){
      h+='<div class="mRow"><div class="mNm">'+((MAPDEFS[m]&&MAPDEFS[m].nm)||m)+'</div>';
      for(const f of facs){
        const t=masteryGet(m,f);
        h+='<div class="mCell'+(t?' got':'')+'">'+(t?'T'+t:'β€”')+'</div>';
      }
      h+='</div>';
    }
    const tot=masteryTotal();
    mg.innerHTML=h+'<div class="mFoot">'+tot.done+' / '+tot.total+' Β· 48 homeworld sites Β· '+facs.length+' enemy banners</div>';
  }
  if(typeof mfBindTabs==='function') mfBindTabs(el,'threat');
}
function initOps(){
  META.threat=META.threat||1; META.threatSel=META.threatSel||1;
  const el=document.getElementById('opsScr');
  if(el&&!el.dataset.opsBriefBound){
    el.dataset.opsBriefBound='1';
    el.addEventListener('mftabchange',e=>renderOpsBrief(e.detail&&e.detail.key));
  }
  /* endGame lives in main.js and loads after this file. Bind once at boot

     so victory/defeat both carry the career lines that already sit in

     endgameRecord().msgs β€” main.js already prints those into #goRewards. */
  renderOps();
}