; ; /* ============================================================================ DROPPED SESSION RECOVERY ---------------------------------------------------------------------------- The WebGL context-loss handler in gl.js shows an overlay reading "your progress is saved to this device and your account", waits 1.7 seconds, and reloads the page. That sentence was not true of the thing the player cares about. Account progress — cores, XP, unlocks — is saved. The MATCH is not, and nothing in this codebase ever saved one, so a context loss five minutes into a fight threw the fight away and dropped the player back at the menu with no way back in. Context loss is not a bug we can promise never to hit. Android reclaims GPU memory when it wants to, and a large swarm on a warm phone is exactly the condition that provokes it. So the honest fix is not only to make it rarer — it is to make it survivable. WHAT IS SNAPSHOTTED, AND WHY THIS SET The sim is structure-of-arrays, so a snapshot is a handful of typed-array slices plus a plain list for structures. We store what cannot be derived: positions, health, ownership, orders, resources, the clock and the setup the match was generated from. We deliberately do NOT store terrain, deposits, hives or doodads — all of those are regenerated deterministically from the map's seed (see srand() in gl.js), so persisting them would be megabytes to reproduce something a single integer already reproduces exactly. WHY localStorage AND NOT IndexedDB This runs inside a `webglcontextlost` handler with a 1.7-second budget before the page reloads. IndexedDB is asynchronous and is not guaranteed to flush in that window; localStorage is synchronous and is written before the handler returns. The snapshot is capped so it cannot exceed the quota and take the rest of the save data down with it. ============================================================================ */ const SESS_KEY = 'mf_dropped_session_v1'; const SESS_MAX_UNITS = 4000; // DEBT: map-total snapshot slice, not a pop cap. Per-seat cap is FACTION_POP_CAP (1000). const SESS_TTL_MS = 45 * 60 * 1000; function sessCanSnapshot(){ try{ return typeof running !== 'undefined' && running && typeof demoMode !== 'undefined' && !demoMode && typeof gameEnded !== 'undefined' && !gameEnded && typeof heroIdx !== 'undefined' && heroIdx >= 0; }catch(e){ return false; } } /* A live unit's full order state. Kept as parallel plain arrays rather than an array of objects: JSON.stringify of 4000 objects is several times slower and several times larger, and this runs against a reload deadline. */ function sessCaptureUnits(){ const idx=[], out={t:[],tm:[],x:[],y:[],hp:[],ang:[],st:[],tx:[],ty:[],hold:[],mode:[], oi:[],cmd:[],tg:[],mh:[],pr:[],pst:[],psl:[],gh:[],gg:[],qk:[],q:[]}; for(let i=0;iSESS_MAX_UNITS){ idx.sort((a,b)=>(uteam[a]===0?0:uteam[a]===1?1:2)-(uteam[b]===0?0:uteam[b]===1?1:2)); idx.length=SESS_MAX_UNITS; } for(const i of idx){ out.t.push(utype[i]); out.tm.push(uteam[i]); out.x.push(Math.round(ux[i])); out.y.push(Math.round(uy[i])); out.hp.push(Math.round(uhp[i])); out.ang.push(+uang[i].toFixed(2)); out.st.push(ustate[i]); out.tx.push(Math.round(utx[i])); out.ty.push(Math.round(uty[i])); out.hold.push(uhold[i]|0); out.mode.push(typeof umode!=='undefined'?(umode[i]|0):0); /* Old slot is the remap key. spawnUnit hands out new indices, so utgt / guard / queue / patrol members stored as raw slots would point at the wrong hulls after a resume. ufield is omitted on purpose: the field ring dies with the match and is rebuilt from tx/ty. */ out.oi.push(i); out.cmd.push(typeof uCmd!=='undefined'?uCmd[i]:-1); out.tg.push(typeof utgt!=='undefined'?utgt[i]:-1); out.mh.push(typeof umarch!=='undefined'?umarch[i]|0:0); out.pr.push(typeof uPatrolRoute!=='undefined'?uPatrolRoute[i]:-1); out.pst.push(typeof uPatrolStep!=='undefined'?uPatrolStep[i]|0:0); out.psl.push(typeof uPatrolSlot!=='undefined'?uPatrolSlot[i]|0:0); out.gh.push(typeof uGuard!=='undefined'?uGuard[i]:-1); out.gg.push(typeof uGuardG!=='undefined'?uGuardG[i]:-1); out.qk.push(typeof uQkind!=='undefined'?uQkind[i]|0:0); const Q=(typeof uQueue!=='undefined'&&uQueue[i])?uQueue[i]:null; out.q.push(Q?Q.map(s=>({t:s.t|0,x:Math.round(s.x||0),y:Math.round(s.y||0),h:s.h|0,g:s.g|0,mv:s.mv|0})):null); } return out; } function sessCaptureBuildings(){ const out=[]; for(let bi=0;bi({x:+p.x,y:+p.y})), form:R.form|0, step:R.step|0, members:R.members.map(e=>[e[0]|0,e[1]|0]) }); } return out; } /* Relic handles survive a seed regen. Unit/building handles do not. */ function sessRemapHandle(h,uMap,bMap){ if(h==null||h===-1) return -1; if(typeof isRelicTg==='function'&&isRelicTg(h)) return h; if(h>=0) return uMap.has(h)?uMap.get(h):-1; const bi=-2-h; return bMap.has(bi)?(-2-bMap.get(bi)):-1; } function sessApplyWallets(w){ if(!w||typeof AI==='undefined') return; if(w.seats&&AI.bases){ for(const row of w.seats){ let S=null; for(let i=0;i=0){ S.commander=best; S.commanderGen=ugen[best]; } } } if(typeof econMirrorAiBanks==='function') econMirrorAiBanks(); } function sessRestoreOrders(U,spawned,uMap,bMap,patrols){ if(!U||!U.oi) return; for(let k=0;k=0?ugen[tg]:-1; } if(typeof uGuard!=='undefined'&&U.gh){ const gh=sessRemapHandle(U.gh[k],uMap,bMap); uGuard[i]=gh; if(typeof uGuardG!=='undefined') uGuardG[i]=gh>=0?ugen[gh]:-1; } if(typeof uQkind!=='undefined'&&U.qk) uQkind[i]=U.qk[k]|0; if(typeof uQueue!=='undefined'&&U.q&&U.q[k]){ uQueue[i]=U.q[k].map(s=>{ const h=sessRemapHandle(s.h,uMap,bMap); return {t:s.t|0,x:s.x,y:s.y,h,g:h>=0?ugen[h]:-1,mv:s.mv|0}; }); } if(typeof ufield!=='undefined'){ ufield[i]=-1; const T=TYPES[utype[i]]; if(T&&!T.air&&(ustate[i]===1||ustate[i]===2||ustate[i]===5)&&typeof requestField==='function') ufield[i]=requestField(utx[i],uty[i],!!T.naval); } } if(typeof patrolRoutes==='undefined') return; patrolRoutes.length=0; if(!patrols||!patrols.length) return; for(const R of patrols){ if(!R||!R.pts||!R.members){ patrolRoutes.push(null); continue; } const members=[]; for(const e of R.members){ const ni=uMap.get(e[0]); if(ni==null||!ualive[ni]) continue; members.push([ni,ugen[ni]]); } if(!members.length){ patrolRoutes.push(null); continue; } const sel=members.map(e=>e[0]); const targets=(typeof patrolTargetRows==='function')?patrolTargetRows(sel,R.pts,R.form):[]; const rec={pts:R.pts,targets,form:R.form|0,members,step:R.step|0,legT:0,waitT:0,gcT:.45,created:performance.now()}; const ri=patrolRoutes.length; patrolRoutes.push(rec); for(let k=0;k3*1024*1024) return false; localStorage.setItem(SESS_KEY,text); return true; }catch(e){ return false; } } function sessLoad(){ try{ const raw=localStorage.getItem(SESS_KEY); if(!raw) return null; const s=JSON.parse(raw); if(!s||s.v!==1||!s.units) return null; /* An hours-old snapshot is not a dropped session, it is an abandoned one, and offering it teaches the player that the prompt is noise. */ if(Date.now()-(s.at||0)>SESS_TTL_MS){ sessClear(); return null; } return s; }catch(e){ return null; } } function sessClear(){ try{ localStorage.removeItem(SESS_KEY); }catch(e){} } function sessHas(){ return !!sessLoad(); } /* A short human description for the resume prompt. A player will not act on "resume session"; they will act on "9 minutes in, 34 units, Vanguard Valley". */ function sessDescribe(s){ if(!s) return ''; const mins=Math.max(0,Math.round((s.t||0)/60)); const mine=(s.units.tm||[]).filter(t=>t===0).length; const M=(typeof MAPDEFS!=='undefined'&&MAPDEFS[s.map])?MAPDEFS[s.map].nm:(s.map||'battlefield'); return mins+' min in · '+mine+' units · '+M; } /* ---- RESTORE --------------------------------------------------------------- Runs AFTER the normal match generation, so the world is already built from the same setup and the same seed. We are placing the fight back onto a battlefield that regenerated identically, not rebuilding the battlefield. */ function sessRestoreInto(s){ if(!s) return false; try{ /* Clear whatever the fresh start spawned before laying the snapshot down, or the player resumes with two commanders and a doubled army. */ for(let i=0;i{ sessSnapshot('contextlost'); },true); /* `pagehide` fires on mobile where `beforeunload` frequently does not. */ window.addEventListener('pagehide',()=>{ sessSnapshot('pagehide'); }); /* Backgrounding is where Android reclaims the GPU, so take the snapshot on the way out rather than hoping to get a handler when it does. */ document.addEventListener('visibilitychange',()=>{ if(document.visibilityState==='hidden') sessSnapshot('hidden'); }); }catch(e){} })(); /* ---- THE PROMPT ------------------------------------------------------------ Offered on the main menu, once, and only while the snapshot is fresh. It sits above WAR ROOM because a player who has just been dropped out of a nine-minute fight is not looking for the menu — they are looking for their match. */ function sessRenderResume(){ const host=document.getElementById('startScreen'); if(!host) return; let el=document.getElementById('sessResume'); const s=sessLoad(); if(!s){ if(el) el.remove(); return; } if(!el){ el=document.createElement('button'); el.id='sessResume'; el.type='button'; el.className='mbtn'; const anchor=document.getElementById('startBtn'); if(anchor&&anchor.parentNode) anchor.parentNode.insertBefore(el,anchor); else host.appendChild(el); if(typeof mfBindTap==='function') mfBindTap(el,sessResume); else el.addEventListener('click',sessResume); } el.innerHTML='◈  RESUME DROPPED SESSION' +'' +sessDescribe(s)+''; } function sessResume(){ const s=sessLoad(); if(!s){ sessRenderResume(); return; } try{ /* Put the setup back BEFORE generating, so the world regenerates from the same seed and the same rules the dropped match was played under. */ if(s.setup&&typeof META!=='undefined') META.setup=s.setup; const su=s.setup||{}; if(su.d!=null&&typeof difficulty!=='undefined') difficulty=su.d; if(su.pkg&&typeof deploymentPackage!=='undefined'&&typeof DEPLOYMENT_PACKAGES!=='undefined'&&DEPLOYMENT_PACKAGES[su.pkg]) deploymentPackage=su.pkg; if(su.bs&&typeof battlefieldPreset!=='undefined'&&typeof battlefieldPresetKey==='function') battlefieldPreset=battlefieldPresetKey(su.bs); if(su.inf!=null&&typeof infestationOn!=='undefined') infestationOn=!!su.inf; if(su.df!=null&&typeof defenseFocus!=='undefined') defenseFocus=su.df?1:0; if(su.rp&&typeof resPace!=='undefined') resPace=+su.rp; if(su.cr!=null&&typeof crateRate!=='undefined') crateRate=crateRateBase=+su.cr; if(Array.isArray(su.ais)&&typeof aiSlots!=='undefined'){ for(let i=0;i=0)playerCommanderId=s.playerCommander; } sessPending=s; if(typeof initAudio==='function') initAudio(); if(typeof sfx==='function') sfx('ui'); if(typeof applyTheme==='function') applyTheme(); if(typeof hideFrontScreens==='function') hideFrontScreens(); if(typeof newSkirmish==='function') newSkirmish(); /* SKIP THE LANDING. A resumed match should not ask the player to choose a drop site again — they landed nine minutes ago, and their HQ is in the snapshot. Put the carrier on the saved HQ and deploy it automatically, so resuming lands them back in the fight rather than back at the start of one. Deferred a frame because newSkirmish generates terrain first and the deploy check tests the ground it is standing on. */ const hq=(s.blds||[]).find(b2=>b2[0]==='hq'&&b2[1]===0); /* Terrain generation takes seconds and deployCarrier() refuses to land on ground that does not exist yet, so this polls for readiness rather than guessing a delay — a fixed timeout was silently landing nothing at all. Bounded, so a map that never becomes deployable leaves the player at the normal drop screen instead of hanging on a spinner. */ let tries=0; const land=function(){ if(++tries>140) return; // ~35 s ceiling try{ if(typeof carrierCanDeploy!=='function'||typeof deployCarrier!=='function') return; if(hq&&typeof carrier!=='undefined'){ carrier.x=hq[2]; carrier.y=hq[3]; } if(typeof matchLive!=='undefined'&&matchLive) return; // already down if(carrierCanDeploy()){ deployCarrier(); return; } }catch(e){} setTimeout(land,250); }; setTimeout(land,250); }catch(e){ sessClear(); sessRenderResume(); } }