massfront-playtest / src /storeui.js
CREATORJD's picture
Publish MASSFRONT web 1.33.33 (part 3)
3597f5a verified
Raw
History Blame Contribute Delete
42.7 kB
;
;
/* ============================================================================
STOREUI — the Armory screen.
----------------------------------------------------------------------------
The old renderArmory() (still defined in src/game/meta.js at boot) was a
flat 11-row list plus a colour strip: no grouping, no sense of what a perk
actually does at the tier you own versus the tier you'd be buying, and no
way to tell a 250-core early buy from a 1200-core late one apart from
squinting at the price. This file takes the function over — same reassign
pattern src/audio.js uses for sfx — and rebuilds it as tabbed categories,
sorted cheapest-first, with the concrete before/after numbers spelled out.
Purchase rules and costs are untouched. The inventory half deliberately
keeps the legacy META.inventory shape from src/game/meta.js: the Account
Armory is the storage layer, while equipped/ready remain the five-slot
Session Loadout consumed by the existing match-start code.
============================================================================ */
/* ---- category map ---------------------------------------------------------
Every STORE id must appear in exactly one bucket. 'cosmetic' has no STORE
items of its own — it hosts the commander-colour row that used to sit
under the list with nowhere in particular to live. */
const ARM_CATS=[
{id:'operations', em:'💰', nm:'OPERATIONS', ds:'Permanent economy and recovery advantages.', items:['cache','trade','salvage','droppod','reactor']},
{id:'battlefield',em:'⚔', nm:'BATTLEFIELD', ds:'Permanent combat and fortification improvements.', items:['targeting','armor','bastion']},
{id:'commander', em:'★', nm:'COMMANDER', ds:'Commander growth, abilities, and cooldowns.', items:['neural','capacitor','orbital']},
{id:'inventory', em:'▦', nm:'ARMORY', ds:'Account storage for every recovered gear item and mission supply.', items:[]},
{id:'loadout', em:'⬢', nm:'LOADOUT', ds:'Five limited deployment slots: three gear categories and two supplies.', items:[]},
{id:'identity', em:'🎨', nm:'IDENTITY', ds:'Visual identity only — no combat advantage.', items:[]},
];
/* Four destinations are easier to scan on a phone than six product-taxonomy
tabs. The original array remains above as the catalog source; this mutable
replacement changes presentation without duplicating any STORE pricing. */
ARM_CATS.splice(0,ARM_CATS.length,
{id:'market',em:'⬡',nm:'MARKET',ds:'Permanent upgrades and daily requisitions.',items:['cache','trade','salvage','droppod','reactor','targeting','armor','bastion','neural','capacitor','orbital']},
{id:'inventory',em:'◇',nm:'VAULT',ds:'Account storage for recovered gear and mission supplies.',items:[]},
{id:'loadout',em:'⬢',nm:'LOADOUT',ds:'Three gear slots and two operation supplies.',items:[]},
{id:'identity',em:'✦',nm:'STYLE',ds:'Unlock commander liveries with earned cores. Choose the one you wear in Profile → Identity.',items:[]});
let armTab='market',armMarketFilter='all',armCart=[],armCartOpen=false;
let armInvFilter='all',armInvSelectedKind='',armInvSelectedId='';
const ARM_MARKET_FILTERS=[
{id:'all',nm:'ALL SYSTEMS',items:[]},
{id:'operations',nm:'ECONOMY',items:['cache','trade','salvage','droppod','reactor']},
{id:'battlefield',nm:'COMBAT',items:['targeting','armor','bastion']},
{id:'commander',nm:'COMMANDER',items:['neural','capacitor','orbital']}
];
/* ---- daily deals + restock pricing -----------------------------------------
Deal discount and per-charge restock costs live here rather than on the
catalog rows so tuning a sale never means touching src/game/meta.js's STORE. */
const ARM_DEAL_PCT=0.25;
const INV_RESTOCK={c_supply:120,c_power:160,c_nanites:280,c_overdrive:360,c_command:500};
/* ---- concrete effect text -------------------------------------------------
applyMetaPerks() in meta.js applies every one of these as a flat multiple
of the tier count (nothing compounds tier-over-tier), so the tier-N effect
is just the tier-1 number times N. Spelling that out is the whole point of
this rebuild: "+8% unit HP" -> "+16%" instead of a bare price. */
function fmt1(n){ return Math.round(n*10)/10; }
function perkFx(id,t){
if(t<=0) return null;
switch(id){
case 'cache': return '+300 mass, +1200 energy at match start';
case 'armor': return '+'+(8*t)+'% unit HP';
case 'targeting': return '+'+(6*t)+'% unit damage';
case 'trade': return '+'+fmt1(1.5*t)+' mass/s, +'+(5*t)+' energy/s income';
case 'neural': return '+'+(15*t)+'% Commander XP';
case 'capacitor': return '−'+(10*t)+'% ability cooldowns';
case 'salvage': return '+'+(40*t)+'% salvage payout';
case 'droppod': return '+'+(25*t)+'% supply pod frequency';
case 'reactor': return '+'+(12*t)+'% energy income';
case 'bastion': return '+'+(15*t)+'% structure HP';
case 'orbital': return 'ORBITAL LANCE ability unlocked';
default: return '';
}
}
/* ---- header: cores / spent / maxed ---------------------------------------- */
function armSpent(){
let s=0;
for(const it of STORE){ const t=META.owned[it.id]||0; for(let i=0;i<t;i++) s+=it.cost[i]; }
for(const k in COLORS){ if(k!=='azure'&&META.owned['col_'+k]) s+=COLORS[k].cost; }
return s;
}
function armMaxedCount(){ return STORE.filter(it=>(META.owned[it.id]||0)>=it.max).length; }
function armStatsHTML(){
const b=invBag(), gear=INV_GEAR.filter(g=>(b.gear[g.id]||0)>0).length;
/* The wallet is the store's HUD: both spendable currencies stay pinned under
the title (see .armStickyHead) so a long inventory scroll never loses the
balance the prices in front of you are being compared against. */
return '<div class="armStats armWallet">'
+'<div class="armStat cr"><b>'+META.cores+'</b><span>⬡ CORES</span></div>'
+'<div class="armStat rd"><b>'+(META.researchData||0)+'</b><span>◆ DATA</span></div>'
+'<div class="armStat"><b>'+armSpent()+'</b><span>⬡ SPENT</span></div>'
+'<div class="armStat"><b>'+gear+'/'+INV_GEAR.length+'</b><span>GEAR FOUND</span></div>'
+'</div>';
}
/* ---- one perk row ----------------------------------------------------------
Sorted cheapest-first within its category so the list itself communicates
progression — the first thing you see in a tab is the thing you can afford
soonest, the last is the late-game spend. */
function armItemHTML(it){
const t=META.owned[it.id]||0, maxed=t>=it.max;
const cost=maxed?0:it.cost[t];
const afford=maxed||META.cores>=cost;
const cur=perkFx(it.id,t), next=maxed?null:perkFx(it.id,t+1);
let fx='<div class="fxRow">';
fx+= cur? '<span class="fxNow">'+cur+'</span>' : '<span class="fxNow dim">not active</span>';
if(next) fx+='<span class="fxArrow">→</span><span class="fxNext">'+next+'</span>';
fx+='</div>';
const warn=(!maxed&&!afford)?'<div class="sWarn">Need '+(cost-META.cores)+' more ⬡</div>':'';
return '<div class="sItem armIt'+(maxed?' owned':'')+(!maxed&&!afford?' cant':'')+'" data-id="'+it.id+'">'
+'<div class="sEm">'+(typeof itemArt==='function'?itemArt('st_'+it.id,it.em,36):it.em)+'</div>'
+'<div class="sTx"><b>'+it.nm+' <span class="sTier">'+t+'/'+it.max+'</span></b>'
+'<div class="sDs">'+it.ds+'</div>'
+fx+warn+'</div>'
+'<div class="sBuy'+(maxed?'':(afford?'':' cant'))+'">'+(maxed?'✓ MAX':'ADD<br>⬡ '+cost)+'</div></div>';
}
function armBodyHTML(){
const cat=ARM_CATS.find(c=>c.id===armTab)||ARM_CATS[0];
const intro='<div class="armCatIntro"><b>'+cat.nm+'</b><span>'+cat.ds+'</span></div>';
if(cat.id==='identity') return intro+armColorsHTML();
if(cat.id==='inventory') return intro+armInventoryHTML();
if(cat.id==='loadout') return intro+armSessionLoadoutHTML();
const F=ARM_MARKET_FILTERS.find(f=>f.id===armMarketFilter)||ARM_MARKET_FILTERS[0],ids=F.items.length?F.items:cat.items;
const filters='<div class="armMarketFilters">'+ARM_MARKET_FILTERS.map(f=>'<button type="button" class="armMarketFilter '+(f.id===F.id?'on':'')+'" data-market-filter="'+f.id+'">'+f.nm+'</button>').join('')+'</div>';
const items=ids.map(id=>STORE.find(s=>s.id===id)).filter(Boolean)
.sort((a,b)=>a.cost[0]-b.cost[0]); // cheap-first = early-buy-first
if(!items.length) return '<div class="devNone">Nothing in this category yet.</div>';
const hero=items[0],heroTier=META.owned[hero.id]||0;
return '<section class="armMarketHero"><div>'+itemArt('st_'+hero.id,hero.em,66)+'</div><span><i>REQUISITION MARKET</i><b>'+hero.nm+'</b><small>'+(heroTier>=hero.max?'SYSTEM MAXED':perkFx(hero.id,Math.min(hero.max,heroTier+1)))+'</small></span></section>'+intro+filters+items.map(armItemHTML).join('');
}
function invRarityLegend(){
return '<div class="invLegend">'+INV_RARITIES.map(r=>
'<span style="--rar:'+r.col+'"><i></i>'+r.nm+'</span>').join('')+'</div>';
}
/* ---- two-layer inventory --------------------------------------------------
Account storage and a match loadout are deliberately different views of the
SAME legacy save fields. No migration or second source of truth: gear/counts
remain in inventory.gear / inventory.consumables, and the five slots remain
equipped.{weapon,armor,utility} plus ready[0..1]. */
const ARM_INV_FILTERS=[
{id:'all',nm:'ALL'}, {id:'weapon',nm:'WEAPONS'}, {id:'armor',nm:'ARMOR'},
{id:'utility',nm:'UTILITY'}, {id:'supply',nm:'SUPPLIES'},
];
const ARM_INV_ART={
w_rangefinder:'mod_optic',w_pulseoptic:'res_optics',w_gaussdir:'st_targeting',w_voidlens:'mod_range',w_relicscope:'res_relictech',
a_fieldplate:'mod_plate',a_reinforced:'st_armor',a_phaseweave:'res_metallurgy',a_aegis:'st_bastion',a_starforged:'res_slot3',
u_toolkit:'mod_tempo',u_fluxcell:'mod_core',u_salvage:'mod_recl',u_chronorig:'st_capacitor',u_commandcore:'res_ability',
c_supply:'st_cache',c_power:'bst_energy',c_nanites:'bst_build',c_overdrive:'st_targeting',c_command:'res_slot3',
c_standard_order:'st_cache',c_campaign_intel:'res_optics',c_warfront_beacon:'bst_energy',
};
const ARM_INV_EFFECTS={
w_rangefinder:{stat:'UNIT DAMAGE',value:'+3%',score:3}, w_pulseoptic:{stat:'UNIT DAMAGE',value:'+5%',score:5},
w_gaussdir:{stat:'UNIT DAMAGE',value:'+8%',score:8}, w_voidlens:{stat:'UNIT DAMAGE',value:'+12%',score:12},
w_relicscope:{stat:'UNIT DAMAGE',value:'+16%',score:16},
a_fieldplate:{stat:'UNIT HEALTH',value:'+3%',score:3}, a_reinforced:{stat:'UNIT HEALTH',value:'+5%',score:5},
a_phaseweave:{stat:'UNIT HEALTH',value:'+8%',score:8}, a_aegis:{stat:'UNIT HEALTH',value:'+12%',score:12},
a_starforged:{stat:'UNIT HEALTH',value:'+16%',score:16},
u_toolkit:{stat:'BUILD SPEED',value:'+5%',score:5}, u_fluxcell:{stat:'ENERGY INCOME',value:'+7%',score:7},
u_salvage:{stat:'SALVAGE RECOVERY',value:'+15%',score:15}, u_chronorig:{stat:'ABILITY COOLDOWN',value:'-10%',score:10},
u_commandcore:{stat:'DAMAGE + HEALTH',value:'+8% / +8%',score:8},
c_supply:{stat:'STARTING MASS',value:'+220',score:220}, c_power:{stat:'STARTING ENERGY',value:'+900',score:900},
c_nanites:{stat:'UNIT HEALTH',value:'+8%',score:8}, c_overdrive:{stat:'UNIT DAMAGE',value:'+10%',score:10},
c_command:{stat:'DAMAGE + HEALTH',value:'+10% / +10%',score:10},
c_standard_order:{stat:'STARTING MASS + ENERGY',value:'+280 / +800',score:1080},
c_campaign_intel:{stat:'ARMY DAMAGE',value:'+6%',score:6},
c_warfront_beacon:{stat:'STARTING MASS + ENERGY',value:'+400 / +1400',score:1800},
};
function armInvEffect(id){ return ARM_INV_EFFECTS[id]||{stat:'MISSION EFFECT',value:'ACTIVE',score:0}; }
function armRestockCost(id){ return INV_RESTOCK[id]||150; }
function armInvIcon(it,size){
return typeof itemArt==='function'?itemArt(ARM_INV_ART[it.id]||('inv_'+it.id),it.em,size):'<span>'+it.em+'</span>';
}
function armInvEntries(){
return INV_GEAR.map(it=>({kind:'gear',it})).concat(INV_CONSUMABLES.map(it=>({kind:'supply',it})));
}
function armInvOwned(e,b){ return e.kind==='gear'?(b.gear[e.it.id]||0):(b.consumables[e.it.id]||0); }
function armInvEquipped(e,b){ return e.kind==='gear'?b.equipped[e.it.slot]===e.it.id:b.ready.indexOf(e.it.id)>=0; }
function armInvStats(b){
const all=armInvEntries(),stored=all.reduce((n,e)=>n+armInvOwned(e,b),0);
const found=all.reduce((n,e)=>n+(armInvOwned(e,b)>0?1:0),0);
const gear=['weapon','armor','utility'].reduce((n,s)=>n+(b.equipped[s]?1:0),0);
return {stored,found,total:all.length,used:gear+b.ready.length,gear,supplies:b.ready.length};
}
function armInvLayerStatsHTML(b,layer){
const s=armInvStats(b);
return '<div class="armInvLayerHead '+layer+'"><div><i>'+(layer==='account'?'ACCOUNT LAYER':'DEPLOYMENT LAYER')+'</i>'
+'<b>'+(layer==='account'?'ACCOUNT ARMORY':'SESSION LOADOUT')+'</b>'
+'<span>'+(layer==='account'?'Items stay in this career after every operation.':'Only fitted gear and readied supplies enter the next operation.')+'</span></div>'
+'<div class="armInvCapacity"><b>'+(layer==='account'?s.stored:(s.used+' / 5'))+'</b><span>'+(layer==='account'?'ITEMS STORED':'SLOT CAPACITY')+'</span></div></div>'
+'<div class="armInvMeters"><div><b>'+s.found+' / '+s.total+'</b><span>DISCOVERED</span></div>'
+'<div><b>'+s.gear+' / 3</b><span>GEAR SLOTS</span></div><div><b>'+s.supplies+' / 2</b><span>MISSION SLOTS</span></div></div>';
}
function armInvFilteredEntries(b){
const rank=Object.fromEntries(INV_RARITIES.map((r,i)=>[r.id,i]));
return armInvEntries().filter(e=>armInvFilter==='all'||(armInvFilter==='supply'?e.kind==='supply':e.kind==='gear'&&e.it.slot===armInvFilter))
.sort((a,z)=>(armInvOwned(z,b)>0?1:0)-(armInvOwned(a,b)>0?1:0)
||(armInvEquipped(z,b)?1:0)-(armInvEquipped(a,b)?1:0)
||rank[z.it.rarity]-rank[a.it.rarity]||a.it.nm.localeCompare(z.it.nm));
}
function armInvEnsureSelection(entries,b){
let e=entries.find(x=>x.kind===armInvSelectedKind&&x.it.id===armInvSelectedId);
if(!e) e=entries.find(x=>armInvEquipped(x,b))||entries.find(x=>armInvOwned(x,b)>0)||entries[0];
armInvSelectedKind=e?e.kind:''; armInvSelectedId=e?e.it.id:'';
return e;
}
function armInvFilterHTML(){
return '<div class="armInvFilters" aria-label="Armory item categories">'+ARM_INV_FILTERS.map(f=>
'<button type="button" class="armInvFilter'+(armInvFilter===f.id?' on':'')+'" data-inv-filter="'+f.id+'">'+f.nm+'</button>').join('')+'</div>';
}
function armInvComparison(e,b){
const fx=armInvEffect(e.it.id);
if(e.kind==='supply'){
const at=b.ready.indexOf(e.it.id);
if(at>=0) return 'MISSION SLOT '+(at+1)+' · one charge will be consumed when the operation launches';
if(b.ready.length>=2) return 'MISSION SLOTS FULL · remove or replace one supply first';
return 'EMPTY MISSION SLOT AVAILABLE · one charge is consumed at deployment';
}
const cur=INV_GEAR.find(g=>g.id===b.equipped[e.it.slot]);
if(!cur) return 'EMPTY '+e.it.slot.toUpperCase()+' SLOT · this full effect will be added';
if(cur.id===e.it.id) return 'CURRENT '+e.it.slot.toUpperCase()+' SLOT · persistent until unequipped';
const old=armInvEffect(cur.id);
if(old.stat===fx.stat&&Number.isFinite(old.score)&&Number.isFinite(fx.score)){
const d=fx.score-old.score;
return (d>=0?'+':'')+d+' POINT'+(Math.abs(d)===1?'':'S')+' VS '+cur.nm.toUpperCase();
}
return 'REPLACES '+cur.nm.toUpperCase()+' · changes '+old.stat.toLowerCase()+' to '+fx.stat.toLowerCase();
}
/* The same comparison, condensed onto the vault row itself: a hovered or
scanned gear item states how it moves the needle versus the slot's current
fit, so the "+12% HP vs X" is read on the item instead of only in the
preview pane below. */
function armInvDelta(e,b){
if(e.kind!=='gear') return '';
const cur=INV_GEAR.find(g=>g.id===b.equipped[e.it.slot]);
if(!cur||cur.id===e.it.id) return '';
const f=armInvEffect(e.it.id),o=armInvEffect(cur.id);
if(o.stat===f.stat&&Number.isFinite(o.score)&&Number.isFinite(f.score)){
const d=f.score-o.score;
return d>0?'<span class="armVaultDelta up">+'+d+' VS '+cur.nm.toUpperCase()+'</span>':'';
}
return '';
}
/* A locked row is a roadmap, not a wall: state how the item is actually
recovered so the tease reads as "the next thing to grind" rather than as a
secret. Higher rarities name the kind of run that drops them. */
function armInvReq(e){
if(e.kind==='supply'&&e.it.mode)return 'WIN '+e.it.mode.toUpperCase()+' OPERATIONS';
if(e.kind==='supply') return 'RECOVER IN OPERATIONS';
if(e.it.rarity==='epic'||e.it.rarity==='legendary') return 'EPIC+ LOOT · WIN HARD OPERATIONS';
return 'RECOVER IN OPERATIONS';
}
/* ITEM 5b — the chassis picker.
A type-scoped charge is meaningless without a target, so the commit button
stays inert until one is chosen and the chooser sits directly above it. The
pick is held in module state rather than META: it is a step in readying, not
something worth persisting if the player walks away mid-decision. */
let armInvLockPick={};
function armInvLockHTML(e,b){
if(!e||e.kind!=='supply') return '';
if(invConsumableScope(e.it.id)!=='type') return '';
const readied=b.ready.indexOf(e.it.id)>=0;
if(readied){
const ty=b.readyTy[e.it.id];
return '<div class="armInvLock armInvLockOn"><span>LOCKED TO</span><b>'
+(invLockName(ty)||'—').toUpperCase()+'</b></div>';
}
const pick=armInvLockPick[e.it.id];
const list=invLockableTypes();
return '<div class="armInvLock"><span>LOCK TO CHASSIS</span>'
+'<div class="armInvLockRow">'+list.map(t=>
'<button type="button" class="armInvChip'+(pick===t?' on':'')+'" data-lock-ty="'+t+'">'
+invLockName(t).toUpperCase()+'</button>').join('')+'</div>'
+'<i>'+(pick!=null?('This charge will boost every '+invLockName(pick)+' you field this match.')
:'Pick the chassis this charge is for.')+'</i></div>';
}
function armInvPreviewHTML(e,b){
if(!e) return '<div class="armInvEmpty"><b>NO ITEMS IN THIS CATEGORY</b><span>Complete operations to recover gear and mission supplies.</span></div>';
const it=e.it,n=armInvOwned(e,b),on=armInvEquipped(e,b),r=invRarity(it.rarity),fx=armInvEffect(it.id);
const full=e.kind==='supply'&&!on&&b.ready.length>=2;
/* Blocked on a chassis, not on stock: say which. */
const needLock=e.kind==='supply'&&!on&&invConsumableScope(it.id)==='type'&&armInvLockPick[it.id]==null;
const action=!n?'RECOVER IN OPERATIONS':on?(e.kind==='gear'?'UNEQUIP FROM SESSION':'REMOVE FROM MISSION')
:full?'MISSION SLOTS FULL':needLock?'PICK A CHASSIS FIRST'
:(e.kind==='gear'?'EQUIP '+it.slot.toUpperCase()
:'READY FOR '+(invConsumableScope(it.id)==='type'?invLockName(armInvLockPick[it.id]).toUpperCase():'MISSION'));
return '<section class="armInvPreview" style="--rar:'+r.col+'" aria-label="Selected item effect preview">'
+'<div class="armInvPreviewArt">'+armInvIcon(it,54)+'</div><div class="armInvPreviewInfo"><i>'+r.nm+' · '+(e.kind==='gear'?it.slot.toUpperCase()+' GEAR':'MISSION CONSUMABLE')+'</i>'
+'<b>'+it.nm+'</b><span>ACCOUNT STOCK · '+n+'</span></div>'
+'<div class="armInvExact"><span>EXACT EFFECT</span><b>'+fx.value+'</b><i>'+fx.stat+'</i></div>'
+'<p>'+it.ds+'</p><div class="armInvCompare">'+armInvComparison(e,b)+'</div>'
+armInvLockHTML(e,b)
+'<button type="button" class="armInvCommit'+((!n||full||needLock)?' disabled':'')+'" data-inv-action="'+e.kind+'" '+((!n||full||needLock)?'disabled':'')+'>'+action+'</button>'
+(e.kind==='supply'&&!it.mode
?'<button type="button" class="armInvRestock" data-inv-restock="'+it.id+'">ADD RESTOCK TO BASKET · ⬡'+armRestockCost(it.id)+'</button>'
:'')
+'<small>'+(e.kind==='gear'?'Gear is never consumed. It remains fitted until you change this slot.'
:it.mode?'Exclusive reward · cannot be purchased or randomly recovered.'
:'Readied supplies remain in Account Armory until the operation begins, then consume one charge.')+'</small></section>';
}
function armInvItemHTML(e,b){
const it=e.it,n=armInvOwned(e,b),on=armInvEquipped(e,b),sel=e.kind===armInvSelectedKind&&it.id===armInvSelectedId;
const r=invRarity(it.rarity),fx=armInvEffect(it.id),slot=e.kind==='gear'?it.slot.toUpperCase():'SUPPLY';
return '<button type="button" class="armVaultItem'+(sel?' selected':'')+(on?' equipped':'')+(n?'':' locked')+'" data-inv-kind="'+e.kind+'" data-inv-id="'+it.id+'" style="--rar:'+r.col+'" aria-pressed="'+(sel?'true':'false')+'">'
+'<span class="armVaultArt">'+armInvIcon(it,38)+'</span><span class="armVaultCopy"><i>'+r.nm+' · '+slot+'</i><b>'+it.nm+'</b><em>'+fx.value+' '+fx.stat+'</em>'
+(n?armInvDelta(e,b):'<em class="armVaultReq">'+armInvReq(e)+'</em>')+'</span>'
+'<span class="armVaultState">'+(n?(on?'IN LOADOUT':'×'+n):'LOCKED')+'</span></button>';
}
function armInventoryHTML(){
const b=invBag(),entries=armInvFilteredEntries(b),selected=armInvEnsureSelection(entries,b);
return armInvLayerStatsHTML(b,'account')+invRarityLegend()+armInvFilterHTML()+armInvPreviewHTML(selected,b)
+'<div class="armInvSectionLabel"><b>STORED ITEMS</b><span>'+entries.filter(e=>armInvOwned(e,b)>0).length+' OWNED IN VIEW</span></div>'
+'<div class="armVaultList">'+entries.map(e=>armInvItemHTML(e,b)).join('')+'</div>';
}
function armLoadGearSlotHTML(slot,b){
const g=INV_GEAR.find(x=>x.id===b.equipped[slot]);
if(!g) return '<div class="armLoadSlot empty"><div class="armLoadSlotTop"><i>'+slot.toUpperCase()+'</i><span>GEAR SLOT</span></div>'
+'<b>EMPTY '+slot.toUpperCase()+'</b><small>No account gear is assigned.</small><button type="button" data-pick-slot="'+slot+'">CHOOSE '+slot.toUpperCase()+'</button></div>';
const r=invRarity(g.rarity),fx=armInvEffect(g.id);
return '<div class="armLoadSlot filled" style="--rar:'+r.col+'"><div class="armLoadSlotTop"><i>'+slot.toUpperCase()+'</i><span>'+r.nm+'</span></div>'
+'<div class="armLoadEquipped"><span>'+armInvIcon(g,36)+'</span><div><b>'+g.nm+'</b><em>'+fx.value+' '+fx.stat+'</em></div></div>'
+'<button type="button" data-remove-gear="'+g.id+'">UNEQUIP</button></div>';
}
function armLoadSupplySlotHTML(n,b){
const c=INV_CONSUMABLES.find(x=>x.id===b.ready[n]);
if(!c) return '<div class="armLoadSlot supply empty"><div class="armLoadSlotTop"><i>MISSION '+(n+1)+'</i><span>CONSUMABLE</span></div>'
+'<b>EMPTY SUPPLY SLOT</b><small>Choose one charge from Account Armory.</small><button type="button" data-pick-slot="supply">CHOOSE SUPPLY</button></div>';
const r=invRarity(c.rarity),fx=armInvEffect(c.id),stock=b.consumables[c.id]||0;
return '<div class="armLoadSlot supply filled" style="--rar:'+r.col+'"><div class="armLoadSlotTop"><i>MISSION '+(n+1)+'</i><span>'+r.nm+'</span></div>'
+'<div class="armLoadEquipped"><span>'+armInvIcon(c,36)+'</span><div><b>'+c.nm+'</b><em>'+fx.value+' '+fx.stat+'</em></div></div>'
+'<small>'+stock+' in Account Armory · consumes 1 at launch</small><button type="button" data-remove-cons="'+c.id+'">REMOVE</button></div>';
}
function armSessionEffectsHTML(b){
const active=[];
for(const s of ['weapon','armor','utility']){
const g=INV_GEAR.find(x=>x.id===b.equipped[s]); if(g) active.push({it:g,kind:'GEAR · '+s.toUpperCase()});
}
for(const id of b.ready){ const c=INV_CONSUMABLES.find(x=>x.id===id); if(c) active.push({it:c,kind:'ONE-MISSION SUPPLY'}); }
return '<section class="armMissionPackage"><div class="armInvSectionLabel"><b>OPERATION EFFECT PACKAGE</b><span>'+active.length+' / 5 ACTIVE</span></div>'
+(active.length?active.map(e=>{const r=invRarity(e.it.rarity),fx=armInvEffect(e.it.id);return '<div class="armMissionFx" style="--rar:'+r.col+'"><i></i><div><b>'+e.it.nm+'</b><span>'+e.kind+'</span></div><strong>'+fx.value+' '+fx.stat+'</strong></div>';}).join('')
:'<div class="armInvEmpty"><b>BASELINE DEPLOYMENT</b><span>No account equipment or mission supplies selected.</span></div>')
+'<p>Gear effects persist every operation. Mission supplies apply once and are consumed only when deployment begins.</p></section>';
}
function armSessionLoadoutHTML(){
const b=invBag();
return armInvLayerStatsHTML(b,'session')+'<div class="armLoadoutRule"><b>5 HARD SLOTS</b><span>One weapon · one armor · one utility · two mission supplies</span></div>'
+'<div class="armLoadoutGrid">'+['weapon','armor','utility'].map(s=>armLoadGearSlotHTML(s,b)).join('')
+armLoadSupplySlotHTML(0,b)+armLoadSupplySlotHTML(1,b)+'</div>'+armSessionEffectsHTML(b)
+'<button type="button" class="armReturnVault" data-open-vault="1">▦ OPEN ACCOUNT ARMORY</button>';
}
/* The STYLE tab is now the UNLOCK counter, not the wardrobe: choosing which
livery you wear moved to Profile → Identity, next to the callsign and the
emblem it belongs with. This tab stays because it owns the only col_* debit
path there is — armCartCheckout's 'color' branch — and because deleting a
category would also break the tab-count row above. Markup comes from
meta.js's mfLiveryRowHTML so the two rows can never drift apart. */
function armColorsHTML(){
return '<div class="sHead">COMMANDER LIVERY — UNLOCK</div>'
+mfLiveryRowHTML('colorRow')
+'<div class="armCatIntro armLiveryNote"><span>Tap a locked colour to stage it in the earned-core basket. '
+'Your active livery is chosen in <em>Profile → Identity</em>.</span></div>';
}
/* ---- tabs -------------------------------------------------------------- */
function armTabsHTML(){
return '<div class="armTabWrap"><div class="tabRow" id="armTabs">'
+ARM_CATS.map(c=>{
const bag=invBag();
const invStats=armInvStats(bag);
const done=c.id==='inventory'?invStats.found
:c.id==='loadout'?invStats.used
:c.id==='identity'?0:c.items.filter(id=>{ const it=STORE.find(s=>s.id===id); return it&&(META.owned[it.id]||0)>=it.max; }).length;
const tot=c.id==='inventory'?invStats.total:0;
return '<button class="tabBtn'+(armTab===c.id?' on':'')+'" data-k="'+c.id+'">'
+'<span class="tEm">'+c.em+'</span>'+c.nm+(c.id==='inventory'&&tot?' <span class="tabCt">'+done+'/'+tot+'</span>':'')+'</button>';
}).join('')
+'</div><div class="armTabHint">SWIPE CATEGORIES →</div></div>';
}
/* ---- always-visible wallet + category bar ----------------------------------
Two sticky elements with the same `top` fight each other (both clamp to 0
and one is glued under the other), so the stats and the tabs share ONE
sticky wrapper and the wrapper is the only thing pinned. */
function armHeadHTML(){
return '<div class="armStickyHead">'+armStatsHTML()+armTabsHTML()+'</div>';
}
/* ---- daily deals -----------------------------------------------------------
Three perks on sale, drawn from a whole-day seed so every device sees the
same offers and a reinstall cannot reroll a discount. Buying the tier at the
sale price sets a per-day claimed flag, so a deal is one purchase per item
per day no matter how many cores the player has accumulated. */
function armDealSeed(){ return Math.floor(Date.now()/86400000); }
function armDealRnd(){
let s=(armDealSeed()*2654435761)>>>0;
return ()=>{ s=(s*1664525+1013904223)>>>0; return s/4294967296; };
}
function armDealItems(){
const st=armDealState();
const r=armDealRnd(), pool=STORE.filter(it=>((META.owned[it.id]||0)<it.max)||st.claimed[it.id]).slice();
for(let i=pool.length-1;i>0;i--){ const j=(r()*(i+1))|0; const t=pool[i]; pool[i]=pool[j]; pool[j]=t; }
return pool.slice(0,3);
}
function armDealState(){
if(!META.deals||META.deals.day!==armDealSeed()) META.deals={day:armDealSeed(),claimed:{}};
if(!META.deals.claimed||typeof META.deals.claimed!=='object') META.deals.claimed={};
return META.deals;
}
function armDealPrice(it){
const t=META.owned[it.id]||0;
return t>=it.max?0:Math.max(1,Math.ceil(it.cost[t]*(1-ARM_DEAL_PCT)));
}
function armDealsHTML(){
const deals=armDealItems();
if(!deals.length) return '';
const st=armDealState();
return '<section class="armDeals" aria-label="Daily deals">'
+'<div class="armDealsHd"><b>DAILY DEALS</b><span>−'+Math.round(ARM_DEAL_PCT*100)+'% · REFRESHES IN 24H</span></div>'
+'<div class="armDealRow">'+deals.map(it=>{
const t=META.owned[it.id]||0, claimed=!!st.claimed[it.id], maxed=t>=it.max;
const real=maxed?0:it.cost[t], price=armDealPrice(it);
return '<button type="button" class="armDeal'+(claimed?' sold':'')+'" data-deal-id="'+it.id+'" aria-label="Daily deal: '+it.nm+'">'
+'<span class="armDealArt">'+(typeof itemArt==='function'?itemArt('st_'+it.id,it.em,30):'<span>'+it.em+'</span>')+'</span>'
+'<span class="armDealTx"><i>'+it.nm.toUpperCase()+'</i><b>'+(maxed?'MAXED':'TIER '+(t+1))+'</b>'
+(maxed?'':'<em>'+perkFx(it.id,t+1)+'</em>')+'</span>'
+'<span class="armDealPrice">'+(claimed?'SOLD':maxed?'✓ MAX':'<s>⬡'+real+'</s><b>⬡'+price+'</b>')+'</span>'
+'</button>';
}).join('')+'</div></section>';
}
/* ---- purchase feedback: the tier badge / MAX chip actually re-renders, this
just adds a short glow so a buy reads as an EVENT and not a silent number
change. */
function armBump(id){
const el=document.querySelector('#storeList [data-id="'+id+'"]');
if(!el) return;
el.classList.add('flash');
setTimeout(()=>el.classList.remove('flash'),650);
}
/* Purchase flash: the row glow (armBump) plus a short full-screen burst so a
buy reads as an EVENT across the whole screen, not a highlight on one row.
The overlay is removed on a timer rather than left for the next render —
renderArmory replaces innerHTML, which would orphan a persistent child. */
function armBuyFlash(em,label){
const o=document.createElement('div');
o.className='armBuyFlash';
o.innerHTML='<i>'+em+'</i><b>'+label+'</b>';
document.body.appendChild(o);
setTimeout(()=>{ if(o.parentNode) o.parentNode.removeChild(o); },950);
}
/* ---- earned-core basket ---------------------------------------------------
A scroll gesture must never be a purchase. Every spend is staged here,
re-priced from the live catalog, shown as one total, then applied only
after the player explicitly confirms. Paid products are intentionally not
exposed until a web checkout or Play Billing bridge can return a receipt
that the server verifies. */
function armCartKey(e){return e.kind+':'+e.id;}
function armCartResolve(e){
if(e.kind==='perk'||e.kind==='deal'){
const it=STORE.find(s=>s.id===e.id),t=it?(META.owned[it.id]||0):0;if(!it||t>=it.max||e.tier!==t+1)return null;
if(e.kind==='deal'){
const st=armDealState();if(st.claimed[it.id]||!armDealItems().some(x=>x.id===it.id))return null;
return {price:armDealPrice(it),nm:it.nm+' DAILY DEAL',em:it.em,note:'TIER '+e.tier};
}
return {price:it.cost[t],nm:it.nm,em:it.em,note:'TIER '+e.tier};
}
if(e.kind==='restock'){
const it=INV_CONSUMABLES.find(x=>x.id===e.id);if(!it||it.mode)return null;
return {price:armRestockCost(it.id),nm:it.nm,em:it.em,note:'+1 MISSION CHARGE'};
}
if(e.kind==='color'){
const C=COLORS[e.id];if(!C||e.id==='azure'||META.owned['col_'+e.id])return null;
return {price:C.cost,nm:C.nm+' COLOR',em:'✦',note:'PERMANENT STYLE'};
}
return null;
}
function armCartAdd(kind,id){
const entry={kind,id};if(kind==='perk'||kind==='deal'){const it=STORE.find(s=>s.id===id);entry.tier=it?(META.owned[id]||0)+1:0;}
const R=armCartResolve(entry);if(!R){toast('That requisition is no longer available');sfx('deny');return;}
if(kind==='perk'||kind==='deal')armCart=armCart.filter(e=>!((e.kind==='perk'||e.kind==='deal')&&e.id===id));
if(armCart.some(e=>armCartKey(e)===armCartKey(entry))){toast(R.nm+' is already in your basket');sfx('ui');return;}
armCart.push(entry);armCartOpen=true;toast(R.nm+' added to basket');sfx('ui');renderArmory();
}
function armCartRows(){return armCart.map(e=>({entry:e,item:armCartResolve(e)})).filter(x=>x.item);}
function armCartTotal(){return armCartRows().reduce((n,x)=>n+x.item.price,0);}
function armCommerceStatus(){
let platform='web';try{if(window.Capacitor&&typeof Capacitor.getPlatform==='function')platform=Capacitor.getPlatform();}catch(e){}
if(platform==='android'){
const ready=!!(window.Capacitor&&Capacitor.Plugins&&Capacitor.Plugins.MassfrontBilling);
return {platform:'GOOGLE PLAY',ready,note:ready?'Billing bridge detected; paid products remain hidden until server receipt verification is enabled.':'Play Billing bridge and server receipt verification required.'};
}
const ready=!!window.MASSFRONT_CHECKOUT_URL;
return {platform:'WEB CHECKOUT',ready,note:ready?'Checkout endpoint detected; paid products remain hidden until webhook verification is enabled.':'Server checkout and verified payment webhook required.'};
}
function armCartHTML(){
const rows=armCartRows(),total=rows.reduce((n,x)=>n+x.item.price,0),C=armCommerceStatus(),confirmed=typeof ECO!=='undefined'&&ECO&&ECO.confirmed;
let h='<section class="armCartBar"><div><span>EARNED-CORE BASKET</span><b>'+rows.length+' ITEM'+(rows.length===1?'':'S')+' · ⬡'+total+'</b></div><button type="button" data-cart-toggle="1">'+(armCartOpen?'CLOSE':'REVIEW')+'</button></section>';
if(!armCartOpen)return h;
h+='<section class="armCartPanel"><div class="armCartRows">'+(rows.length?rows.map(x=>'<div class="armCartRow"><i>'+x.item.em+'</i><span><b>'+x.item.nm+'</b><small>'+x.item.note+'</small></span><strong>⬡'+x.item.price+'</strong><button type="button" data-cart-remove="'+armCartKey(x.entry)+'" aria-label="Remove '+x.item.nm+'">×</button></div>').join(''):'<div class="armCartEmpty">Your basket is empty. Tap an upgrade, deal, supply, or locked color to stage it here.</div>')+'</div>';
h+='<div class="armCartTotal"><span>TOTAL</span><b>⬡ '+total+'</b></div><button type="button" class="armCartCheckout" data-cart-checkout="1" '+(!rows.length||META.cores<total||confirmed?'disabled':'')+'>'+(confirmed?'SERVER CART UPDATE REQUIRED':META.cores<total?'NEED '+(total-META.cores)+' MORE CORES':'CONFIRM EARNED-CORE PURCHASE')+'</button>';
h+='<div class="armCommerceState '+(C.ready?'ready':'locked')+'"><b>'+C.platform+' · '+(C.ready?'BRIDGE DETECTED':'PAID CHECKOUT LOCKED')+'</b><span>'+C.note+'</span></div></section>';
return h;
}
function armCartCheckout(){
const rows=armCartRows();if(rows.length!==armCart.length){armCart=rows.map(x=>x.entry);toast('Basket refreshed because the catalog changed');sfx('deny');renderArmory();return;}
const total=rows.reduce((n,x)=>n+x.item.price,0);if(!rows.length)return;
if(typeof ECO!=='undefined'&&ECO&&ECO.confirmed){toast('Server-confirmed basket checkout is not active in this build');sfx('deny');return;}
if(META.cores<total){toast('Not enough earned cores');sfx('alarm');return;}
for(const x of rows){const e=x.entry;
if(e.kind==='perk'||e.kind==='deal'){META.owned[e.id]=e.tier;if(e.kind==='deal')armDealState().claimed[e.id]=1;}
else if(e.kind==='restock'){const b=invBag();b.consumables[e.id]=(b.consumables[e.id]||0)+1;}
else if(e.kind==='color'){META.owned['col_'+e.id]=1;META.color=e.id;}
}
META.cores-=total;armCart=[];armCartOpen=false;metaSave();applyColor();renderMetaHead();sfx('pickup');sfx('level');armBuyFlash('⬡','REQUISITION CONFIRMED');toast('Basket purchased · ⬡'+total);renderArmory();
}
/* ---- the takeover ------------------------------------------------------
Reassigned, not redeclared — renderArmory already exists as a global
function from meta.js by the time this file runs, same as sfx does when
audio.js takes it over. */
renderArmory=function(){
const list=document.getElementById('storeList');
if(!list) return;
/* The screen is hidden (display:none) exactly when this render is the one
that precedes opening the Armory — any later render happens with it visible
and must not replay the entrance. */
const opening=(()=>{ const s=document.getElementById('armory'); return !!(s&&getComputedStyle(s).display==='none'); })();
list.innerHTML=armHeadHTML()+armCartHTML()+(armTab==='market'?armDealsHTML():'')+armBodyHTML();
const tabs=document.getElementById('armTabs');
if(tabs){
tabs.classList.toggle('fits',tabs.scrollWidth<=tabs.clientWidth+2);
const active=tabs.querySelector('.tabBtn.on');
/* scrollIntoView also moved #storeList vertically on Android, sometimes
putting the entire category bar above the screen. Move only this row's
horizontal scroll position so tabs remain reachable after every render. */
if(active) requestAnimationFrame(()=>{
tabs.scrollLeft=Math.max(0,active.offsetLeft-(tabs.clientWidth-active.offsetWidth)*0.5);
});
}
list.querySelectorAll('.tabBtn').forEach(btn=>{
mfBindTap(btn,ev=>{
ev.stopPropagation();
armTab=btn.dataset.k; sfx('ui'); renderArmory(); list.scrollTop=0;
});
});
list.querySelectorAll('.armMarketFilter').forEach(btn=>mfBindTap(btn,ev=>{
ev.stopPropagation();armMarketFilter=btn.dataset.marketFilter||'all';sfx('ui');renderArmory();
}));
list.querySelectorAll('[data-cart-toggle]').forEach(btn=>mfBindTap(btn,ev=>{
ev.stopPropagation();armCartOpen=!armCartOpen;sfx('ui');renderArmory();
}));
list.querySelectorAll('[data-cart-remove]').forEach(btn=>mfBindTap(btn,ev=>{
ev.stopPropagation();armCart=armCart.filter(e=>armCartKey(e)!==btn.dataset.cartRemove);sfx('ui');renderArmory();
}));
list.querySelectorAll('[data-cart-checkout]').forEach(btn=>mfBindTap(btn,ev=>{ev.stopPropagation();armCartCheckout();}));
list.querySelectorAll('.armInvFilter').forEach(el=>{
mfBindTap(el,ev=>{
ev.stopPropagation();
armInvFilter=el.dataset.invFilter||'all';
armInvSelectedKind=''; armInvSelectedId='';
sfx('ui'); renderArmory(); list.scrollTop=0;
});
});
list.querySelectorAll('.armVaultItem').forEach(el=>{
mfBindTap(el,ev=>{
ev.stopPropagation();
armInvSelectedKind=el.dataset.invKind||''; armInvSelectedId=el.dataset.invId||'';
sfx('ui'); renderArmory();
requestAnimationFrame(()=>{ const p=list.querySelector('.armInvPreview'); if(p) list.scrollTop=Math.max(0,p.offsetTop-112); });
});
if(typeof armInvLongPress==='function') armInvLongPress(el);
});
list.querySelectorAll('.armInvCommit:not([disabled])').forEach(el=>{
mfBindTap(el,ev=>{
ev.stopPropagation();
if(el.dataset.invAction==='gear'){
const g=INV_GEAR.find(x=>x.id===armInvSelectedId);
if(g&&invEquipGear(g.id)){
const on=invBag().equipped[g.slot]===g.id;
toast((on?'Fitted ':'Removed ')+g.nm+(on?' to the '+g.slot+' slot':'')); sfx('ui'); renderArmory();
}
}else{
const c=INV_CONSUMABLES.find(x=>x.id===armInvSelectedId);
if(c&&invReadyConsumable(c.id,armInvLockPick[c.id])){
const on=invBag().ready.indexOf(c.id)>=0;
toast((on?'Readied ':'Removed ')+c.nm+(on?' for the next mission':'')); sfx('ui'); renderArmory();
}
}
});
});
list.querySelectorAll('[data-lock-ty]').forEach(el=>mfBindTap(el,ev=>{
ev.stopPropagation();
armInvLockPick[armInvSelectedId]=+el.dataset.lockTy;
sfx('ui'); renderArmory();
}));
list.querySelectorAll('[data-pick-slot]').forEach(el=>mfBindTap(el,ev=>{
ev.stopPropagation(); armTab='inventory'; armInvFilter=el.dataset.pickSlot||'all';
armInvSelectedKind=''; armInvSelectedId=''; sfx('ui'); renderArmory(); list.scrollTop=0;
}));
list.querySelectorAll('[data-remove-gear]').forEach(el=>mfBindTap(el,ev=>{
ev.stopPropagation(); const g=INV_GEAR.find(x=>x.id===el.dataset.removeGear);
if(g&&invEquipGear(g.id)){ toast('Removed '+g.nm+' from session loadout'); sfx('ui'); renderArmory(); }
}));
list.querySelectorAll('[data-remove-cons]').forEach(el=>mfBindTap(el,ev=>{
ev.stopPropagation(); const c=INV_CONSUMABLES.find(x=>x.id===el.dataset.removeCons);
if(c&&invReadyConsumable(c.id)){ toast('Returned '+c.nm+' to Account Armory'); sfx('ui'); renderArmory(); }
}));
list.querySelectorAll('[data-open-vault]').forEach(el=>mfBindTap(el,ev=>{
ev.stopPropagation(); armTab='inventory'; sfx('ui'); renderArmory(); list.scrollTop=0;
}));
/* Every requisition tap stages an item. Currency changes only in the single
explicit cart confirmation handler above. */
list.querySelectorAll('.armIt').forEach(el=>{
mfBindTap(el,()=>{
const it=STORE.find(s=>s.id===el.dataset.id); if(!it) return;
armCartAdd('perk',it.id);
});
});
list.querySelectorAll('.armDeal').forEach(el=>{
mfBindTap(el,()=>{
const it=STORE.find(s=>s.id===el.dataset.dealId); if(!it) return;
armCartAdd('deal',it.id);
});
});
list.querySelectorAll('[data-inv-restock]').forEach(el=>mfBindTap(el,ev=>{
ev.stopPropagation();
const c=INV_CONSUMABLES.find(x=>x.id===el.dataset.invRestock); if(!c) return;
armCartAdd('restock',c.id);
}));
/* Locked swatch: stage it, exactly as before — this is still the only route
to the col_* checkout. Owned swatch: applying it here is the same one-line
state change Profile → Identity makes, so it keeps working rather than
becoming a dead tap. */
mfLiveryRowWire(list,key=>{
const C=COLORS[key]; if(!C) return;
const owned=key==='azure'||META.owned['col_'+key];
if(!owned){armCartAdd('color',key);return;}
sfx('ui');
META.color=key; metaSave(); applyColor();
renderMetaHead(); renderArmory();
});
/* Entrance: stagger indices for the slide/pop, then play the sequence only
on the first render of a visit. Removing the class on a timer lets the next
opening replay it cleanly. */
Array.from(list.children).forEach((el,i)=>el.style.setProperty('--i',i));
const tabBtns=list.querySelectorAll('.tabBtn');
tabBtns.forEach((el,i)=>el.style.setProperty('--i',i));
if(opening){
list.classList.remove('armEnter'); void list.offsetWidth; list.classList.add('armEnter');
setTimeout(()=>list.classList.remove('armEnter'),1100);
}
};
/* ---- boot ----------------------------------------------------------------
Defensive stylesheet injection: index.html already links store.css, but
this file owns that stylesheet, not index.html, so it makes sure its own
CSS is attached rather than assuming another file keeps doing it. */
function ensureStoreCss(){
const href='./src/styles/store.css';
const links=document.getElementsByTagName('link');
for(let i=0;i<links.length;i++) if(links[i].getAttribute('href')===href) return;
const l=document.createElement('link');
l.rel='stylesheet'; l.href=href;
document.head.appendChild(l);
}
function initStoreUI(){
ensureStoreCss();
}