Spaces:
Running
Running
| ; | |
| ; | |
| /* ============================================================================ | |
| DEVELOPMENT β research, materials, crafting, and things that wear out | |
| ---------------------------------------------------------------------------- | |
| The Armory sells permanent percentages. Once bought they are simply true | |
| forever, which is why the store stops mattering: nothing you own is ever at | |
| stake. Development is the opposite half of the economy. | |
| MATERIALS salvaged from matches β spent, not banked forever | |
| RESEARCH a tree that unlocks WHAT you may build, never a stat by itself | |
| MODULES crafted, equipped, and visible on the machine that carries them | |
| WEAR a module is consumed by use and eventually BREAKS | |
| Wear is the whole point. A module that lasts forever is just another store | |
| entry with extra steps; one that lasts eleven matches makes you decide when | |
| to spend it. And because every equipped module puts a mark on the Commander | |
| and the HQ in the field, its loss is something you SEE β the icon goes out, | |
| the bonus goes with it, and you know exactly what changed. | |
| ============================================================================ */ | |
| /* ---- MATERIALS ------------------------------------------------------------- | |
| Four, deliberately. Enough that recipes can want different things; few | |
| enough that a player can hold all four in their head. */ | |
| const MATS={ | |
| alloy :{nm:'Alloy', em:'β¬', ds:'Structural plate β salvaged from wrecks'}, | |
| circuit:{nm:'Circuitry',em:'β', ds:'Intact logic boards β rarer than plate'}, | |
| isotope:{nm:'Isotope', em:'β’', ds:'Reactor fuel β dropped by heavy kills'}, | |
| relic :{nm:'Relic Core',em:'β',ds:'Pre-collapse tech β only from ruins and hives'}, | |
| }; | |
| function matBag(){ META.mats=META.mats||{alloy:0,circuit:0,isotope:0,relic:0}; return META.mats; } | |
| function matHas(cost){ const b=matBag(); for(const k in cost) if((b[k]||0)<cost[k]) return false; return true; } | |
| function matSpend(cost){ const b=matBag(); for(const k in cost) b[k]=(b[k]||0)-cost[k]; metaSave(); } | |
| function matGrant(g){ const b=matBag(); for(const k in g) b[k]=(b[k]||0)+g[k]; metaSave(); } | |
| /* ---- ITEM ART -------------------------------------------------------------- | |
| Every item carries a real painted icon rather than an emoji. Emoji render as | |
| a different typeface on every platform, carry someone else's design language, | |
| and cannot be made to share a palette with the game. These are generated by | |
| tools/make-icons.py from the same primitives and the same palette as the rest | |
| of the art, so the whole set reads as one family. The emoji stays in the data | |
| as the alt text and as the fallback if an image ever fails to load. */ | |
| function itemArt(id,em,size){ | |
| const s=size||34; | |
| const src=(typeof ITEM_ART!=='undefined'&&ITEM_ART[id])||('./assets/icons/items/'+id+'.png'); | |
| return '<img class="itemArt" src="'+src+'" alt="'+(em||'')+'" ' | |
| +'style="width:'+s+'px;height:'+s+'px" ' | |
| +'onerror="this.outerHTML=\'<span class="itemEm">'+(em||'')+'</span>\'">'; | |
| } | |
| function matCostStr(c){ | |
| return Object.keys(c).map(k=> | |
| '<span class="costOne">'+itemArt('mat_'+k,MATS[k].em,15)+c[k]+'</span>').join(''); | |
| } | |
| /* What a match pays out. Scaled by the threat the player chose to fight at, so | |
| the endgame ladder feeds the crafting economy instead of running beside it. */ | |
| function fieldRecoveryFromMatch(res){ | |
| const committed=typeof matchCommitted==='function'?matchCommitted(res.win):(res.win||(stats.t|0)>=180); | |
| if(!committed) return {alloy:0,circuit:0}; | |
| const lossScale=res.win?1:0.35; | |
| const reserveAlloy=Math.min(res.win?12:5,Math.floor(Math.max(0,res.fieldMass||0)/300)*lossScale)|0; | |
| const reclaimedAlloy=Math.min(res.win?8:4,Math.floor(Math.max(0,res.reclaimed||0)/180)*lossScale)|0; | |
| return { | |
| alloy:reserveAlloy+reclaimedAlloy, | |
| circuit:Math.min(res.win?8:3,Math.floor(Math.max(0,res.fieldEnergy||0)/1400)*lossScale)|0, | |
| reserveAlloy,reclaimedAlloy, | |
| }; | |
| } | |
| function matsFromMatch(res){ | |
| if(typeof matchCommitted==='function'&&!matchCommitted(res.win)) return {alloy:0,circuit:0,isotope:0,relic:0}; | |
| const t=(typeof threatSel==='function')?threatSel():1; | |
| const s=res.win?1:0.35; | |
| const recovery=fieldRecoveryFromMatch(res); | |
| return { | |
| alloy : Math.round((8+res.kills*0.004+res.built*0.5)*s*(1+t*0.14))+recovery.alloy, | |
| circuit: Math.round((3+res.kills*0.0016)*s*(1+t*0.12))+recovery.circuit, | |
| isotope: Math.round((res.win?2:0)+res.kills*0.0009*s*(1+t*0.10)), | |
| /* Xenobiology is the RIGHT to bank Relic Cores from hives. Ungated drops | |
| made the node a displayed cost with no effect β you never needed it. */ | |
| relic : (typeof devHas==='function'&&devHas('xeno')&&res.nests>0) | |
| ? Math.round(s*(1+t*0.08)) : 0, | |
| }; | |
| } | |
| /* ---- RESEARCH -------------------------------------------------------------- | |
| Three branches, and a rule: a research node never grants a stat. It grants | |
| the RIGHT to craft something, an extra slot, or better salvage. Stats come | |
| from modules, which wear out β so power always has a running cost. */ | |
| const DEVTREE=[ | |
| {id:'metallurgy', br:'FABRICATION', nm:'Field Metallurgy', ds:'Unlocks plating modules', | |
| cost:{alloy:40}, data:6, req:[]}, | |
| {id:'optics', br:'FABRICATION', nm:'Gunnery Optics', ds:'Unlocks targeting modules', | |
| cost:{alloy:30,circuit:14}, data:10, req:['metallurgy']}, | |
| {id:'servos', br:'FABRICATION', nm:'Heavy Servos', ds:'Unlocks mobility and build modules', | |
| cost:{alloy:60,circuit:20}, data:16, req:['optics']}, | |
| {id:'slot2', br:'FABRICATION', nm:'Modular Frames', ds:'A second module slot', | |
| cost:{alloy:90,circuit:40,isotope:10}, data:24, req:['servos']}, | |
| {id:'salvage', br:'DOCTRINE', nm:'Salvage Doctrine', ds:'+35% materials from every match', | |
| cost:{alloy:25}, data:6, req:[]}, | |
| {id:'logistics', br:'DOCTRINE', nm:'Forward Logistics',ds:'Modules wear 25% slower', | |
| cost:{alloy:50,circuit:18}, data:12, req:['salvage']}, | |
| {id:'breakthrough',br:'DOCTRINE', nm:'Breakthrough Protocol', | |
| ds:'Unlocks a timed assault push for infantry, armour and anti-tank platoons', | |
| cost:{alloy:72,circuit:30,isotope:5}, data:20, req:['logistics'], art:'res_ability'}, | |
| {id:'intercept', br:'DOCTRINE', nm:'Interceptor Protocol', | |
| ds:'Unlocks high-speed interception for aircraft and anti-air screens', | |
| cost:{alloy:58,circuit:42,isotope:7}, data:22, req:['logistics'], art:'res_optics'}, | |
| {id:'refit', br:'DOCTRINE', nm:'Field Refit', ds:'Unlocks repairing worn modules', | |
| cost:{alloy:70,circuit:26}, data:18, req:['logistics']}, | |
| {id:'fieldservice',br:'DOCTRINE', nm:'Field Service Net', | |
| ds:'Unlocks a selected support platoon repair-and-shield pulse', | |
| cost:{alloy:82,circuit:48,isotope:6}, data:24, req:['refit'], art:'res_servos'}, | |
| {id:'firemission',br:'DOCTRINE', nm:'Fire Mission Protocol', | |
| ds:'Unlocks the player-commanded Charged Barrage for selected artillery', | |
| cost:{alloy:90,circuit:44,isotope:8}, data:26, req:['logistics'], art:'res_refit'}, | |
| {id:'slot3', br:'DOCTRINE', nm:'Command Retrofit', ds:'A third module slot', | |
| cost:{alloy:140,circuit:70,isotope:26,relic:4}, data:40, req:['refit','slot2']}, | |
| {id:'xeno', br:'XENOLOGY', nm:'Xenobiology', ds:'Hives drop Relic Cores', | |
| cost:{isotope:8}, data:8, req:[]}, | |
| {id:'reactor', br:'XENOLOGY', nm:'Captured Reactors',ds:'Unlocks isotope modules', | |
| cost:{isotope:22,circuit:20}, data:14, req:['xeno']}, | |
| {id:'relictech', br:'XENOLOGY', nm:'Relic Reverse-Eng',ds:'Unlocks relic modules β the strongest, and the most fragile', | |
| cost:{relic:6,circuit:50}, data:26, req:['reactor']}, | |
| {id:'ability', br:'XENOLOGY', nm:'Weaponised Relics',ds:'Unlocks craftable Commander abilities', | |
| cost:{relic:12,isotope:40}, data:42, req:['relictech']}, | |
| /* Faction doctrines are deliberately separate unlock paths. The original | |
| account tree made four armies read as different paint over the same | |
| technology. These keys are faction-prefixed so ownership can never leak | |
| between lines when player-faction selection becomes available. */ | |
| {id:'asc_siege_foundry', fac:'ascendancy', br:'FABRICATION', nm:'Siege Foundry', ds:'Dominion artillery leave factories with +12% maximum health', | |
| cost:{alloy:55,circuit:18}, data:12, req:[]}, | |
| {id:'asc_iron_discipline', fac:'ascendancy', br:'DOCTRINE', nm:'Iron Discipline', ds:'Dominion formation orders use 12% tighter spacing for groups of 4 or more', | |
| cost:{alloy:42,isotope:8}, data:14, req:['asc_siege_foundry']}, | |
| {id:'asc_crown_battery', fac:'ascendancy', br:'XENOLOGY', nm:'Crown Battery', ds:'Entering Siege overcharges artillery for 8s: cooldowns recover 28% faster for 2.5 energy/s; 18s recovery', | |
| cost:{circuit:34,isotope:18,relic:1}, data:24, req:['asc_iron_discipline']}, | |
| {id:'syn_quantum_grid', fac:'syndicate', br:'FABRICATION', nm:'Quantum Grid', ds:'Syndicate weapons, shields and active fields draw 15% less grid energy', | |
| cost:{circuit:36,isotope:14}, data:12, req:[]}, | |
| {id:'syn_drone_mesh', fac:'syndicate', br:'DOCTRINE', nm:'Drone Mesh', ds:'Syndicate scouts and aircraft mark nearby enemies for 3.5s; allied hits deal +10% damage', | |
| cost:{alloy:30,circuit:42}, data:15, req:['syn_quantum_grid']}, | |
| {id:'syn_phase_lattice', fac:'syndicate', br:'XENOLOGY', nm:'Phase Lattice', ds:'Phase Arks transfer cargo at 96 range instead of requiring a 22-range landing approach', | |
| cost:{circuit:58,isotope:24,relic:1}, data:26, req:['syn_drone_mesh']}, | |
| {id:'hor_gene_splice', fac:'horde', br:'FABRICATION', nm:'Gene Splice Pit', ds:'AI DOSSIER - future Brood player tech for biomass kill-recovery; no player combat effect while Brood is AI-only', | |
| cost:{alloy:24,isotope:16}, data:12, req:[]}, | |
| {id:'hor_synaptic_tide', fac:'horde', br:'DOCTRINE', nm:'Synaptic Tide', ds:'AI DOSSIER - Tidecasters form automatically at critical Brood mass; player unlock is reserved until Brood is playable', | |
| cost:{circuit:26,isotope:28}, data:16, req:['hor_gene_splice']}, | |
| {id:'hor_living_siege', fac:'horde', br:'XENOLOGY', nm:'Living Siege', ds:'AI DOSSIER - Massflesh is currently an AI breakthrough carrier; player unlock is reserved until Brood is playable', | |
| cost:{alloy:60,isotope:32,relic:2}, data:28, req:['hor_synaptic_tide']}, | |
| ]; | |
| function devDone(){ META.res=META.res||{}; return META.res; } | |
| function devHas(id){ return !!devDone()[id]; } | |
| function devAvail(n){ return n.req.every(devHas); } | |
| function devMissing(n){ | |
| const b=matBag(), out=[]; | |
| for(const k in n.cost){ const d=n.cost[k]-(b[k]||0); if(d>0) out.push(Math.ceil(d)+' '+MATS[k].nm); } | |
| const data=n.data-(META.researchData||0); if(data>0) out.push(Math.ceil(data)+' Research Data'); | |
| return out; | |
| } | |
| function devBuy(n,silent){ | |
| if(!n||devHas(n.id)) return false; | |
| if(typeof mfFactionTechPurchasable==='function'&&!mfFactionTechPurchasable(n.id)){ | |
| if(!silent) toast('AI DOSSIER β Brood evolution becomes researchable only when the faction is playable'); | |
| return false; | |
| } | |
| if(!devAvail(n)){ if(!silent) toast('π Requires '+n.req.map(r=>DEVTREE.find(x=>x.id===r).nm).join(', ')); return false; } | |
| if((META.researchData||0)<n.data||!matHas(n.cost)){ if(!silent) toast('Need '+devMissing(n).join(', ')); return false; } | |
| META.researchData-=n.data; matSpend(n.cost); | |
| devDone()[n.id]=1; metaSave(); | |
| if(!silent){ toast('π¬ RESEARCHED β '+n.nm); sfx('level'); buzz(30); renderDevelop(); renderMetaHead(); } | |
| return true; | |
| } | |
| /* The research queue is a spend plan, not a shopping list. After a match pays | |
| Data and salvage, complete every queued node that is actually affordable so | |
| the debrief β Development loop does not require a second trip to tap RESEARCH. */ | |
| function devFlushQueue(){ | |
| const q=Array.isArray(META.resQueue)?META.resQueue.slice():[]; | |
| if(!q.length) return []; | |
| const done=[]; | |
| const next=[]; | |
| for(const id of q){ | |
| const n=DEVTREE.find(x=>x.id===id); | |
| if(!n||devHas(id)) continue; | |
| if(devBuy(n,true)) done.push(n); | |
| else next.push(id); | |
| } | |
| META.resQueue=next; | |
| if(done.length){ | |
| metaSave(); | |
| toast('π¬ RESEARCH COMPLETE β '+done.map(n=>n.nm).join(', ')); | |
| if(typeof sfx==='function') sfx('level'); | |
| if(typeof renderDevelop==='function') renderDevelop(); | |
| if(typeof renderMetaHead==='function') renderMetaHead(); | |
| } else if(q.join('|')!==next.join('|')) metaSave(); | |
| return done; | |
| } | |
| function moduleSlots(){ return 1+(devHas('slot2')?1:0)+(devHas('slot3')?1:0); } | |
| function wearRate(){ return devHas('logistics')?0.75:1; } | |
| function matYield(){ return devHas('salvage')?1.35:1; } | |
| /* Every committed operation advances account research, including a defeat. | |
| The Research Complex remains the fastest source through studies and a live | |
| network bonus, but making it the ONLY source created a progression deadlock: | |
| a newcomer who lost the lab earned zero Data and could never improve the lab. | |
| Short surrender loops still pay nothing. */ | |
| function researchDataFromMatch(win){ | |
| const studies=Math.max(0,typeof resDone!=='undefined'?resDone|0:0); | |
| let labs=0; | |
| if((stats.t|0)>=90&&typeof blds!=='undefined') | |
| for(const B of blds) if(B.alive&&B.team===0&&B.type==='techlab'&&B.prog>=1) labs++; | |
| const studyData=studies*3; | |
| const network=Math.min(3,labs); | |
| const hold=win&&labs?2:0; | |
| const committed=typeof matchCommitted==='function'?matchCommitted(win):(win||(stats.t|0)>=180); | |
| const mission=committed?(win?4:2):0; | |
| const challenge=win?clamp(difficulty|0,0,2):0; | |
| const raw=mission+studyData+network+hold+challenge; | |
| const threat=(typeof threatSel==='function')?1+Math.max(0,threatSel()-1)*0.06:1; | |
| const rewardScale=win?1:(committed?.35:0); | |
| const total=Math.min(24,Math.round(raw*threat*rewardScale)); | |
| META.researchData=(META.researchData||0)+total; | |
| const parts=win?[['Mission recovery',mission],['Studies',studyData],['Lab network',network], | |
| ['Objective hold',hold],['Challenge',challenge]].filter(p=>p[1]>0): | |
| (committed&&total?[['Committed defeat Β· 35% recovery',total]]:[]); | |
| return {total,parts}; | |
| } | |
| /* ---- MODULES --------------------------------------------------------------- | |
| Each one states plainly what it does, what it costs, how long it lasts, and | |
| what mark it puts in the field. `dur` is in MATCHES: a player can read | |
| "eleven matches" and plan around it in a way that an abstract charge count | |
| never allows. */ | |
| const MODULES=[ | |
| {id:'plate', nm:'Reactive Plating', em:'π‘', col:[120,200,255], req:'metallurgy', | |
| ds:'Your units +10% health', dur:12, cost:{alloy:35}, | |
| apply:()=>{ resHpMult*=1.10; }}, | |
| {id:'optic', nm:'Targeting Uplink', em:'π―', col:[255,190,110], req:'optics', | |
| ds:'Your units +8% damage', dur:10, cost:{alloy:24,circuit:10}, | |
| apply:()=>{ armyDmgMult+=0.08; }}, | |
| {id:'range', nm:'Sensor Mast', em:'π‘', col:[150,255,200], req:'optics', | |
| ds:'Your units +10% range', dur:10, cost:{alloy:20,circuit:14}, | |
| apply:()=>{ resRngMult*=1.10; }}, | |
| {id:'tempo', nm:'Drive Governors', em:'β', col:[200,180,255], req:'servos', | |
| ds:'Structures build 20% faster', dur:14, cost:{alloy:40,circuit:12}, | |
| apply:()=>{ bldSpeedMult*=1.20; }}, | |
| {id:'recl', nm:'Reclaim Servos', em:'β»', col:[140,235,150], req:'servos', | |
| ds:'+40% salvage reclaimed', dur:14, cost:{alloy:45,circuit:8}, | |
| apply:()=>{ salvageMult*=1.40; }}, | |
| {id:'core', nm:'Fusion Overdraw', em:'β’', col:[255,150,90], req:'reactor', | |
| ds:'+18% energy income', dur:9, cost:{isotope:16,circuit:16}, | |
| apply:()=>{ resEnergyMult*=1.18; }}, | |
| {id:'relic', nm:'Relic Lattice', em:'β', col:[255,120,220], req:'relictech', | |
| ds:'+15% health AND +12% damage β burns out fast', dur:5, cost:{relic:3,circuit:30}, | |
| apply:()=>{ resHpMult*=1.15; armyDmgMult+=0.12; }}, | |
| {id:'emp', nm:'EMP Charge', em:'β‘', col:[120,255,255], req:'ability', | |
| ds:'Commander ability: stun everything in a wide radius', dur:6, cost:{relic:5,isotope:30}, | |
| apply:()=>{ abUnlock[4]=true; }}, | |
| ]; | |
| function modOwned(){ META.mods=META.mods||{}; return META.mods; } | |
| function modEquipped(){ META.equip=META.equip||[]; return META.equip; } | |
| function modCraft(m){ | |
| if(!devHas(m.req)){ toast('π Research '+(DEVTREE.find(r=>r.id===m.req)||{}).nm+' first'); return; } | |
| if(!matHas(m.cost)){ toast('Not enough materials'); return; } | |
| matSpend(m.cost); | |
| const o=modOwned(); | |
| /* Crafting a module you already own TOPS IT UP rather than making a second | |
| copy β inventory management is not the interesting part of this system. */ | |
| o[m.id]=Math.min((o[m.id]||0)+m.dur, m.dur*2); | |
| metaSave(); | |
| toast('π§ CRAFTED β '+m.nm+' Β· '+o[m.id]+' matches of wear'); | |
| sfx('level'); buzz(25); renderDevelop(); | |
| } | |
| function modRepair(m){ | |
| if(!devHas('refit')){ toast('π Research Field Refit to repair modules'); return; } | |
| const half={}; for(const k in m.cost) half[k]=Math.max(1,Math.round(m.cost[k]*0.5)); | |
| if(!matHas(half)){ toast('Repair needs '+matCostStr(half)); return; } | |
| matSpend(half); | |
| const o=modOwned(); o[m.id]=Math.min((o[m.id]||0)+Math.ceil(m.dur*0.6), m.dur*2); | |
| metaSave(); toast('π§ Refitted β '+m.nm); sfx('ui'); renderDevelop(); | |
| } | |
| function modToggle(m){ | |
| const eq=modEquipped(), i=eq.indexOf(m.id); | |
| if(i>=0){ eq.splice(i,1); } | |
| else{ | |
| if(!(modOwned()[m.id]>0)){ toast('Craft '+m.nm+' first'); return; } | |
| if(eq.length>=moduleSlots()){ toast('All '+moduleSlots()+' slots are full β unequip one first'); return; } | |
| eq.push(m.id); | |
| } | |
| metaSave(); renderDevelop(); sfx('ui'); | |
| } | |
| /* Applied once when a match starts, after the Armory perks. */ | |
| function applyModules(){ | |
| bldSpeedMult=1; | |
| for(const id of modEquipped()){ | |
| const m=MODULES.find(x=>x.id===id); | |
| if(m&&modOwned()[id]>0) try{ m.apply(); }catch(e){} | |
| } | |
| } | |
| /* Consumed once when a match ends. Anything that hits zero BREAKS: it comes | |
| off the machine, its mark goes out, and the player is told which one. */ | |
| function wearModules(){ | |
| const o=modOwned(), eq=modEquipped(); | |
| const broke=[]; | |
| const w=wearRate(); | |
| for(let i=eq.length-1;i>=0;i--){ | |
| const id=eq[i], m=MODULES.find(x=>x.id===id); | |
| if(!m) { eq.splice(i,1); continue; } | |
| o[id]=Math.max(0,(o[id]||0)-w); | |
| if(o[id]<=0){ o[id]=0; eq.splice(i,1); broke.push(m); } | |
| } | |
| metaSave(); | |
| return broke; | |
| } | |
| /* What the field renderer asks for: the marks currently riding on the player's | |
| machines. An empty list is exactly what a broken module looks like. */ | |
| function activeModuleMarks(){ | |
| const out=[]; | |
| for(const id of modEquipped()){ | |
| const m=MODULES.find(x=>x.id===id); | |
| if(m&&modOwned()[id]>0) out.push(m); | |
| } | |
| return out; | |
| } | |
| /* ---- UI --------------------------------------------------------------------- */ | |
| let devTab='research', devBranch='FABRICATION'; | |
| function devBranchNav(){ | |
| return '<div class="devBranchNav">'+['FABRICATION','DOCTRINE','XENOLOGY'].map(br=>{ | |
| const ns=DEVTREE.filter(n=>n.br===br), done=ns.filter(n=>devHas(n.id)).length; | |
| return '<button class="devBranchBtn'+(devBranch===br?' on':'')+'" data-br="'+br+'">' | |
| +br+' <span>'+done+'/'+ns.length+'</span></button>'; | |
| }).join('')+'</div>'; | |
| } | |
| function devResearchExtra(n,done,open){ | |
| let h=''; | |
| if(n.req.length){ | |
| h+='<div class="devReqs"><b>REQUIRES</b>'+n.req.map(id=>{ | |
| const r=DEVTREE.find(x=>x.id===id); | |
| return '<span class="'+(devHas(id)?'ok':'no')+'">'+(devHas(id)?'β ':'')+(r?r.nm:id)+'</span>'; | |
| }).join('')+'</div>'; | |
| } | |
| if(open&&!done){ const miss=devMissing(n); if(miss.length) h+='<div class="devNeed">NEED '+miss.join(' Β· ')+'</div>'; } | |
| if(done){ | |
| const mods=MODULES.filter(m=>m.req===n.id); | |
| if(mods.length) h+='<button class="devSub devJump" data-jump="craft">OPEN CRAFTING Β· '+mods.length+' UNLOCKED</button>'; | |
| else if(n.id==='slot2'||n.id==='slot3') h+='<button class="devSub devJump" data-jump="loadout">OPEN LOADOUT</button>'; | |
| } | |
| return h; | |
| } | |
| function renderDevelop(){ | |
| const scr=document.getElementById('devScr'); if(!scr) return; | |
| const b=matBag(); | |
| const mr=document.getElementById('matRow'); | |
| if(mr) mr.innerHTML=Object.keys(MATS).map(k=> | |
| '<div class="matChip">'+itemArt('mat_'+k,MATS[k].em,20)+Math.floor(b[k]||0)+'</div>').join('') | |
| +'<div class="matChip data"><span>β</span>'+Math.floor(META.researchData||0)+' DATA</div>' | |
| +'<div class="matChip core"><span>⬑</span>'+(META.cores|0)+'</div>'; | |
| const tr=document.getElementById('devTabs'); | |
| if(tr){ | |
| tr.innerHTML=[['research','π¬','RESEARCH'],['craft','π§','CRAFTING'],['loadout','β ','LOADOUT']] | |
| .map(([k,e,n])=>'<button class="tabBtn'+(devTab===k?' on':'')+'" data-k="'+k+'">' | |
| +'<span class="tEm">'+e+'</span>'+n+'</button>').join(''); | |
| tr.querySelectorAll('.tabBtn').forEach(x=>mfBindTap(x,ev=>{ | |
| ev.stopPropagation(); devTab=x.dataset.k; sfx('ui'); renderDevelop(); | |
| })); | |
| } | |
| const g=document.getElementById('devBody'); if(!g) return; | |
| let h=''; | |
| if(devTab==='research'){ | |
| const total=DEVTREE.filter(n=>devHas(n.id)).length; | |
| h+='<div class="devProgress"><b>'+total+'/'+DEVTREE.length+'</b><span>ACCOUNT RESEARCH Β· FIELD LABS BANK β DATA</span></div>'+devBranchNav(); | |
| h+='<div class="devBr">'+devBranch+' PATH</div>'; | |
| for(const n of DEVTREE.filter(x=>x.br===devBranch)){ | |
| const done=devHas(n.id), open=devAvail(n); | |
| const can=open&&!done&&(META.researchData||0)>=n.data&&matHas(n.cost); | |
| h+='<div class="devItem'+(done?' done':open?'':' lock')+'" data-r="'+n.id+'">' | |
| +'<div class="modEm">'+itemArt(n.art||'res_'+n.id,'π¬',34)+'</div>' | |
| +'<div class="devTx"><b>'+n.nm+'</b><span>'+n.ds+'</span>' | |
| +'<div class="devCost">'+(done?'RESEARCHED':matCostStr(n.cost)+' β '+n.data+' DATA')+'</div></div>' | |
| +'<div class="devAct'+(can?' go':'')+'">'+(done?'β':open?'RESEARCH':'π')+'</div></div>' | |
| +devResearchExtra(n,done,open); | |
| } | |
| } else if(devTab==='craft'){ | |
| const o=modOwned(); | |
| for(const m of MODULES){ | |
| const locked=!devHas(m.req), have=o[m.id]||0; | |
| h+='<div class="devItem'+(locked?' lock':'')+'" data-c="'+m.id+'">' | |
| +'<div class="modEm">'+itemArt('mod_'+m.id,m.em,34)+'</div>' | |
| +'<div class="devTx"><b>'+m.nm+'</b><span>'+m.ds+'</span>' | |
| +(locked?'<div class="devReqInline">REQUIRES '+((DEVTREE.find(x=>x.id===m.req)||{}).nm||m.req)+'</div>':'') | |
| +'<div class="devCost">'+matCostStr(m.cost)+'<span class="costSep">lasts '+m.dur+' matches</span></div>' | |
| +(have>0?'<div class="wearO"><div class="wearF" style="width:'+Math.min(100,have/m.dur*100)+'%"></div></div>':'') | |
| +'</div>' | |
| +'<div class="devAct'+(locked?'':' go')+'">'+(locked?'π':(have>0?'+'+m.dur:'CRAFT'))+'</div></div>'; | |
| } | |
| } else { | |
| const eq=modEquipped(), o=modOwned(); | |
| h+='<div class="devBr">EQUIPPED β '+eq.length+' / '+moduleSlots()+' SLOTS</div>'; | |
| if(!eq.length) h+='<div class="devNone">Nothing fitted. Equipped modules appear as marks on your Commander and HQ in the field.</div>'; | |
| for(const m of MODULES){ | |
| const have=o[m.id]||0; | |
| if(have<=0&&eq.indexOf(m.id)<0) continue; | |
| const on=eq.indexOf(m.id)>=0; | |
| const worn=have<=m.dur*0.25; | |
| h+='<div class="devItem'+(on?' done':'')+'" data-e="'+m.id+'">' | |
| +'<div class="modEm">'+itemArt('mod_'+m.id,m.em,34)+'</div>' | |
| +'<div class="devTx"><b>'+m.nm+'</b><span>'+m.ds+'</span>' | |
| +'<div class="wearO"><div class="wearF'+(worn?' low':'')+'" style="width:'+Math.min(100,have/m.dur*100)+'%"></div></div>' | |
| +'<div class="devCost">'+Math.ceil(have)+' matches left'+(worn?' Β· WEARING OUT':'')+'</div></div>' | |
| +'<div class="devAct'+(on?'':' go')+'">'+(on?'FITTED':'FIT')+'</div></div>'; | |
| if(have<m.dur&&devHas('refit')) | |
| h+='<div class="devSub" data-x="'+m.id+'">π§ REFIT β '+matCostStr( | |
| Object.fromEntries(Object.entries(m.cost).map(([k,v])=>[k,Math.max(1,Math.round(v*0.5))])))+'</div>'; | |
| } | |
| } | |
| g.innerHTML=h; | |
| const bind=function(el,fn){ if(typeof mfBindTap==='function') mfBindTap(el,fn); else el.addEventListener('click',fn); }; | |
| g.querySelectorAll('[data-br]').forEach(el=>bind(el,ev=>{ | |
| ev.stopPropagation(); devBranch=el.dataset.br; sfx('ui'); renderDevelop(); })); | |
| g.querySelectorAll('[data-jump]').forEach(el=>bind(el,ev=>{ | |
| ev.stopPropagation(); devTab=el.dataset.jump; sfx('ui'); renderDevelop(); })); | |
| g.querySelectorAll('[data-r]').forEach(el=>bind(el,()=>{ | |
| const n=DEVTREE.find(x=>x.id===el.dataset.r); if(n) devBuy(n); })); | |
| g.querySelectorAll('[data-c]').forEach(el=>bind(el,()=>{ | |
| const m=MODULES.find(x=>x.id===el.dataset.c); if(m) modCraft(m); })); | |
| g.querySelectorAll('[data-e]').forEach(el=>bind(el,()=>{ | |
| const m=MODULES.find(x=>x.id===el.dataset.e); if(m) modToggle(m); })); | |
| g.querySelectorAll('[data-x]').forEach(el=>bind(el,ev=>{ | |
| ev.stopPropagation(); | |
| const m=MODULES.find(x=>x.id===el.dataset.x); if(m) modRepair(m); })); | |
| } | |
| /* Called at match end, after rewards. */ | |
| function developRecord(res){ | |
| const recovery=fieldRecoveryFromMatch(res),g=matsFromMatch(res), y=matYield(); | |
| for(const k in g) g[k]=Math.round(g[k]*y); | |
| matGrant(g); | |
| const broke=wearModules(); | |
| /* metaGrant already banked Research Data. Flush now so queued nodes spend | |
| the payout in this same debrief rather than sitting READY until tapped. */ | |
| const researched=typeof devFlushQueue==='function'?devFlushQueue():[]; | |
| return {mats:g,recovery,broke,researched}; | |
| } | |
| function initDevelop(){ | |
| matBag(); devDone(); modOwned(); modEquipped(); | |
| /* Cloud merge used to pick the career with more unused Data over the one | |
| that had already spent it on nodes. Count ownership so research persists. */ | |
| if(typeof careerWeight==='function'&&!careerWeight._devProgress){ | |
| const _cw=careerWeight; | |
| careerWeight=function(m){ | |
| let nodes=0, mats=0; | |
| if(m&&m.res) for(const k in m.res) if(m.res[k]) nodes++; | |
| if(m&&m.mats) for(const k in m.mats) mats+=(m.mats[k]|0); | |
| return _cw(m)+nodes*80+mats*2; | |
| }; | |
| careerWeight._devProgress=true; | |
| } | |
| renderDevelop(); | |
| /* Field Research Archives bank Data mid-match. Spend it into the queued | |
| node immediately so the pickup is the unlock, not a number that waits. */ | |
| if(typeof applyCrate==='function'&&!applyCrate._devFlush){ | |
| const _ac=applyCrate; | |
| applyCrate=function(k,x,y){ | |
| const before=META.researchData||0; | |
| const r=_ac(k,x,y); | |
| if((META.researchData||0)>before) devFlushQueue(); | |
| return r; | |
| }; | |
| applyCrate._devFlush=true; | |
| } | |
| } | |