; ; /* ============================================================================ 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(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()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+=''; } 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='T'+threatSel()+' ยท '+THREAT_NM[threatSel()]+' โ€” 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 '
' +'' +'
'+k.nm+''+k.ds+'' +(u.open?'โœ“ ':'๐Ÿ”’ ')+u.label+(u.open?'':' ยท '+u.progress)+'
' +'
+'+Math.round(k.rw*100)+'%
'; }).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 ')+'ร—'+payoutMult().toFixed(2)+''; 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='
' +'
ROTATION '+String(wd.week).slice(-2)+'WEEKLY OPERATION
' +'HIGH VALUE CONTRACT
' +'
' +'
' +'
COMBAT ZONE'+map.nm+''+map.ds+'
' +'
'+haz.em+' '+haz.nm+'
' +'
' +'
'+(typeof facIcon==='function'?facIcon(wd.fac,34):fac.em)+'
' +'
OPPOSING COMMANDER'+commander+''+facNm+'
' +'
' +'
'+goal.em+'
PRIMARY OBJECTIVE'+goal.nm+'

'+goal.ds+'

' +'
' +'
EST. DURATION'+(mins?'โ‰ˆ '+mins+' MIN':'OPEN ENDED')+'
' +'
THREATT'+playThreat+(playThreat
' +'
ENVIRONMENT'+haz.em+' '+haz.nm+'
' +'
'+modNames.map(n=>''+n+'').join('')+'
' +'
PROJECTED CORE RANGE+'+forecast.min+'โ€“'+forecast.max+'
' +'ร—'+forecast.mult.toFixed(2)+' XP & CORE PAYOUT
Performance determines final recovery
' +(playThreatContract target T'+wd.threat+' ยท currently scales to unlocked T'+playThreat+'':'') +'
PERSONAL BEST'+(wb.best||0).toLocaleString()+'' +''+(wb.runs?wb.runs+' completed run'+(wb.runs>1?'s':''):'No attempts this rotation')+'
' +'
'; } /* Mastery */ const mg=document.getElementById('masteryGrid'); if(mg){ const facs=endgameEnemyFactions(); let h='
'+facs.map(f=>'
'+((FACTIONS[f]&&FACTIONS[f].em)||'โ—†')+'
').join('')+'
'; const maps=(typeof homeworldMapIds==='function'?homeworldMapIds():Object.keys(MAPDEFS||{})); for(const m of maps){ h+='
'+((MAPDEFS[m]&&MAPDEFS[m].nm)||m)+'
'; for(const f of facs){ const t=masteryGet(m,f); h+='
'+(t?'T'+t:'โ€”')+'
'; } h+='
'; } const tot=masteryTotal(); mg.innerHTML=h+'
'+tot.done+' / '+tot.total+' ยท 48 homeworld sites ยท '+facs.length+' enemy banners
'; } 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(); }