Spaces:
Running
Running
File size: 35,601 Bytes
b51e03a 3597f5a fcce7e3 84bc42a fcce7e3 42a25fd fcce7e3 bebd577 b51e03a bebd577 b51e03a bebd577 fcce7e3 bebd577 fcce7e3 bebd577 fcce7e3 bebd577 fcce7e3 3597f5a fcce7e3 bebd577 fcce7e3 bebd577 369b236 84bc42a 369b236 fcce7e3 181ede2 42a25fd b51e03a 181ede2 b51e03a 181ede2 b51e03a 181ede2 b51e03a 181ede2 b51e03a 181ede2 b51e03a 181ede2 fcce7e3 eeb3b99 fcce7e3 3597f5a 42a25fd 3597f5a fcce7e3 84bc42a 3597f5a 84bc42a 3597f5a fcce7e3 84bc42a fcce7e3 84bc42a fcce7e3 bebd577 fcce7e3 b51e03a bebd577 fcce7e3 b51e03a fcce7e3 b51e03a fcce7e3 84bc42a fcce7e3 84bc42a fcce7e3 84bc42a fcce7e3 84bc42a fcce7e3 84bc42a fcce7e3 84bc42a fcce7e3 84bc42a fcce7e3 84bc42a fcce7e3 84bc42a eeb3b99 fcce7e3 84bc42a fcce7e3 84bc42a fcce7e3 84bc42a fcce7e3 84bc42a fcce7e3 84bc42a fcce7e3 84bc42a fcce7e3 84bc42a fcce7e3 84bc42a fcce7e3 84bc42a fcce7e3 84bc42a fcce7e3 84bc42a 42a25fd 84bc42a fcce7e3 84bc42a fcce7e3 84bc42a fcce7e3 369b236 84bc42a fcce7e3 84bc42a fcce7e3 84bc42a fcce7e3 84bc42a fcce7e3 84bc42a fcce7e3 84bc42a fcce7e3 84bc42a fcce7e3 84bc42a fcce7e3 84bc42a fcce7e3 84bc42a fcce7e3 b51e03a fcce7e3 b51e03a fcce7e3 b51e03a fcce7e3 b51e03a fcce7e3 b51e03a fcce7e3 b51e03a fcce7e3 bebd577 fcce7e3 3597f5a 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 | ;
;
/* ============================================================================
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 β recovered from victories and battlefield kills'},
relic :{nm:'Relic Core',em:'β',ds:'Pre-collapse tech β recovered from hives after Xenobiology'},
};
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 rel=(typeof ITEM_ART!=='undefined'&&ITEM_ART[id])||('assets/icons/items/'+id+'.png');
/* New research art must resolve from the OTA asset map on older Android
installers, not only from files bundled into a fresh 1.33.89 package. */
const src=typeof mf2AssetURL==='function'?mf2AssetURL(rel):('./'+String(rel).replace(/^\.\//,''));
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:'Upgrades the basic artillery barrage with a larger coordinated salvo and tighter dispersion',
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 the playable armies read as different paint over the same
technology. These keys are faction-prefixed so ownership can never leak
between lines. Brood capabilities belong to enemy simulation and Intel,
never to this player progression tree. */
{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']},
];
function devDone(){ META.res=META.res||{}; return META.res; }
function devHas(id){
/* Online's shared neutral setup cannot inherit one browser's paid/unlocked
account tech. Per-seat progression requires an explicit future protocol. */
if(window.__MF_NETWORK_SETUP__)return false;
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('BROOD THREAT DATA β enemy capabilities cannot be researched or equipped');
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('π¬ DEVELOPMENT UNLOCKED β '+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('π¬ DEVELOPMENT 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;
}
/* These values already governed the simulation below; naming them lets the
Development sheet quote the exact same rules instead of restating numbers in
UI copy. They are deliberately not save fields. */
const DEV_MODULE_WEAR_MUL=0.75, DEV_MATERIAL_YIELD_MUL=1.35,
DEV_MODULE_DURABILITY_CAP=2, DEV_REFIT_COST_MUL=0.5,
DEV_REFIT_RESTORE_MUL=0.6;
function moduleSlots(){ return 1+(devHas('slot2')?1:0)+(devHas('slot3')?1:0); }
function wearRate(){ return devHas('logistics')?DEV_MODULE_WEAR_MUL:1; }
function matYield(){ return devHas('salvage')?DEV_MATERIAL_YIELD_MUL: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', compat:'All player combat and support units', 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', compat:'All player combat and support units', 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', compat:'All player armed units', 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', compat:'Player structure construction network', 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', compat:'Player reclaim and salvage recovery', 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', compat:'Player battlefield energy economy', 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', compat:'All player combat and support units', 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', compat:'Commander active-ability loadout', dur:6, cost:{relic:5,isotope:30},
apply:()=>{ abUnlock[4]=true; }},
];
/* ---- READ-ONLY DEVELOPMENT PRESENTERS --------------------------------------
These functions describe the live catalogs; they neither spend resources nor
add save fields. Keeping the quote beside MODULES/DEVTREE prevents the graph,
crafting list, and future inspectors from inventing their own numbers. */
function devFactionLabel(id){
let key=id||'';
if(typeof facCanonicalId==='function') key=facCanonicalId(key)||key;
if(typeof facArt==='function'){
const a=facArt(key)||facArt(id); if(a&&a.nm) return a.nm;
}
return key?key.replace(/^./,c=>c.toUpperCase()):'All factions';
}
function devNodeScope(n){
if(!n) return 'ACCOUNT Β· FUTURE RTS MATCHES';
if(typeof mfFactionTechPurchasable==='function'&&!mfFactionTechPurchasable(n.id))
return 'AI DOSSIER Β· NO CURRENT PLAYER EFFECT';
return n.fac
? 'ACCOUNT UNLOCK Β· ACTIVE IN '+devFactionLabel(n.fac).toUpperCase()+' RTS MATCHES'
: 'ACCOUNT UNLOCK Β· ACTIVE IN FUTURE RTS MATCHES';
}
function devNodeUnlocks(n){
if(!n) return [];
const out=[];
for(const m of MODULES) if(m.req===n.id) out.push({kind:'module',id:m.id,label:'MODULE Β· '+m.nm});
if(n.id==='slot2'||n.id==='slot3') out.push({kind:'loadout',id:n.id,label:'LOADOUT Β· +1 MODULE SLOT'});
else if(n.id==='salvage') out.push({kind:'system',id:n.id,label:'MATCH MATERIAL RECOVERY Β· +'+Math.round((DEV_MATERIAL_YIELD_MUL-1)*100)+'%'});
else if(n.id==='logistics') out.push({kind:'system',id:n.id,label:'EQUIPPED MODULE WEAR Β· -'+Math.round((1-DEV_MODULE_WEAR_MUL)*100)+'%'});
else if(n.id==='refit') out.push({kind:'system',id:n.id,label:'REFIT Β· '+Math.round(DEV_REFIT_COST_MUL*100)+'% MATERIAL COST Β· RESTORES '+Math.round(DEV_REFIT_RESTORE_MUL*100)+'% DURABILITY'});
else if(n.id==='xeno') out.push({kind:'system',id:n.id,label:'HIVE RECOVERY Β· RELIC CORE REWARDS ENABLED'});
if(!out.length) out.push({kind:n.fac?'doctrine':'system',id:n.id,label:(n.fac?'FACTION DOCTRINE Β· ':'SYSTEM Β· ')+n.ds});
return out;
}
function devNodeImpact(n){
if(!n) return 0;
const direct=DEVTREE.filter(x=>x.req&&x.req.indexOf(n.id)>=0).length;
return devNodeUnlocks(n).length*4+direct;
}
function devRecommendNext(nodes){
const source=Array.isArray(nodes)?nodes:DEVTREE;
const pool=source.filter(n=>!devHas(n.id)&&
(typeof mfFactionTechPurchasable!=='function'||mfFactionTechPurchasable(n.id)));
if(!pool.length) return null;
const ranked=pool.map(n=>{
const open=devAvail(n),ready=open&&(META.researchData||0)>=n.data&&matHas(n.cost);
const cost=(n.data||0)+Object.values(n.cost||{}).reduce((a,v)=>a+(+v||0),0);
return {n,state:ready?'ready':open?'funding':'locked',rank:ready?0:open?1:2,impact:devNodeImpact(n),cost,order:DEVTREE.indexOf(n)};
}).sort((a,b)=>a.rank-b.rank||b.impact-a.impact||a.cost-b.cost||a.order-b.order);
const r=ranked[0];
return {id:r.n.id,nm:r.n.nm,state:r.state,
reason:r.state==='ready'?'READY NOW Β· COSTS AND PREREQUISITES MET':
r.state==='funding'?'OPEN PATH Β· GATHER '+devMissing(r.n).join(' Β· '):
'PREREQUISITE PATH Β· '+r.n.req.filter(id=>!devHas(id)).map(id=>(DEVTREE.find(x=>x.id===id)||{nm:id}).nm).join(' Β· ')};
}
function devRecommendAfter(n){
if(!n||!devHas(n.id)) return null;
const direct=DEVTREE.filter(x=>x.req&&x.req.indexOf(n.id)>=0&&!devHas(x.id));
if(direct.length) return devRecommendNext(direct);
const same=DEVTREE.filter(x=>!devHas(x.id)&&((x.fac||'')===(n.fac||'')));
return devRecommendNext(same);
}
function devNodeOutcome(n){
const unlocks=devNodeUnlocks(n);
return {scope:devNodeScope(n),unlocks,recommendation:devRecommendAfter(n),
jump:unlocks.some(x=>x.kind==='module')?'craft':unlocks.some(x=>x.kind==='loadout')?'loadout':''};
}
function modRefitCost(m){
const out={}; for(const k in m.cost) out[k]=Math.max(1,Math.round(m.cost[k]*DEV_REFIT_COST_MUL));
return out;
}
function modCraftQuote(m,have){
const cap=m.dur*DEV_MODULE_DURABILITY_CAP;
have=Math.min(cap,Math.max(0,have==null?(modOwned()[m.id]||0):+have||0));
const craftResult=Math.min(have+m.dur,cap),craftAdd=craftResult-have;
const refitRequested=Math.ceil(m.dur*DEV_REFIT_RESTORE_MUL);
const refitResult=Math.min(have+refitRequested,cap),refitAdd=refitResult-have;
return {compatibility:m.compat||'Player battlefield loadout',scope:'ACCOUNT INVENTORY Β· ACTIVE IN FUTURE RTS MATCHES WHILE FITTED',
current:have,cap,atCap:have>=cap,
craftRequested:m.dur,craftAdd,craftResult,canCraft:craftAdd>0,
refitCost:modRefitCost(m),refitRequested,refitAdd,refitResult,canRefit:refitAdd>0};
}
function modWearDisplay(n){ return Math.round(Math.max(0,n)*10)/10; }
function modOwned(){
META.mods=META.mods||{};
/* Imported/legacy saves can contain durability above the advertised cap.
Clamp the authoritative inventory itself, not just quotes, or the module
keeps applying for hidden extra matches while every presenter says FULL. */
for(const m of MODULES){
if(!Object.prototype.hasOwnProperty.call(META.mods,m.id)) continue;
const raw=+META.mods[m.id],cap=m.dur*DEV_MODULE_DURABILITY_CAP;
META.mods[m.id]=Math.min(cap,Math.max(0,Number.isFinite(raw)?raw:0));
}
return META.mods;
}
function modEquipped(){
/* Imported careers could fit duplicate, broken or unknown module ids and
bypass the one-to-three slot limit. applyModules then applied duplicates
twice while the UI still claimed a bounded loadout. Normalize the saved
fit itself, not only the presenter; preserve unrecognized owned stock for
forward compatibility, but never let it run as an active module. */
const raw=Array.isArray(META.equip)?META.equip:[],owned=modOwned(),res=META.res||{};
const slots=1+(res.slot2?1:0)+(res.slot3?1:0),clean=[];
for(const id of raw){
if(clean.length>=slots)break;
if(typeof id!=='string'||clean.indexOf(id)>=0||!(owned[id]>0)||
!MODULES.some(m=>m.id===id))continue;
clean.push(id);
}
if(raw.length!==clean.length||raw.some((id,i)=>id!==clean[i]))META.equip=clean;
else META.equip=raw;
return META.equip;
}
function modCraft(m){
if(!devHas(m.req)){ toast('π Unlock '+(DEVTREE.find(r=>r.id===m.req)||{}).nm+' in Development first'); return; }
const quote=modCraftQuote(m);
if(!quote.canCraft){ toast(m.nm+' is already at durability cap ('+quote.current+' / '+quote.cap+')'); return false; }
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]=quote.craftResult;
metaSave();
toast('π§ CRAFTED β '+m.nm+' Β· +'+modWearDisplay(quote.craftAdd)+' durability Β· '
+modWearDisplay(quote.current)+' β '+modWearDisplay(quote.craftResult)+' / '+quote.cap);
sfx('level'); buzz(25); renderDevelop();
return true;
}
function modRepair(m){
if(!devHas('refit')){ toast('π Unlock Field Refit in Development to repair modules'); return; }
const quote=modCraftQuote(m);
if(!quote.canRefit){ toast(m.nm+' is already at durability cap ('+quote.current+' / '+quote.cap+')'); return false; }
const half=quote.refitCost;
if(!matHas(half)){ toast('Repair needs '+matCostStr(half)); return; }
matSpend(half);
const o=modOwned(); o[m.id]=quote.refitResult;
metaSave(); toast('π§ REFITTED β '+m.nm+' Β· +'+modWearDisplay(quote.refitAdd)+' durability Β· '
+modWearDisplay(quote.current)+' β '+modWearDisplay(quote.refitResult)+' / '+quote.cap);
sfx('ui'); renderDevelop();
return true;
}
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='';
const outcome=devNodeOutcome(n);
h+='<div class="devReqInline">AFFECTS Β· '+outcome.scope+'</div>'
+'<div class="devReqInline">OUTCOME Β· '+outcome.unlocks.map(x=>x.label).join(' Β· ')+'</div>';
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>';
if(outcome.recommendation) h+='<div class="devReqInline">RECOMMENDED NEXT Β· '+outcome.recommendation.nm+' Β· '+outcome.recommendation.reason+'</div>';
}
return h;
}
function renderDevelop(){
const scr=document.getElementById('devScr'); if(!scr) return;
const title=scr.querySelector('h2'); if(title) title.textContent='π¬ DEVELOPMENT';
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 Β· DEV ONLY</div>';
const tr=document.getElementById('devTabs');
if(tr){
tr.setAttribute('role','tablist');
tr.setAttribute('aria-label','Development categories');
/* Arsenal owns the broad account LOADOUT. This tab is specifically the
crafted module fit, so naming it prevents two destinations promising the
same job while preserving the separate progression system. */
tr.innerHTML=[['research','π¬','UNLOCKS'],['craft','π§','CRAFTING'],['loadout','β
','MODULE FIT']]
.map(([k,e,n])=>'<button class="tabBtn'+(devTab===k?' on':'')+'" data-k="'+k+'" role="tab" aria-selected="'+(devTab===k?'true':'false')+'" aria-controls="devBody">'
+'<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;
g.setAttribute('role','tabpanel');
let h=mfProgressionGuideHTML('development');
if(devTab==='research'){
const total=DEVTREE.filter(n=>devHas(n.id)).length;
h+='<div class="devProgress"><b>'+total+'/'+DEVTREE.length+'</b>'+mfOwnershipBadgeHTML('permanent')+'<span>ACCOUNT UNLOCKS CARRY INTO FUTURE MATCHES Β· FIELD STUDIES 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">'+mfOwnershipBadgeHTML('permanent')+'<b>'+n.nm+'</b><span>'+n.ds+'</span>'
+'<div class="devCost">'+(done?'UNLOCKED':matCostStr(n.cost)+' β '+n.data+' DATA')+'</div></div>'
+'<div class="devAct'+(can?' go':'')+'">'+(done?'β':open?'UNLOCK':'π')+'</div></div>'
+devResearchExtra(n,done,open);
}
} else if(devTab==='craft'){
const o=modOwned();
h+='<div class="devProgress"><b>ACCOUNT</b>'+mfOwnershipBadgeHTML('crafted')+'<span>CRAFTED MODULES CARRY BETWEEN MATCHES AND WEAR WITH USE</span></div>';
for(const m of MODULES){
const locked=!devHas(m.req), quote=modCraftQuote(m,o[m.id]),have=quote.current;
const canCraft=!locked&"e.canCraft;
h+='<div class="devItem'+(locked?' lock':'')+'" data-c="'+m.id+'">'
+'<div class="modEm">'+itemArt('mod_'+m.id,m.em,34)+'</div>'
+'<div class="devTx">'+mfOwnershipBadgeHTML('crafted')+'<b>'+m.nm+'</b><span>'+m.ds+'</span>'
+'<div class="devReqInline">COMPATIBLE Β· '+quote.compatibility+'</div>'
+'<div class="devReqInline">OUTPUT Β· +'+modWearDisplay(quote.craftAdd)+' DURABILITY Β· RESULT '+modWearDisplay(quote.current)+' β '+modWearDisplay(quote.craftResult)+' / '+quote.cap+' CAP</div>'
+(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">RECIPE +'+m.dur+' Β· APPLIED +'+modWearDisplay(quote.craftAdd)+(quote.atCap?' Β· CAP REACHED':'')+'</span></div>'
+(have>0?'<div class="wearO"><div class="wearF" style="width:'+Math.min(100,have/quote.cap*100)+'%"></div></div>':'')
+'</div>'
+'<div class="devAct'+(canCraft?' go':'')+'">'+(locked?'π':!quote.canCraft?'CAP REACHED':(have>0?'CRAFT +'+modWearDisplay(quote.craftAdd):'CRAFT'))+'</div></div>';
}
} else {
const eq=modEquipped(), o=modOwned();
h+='<div class="devProgress"><b>ACCOUNT</b>'+mfOwnershipBadgeHTML('crafted')+'<span>EQUIPPED MODULES APPLY IN FUTURE MATCHES UNTIL DURABILITY IS SPENT</span></div>';
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 quote=modCraftQuote(m,o[m.id]),have=quote.current;
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">'+mfOwnershipBadgeHTML('crafted')+'<b>'+m.nm+'</b><span>'+m.ds+'</span>'
+'<div class="devReqInline">COMPATIBLE Β· '+quote.compatibility+'</div>'
+'<div class="wearO"><div class="wearF'+(worn?' low':'')+'" style="width:'+Math.min(100,have/quote.cap*100)+'%"></div></div>'
+'<div class="devCost">'+Math.ceil(have)+' matches left Β· '+modWearDisplay(have)+' / '+quote.cap+' DURABILITY CAP'+(worn?' Β· WEARING OUT':'')+'</div></div>'
+'<div class="devAct'+(on?'':' go')+'">'+(on?'FITTED':'FIT')+'</div></div>';
if(quote.canRefit&&devHas('refit'))
h+='<div class="devSub" data-x="'+m.id+'">π§ REFIT β '+matCostStr(quote.refitCost)
+' Β· REFIT RESTORES +'+modWearDisplay(quote.refitAdd)+' Β· RESULT '+modWearDisplay(quote.current)+' β '+modWearDisplay(quote.refitResult)+' / '+quote.cap+'</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;
}
}
|