Spaces:
Running
Running
Download src/session.js from CREATORJD/massfront-playtest: direct link, hf CLI and curl.
- Browser
- Download file 68.9 kB
-
https://huggingface.co/spaces/CREATORJD/massfront-playtest/resolve/main/src/session.js
- Command line
-
hf download hf://spaces/CREATORJD/massfront-playtest/src/session.js
-
curl -L -o session.js https://huggingface.co/spaces/CREATORJD/massfront-playtest/resolve/main/src/session.js
68.9 kB
| ; | |
| ; | |
| /* ============================================================================ | |
| 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. Terrain geometry is regenerated, but finite | |
| resource reserves and authored-site damage/collections are stored: those are | |
| player-caused state and regenerating them would duplicate rewards. | |
| 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. Each combat faction is capped by FACTION_POP_CAP (500). | |
| const SESS_TTL_MS = 45 * 60 * 1000; | |
| let sessMatchProfileId=null; | |
| function sessBindMatchProfile(id){ sessMatchProfileId=typeof id==='string'&&id?id:null; } | |
| function sessCanSnapshot(){ | |
| try{ | |
| /* Authored campaigns, Weekly runs and Training missions borrow the | |
| player's setup and keep mode-specific state outside this schema. Saving | |
| only the generic battle would create a prompt that can never be restored | |
| faithfully, so those modes stay fail-closed until their complete state | |
| has an explicit recovery contract. */ | |
| const unsupportedCampaign= | |
| (typeof storyCampaignPlanBorrowed==='function'&&storyCampaignPlanBorrowed())|| | |
| (typeof storyCampaignActiveId!=='undefined'&&!!storyCampaignActiveId), | |
| unsupportedWeekly=typeof weeklyMode!=='undefined'&&!!weeklyMode, | |
| unsupportedTraining=typeof trainingMissionActive==='function'&&trainingMissionActive(), | |
| /* Online seats reconnect through the match authority; a local snapshot | |
| cannot faithfully restore the other commanders or shared clock. */ | |
| unsupportedNetwork=typeof window!=='undefined'&&!!window.__MF_NETWORK_SETUP__; | |
| return typeof running !== 'undefined' && running && | |
| typeof demoMode !== 'undefined' && !demoMode && | |
| typeof gameEnded !== 'undefined' && !gameEnded && | |
| typeof heroIdx !== 'undefined' && heroIdx >= 0 && | |
| typeof ualive !== 'undefined' && !!ualive[heroIdx] && | |
| !unsupportedCampaign && !unsupportedWeekly && !unsupportedTraining && !unsupportedNetwork; | |
| }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:[],vt:[],kl:[], | |
| oi:[],cmd:[],tg:[],mh:[],pr:[],pst:[],psl:[],gh:[],gg:[],qk:[],q:[]}; | |
| for(let i=0;i<unitHigh;i++) if(ualive[i]) idx.push(i); | |
| /* Over the cap, keep the player's army and the enemy's commanders first — | |
| losing some wildlife on a resume is survivable; losing your own force is | |
| the thing this exists to prevent. */ | |
| if(idx.length>SESS_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); | |
| /* Veterancy and its kill counter. spawnUnit ZEROES both on every spawn, so | |
| a restore that does not carry them hands the player back a rankless army: | |
| the ★ chevrons vanish from the world and the selection panel, and the | |
| +15%-per-rank damage in the damage roll (uvet[i]*0.15) is silently gone. | |
| airlift.js already round-trips exactly this pair for the same reason. */ | |
| out.vt.push(typeof uvet!=='undefined'?uvet[i]|0:0); | |
| out.kl.push(typeof ukills!=='undefined'?ukills[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<blds.length;bi++){ | |
| const B=blds[bi]; | |
| if(!B||!B.alive) continue; | |
| out.push([B.type, B.team, Math.round(B.x), Math.round(B.y), | |
| Math.round(B.hp), +(B.prog||0), B.lvl|0, | |
| B.buildPaidM==null?null:+B.buildPaidM.toFixed(3), | |
| B.buildPaidE==null?null:+B.buildPaidE.toFixed(3), | |
| bi, | |
| /* Seat ownership. Without these every resume stripped allyAI, | |
| at which point the econTick guard stopped firing and the | |
| ally seat's reactors started paying the human's bank. Old | |
| snapshots lack the fields and restore as undefined = player- | |
| owned, which matches their pre-fix reality. */ | |
| B.allyAI==null?null:B.allyAI, | |
| B.aiBaseSlot==null?null:B.aiBaseSlot, | |
| /* addBld DERIVES b.fac from the team, and main.js then overwrites | |
| it on every AI/ally base structure (baseB: `B.fac=fac`). Ally | |
| bases are team 0, so a restore that lets addBld re-derive gives | |
| them the player's own faction architecture instead of the kit | |
| they were built with — the ally's base changes appearance | |
| across a resume. aiBehavior has no derivation at all: without | |
| it aiBuildingBehavior() falls back to the nearest base, so a | |
| forward structure in a 1v3 answers to the wrong doctrine. */ | |
| B.fac==null?null:B.fac, | |
| B.aiBehavior==null?null:B.aiBehavior, | |
| /* A restored extractor must reclaim the same finite node even | |
| when its rounded structure coordinate is outside addBld's | |
| three-unit exact-bind window. */ | |
| B.dep>=0?B.dep:null, | |
| B.geo>=0?B.geo:null, | |
| /* Completion grants are historical state, not a function of a | |
| surviving unit. A dead package Prospector must stay dead, and | |
| a 99.96%-complete Extractor must still grant one on completion. */ | |
| B.type==='mex'?(B.freeMiner?1:0):null, | |
| /* Paid maintenance intent and its hostile-fire gate are gameplay | |
| state. Persist both so a recovery neither spends without the | |
| player's order nor bypasses the four-second repair lockout. */ | |
| B.repairOn?1:0, | |
| +Math.max(0,B.dmgT||0).toFixed(3)]); | |
| } | |
| return out; | |
| } | |
| function sessCaptureWallets(){ | |
| const seats=[], allies=[]; | |
| if(typeof AI!=='undefined'){ | |
| if(AI.bases) for(const S of AI.bases) | |
| seats.push({slot:S.slot,mass:+(S.mass||0).toFixed(2),energy:+(S.energy||0).toFixed(2), | |
| mcap:S.mcap|0,ecap:S.ecap|0}); | |
| if(AI.allies) for(const A of AI.allies) | |
| allies.push({slot:A.slot,mass:+(A.mass||0).toFixed(2),energy:+(A.energy||0).toFixed(2)}); | |
| } | |
| return {seats,allies}; | |
| } | |
| function sessCapturePatrols(){ | |
| if(typeof patrolRoutes==='undefined'||!patrolRoutes.length) return []; | |
| const out=[]; | |
| for(let ri=0;ri<patrolRoutes.length;ri++){ | |
| const R=patrolRoutes[ri]; | |
| if(!R||!R.pts||!R.members){ out.push(null); continue; } | |
| out.push({ | |
| pts:R.pts.map(p=>({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; | |
| } | |
| /* Storage is untrusted and old builds can leave a partially written record. | |
| Validate every array restore indexes or iterates before the fresh match is | |
| touched; catching an exception after killUnit() is already too late. */ | |
| function sessCheckCoreState(s){ | |
| const bad=()=>({ok:false,code:'SESSION_CORE_STATE_INVALID'}); | |
| const U=s&&s.units,rows=s&&s.blds; | |
| if(!U||typeof U!=='object'||!Array.isArray(rows)||rows.length>SESS_MAX_UNITS*2)return bad(); | |
| const required=['t','tm','x','y','hp','ang','st','tx','ty','hold','mode']; | |
| const extended=['vt','kl','oi','cmd','tg','mh','pr','pst','psl','gh','gg','qk','q']; | |
| if(required.some(k=>!Array.isArray(U[k])))return bad(); | |
| const n=U.t.length;if(!n||n>SESS_MAX_UNITS||required.some(k=>U[k].length!==n))return bad(); | |
| if(s.v===2&&extended.some(k=>!Array.isArray(U[k])))return bad(); | |
| if(extended.some(k=>U[k]!=null&&(!Array.isArray(U[k])||U[k].length!==n)))return bad(); | |
| const int=v=>Number.isInteger(v),finite=v=>Number.isFinite(v),oldUnits=new Set(); | |
| const span=typeof MAP==='number'?MAP:3200,inMap=v=>finite(v)&&v>=-span&&v<=span*2; | |
| const validSeat=v=>int(v)&&(typeof populationSlotValid==='function'?populationSlotValid(v):v>=-1&&v<=3); | |
| let playerHero=false; | |
| for(let k=0;k<n;k++){ | |
| const type=U.t[k],team=U.tm[k]; | |
| if(!int(type)||type<0||type>=TYPES.length||!int(team)||team<0||team>2|| | |
| !inMap(U.x[k])||!inMap(U.y[k])||!finite(U.hp[k])||U.hp[k]<0||U.hp[k]>1e9|| | |
| !finite(U.ang[k])||!int(U.st[k])||U.st[k]<0||U.st[k]>255|| | |
| !inMap(U.tx[k])||!inMap(U.ty[k])||!int(U.hold[k])||U.hold[k]<0||U.hold[k]>255|| | |
| !int(U.mode[k])||U.mode[k]<0||U.mode[k]>255)return bad(); | |
| if(team===0&&(type===4||(TYPES[type]&&TYPES[type].cat==='hero'))&& | |
| (s.v===1||U.cmd&&U.cmd[k]===POP_PLAYER_SLOT))playerHero=true; | |
| for(const key of ['vt','kl','tg','mh','pr','pst','psl','gh','gg','qk']) | |
| if(U[key]!=null&&!int(U[key][k]))return bad(); | |
| if(U.cmd&&!validSeat(U.cmd[k]))return bad(); | |
| if(U.oi){const oi=U.oi[k];if(!int(oi)||oi<0||oi>=(typeof MAXU==='number'?MAXU:100000)||oldUnits.has(oi))return bad();oldUnits.add(oi);} | |
| if(U.q){ | |
| const Q=U.q[k];if(Q!=null&&(!Array.isArray(Q)||Q.length>(typeof QUEUE_MAX==='number'?QUEUE_MAX:8)))return bad(); | |
| if(Q)for(const step of Q)if(!step||typeof step!=='object'||!int(step.t)||step.t<0||step.t>2|| | |
| !inMap(step.x)||!inMap(step.y)||!int(step.h)||!int(step.g)||!int(step.mv)||step.mv<0||step.mv>1)return bad(); | |
| } | |
| } | |
| if(!playerHero)return bad(); | |
| const oldBlds=new Set(),depClaims=new Set(),geoClaims=new Set(); | |
| const nullableFinite=v=>v==null||finite(v),nullableInt=v=>v==null||int(v); | |
| for(const B of rows){ | |
| if(!Array.isArray(B)||B.length<(s.v===2?17:14)||typeof B[0]!=='string'|| | |
| !Object.prototype.hasOwnProperty.call(BT,B[0])||!int(B[1])||B[1]<0||B[1]>2|| | |
| !inMap(B[2])||!inMap(B[3])||!finite(B[4])||B[4]<0||B[4]>1e9|| | |
| !finite(B[5])||B[5]<0||B[5]>1||!int(B[6])||B[6]<0||B[6]>100|| | |
| !nullableFinite(B[7])||B[7]<0||!nullableFinite(B[8])||B[8]<0|| | |
| !int(B[9])||B[9]<0||oldBlds.has(B[9])||!nullableInt(B[10])||!nullableInt(B[11])|| | |
| !(B[12]==null||typeof B[12]==='string')||!(B[13]==null||typeof B[13]==='string'))return bad(); | |
| oldBlds.add(B[9]); | |
| if(s.v===2){ | |
| const dep=B[14],geo=B[15],grant=B[16]; | |
| if(!nullableInt(dep)||!nullableInt(geo)||(dep!=null&&(dep<0||B[0]!=='mex'||depClaims.has(dep)))|| | |
| (geo!=null&&(geo<0||B[0]!=='geo'||geoClaims.has(geo)))|| | |
| (B[0]==='mex'&&(grant!==0&&grant!==1))||(B[0]!=='mex'&&grant!=null))return bad(); | |
| if((B.length>17&&B[17]!==0&&B[17]!==1)||(B.length>18&&(!finite(B[18])||B[18]<0||B[18]>60)))return bad(); | |
| if(dep!=null)depClaims.add(dep);if(geo!=null)geoClaims.add(geo); | |
| } | |
| } | |
| const finiteArray=(a,len)=>Array.isArray(a)&&a.length===len&&a.every(v=>finite(v)&&v>=0); | |
| if(s.v===2&&(s.resM==null||s.resE==null||s.kills==null||s.built==null|| | |
| s.wallets==null||s.patrols==null||s.wave==null||!finite(s.clock)||!finite(s.t)))return bad(); | |
| if((s.resM!=null&&!finiteArray(s.resM,2))||(s.resE!=null&&!finiteArray(s.resE,2))|| | |
| (s.kills!=null&&!finiteArray(s.kills,3))||(s.built!=null&&!finiteArray(s.built,2)))return bad(); | |
| if(s.wallets!=null){ | |
| const W=s.wallets;if(!W||!Array.isArray(W.seats)||!Array.isArray(W.allies))return bad(); | |
| const walletRow=(R,seat)=>R&&validSeat(R.slot)&&finite(R.mass)&&finite(R.energy)&&R.mass>=0&&R.energy>=0&& | |
| (!seat||s.v===1?(R.mcap==null||int(R.mcap)&&R.mcap>0)&&(R.ecap==null||int(R.ecap)&&R.ecap>0): | |
| int(R.mcap)&&R.mcap>0&&int(R.ecap)&&R.ecap>0); | |
| if(W.seats.some(R=>!walletRow(R,true))||W.allies.some(R=>!walletRow(R,false)))return bad(); | |
| } | |
| if(s.patrols!=null){ | |
| if(!Array.isArray(s.patrols)||s.patrols.length>SESS_MAX_UNITS)return bad(); | |
| for(const R of s.patrols)if(R!=null){ | |
| if(!R||!Array.isArray(R.pts)||!Array.isArray(R.members)||!int(R.form)||!int(R.step)|| | |
| R.pts.length<2||R.pts.length>6||R.form<0||R.form>=(typeof FORMS!=='undefined'?FORMS.length:6)|| | |
| R.step<0||R.step>=R.pts.length||R.members.length>n||R.pts.some(P=>!P||!inMap(P.x)||!inMap(P.y))|| | |
| R.members.some(E=>!Array.isArray(E)||E.length<2||!int(E[0])||!int(E[1])))return bad(); | |
| } | |
| } | |
| if(s.wave!=null&&(!s.wave||!int(s.wave.n)||s.wave.n<0||!finite(s.wave.timer)||s.wave.timer<0))return bad(); | |
| if((s.clock!=null&&(!finite(s.clock)||s.clock<0))||(s.t!=null&&(!finite(s.t)||s.t<0)))return bad(); | |
| return {ok:true,units:U,blds:rows}; | |
| } | |
| /* Core validation proves that every saved row is well-shaped. This second gate | |
| proves the regenerated match can ADMIT the whole roster before the fresh | |
| landing is erased. Combat factions own one shared cap, while every expected | |
| but absent Commander reserves one place. The final `used + missing <= cap` | |
| invariant is sufficient for every saved-order prefix: a Commander swaps one | |
| missing reservation for one used body, while an ordinary unit is the only | |
| operation that increases the sum. Neutral wildlife keeps its dynamic cap. */ | |
| function sessCheckRosterRealizable(s){ | |
| const bad=()=>({ok:false,code:'SESSION_ROSTER_UNREALIZABLE'}),U=s&&s.units; | |
| if(!U||typeof populationExpectedSlots!=='function'||typeof populationResolveSlot!=='function'|| | |
| typeof populationCapFor!=='function')return bad(); | |
| const expected=[new Set(populationExpectedSlots(0)),new Set(populationExpectedSlots(1))], | |
| heroSeats=[new Set(),new Set()],counts=[0,0,0], | |
| hostileBrood=typeof broodIsEnemy==='function'&&broodIsEnemy(); | |
| if(!expected[0].size||!expected[1].size)return bad(); | |
| for(let k=0;k<U.t.length;k++){ | |
| const team=U.tm[k],type=U.t[k],factionTeam=team===2&&hostileBrood?1:team; | |
| if(factionTeam===0||factionTeam===1){ | |
| const cmd=U.cmd?U.cmd[k]:undefined, | |
| slot=populationResolveSlot(team,cmd,U.x[k],U.y[k]); | |
| if(!expected[factionTeam].has(slot))return bad(); | |
| counts[factionTeam]++; | |
| /* Hostile infestation bodies share the enemy wallet but do not satisfy an | |
| AI Commander's reserved seat; spawnUnit mirrors that same distinction. */ | |
| if(team<2&&TYPES[type]&&TYPES[type].cat==='hero')heroSeats[team].add(slot); | |
| }else counts[2]++; | |
| } | |
| const missing=[0,0],caps=[populationCapFor(0),populationCapFor(1),populationCapFor(2)]; | |
| for(let team=0;team<2;team++){ | |
| for(const slot of expected[team])if(!heroSeats[team].has(slot))missing[team]++; | |
| if(!Number.isFinite(caps[team])||counts[team]+missing[team]>caps[team])return bad(); | |
| } | |
| if(!Number.isFinite(caps[2])||counts[2]>caps[2])return bad(); | |
| if(s.wallets){ | |
| const allySeats=new Set([...expected[0]].filter(slot=>slot!==POP_PLAYER_SLOT)); | |
| const rowsOk=(rows,seats,exact)=>{ | |
| const seen=new Set(); | |
| if(!Array.isArray(rows))return false; | |
| for(const R of rows)if(!R||!seats.has(R.slot)||seen.has(R.slot))return false;else seen.add(R.slot); | |
| return !exact||seen.size===seats.size; | |
| }; | |
| if(!rowsOk(s.wallets.seats,expected[1],s.v===2)||!rowsOk(s.wallets.allies,allySeats,s.v===2))return bad(); | |
| } | |
| return {ok:true,counts,missing,caps}; | |
| } | |
| /* sessResume applies this envelope before terrain generation. Keep corrupt or | |
| stale menu values from becoming NaN simulation globals or unknown factions | |
| before the post-generation world check has a chance to run. */ | |
| function sessCheckSetupEnvelope(s){ | |
| const bad=()=>({ok:false,code:'SESSION_SETUP_STATE_INVALID'}),S=s&&s.setup, | |
| has=(O,k)=>!!O&&Object.prototype.hasOwnProperty.call(O,k), | |
| factionRuntime=v=>typeof facRuntimeKey==='function'?facRuntimeKey(v)||v:v, | |
| playerFactionOk=v=>{const key=factionRuntime(v);return typeof v==='string'&& | |
| (typeof playableFactions==='function'?playableFactions().includes(key): | |
| key==='nova'||!!(typeof FACTIONS!=='undefined'&&FACTIONS[key]));}, | |
| enemyFactionOk=v=>{const key=factionRuntime(v);return typeof v==='string'&& | |
| typeof FACTIONS!=='undefined'&&has(FACTIONS,key);}, | |
| battlefieldOk=v=>typeof v==='string'&&((s.v===1&&v==='grand')|| | |
| (typeof BATTLEFIELD_PRESETS!=='undefined'&&has(BATTLEFIELD_PRESETS,v))), | |
| commanderOk=(fac,id)=>{ | |
| if(!playerFactionOk(fac)||typeof id!=='string'||typeof commanderById!=='function'|| | |
| typeof COMMANDER_ROSTERS==='undefined')return false; | |
| const key=typeof commanderFactionKey==='function'?commanderFactionKey(fac):fac, | |
| C=commanderById(id),R=COMMANDER_ROSTERS[key]||[]; | |
| return !!C&&!C.aiOnly&&R.indexOf(C)>=0; | |
| }; | |
| if(s.v===2&&(!S||typeof S!=='object'||Array.isArray(S)))return bad(); | |
| if(s.v===2){ | |
| const setupKeys=['d','t','m','f','pf','pc','bs','pkg','g','tl','rp','cr','ps','ais','df','inf'], | |
| topKeys=['map','theme','goal','timeLimit','aiFac','playerFac','playerCommander']; | |
| if(setupKeys.some(k=>!has(S,k)||S[k]==null)||topKeys.some(k=>!has(s,k)||s[k]==null)|| | |
| S.m!==s.map||S.t!==s.theme||S.g!==s.goal||S.tl!==s.timeLimit|| | |
| S.pf!==s.playerFac||S.pc!==s.playerCommander||!commanderOk(S.pf,S.pc)|| | |
| !enemyFactionOk(s.aiFac)||(S.f!=='random'&&S.f!==s.aiFac)|| | |
| !Array.isArray(S.ais)||S.ais.length!==aiSlots.length)return bad(); | |
| } | |
| if(S!=null){ | |
| if(typeof S!=='object'||Array.isArray(S)||(S.m!=null&&S.m!==s.map)||(S.t!=null&&S.t!==s.theme)|| | |
| (S.d!=null&&(!Number.isInteger(S.d)||S.d<0||S.d>2))|| | |
| (S.bs!=null&&!battlefieldOk(S.bs))|| | |
| (S.pkg!=null&&(!DEPLOYMENT_PACKAGES||!Object.prototype.hasOwnProperty.call(DEPLOYMENT_PACKAGES,S.pkg)))|| | |
| (S.g!=null&&(!Array.isArray(GOALS)||!GOALS.some(G=>G.id===S.g)))|| | |
| (S.tl!=null&&(!Number.isFinite(S.tl)||S.tl<0||S.tl>7200))|| | |
| (S.rp!=null&&(!Number.isFinite(S.rp)||S.rp<=0||S.rp>4))|| | |
| (S.cr!=null&&(!Number.isFinite(S.cr)||S.cr<0||S.cr>4))|| | |
| (S.ps!=null&&(!Array.isArray(START_ZONES)||!START_ZONES.some(Z=>Z.id===S.ps)))|| | |
| (S.f!=null&&S.f!=='random'&&!enemyFactionOk(S.f))|| | |
| (S.pf!=null&&!playerFactionOk(S.pf))|| | |
| (S.pc!=null&&typeof S.pc!=='string')|| | |
| (S.df!=null&&S.df!==0&&S.df!==1&&typeof S.df!=='boolean')|| | |
| (S.inf!=null&&S.inf!==0&&S.inf!==1&&typeof S.inf!=='boolean'))return bad(); | |
| if(S.ais!=null){ | |
| if(!Array.isArray(S.ais)||S.ais.length>aiSlots.length)return bad(); | |
| for(const A of S.ais)if(!A||typeof A!=='object'||typeof A.on!=='boolean'||typeof A.ally!=='boolean'|| | |
| !Number.isInteger(A.diff)||A.diff<0||A.diff>2||!START_ZONES.some(Z=>Z.id===A.zone)|| | |
| typeof A.behavior!=='string'||!AI_BEHAVIOR_TYPES[A.behavior])return bad(); | |
| } | |
| } | |
| if((s.goal!=null&&!GOALS.some(G=>G.id===s.goal))|| | |
| (s.timeLimit!=null&&(!Number.isFinite(s.timeLimit)||s.timeLimit<0||s.timeLimit>7200))|| | |
| (s.aiFac!=null&&s.aiFac!=='random'&&!enemyFactionOk(s.aiFac))|| | |
| (s.playerFac!=null&&!playerFactionOk(s.playerFac))||(s.playerCommander!=null&&typeof s.playerCommander!=='string'))return bad(); | |
| return {ok:true,setup:S}; | |
| } | |
| /* FULL_V1 worlds are deterministic only when the authored plan, normalized | |
| spawn topology and realized resource coordinates all match. These helpers | |
| keep that static compatibility separate from live site damage/collection. */ | |
| function sessLocationPrecheck(s){ | |
| const map=s&&s.map,D=typeof MAPDEFS!=='undefined'&&MAPDEFS[map]; | |
| if(!D)return {ok:false,code:'SESSION_LOCATION_MAP_UNKNOWN'}; | |
| if((s.theme&&D.theme&&s.theme!==D.theme)||(s.setup&&s.setup.m&&s.setup.m!==map)) | |
| return {ok:false,code:'SESSION_LOCATION_SETUP_MISMATCH'}; | |
| const pre=typeof mfPreflightLocationPlanV1==='function'?mfPreflightLocationPlanV1(map):null; | |
| if(pre&&(!pre.ok||pre.status==='HYBRID_V1'))return {ok:false,code:!pre.ok? | |
| (pre.error&&pre.error.code||'SESSION_LOCATION_PREFLIGHT_FAILED'):'SESSION_LOCATION_HYBRID_UNSUPPORTED'}; | |
| const full=!!(pre&&pre.status==='FULL_V1'); | |
| const stampVer=typeof SITE_STAMP!=='undefined'?SITE_STAMP.ver:0; | |
| if(!full){ | |
| /* Version 1 remains a supported legacy payload. Version 2 names the source | |
| contract, so a map that changed status must not silently cross it. */ | |
| if(s.v===2){ | |
| const L=s.location,P=L&&L.planner,status=pre&&pre.status||'LEGACY_V0'; | |
| if(!L||L.schema!=='DroppedLocationStateV1'||L.version!==1||L.mapSeed!==D.seed||!P|| | |
| P.schema!=='LocationPlanExecutionV1'||P.version!==1||P.stampVersion!==stampVer||P.status!==status) | |
| return {ok:false,full:false,code:'SESSION_LOCATION_LEGACY_CONTRACT_MISMATCH'}; | |
| } | |
| return {ok:true,full:false,preflight:pre,location:s.v===2?s.location:null}; | |
| } | |
| const L=s.location,P=L&&L.planner; | |
| if(s.v!==2||!L||L.schema!=='DroppedLocationStateV1'||L.version!==1) | |
| return {ok:false,full:true,code:'SESSION_FULL_V1_REQUIRES_V2'}; | |
| if(L.mapSeed!==D.seed)return {ok:false,full:true,code:'SESSION_LOCATION_SEED_MISMATCH'}; | |
| if(!P||P.schema!=='LocationPlanExecutionV1'||P.version!==1||P.stampVersion!==stampVer|| | |
| P.status!=='FULL_V1'||P.planHash!==pre.planHash||typeof P.realizationHash!=='string'||!P.realizationHash|| | |
| typeof P.topologyKey!=='string'||!P.topologyKey) | |
| return {ok:false,full:true,code:'SESSION_LOCATION_PLAN_MISMATCH'}; | |
| return {ok:true,full:true,preflight:pre,location:L}; | |
| } | |
| function sessExpectedSitePropIds(){ | |
| const out=[]; | |
| if(typeof sitePropPlan!=='undefined')for(const R of sitePropPlan)if(R&&R.id)out.push(R.id); | |
| if(typeof cityZones!=='undefined')for(const Z of cityZones){ | |
| if(!Z||!Z.siteId)continue; | |
| for(let k=0;k<(Z.ind?3:2);k++)out.push(Z.siteId+'/loot/'+k); | |
| if(Z.ind)for(let k=0;k<5;k++)out.push(Z.siteId+'/ring-tank/'+k); | |
| } | |
| return out; | |
| } | |
| function sessCaptureLocation(){ | |
| const map=typeof curMap!=='undefined'?curMap:'',D=typeof MAPDEFS!=='undefined'&&MAPDEFS[map]; | |
| const pre=typeof mfPreflightLocationPlanV1==='function'?mfPreflightLocationPlanV1(map):null; | |
| const status=pre&&pre.status||'LEGACY_V0',plan=typeof SITE_STAMP!=='undefined'&&SITE_STAMP.plan; | |
| const base={schema:'DroppedLocationStateV1',version:1,mapSeed:D&&D.seed, | |
| planner:{schema:plan&&plan.schema||'LocationPlanExecutionV1',version:plan&&plan.version||1, | |
| stampVersion:typeof SITE_STAMP!=='undefined'?SITE_STAMP.ver:0,status:status, | |
| planHash:pre&&pre.planHash||'',realizationHash:typeof SITE_STAMP!=='undefined'?SITE_STAMP.realizationHash||'':'', | |
| topologyKey:typeof mfWorldTopologyKey==='function'?mfWorldTopologyKey():''}, | |
| /* Finite reserves are gameplay state on every map contract, not only on | |
| authored FULL_V1 sites. Returning before this snapshot silently refilled | |
| legacy/PENDING deposits and geysers after a real page reload. */ | |
| resources:{ | |
| mass:deposits.map((D2,i)=>[i,+D2.x,+D2.y,D2.initialTier|0,+D2.capacity,+D2.remaining.toFixed(3),D2.surveyed|0]), | |
| energy:geysers.map((G,i)=>[i,+G.x,+G.y,G.initialTier|0,+G.capacity,+G.remaining.toFixed(3),G.surveyed|0]) | |
| }}; | |
| if(status!=='FULL_V1')return base; | |
| if(!pre.ok||!SITE_STAMP.ok||SITE_STAMP.map!==map||!plan||plan.status!=='FULL_V1'|| | |
| plan.planHash!==pre.planHash||plan.topologyKey!==base.planner.topologyKey||!base.planner.realizationHash)return null; | |
| base.siteIds=cityZones.filter(Z=>Z&&Z.siteId).map(Z=>Z.siteId); | |
| base.plotIds=cityPlan.filter(P=>P&&P.siteObjectId).map(P=>P.siteObjectId); | |
| base.propIds=sessExpectedSitePropIds(); | |
| if(base.siteIds.length!==cityZones.length||base.plotIds.length!==cityPlan.length|| | |
| sitePropPlan.some(R=>!R||!R.id))return null; | |
| base.zones=cityZones.filter(Z=>Z&&Z.siteId).map(Z=>[Z.siteId,Z.total|0,Z.razed|0,Z.claimed?1:0]); | |
| base.relics=relics.filter(R=>R&&R.id).map(R=>[R.id,+clamp(R.hp||0,0,R.hpm).toFixed(3),R.alive?1:0,R.part?1:0, | |
| +(R.lean||0).toFixed(5),+(R.burn||0).toFixed(5),R.fallT==null?null:+R.fallT.toFixed(3)]); | |
| if(base.relics.length!==relics.length)return null; | |
| base.tanks=tanks.filter(T=>T&&T.id).map(T=>[T.id,+T.x,+T.y,+T.s,+clamp(T.hp||0,0,260).toFixed(3), | |
| T.alive?1:0,+(T.fuse||0).toFixed(3)]); | |
| base.crates=crates.filter(C=>C&&C.id).map(C=>[C.id,+C.x,+C.y,C.kind&&C.kind.id||'mass', | |
| +(C.alt||0).toFixed(3),+(C.t||0).toFixed(3),C.seen?1:0,C.site||'',C.siteName||'', | |
| C.announced?1:0,C.mission?1:0]); | |
| base.floraIds=trees.filter(T=>T&&T.id).map(T=>T.id); | |
| base.timers=[typeof crateT==='number'?+crateT.toFixed(3):0, | |
| typeof sitePickupT==='number'?+sitePickupT.toFixed(3):0]; | |
| return base; | |
| } | |
| function sessLocationGeneratedCheck(s){ | |
| const C=sessLocationPrecheck(s);if(!C.ok)return C; | |
| if(typeof curMap==='undefined'||curMap!==s.map) | |
| return {ok:false,full:C.full,code:'SESSION_LOCATION_MAP_MISMATCH'}; | |
| if(!C.full)return C; | |
| const L=C.location,P=L.planner,S=typeof SITE_STAMP!=='undefined'?SITE_STAMP:null; | |
| if(!S||!S.ok||S.ver!==P.stampVersion||S.map!==s.map||!S.plan|| | |
| S.plan.schema!==P.schema||S.plan.version!==P.version||S.plan.status!=='FULL_V1'||S.plan.planHash!==P.planHash|| | |
| S.realizationHash!==P.realizationHash||S.plan.realizationHash!==P.realizationHash|| | |
| S.plan.topologyKey!==P.topologyKey||typeof mfWorldTopologyKey!=='function'||mfWorldTopologyKey()!==P.topologyKey) | |
| return {ok:false,full:true,code:'SESSION_LOCATION_REALIZATION_MISMATCH'}; | |
| return C; | |
| } | |
| function sessLocationCurrentCheck(s){ | |
| const C=sessLocationGeneratedCheck(s);if(!C.ok)return C; | |
| const same=(a,b)=>Number.isFinite(a)&&Number.isFinite(b)&&Math.abs(a-b)<.001; | |
| /* V2 owns finite resource state on every planner status. A deterministic | |
| regen proves coordinates and capacities; the save supplies what remains. */ | |
| if(s.v===2){ | |
| const L=C.location,M=L&&L.resources&&L.resources.mass,E=L&&L.resources&&L.resources.energy; | |
| const resourceOk=(rows,live)=>Array.isArray(rows)&&rows.length===live.length&&rows.every((R,i)=>R&&R.length>=7&& | |
| R[0]===i&&same(R[1],live[i].x)&&same(R[2],live[i].y)&&R[3]===(live[i].initialTier|0)&& | |
| same(R[4],live[i].capacity)&&Number.isFinite(R[5])&&R[5]>=0&&R[5]<=live[i].capacity&& | |
| Number.isInteger(R[6])&&R[6]>=0&&R[6]<=7); | |
| if(!resourceOk(M,deposits)||!resourceOk(E,geysers)) | |
| return {ok:false,full:C.full,code:'SESSION_LOCATION_RESOURCE_INVALID'}; | |
| const depClaims=new Set(),geoClaims=new Set(); | |
| for(const B of s.blds){ | |
| const dep=B[14],geo=B[15],grant=B[16]; | |
| if((B[0]==='mex'&&(grant!==0&&grant!==1))||(B[0]!=='mex'&&grant!=null)) | |
| return {ok:false,full:C.full,code:'SESSION_LOCATION_BUILDINGS_INVALID'}; | |
| if(dep!=null&&(!Number.isInteger(dep)||dep<0||dep>=deposits.length||B[0]!=='mex'||depClaims.has(dep))) | |
| return {ok:false,full:C.full,code:'SESSION_LOCATION_RESOURCE_CLAIM_INVALID'}; | |
| if(geo!=null&&(!Number.isInteger(geo)||geo<0||geo>=geysers.length||B[0]!=='geo'||geoClaims.has(geo))) | |
| return {ok:false,full:C.full,code:'SESSION_LOCATION_RESOURCE_CLAIM_INVALID'}; | |
| if(dep!=null)depClaims.add(dep);if(geo!=null)geoClaims.add(geo); | |
| } | |
| } | |
| if(!C.full)return C; | |
| const L=C.location,uniq=a=>Array.isArray(a)&&a.every(id=>typeof id==='string'&&!!id)&&new Set(a).size===a.length; | |
| const siteIds=cityZones.filter(Z=>Z&&Z.siteId).map(Z=>Z.siteId); | |
| const plotIds=cityPlan.filter(P=>P&&P.siteObjectId).map(P=>P.siteObjectId); | |
| const propIds=sessExpectedSitePropIds(); | |
| if(!uniq(L.siteIds)||!uniq(L.plotIds)||!uniq(L.propIds)||JSON.stringify(L.siteIds)!==JSON.stringify(siteIds)|| | |
| JSON.stringify(L.plotIds)!==JSON.stringify(plotIds)||JSON.stringify(L.propIds)!==JSON.stringify(propIds)|| | |
| JSON.stringify(plotIds)!==JSON.stringify(relics.map(R=>R&&R.id||''))) | |
| return {ok:false,full:true,code:'SESSION_LOCATION_IDENTITY_MISMATCH'}; | |
| const zones=new Map(cityZones.filter(Z=>Z&&Z.siteId).map(Z=>[Z.siteId,Z])); | |
| const relicMap=new Map(relics.filter(R=>R&&R.id).map(R=>[R.id,R])); | |
| const tankMap=new Map(tanks.filter(T=>T&&T.id).map(T=>[T.id,T])); | |
| const crateMap=new Map(crates.filter(K=>K&&K.id).map(K=>[K.id,K])); | |
| const floraMap=new Map(trees.filter(T=>T&&T.id).map(T=>[T.id,T])); | |
| const rowsOk=(rows,map,check)=>Array.isArray(rows)&&rows.length===map.size&&uniq(rows.map(R=>R&&R[0]))&&rows.every(R=>R&&map.has(R[0])&&check(R,map.get(R[0]))); | |
| if(!rowsOk(L.relics,relicMap,(R,O)=>R.length>=7&&Number.isFinite(R[1])&&R[1]>=0&&R[1]<=O.hpm&& | |
| (R[2]===0||R[2]===1)&&(!R[2]||R[1]>0)&&(R[3]===0||R[3]===1)&&(!R[3]||O.kind===5)&& | |
| Number.isFinite(R[4])&&Math.abs(R[4])<=.5&&Number.isFinite(R[5])&&R[5]>=0&&R[5]<=1.5&& | |
| (R[6]==null||(Number.isFinite(R[6])&&R[6]>=0)))|| | |
| !rowsOk(L.tanks,tankMap,(R,O)=>R.length>=7&&same(R[1],O.x)&&same(R[2],O.y)&&same(R[3],O.s)&& | |
| Number.isFinite(R[4])&&R[4]>=0&&R[4]<=260&&(R[5]===0||R[5]===1)&& | |
| Number.isFinite(R[6])&&R[6]>=0&&R[6]<=3)) | |
| return {ok:false,full:true,code:'SESSION_LOCATION_DYNAMIC_INVALID'}; | |
| const deadByZone=new Map(L.siteIds.map(id=>[id,0])); | |
| for(const R of L.relics)if(!R[2]){ | |
| const O=relicMap.get(R[0]),Z=cityZones[O.zone]; | |
| if(!Z||!deadByZone.has(Z.siteId))return {ok:false,full:true,code:'SESSION_LOCATION_ZONE_LINK_INVALID'}; | |
| deadByZone.set(Z.siteId,deadByZone.get(Z.siteId)+1); | |
| } | |
| if(!rowsOk(L.zones,zones,(R,Z)=>R.length>=4&&R[1]===Z.total&&R[2]===deadByZone.get(R[0])&& | |
| R[2]>=0&&R[2]<=R[1]&&(R[3]===0||R[3]===1)&&(!R[3]||(R[1]>0&&R[2]===R[1])))) | |
| return {ok:false,full:true,code:'SESSION_LOCATION_ZONE_PROGRESS_INVALID'}; | |
| if(!Array.isArray(L.crates)||!uniq(L.crates.map(R=>R&&R[0]))||L.crates.some(R=>!R||R.length<11|| | |
| !crateMap.has(R[0])||!same(R[1],crateMap.get(R[0]).x)||!same(R[2],crateMap.get(R[0]).y)|| | |
| !CRATE_KINDS.some(K=>K.id===R[3])||!Number.isFinite(R[4])||!Number.isFinite(R[5])|| | |
| (R[6]!==0&&R[6]!==1)||(R[9]!==0&&R[9]!==1)||(R[10]!==0&&R[10]!==1))) | |
| return {ok:false,full:true,code:'SESSION_LOCATION_CRATE_INVALID'}; | |
| const expectedFlora=new Set(sitePropPlan.filter(R=>R&&R.kind==='flora').map(R=>R.id)); | |
| if(!uniq(L.floraIds)||L.floraIds.some(id=>!expectedFlora.has(id)||!floraMap.has(id))) | |
| return {ok:false,full:true,code:'SESSION_LOCATION_FLORA_INVALID'}; | |
| if(!Array.isArray(L.timers)||L.timers.length!==2||L.timers.some(v=>!Number.isFinite(v)||v<0||v>1e5)) | |
| return {ok:false,full:true,code:'SESSION_LOCATION_TIMERS_INVALID'}; | |
| C.maps={zones,relics:relicMap,tanks:tankMap,crates:crateMap,flora:floraMap};return C; | |
| } | |
| function sessApplyResourceState(C){ | |
| const L=C&&C.location,R=L&&L.resources;if(!R)return true; | |
| for(const row of R.mass){const D=deposits[row[0]],before=D.tier;D.remaining=row[5];D.surveyed=row[6]|0;D.taken=false; | |
| const after=depositTier(D);if(after!==before&&typeof refreshResourceTerrainNode==='function')refreshResourceTerrainNode(D,'mass',before,after);} | |
| for(const row of R.energy){const G=geysers[row[0]],before=G.tier;G.remaining=row[5];G.surveyed=row[6]|0;G.taken=false; | |
| const after=geyserTier(G);if(after!==before&&typeof refreshResourceTerrainNode==='function')refreshResourceTerrainNode(G,'energy',before,after);} | |
| /* `taken` is derived from the structures actually restored, never trusted | |
| from storage. That keeps a collected/depleted field from being reserved by | |
| stale bytes and also fixes restored geothermal plants, whose legacy addBld | |
| path did not claim their vent. */ | |
| for(const B of blds){ | |
| if(!B||!B.alive)continue; | |
| if(B.type==='mex'){ | |
| if(!(B.dep>=0&&B.dep<deposits.length)&&typeof econBindResourceNode==='function')econBindResourceNode(B); | |
| if(B.dep>=0&&B.dep<deposits.length){const D=deposits[B.dep];D.taken=true;B.nodeTier=depositTier(D);B.nodeRemaining=D.remaining;} | |
| }else if(B.type==='geo'){ | |
| if(!(B.geo>=0&&B.geo<geysers.length)&&typeof econBindResourceNode==='function')econBindResourceNode(B); | |
| if(B.geo>=0&&B.geo<geysers.length){const G=geysers[B.geo];G.taken=true;B.nodeTier=geyserTier(G);B.nodeRemaining=G.remaining;} | |
| } | |
| } | |
| return true; | |
| } | |
| function sessApplyLocationState(C){ | |
| if(!C)return false; | |
| if(!C.full)return sessApplyResourceState(C); | |
| const L=C.location; | |
| for(const R of L.zones){const Z=C.maps.zones.get(R[0]);Z.razed=R[2]|0;Z.claimed=R[3]?1:0;} | |
| for(const R of L.relics){const O=C.maps.relics.get(R[0]);O.hp=R[1];O.alive=!!R[2];O.part=R[3]?1:0; | |
| O.lean=R[4]||0;O.burn=R[5]||0;if(R[6]==null)delete O.fallT;else O.fallT=R[6];} | |
| for(const R of L.tanks){const O=C.maps.tanks.get(R[0]);O.hp=R[4];O.alive=!!R[5];O.fuse=R[6]||0;} | |
| const savedCrates=new Map(L.crates.map(R=>[R[0],R])),expected=new Set(L.propIds); | |
| for(let i=crates.length-1;i>=0;i--)if(crates[i].id&&expected.has(crates[i].id)&&!savedCrates.has(crates[i].id))crates.splice(i,1); | |
| for(const R of L.crates){const O=C.maps.crates.get(R[0]),K=CRATE_KINDS.find(Q=>Q.id===R[3]); | |
| O.kind=K;O.alt=R[4];O.t=R[5];O.seen=!!R[6];O.site=R[7]||'';O.siteName=R[8]||''; | |
| O.announced=!!R[9];O.mission=!!R[10];} | |
| const liveFlora=new Set(L.floraIds); | |
| for(let i=trees.length-1;i>=0;i--)if(trees[i].id&&C.maps.flora.has(trees[i].id)&&!liveFlora.has(trees[i].id))trees.splice(i,1); | |
| if(!sessApplyResourceState(C))return false; | |
| crateT=L.timers[0];sitePickupT=L.timers[1]; | |
| if(typeof mfMoveBlockersDirty==='function')mfMoveBlockersDirty(); | |
| if(typeof mfArtObsRockGridReset==='function')mfArtObsRockGridReset(); | |
| if(typeof markBuildZone==='function')markBuildZone(); | |
| return true; | |
| } | |
| function sessCaptureWrecks(){ | |
| if(typeof wrecks==='undefined')return []; | |
| return wrecks.map(W=>[+W.x,+W.y,+(W.a||0),+(W.s||0),+(W.mass||0),+(W.m0||0), | |
| +(W.en||0),+(W.e0||0),W.kind|0,String(W.style||''),+(W.life||0),+(W.glow||0),+(W.ts||0)]); | |
| } | |
| function sessCheckWrecks(s){ | |
| if(s.v===1&&!Array.isArray(s.wrecks))return {ok:true,rows:null}; | |
| const rows=s.wrecks,cap=typeof WRECK_CAP==='number'?WRECK_CAP:600,span=typeof MAP==='number'?MAP:20000; | |
| if(!Array.isArray(rows)||rows.length>cap)return {ok:false,code:'SESSION_WRECKS_INVALID'}; | |
| for(const R of rows){ | |
| if(!Array.isArray(R)||R.length<13||R.slice(0,9).some(v=>!Number.isFinite(v))|| | |
| !Number.isFinite(R[10])||!Number.isFinite(R[11])||!Number.isFinite(R[12])|| | |
| R[0]<-span||R[0]>span*2||R[1]<-span||R[1]>span*2||R[3]<0||R[3]>2000|| | |
| R[4]<-.01||R[5]<-.01||R[6]<-.01||R[7]<-.01||R[8]<0||R[8]>32|| | |
| typeof R[9]!=='string'||R[9].length>64||R[10]<0||R[11]<0||R[12]<0) | |
| return {ok:false,code:'SESSION_WRECKS_INVALID'}; | |
| } | |
| return {ok:true,rows}; | |
| } | |
| function sessApplyWrecks(C){ | |
| if(!C||!C.rows)return; | |
| wrecks.length=0; | |
| for(const R of C.rows)wrecks.push({x:R[0],y:R[1],a:R[2],s:R[3],mass:Math.max(0,R[4]),m0:Math.max(0,R[5]), | |
| en:Math.max(0,R[6]),e0:Math.max(0,R[7]),kind:R[8]|0,style:R[9],life:R[10],glow:R[11],ts:R[12]}); | |
| } | |
| function sessCaptureHeroState(){ | |
| return {lvl:heroLvl|0,xp:+heroXp,next:+heroXpNext,pending:pendingLevels|0, | |
| modifiers:[+heroDmgMult,+heroRegen,+commanderHpMult,+armyDmgMult,+salvageMult,+bldHpMult,+playerBuildMult,+blastRadius], | |
| unlock:abUnlock.map(Boolean),cool:abCool.map(v=>+v||0), | |
| /* These three are live level-up effects, not derivable from heroLvl: the | |
| player can choose upgrades in different orders and repeat some of them. */ | |
| maxHp:heroIdx>=0&&ualive[heroIdx]?+uhpm[heroIdx]:0, | |
| abilityCd:AB_CD.map(v=>+v),income:[+bonusMass,+bonusEnergy]}; | |
| } | |
| function sessCheckHeroState(s){ | |
| const H=s.hero;if(!H)return {ok:s.v===1,state:null,code:'SESSION_HERO_STATE_MISSING'}; | |
| const progression=Number.isFinite(H.maxHp)&&H.maxHp>0&&H.maxHp<=1e9&& | |
| Array.isArray(H.abilityCd)&&H.abilityCd.length===5&&H.abilityCd.every(v=>Number.isFinite(v)&&v>0&&v<=1e5)&& | |
| Array.isArray(H.income)&&H.income.length===2&&H.income.every(v=>Number.isFinite(v)&&v>=0&&v<=1e5); | |
| if(!Number.isInteger(H.lvl)||H.lvl<1||H.lvl>100||!Number.isFinite(H.xp)||H.xp<0|| | |
| !Number.isFinite(H.next)||H.next<=0||H.xp>=H.next||!Number.isInteger(H.pending)||H.pending<0||H.pending>=H.lvl|| | |
| !Array.isArray(H.modifiers)||H.modifiers.length!==8||H.modifiers.some(v=>!Number.isFinite(v)||v<0||v>1e5)|| | |
| !Array.isArray(H.unlock)||H.unlock.length!==5||H.unlock.some(v=>typeof v!=='boolean')|| | |
| !Array.isArray(H.cool)||H.cool.length!==5||H.cool.some(v=>!Number.isFinite(v)||v<0||v>1e5)|| | |
| (s.v===2&&!progression)||(s.v===1&&(H.maxHp!=null||H.abilityCd!=null||H.income!=null)&&!progression)) | |
| return {ok:false,state:null,code:'SESSION_HERO_STATE_INVALID'}; | |
| return {ok:true,state:H}; | |
| } | |
| function sessApplyHeroState(C){ | |
| const H=C&&C.state;if(!H)return; | |
| heroLvl=H.lvl;heroXp=H.xp;heroXpNext=H.next;pendingLevels=H.pending; | |
| heroDmgMult=H.modifiers[0];heroRegen=H.modifiers[1];commanderHpMult=H.modifiers[2]; | |
| armyDmgMult=H.modifiers[3];salvageMult=H.modifiers[4];bldHpMult=H.modifiers[5]; | |
| playerBuildMult=H.modifiers[6];blastRadius=H.modifiers[7]; | |
| abUnlock=H.unlock.slice();abCool=H.cool.slice(); | |
| if(H.abilityCd)for(let i=0;i<AB_CD.length;i++)AB_CD[i]=H.abilityCd[i]; | |
| if(H.income){bonusMass=H.income[0];bonusEnergy=H.income[1];} | |
| if(H.maxHp>0&&heroIdx>=0&&ualive[heroIdx]){uhpm[heroIdx]=H.maxHp;uhp[heroIdx]=Math.min(uhp[heroIdx],H.maxHp);} | |
| } | |
| /* The account bag no longer contains a consumed charge after deployment. A | |
| regenerated recovery world therefore cannot infer the old per-chassis | |
| multiplier from inventory; preserve the effective match state instead. */ | |
| function sessCaptureLoadoutState(){ | |
| return {schema:1, | |
| consumables:typeof _mfMatchCons!=='undefined'&&Array.isArray(_mfMatchCons)?_mfMatchCons.map(c=>c.id):[], | |
| gear:typeof _mfMatchGear!=='undefined'&&Array.isArray(_mfMatchGear)?_mfMatchGear.map(g=>g.id):[], | |
| typeHp:Array.from(typeHpMult),typeDmg:Array.from(typeDmgMult), | |
| modifiers:[resHpMult,resRngMult,resEnergyMult,bldSpeedMult, | |
| resBldHpMult,resDefDmgMult,labBufferMult]}; | |
| } | |
| function sessCheckLoadoutState(s){ | |
| const L=s.loadout,bad={ok:false,code:'SESSION_LOADOUT_INVALID'}; | |
| if(!L||L.schema!==1||!Array.isArray(L.consumables)||L.consumables.length>2|| | |
| !Array.isArray(L.gear)||L.gear.length>3|| | |
| L.consumables.some((id,i)=>L.consumables.indexOf(id)!==i|| | |
| !INV_CONSUMABLES.some(c=>c.id===id))|| | |
| L.gear.some((id,i)=>L.gear.indexOf(id)!==i||!INV_GEAR.some(g=>g.id===id))|| | |
| !Array.isArray(L.typeHp)||L.typeHp.length!==typeHpMult.length|| | |
| !Array.isArray(L.typeDmg)||L.typeDmg.length!==typeDmgMult.length|| | |
| L.typeHp.some(v=>!Number.isFinite(v)||v<.1||v>10)|| | |
| L.typeDmg.some(v=>!Number.isFinite(v)||v<.1||v>10)|| | |
| !Array.isArray(L.modifiers)||L.modifiers.length!==7|| | |
| L.modifiers.some(v=>!Number.isFinite(v)||v<0||v>100))return bad; | |
| return {ok:true,state:L}; | |
| } | |
| function sessApplyLoadoutState(C){ | |
| const L=C&&C.state;if(!L)return; | |
| typeHpMult.set(L.typeHp);typeDmgMult.set(L.typeDmg); | |
| [resHpMult,resRngMult,resEnergyMult,bldSpeedMult, | |
| resBldHpMult,resDefDmgMult,labBufferMult]=L.modifiers; | |
| _mfMatchCons=L.consumables.map(id=>INV_CONSUMABLES.find(c=>c.id===id)); | |
| _mfMatchGear=L.gear.map(id=>INV_GEAR.find(g=>g.id===id)); | |
| } | |
| /* 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<AI.bases.length;i++) if(AI.bases[i].slot===row.slot){ S=AI.bases[i]; break; } | |
| if(!S) continue; | |
| if(row.mass!=null) S.mass=row.mass; | |
| if(row.energy!=null) S.energy=row.energy; | |
| if(row.mcap) S.mcap=row.mcap; | |
| if(row.ecap) S.ecap=row.ecap; | |
| } | |
| } | |
| if(w.allies&&AI.allies){ | |
| for(const row of w.allies){ | |
| let A=null; | |
| for(let i=0;i<AI.allies.length;i++) if(AI.allies[i].slot===row.slot){ A=AI.allies[i]; break; } | |
| if(!A) continue; | |
| if(row.mass!=null) A.mass=row.mass; | |
| if(row.energy!=null) A.energy=row.energy; | |
| } | |
| } | |
| /* Fresh deploy wrote commander indices we then killed. Rebind by seat. */ | |
| if(AI.bases){ | |
| for(const S of AI.bases){ | |
| let best=-1,bd=1e18; | |
| for(let i=0;i<unitHigh;i++){ | |
| if(!ualive[i]||uteam[i]!==1) continue; | |
| const T=TYPES[utype[i]]; | |
| if(!(utype[i]===4||(T&&T.cat==='hero'))) continue; | |
| if(typeof uCmd!=='undefined'&&uCmd[i]===S.slot){ best=i; break; } | |
| const d=dist2(ux[i],uy[i],S.x,S.y); | |
| if(d<bd){ bd=d; best=i; } | |
| } | |
| if(best>=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<spawned.length;k++){ | |
| const i=spawned[k]; if(i<0) continue; | |
| if(typeof umarch!=='undefined'&&U.mh) umarch[i]=U.mh[k]|0; | |
| if(typeof utgt!=='undefined'&&U.tg){ | |
| const tg=sessRemapHandle(U.tg[k],uMap,bMap); | |
| utgt[i]=tg; | |
| if(typeof utgtg!=='undefined') utgtg[i]=tg>=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,mfNavUnitClearance(T)); | |
| } | |
| } | |
| 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;k<sel.length;k++){ | |
| const i=sel[k]; | |
| if(typeof uPatrolRoute!=='undefined') uPatrolRoute[i]=ri; | |
| if(typeof uPatrolStep!=='undefined') uPatrolStep[i]=rec.step; | |
| if(typeof uPatrolSlot!=='undefined') uPatrolSlot[i]=k; | |
| } | |
| if(typeof refreshPatrolRoute==='function') refreshPatrolRoute(ri,true); | |
| } | |
| } | |
| function sessSnapshot(reason){ | |
| if(!sessCanSnapshot()) return false; | |
| try{ | |
| /* Bind to the career that launched this fight, not a profile picker that | |
| might have changed while the match kept running in the background. */ | |
| const profileId=sessMatchProfileId||typeof PROFILES!=='undefined'&&PROFILES&&PROFILES.active; | |
| if(typeof profileId!=='string'||!profileId)return false; | |
| const location=sessCaptureLocation(); | |
| const currentPre=typeof mfPreflightLocationPlanV1==='function'?mfPreflightLocationPlanV1(curMap):null; | |
| if(currentPre&¤tPre.status==='FULL_V1'&&!location)return false; | |
| const snap={ | |
| v:2, at:Date.now(), reason:reason||'unknown',profileId, | |
| /* The setup is what regenerates the world. Everything terrain-shaped | |
| falls out of it, which is why the snapshot is kilobytes not megabytes. */ | |
| setup: (typeof META!=='undefined'&&META.setup)?META.setup:null, | |
| map: typeof curMap!=='undefined'?curMap:null, | |
| theme: typeof curTheme!=='undefined'?curTheme:null, | |
| goal: typeof goalSel!=='undefined'?goalSel:null, | |
| timeLimit: typeof timeLimit!=='undefined'?timeLimit:0, | |
| aiFac: (typeof AI!=='undefined'&&AI.fac)?AI.fac:null, | |
| playerFac: typeof playerFaction!=='undefined'?playerFaction:'nova', | |
| playerCommander: typeof playerCommanderId!=='undefined'?playerCommanderId:'nova_kai', | |
| clock: typeof matchClock!=='undefined'?matchClock:0, | |
| t: (typeof stats!=='undefined'&&stats.t)?stats.t:0, | |
| kills: (typeof stats!=='undefined'&&stats.kills)?stats.kills.slice():[0,0,0], | |
| built: (typeof stats!=='undefined'&&stats.built)?stats.built.slice():[0,0], | |
| resM: typeof resM!=='undefined'?[resM[0],resM[1]]:[0,0], | |
| resE: typeof resE!=='undefined'?[resE[0],resE[1]]:[0,0], | |
| wallets: sessCaptureWallets(), | |
| wave: (typeof AI!=='undefined')?{n:AI.wave|0,timer:AI.waveTimer|0}:null, | |
| units: sessCaptureUnits(), | |
| blds: sessCaptureBuildings(), | |
| patrols: sessCapturePatrols(), | |
| location, | |
| /* Destruction rewards have already been emitted. Preserve what remains | |
| instead of recreating salvage or rolling Commander XP backward. */ | |
| wrecks:sessCaptureWrecks(), | |
| hero:sessCaptureHeroState(), | |
| loadout:sessCaptureLoadoutState(), | |
| extraStats:{nests:stats.nests|0,reclaimed:+(stats.reclaimed||0),campaignCache:stats.campaignCache|0}, | |
| }; | |
| const text=JSON.stringify(snap); | |
| /* A snapshot that blows the quota would throw and take nothing with it — | |
| but it could also evict the account save on some engines, so refuse | |
| rather than risk it. 3 MB is far above a realistic match and far below | |
| the 5 MB localStorage floor. */ | |
| if(text.length>3*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.v!==2)||!s.units) return null; | |
| /* Legacy snapshots had no profile identity. They cannot be safely offered | |
| to a different local career, so fail closed once instead of importing | |
| somebody else's units, currency and battle outcome. */ | |
| if(typeof s.profileId!=='string'||!s.profileId){ | |
| window.__mfSessionReject='SESSION_PROFILE_UNBOUND';sessClear();return null; | |
| } | |
| if(typeof PROFILES==='undefined'||!PROFILES||s.profileId!==PROFILES.active)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; } | |
| const core=sessCheckCoreState(s),setup=sessCheckSetupEnvelope(s); | |
| if(!core.ok||!setup.ok){window.__mfSessionReject=!core.ok?core.code:setup.code;sessClear();return null;} | |
| /* This is deliberately before sessResume writes META/setup globals. A | |
| hashless old payload may still resume a legacy map, but never FULL_V1. */ | |
| const location=sessLocationPrecheck(s); | |
| if(!location.ok){ window.__mfSessionReject=location.code;sessClear();return null; } | |
| if(s.v===2){ | |
| const W=sessCheckWrecks(s),H=sessCheckHeroState(s),L=sessCheckLoadoutState(s),X=s.extraStats; | |
| const extraOk=!!X&&Number.isInteger(X.nests)&&X.nests>=0&&Number.isFinite(X.reclaimed)&& | |
| X.reclaimed>=0&&Number.isInteger(X.campaignCache)&&X.campaignCache>=0; | |
| if(!W.ok||!H.ok||!L.ok||!extraOk){window.__mfSessionReject=!W.ok?W.code:!H.ok?H.code:!L.ok?L.code:'SESSION_STATS_INVALID'; | |
| 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; | |
| if(typeof PROFILES==='undefined'||!PROFILES||s.profileId!==PROFILES.active){ | |
| window.__mfSessionReject='SESSION_PROFILE_MISMATCH';return false; | |
| } | |
| /* Validate the regenerated FULL_V1 world and every additive payload before | |
| killing even one fresh object. A failed recovery must leave a playable | |
| freshly generated match, not half of the snapshot and half of the reset. */ | |
| const coreCheck=sessCheckCoreState(s),setupCheck=coreCheck.ok?sessCheckSetupEnvelope(s):null, | |
| rosterCheck=coreCheck.ok&&setupCheck.ok?sessCheckRosterRealizable(s):null, | |
| locationCheck=coreCheck.ok&&setupCheck.ok&&rosterCheck.ok?sessLocationCurrentCheck(s):null; | |
| const wreckCheck=coreCheck.ok&&setupCheck.ok?sessCheckWrecks(s):null, | |
| heroCheck=coreCheck.ok&&setupCheck.ok?sessCheckHeroState(s):null, | |
| loadoutCheck=coreCheck.ok&&setupCheck.ok?sessCheckLoadoutState(s):null; | |
| const X=s.extraStats,extraOk=s.v===1||!!X&&Number.isInteger(X.nests)&&X.nests>=0&& | |
| Number.isFinite(X.reclaimed)&&X.reclaimed>=0&&Number.isInteger(X.campaignCache)&&X.campaignCache>=0; | |
| if(!coreCheck.ok||!setupCheck.ok||!rosterCheck.ok||!locationCheck.ok||!wreckCheck.ok||!heroCheck.ok||!loadoutCheck.ok||!extraOk){ | |
| window.__mfSessionReject=!coreCheck.ok?coreCheck.code:!setupCheck.ok?setupCheck.code:!rosterCheck.ok?rosterCheck.code:!locationCheck.ok?locationCheck.code:!wreckCheck.ok?wreckCheck.code: | |
| !heroCheck.ok?heroCheck.code:!loadoutCheck.ok?loadoutCheck.code:'SESSION_STATS_INVALID'; | |
| sessClear();return false; | |
| } | |
| try{ | |
| /* Restore effective modifiers before replay spawns units and structures: | |
| those constructors compute maximum health from the live multipliers. */ | |
| sessApplyLoadoutState(loadoutCheck); | |
| /* 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<unitHigh;i++) if(ualive[i]) killUnit(i,true); | |
| /* EVERY structure, nests included. sessCaptureBuildings has no type filter, | |
| so the snapshot already holds every live hive; exempting them from the | |
| wipe therefore did not preserve them, it DUPLICATED them. setupNests() | |
| is seeded off the map seed (srand((def.seed^0x1234f)|1)), so the fresh | |
| generation lays hives down at the exact same coordinates the snapshot | |
| recorded — one resume put two stacked nests on every site, a second | |
| resume put four, and every one of them trickles and erupts independently. | |
| It also resurrected at full health every hive the player had cleared. | |
| Wiping them and replaying the snapshot makes the snapshot authoritative, | |
| which is what it is for the other 30-odd structure types already. */ | |
| for(const B of blds) if(B.alive) B.alive=false; | |
| /* The regenerated baseline may contain extractors/geothermal plants that | |
| are absent from the snapshot. Dead structures no longer own anything; | |
| clear all reservations before the saved live structures reclaim their | |
| explicit node indices below. FULL_V1 re-derives this again after finite | |
| resource state is applied, while legacy/PENDING now gets the same rule. */ | |
| for(const D of deposits)D.taken=false; | |
| for(const G of geysers)G.taken=false; | |
| refreshBldLive(); | |
| const bMap=new Map(); | |
| for(const b of s.blds){ | |
| const [type,team,x,y,hp,prog,lvl,paidM,paidE,oldBi,allyAI,aiBaseSlot,bfac,bBehavior,depRef,geoRef,freeMiner,repairOn,dmgT]=b; | |
| /* Structures are restored before the captured unit roster. Suppress | |
| instant package grants here or each Extractor creates a fresh | |
| Prospector before its saved one is replayed below. */ | |
| const before=blds.length,B=addBld(type,team,x,y,true,0,true); | |
| if(!B||blds.length!==before+1||blds[blds.length-1]!==B)throw new Error('SESSION_BUILDING_REPLAY_FAILED'); | |
| B.hp=hp; | |
| /* Current snapshots preserve exact progress plus grant state. The | |
| clamp is a defensive bridge for any intermediate v2 payload that | |
| rounded an unspent near-complete Extractor to 1. */ | |
| B.prog=type==='mex'&&freeMiner===0&&prog>=1?1-1e-6:prog; | |
| B.lvl=lvl||1; | |
| if(type==='mex')B.freeMiner=freeMiner==null?B.prog>=1:!!freeMiner; | |
| if(paidM!=null)B.buildPaidM=paidM;if(paidE!=null)B.buildPaidE=paidE; | |
| B.repairOn=repairOn===1;B.repairStalled=false;B.dmgT=dmgT==null?0:dmgT; | |
| bMap.set(oldBi,blds.length-1); | |
| if(allyAI!=null)B.allyAI=allyAI; if(aiBaseSlot!=null)B.aiBaseSlot=aiBaseSlot; | |
| if(bfac!=null)B.fac=bfac; if(bBehavior!=null)B.aiBehavior=bBehavior; | |
| if(depRef!=null&&depRef>=0&&depRef<deposits.length){ | |
| if(B.dep>=0&&B.dep!==depRef&&deposits[B.dep])deposits[B.dep].taken=false; | |
| B.dep=depRef;deposits[depRef].taken=true;B.rich=(deposits[depRef].initialTier||1)>=3; | |
| } | |
| if(geoRef!=null&&geoRef>=0&&geoRef<geysers.length){ | |
| if(B.geo>=0&&B.geo!==geoRef&&geysers[B.geo])geysers[B.geo].taken=false; | |
| B.geo=geoRef;geysers[geoRef].taken=true; | |
| } | |
| } | |
| if(bMap.size!==s.blds.length)throw new Error('SESSION_BUILDING_REPLAY_INCOMPLETE'); | |
| refreshBldLive(); | |
| /* These are direct state assignments. Never call collapseBlock, blowTank, | |
| addWreck or a pickup handler here: each of those emits gameplay rewards. */ | |
| if(!sessApplyLocationState(locationCheck))throw new Error('SESSION_LOCATION_REPLAY_FAILED'); | |
| sessApplyWrecks(wreckCheck); | |
| sessApplyHeroState(heroCheck); | |
| const U=s.units, uMap=new Map(), spawned=[]; | |
| for(let k=0;k<U.t.length;k++){ | |
| const cmd=U.cmd?U.cmd[k]:undefined; | |
| const i=spawnUnit(U.t[k],U.tm[k],U.x[k],U.y[k],cmd); | |
| if(i<0)throw new Error('SESSION_UNIT_REPLAY_FAILED'); | |
| spawned.push(i); | |
| if(U.oi) uMap.set(U.oi[k],i); | |
| uhp[i]=U.hp[k]; uang[i]=U.ang[k]; ustate[i]=U.st[k]; | |
| utx[i]=U.tx[k]; uty[i]=U.ty[k]; uhold[i]=U.hold[k]; | |
| if(typeof umode!=='undefined') umode[i]=U.mode[k]; | |
| /* Guarded on the array, not on the snapshot version: a v1 snapshot taken | |
| before these were captured simply has no vt/kl and keeps its old | |
| behaviour of resuming at rank 0. */ | |
| if(typeof uvet!=='undefined'&&U.vt) uvet[i]=U.vt[k]|0; | |
| if(typeof ukills!=='undefined'&&U.kl) ukills[i]=U.kl[k]|0; | |
| } | |
| if(spawned.length!==U.t.length||spawned.some(i=>i<0||!ualive[i])||U.oi&&uMap.size!==U.t.length) | |
| throw new Error('SESSION_UNIT_REPLAY_INCOMPLETE'); | |
| /* Player ledger first. Seat wallets then overwrite the team-1 mirror so a | |
| 1v2/1v3 resume does not dump three mex belts into one shared bank. */ | |
| /* A wholesale RESTORE, not income - econSetBanks, never credit(). Called | |
| PER TEAM on purpose: the team-less form also resets both caps to | |
| MCAP0/ECAP0, which would silently wipe the silo bonuses this save is | |
| carrying. Keeps the A6 invariant true without pretending a restore is an | |
| earning. */ | |
| if(typeof econSetBanks==='function'&&s.resM&&s.resE){ | |
| econSetBanks(s.resM[0], s.resE[0], 0); | |
| if(!s.wallets) econSetBanks(s.resM[1], s.resE[1], 1); | |
| } | |
| sessApplyWallets(s.wallets); | |
| sessRestoreOrders(U, spawned, uMap, bMap, s.patrols); | |
| if(typeof stats!=='undefined'){ | |
| stats.t=s.t||0; | |
| if(s.kills) stats.kills=s.kills.slice(); | |
| if(s.built) stats.built=s.built.slice(); | |
| if(X){stats.nests=X.nests;stats.reclaimed=X.reclaimed;stats.campaignCache=X.campaignCache;} | |
| } | |
| if(typeof matchClock!=='undefined') matchClock=s.clock||0; | |
| if(typeof AI!=='undefined'&&s.wave){ AI.wave=s.wave.n==null?1:s.wave.n; AI.waveTimer=s.wave.timer==null?60:s.wave.timer; } | |
| /* THE COMMANDER IS WHAT checkVictory KEYS OFF. heroIdx is set once at spawn | |
| and cleared on death; a restore that does not re-point it leaves it at -1 | |
| and the resumed match ends as an instant defeat on the very first tick. | |
| Same for the enemy commanders, or the win condition can never be met. */ | |
| heroIdx=-1; enemyHeroIdx=-1; enemyHeroIdxs.length=0; | |
| for(let i=0;i<unitHigh;i++){ | |
| if(!ualive[i]) continue; | |
| const T=TYPES[utype[i]]; | |
| const isCdr = utype[i]===4 || (T && T.cat==='hero'); | |
| if(!isCdr) continue; | |
| /* The PLAYER seat, not merely team 0. Ally commanders are also team 0 | |
| and cat==="hero", and they spawn at LOWER indices than the player on | |
| a resume - so this first-match scan handed heroIdx to the ALLY, and | |
| from that moment abilities, the HUD, hero XP and the victory check | |
| all pointed at a unit the player does not control. uCmd is captured | |
| (U.cmd) and replayed through spawnUnit above, so the seat is known | |
| here. The fallback keeps a legacy snapshot with no seat data | |
| resumable, which is what it did before. */ | |
| if(uteam[i]===0){ | |
| if(uCmd[i]===POP_PLAYER_SLOT){ if(heroIdx<0||uCmd[heroIdx]!==POP_PLAYER_SLOT) heroIdx=i; } | |
| else if(heroIdx<0) heroIdx=i; // legacy snapshot: better a hero than none | |
| } | |
| else if(uteam[i]===1){ if(enemyHeroIdx<0) enemyHeroIdx=i; enemyHeroIdxs.push(i); } | |
| } | |
| if(heroIdx<0)throw new Error('SESSION_PLAYER_COMMANDER_REPLAY_FAILED'); | |
| /* The first hero-state application restored globals before spawnUnit used | |
| them. Now that the player seat has a new unit index, bind Nano Plating's | |
| exact live max HP to that unit as well. */ | |
| sessApplyHeroState(heroCheck); | |
| sessClear(); | |
| return true; | |
| }catch(e){ window.__mfSessionReject='SESSION_RESTORE_REPLAY_FAILED';sessClear();return false; } | |
| } | |
| /* Snapshot on the two events that actually lose a match: the GPU going away, | |
| and the page being torn down while a fight is live. */ | |
| (function sessInstall(){ | |
| try{ | |
| const cvEl=document.getElementById('gl'); | |
| if(cvEl) cvEl.addEventListener('webglcontextlost',()=>{ 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' | |
| +'<small style="display:block;margin-top:3px;font-size:9px;opacity:.8;letter-spacing:.06em">' | |
| +sessDescribe(s)+'</small>'; | |
| } | |
| function sessResume(){ | |
| const s=sessLoad(); | |
| if(!s){ sessRenderResume(); return; } | |
| try{ | |
| const compatibility=sessLocationPrecheck(s); | |
| if(!compatibility.ok){sessClear();sessRenderResume();return;} | |
| /* Apply only the topology-bearing fields first. If normalization cannot | |
| reproduce the saved key, restore the menu globals before any terrain or | |
| fresh match state is generated. */ | |
| const prior={map:curMap,theme:curTheme,preset:battlefieldPreset,start:playerStartZone, | |
| slots:aiSlots.map(A=>({...A})),setup:typeof META!=='undefined'?META.setup:null}; | |
| const su=s.setup||{}; | |
| if(su.bs&&typeof battlefieldPreset!=='undefined'&&typeof battlefieldPresetKey==='function') battlefieldPreset=battlefieldPresetKey(su.bs); | |
| if(su.ps&&START_ZONES.some(Z=>Z.id===su.ps))playerStartZone=su.ps; | |
| if(Array.isArray(su.ais)&&typeof aiSlots!=='undefined'){ | |
| for(let i=0;i<Math.min(aiSlots.length,su.ais.length);i++){ | |
| const S=su.ais[i]||{}; | |
| aiSlots[i].on=!!S.on; | |
| aiSlots[i].diff=(S.diff|0); | |
| aiSlots[i].ally=!!S.ally; | |
| if(S.zone) aiSlots[i].zone=S.zone; | |
| if(S.behavior&&typeof aiBehaviorKey==='function') aiSlots[i].behavior=aiBehaviorKey(S.behavior); | |
| } | |
| } | |
| if(s.map&&typeof curMap!=='undefined') curMap=s.map; | |
| if(s.theme&&typeof curTheme!=='undefined') curTheme=s.theme; | |
| if(typeof normalizeAiSlotsForBattlefield==='function')normalizeAiSlotsForBattlefield(); | |
| if(compatibility.full&&mfWorldTopologyKey()!==compatibility.location.planner.topologyKey){ | |
| curMap=prior.map;curTheme=prior.theme;battlefieldPreset=prior.preset;playerStartZone=prior.start; | |
| for(let i=0;i<aiSlots.length;i++)Object.assign(aiSlots[i],prior.slots[i]); | |
| if(typeof META!=='undefined')META.setup=prior.setup; | |
| window.__mfSessionReject='SESSION_LOCATION_TOPOLOGY_MISMATCH';sessClear();sessRenderResume();return; | |
| } | |
| /* Static compatibility is established. The remaining setup values do not | |
| participate in site realization and can now become live. */ | |
| if(s.setup&&typeof META!=='undefined') META.setup=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.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(s.goal&&typeof goalSel!=='undefined') goalSel=s.goal; | |
| if(typeof timeLimit!=='undefined') timeLimit=s.timeLimit||0; | |
| if(s.aiFac&&typeof aiFactionSel!=='undefined'){ aiFactionSel=s.aiFac; | |
| if(typeof AI!=='undefined'){ AI.fac=s.aiFac; if(typeof aiFacPicked!=='undefined') aiFacPicked=true; } } | |
| else if(su.f&&typeof aiFactionSel!=='undefined') aiFactionSel=su.f; | |
| if(s.playerFac&&typeof playerFaction!=='undefined'&& | |
| (typeof playableFactions!=='function'||playableFactions().includes(s.playerFac)))playerFaction=s.playerFac; | |
| if(s.playerCommander&&typeof playerCommanderId!=='undefined'&&typeof commanderById==='function'){ | |
| const C=commanderById(s.playerCommander),R=COMMANDER_ROSTERS[playerFaction]||[]; | |
| if(C&&!C.aiOnly&&R.indexOf(C)>=0)playerCommanderId=s.playerCommander; | |
| } | |
| /* Do not require SITE_STAMP here: on a cold reload no world has been | |
| generated yet. newSkirmish() realizes the saved topology below, and | |
| sessRestoreInto() performs the full identity/resource check before it | |
| removes a single fresh unit or structure. */ | |
| sessPending=s; | |
| if(typeof initAudio==='function') initAudio(); | |
| if(typeof sfx==='function') sfx('ui'); | |
| if(typeof hideFrontScreens==='function') hideFrontScreens(); | |
| if(typeof audMusicEnterMatch==='function') audMusicEnterMatch(); | |
| /* 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. */ | |
| if(typeof mfLoadScreenFill==='function') mfLoadScreenFill(); | |
| const load=document.getElementById('loadScr');if(load)load.style.display='flex'; | |
| /* Terrain realization is synchronous and can take seconds. Yield two | |
| frames so the selected-world dossier actually paints instead of jumping | |
| from the menu to a frozen browser frame. Keep restoration itself one | |
| transaction inside the callback; a failure still clears the bad save. */ | |
| requestAnimationFrame(()=>requestAnimationFrame(()=>{ | |
| try{ | |
| if(typeof applyTheme==='function') applyTheme(); | |
| if(typeof newSkirmish==='function') newSkirmish(); | |
| 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(); } | |
| finally{ | |
| if(load)load.style.display='none'; | |
| if(typeof stopAttract==='function')stopAttract(); | |
| if(typeof mfFlowLayout==='function')mfFlowLayout(); | |
| } | |
| })); | |
| }catch(e){ sessClear(); sessRenderResume(); } | |
| } | |