; ; /* ============================================================================ 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(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 '
' +'
'+META.cores+'⬡ CORES
' +'
'+(META.researchData||0)+'◆ DATA
' +'
'+armSpent()+'⬡ SPENT
' +'
'+gear+'/'+INV_GEAR.length+'GEAR FOUND
' +'
'; } /* ---- 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='
'; fx+= cur? ''+cur+'' : 'not active'; if(next) fx+=''+next+''; fx+='
'; const warn=(!maxed&&!afford)?'
Need '+(cost-META.cores)+' more ⬡
':''; return '
' +'
'+(typeof itemArt==='function'?itemArt('st_'+it.id,it.em,36):it.em)+'
' +'
'+it.nm+' '+t+'/'+it.max+'' +'
'+it.ds+'
' +fx+warn+'
' +'
'+(maxed?'✓ MAX':'ADD
⬡ '+cost)+'
'; } function armBodyHTML(){ const cat=ARM_CATS.find(c=>c.id===armTab)||ARM_CATS[0]; const intro='
'+cat.nm+''+cat.ds+'
'; 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='
'+ARM_MARKET_FILTERS.map(f=>'').join('')+'
'; 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 '
Nothing in this category yet.
'; const hero=items[0],heroTier=META.owned[hero.id]||0; return '
'+itemArt('st_'+hero.id,hero.em,66)+'
REQUISITION MARKET'+hero.nm+''+(heroTier>=hero.max?'SYSTEM MAXED':perkFx(hero.id,Math.min(hero.max,heroTier+1)))+'
'+intro+filters+items.map(armItemHTML).join(''); } function invRarityLegend(){ return '
'+INV_RARITIES.map(r=> ''+r.nm+'').join('')+'
'; } /* ---- 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):''+it.em+''; } 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 '
'+(layer==='account'?'ACCOUNT LAYER':'DEPLOYMENT LAYER')+'' +''+(layer==='account'?'ACCOUNT ARMORY':'SESSION LOADOUT')+'' +''+(layer==='account'?'Items stay in this career after every operation.':'Only fitted gear and readied supplies enter the next operation.')+'
' +'
'+(layer==='account'?s.stored:(s.used+' / 5'))+''+(layer==='account'?'ITEMS STORED':'SLOT CAPACITY')+'
' +'
'+s.found+' / '+s.total+'DISCOVERED
' +'
'+s.gear+' / 3GEAR SLOTS
'+s.supplies+' / 2MISSION SLOTS
'; } 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 '
'+ARM_INV_FILTERS.map(f=> '').join('')+'
'; } 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?'+'+d+' VS '+cur.nm.toUpperCase()+'':''; } 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 '
LOCKED TO' +(invLockName(ty)||'—').toUpperCase()+'
'; } const pick=armInvLockPick[e.it.id]; const list=invLockableTypes(); return '
LOCK TO CHASSIS' +'
'+list.map(t=> '').join('')+'
' +''+(pick!=null?('This charge will boost every '+invLockName(pick)+' you field this match.') :'Pick the chassis this charge is for.')+'
'; } function armInvPreviewHTML(e,b){ if(!e) return '
NO ITEMS IN THIS CATEGORYComplete operations to recover gear and mission supplies.
'; 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 '
' +'
'+armInvIcon(it,54)+'
'+r.nm+' · '+(e.kind==='gear'?it.slot.toUpperCase()+' GEAR':'MISSION CONSUMABLE')+'' +''+it.nm+'ACCOUNT STOCK · '+n+'
' +'
EXACT EFFECT'+fx.value+''+fx.stat+'
' +'

'+it.ds+'

'+armInvComparison(e,b)+'
' +armInvLockHTML(e,b) +'' +(e.kind==='supply'&&!it.mode ?'' :'') +''+(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.')+'
'; } 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 ''; } function armInventoryHTML(){ const b=invBag(),entries=armInvFilteredEntries(b),selected=armInvEnsureSelection(entries,b); return armInvLayerStatsHTML(b,'account')+invRarityLegend()+armInvFilterHTML()+armInvPreviewHTML(selected,b) +'
STORED ITEMS'+entries.filter(e=>armInvOwned(e,b)>0).length+' OWNED IN VIEW
' +'
'+entries.map(e=>armInvItemHTML(e,b)).join('')+'
'; } function armLoadGearSlotHTML(slot,b){ const g=INV_GEAR.find(x=>x.id===b.equipped[slot]); if(!g) return '
'+slot.toUpperCase()+'GEAR SLOT
' +'EMPTY '+slot.toUpperCase()+'No account gear is assigned.
'; const r=invRarity(g.rarity),fx=armInvEffect(g.id); return '
'+slot.toUpperCase()+''+r.nm+'
' +'
'+armInvIcon(g,36)+'
'+g.nm+''+fx.value+' '+fx.stat+'
' +'
'; } function armLoadSupplySlotHTML(n,b){ const c=INV_CONSUMABLES.find(x=>x.id===b.ready[n]); if(!c) return '
MISSION '+(n+1)+'CONSUMABLE
' +'EMPTY SUPPLY SLOTChoose one charge from Account Armory.
'; const r=invRarity(c.rarity),fx=armInvEffect(c.id),stock=b.consumables[c.id]||0; return '
MISSION '+(n+1)+''+r.nm+'
' +'
'+armInvIcon(c,36)+'
'+c.nm+''+fx.value+' '+fx.stat+'
' +''+stock+' in Account Armory · consumes 1 at launch
'; } 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 '
OPERATION EFFECT PACKAGE'+active.length+' / 5 ACTIVE
' +(active.length?active.map(e=>{const r=invRarity(e.it.rarity),fx=armInvEffect(e.it.id);return '
'+e.it.nm+''+e.kind+'
'+fx.value+' '+fx.stat+'
';}).join('') :'
BASELINE DEPLOYMENTNo account equipment or mission supplies selected.
') +'

Gear effects persist every operation. Mission supplies apply once and are consumed only when deployment begins.

'; } function armSessionLoadoutHTML(){ const b=invBag(); return armInvLayerStatsHTML(b,'session')+'
5 HARD SLOTSOne weapon · one armor · one utility · two mission supplies
' +'
'+['weapon','armor','utility'].map(s=>armLoadGearSlotHTML(s,b)).join('') +armLoadSupplySlotHTML(0,b)+armLoadSupplySlotHTML(1,b)+'
'+armSessionEffectsHTML(b) +''; } /* 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 '
COMMANDER LIVERY — UNLOCK
' +mfLiveryRowHTML('colorRow') +'
Tap a locked colour to stage it in the earned-core basket. ' +'Your active livery is chosen in Profile → Identity.
'; } /* ---- tabs -------------------------------------------------------------- */ function armTabsHTML(){ return '
' +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 ''; }).join('') +'
SWIPE CATEGORIES →
'; } /* ---- 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 '
'+armStatsHTML()+armTabsHTML()+'
'; } /* ---- 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)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 '
' +'
DAILY DEALS−'+Math.round(ARM_DEAL_PCT*100)+'% · REFRESHES IN 24H
' +'
'+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 ''; }).join('')+'
'; } /* ---- 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=''+em+''+label+''; 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='
EARNED-CORE BASKET'+rows.length+' ITEM'+(rows.length===1?'':'S')+' · ⬡'+total+'
'; if(!armCartOpen)return h; h+='
'+(rows.length?rows.map(x=>'
'+x.item.em+''+x.item.nm+''+x.item.note+'⬡'+x.item.price+'
').join(''):'
Your basket is empty. Tap an upgrade, deal, supply, or locked color to stage it here.
')+'
'; h+='
TOTAL⬡ '+total+'
'; 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