File size: 22,661 Bytes
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502e0c9
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
;
;
/* ============================================================================

   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;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);
    /* 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).toFixed(3), B.lvl|0,
              B.buildPaidM==null?null:+B.buildPaidM.toFixed(3),
              B.buildPaidE==null?null:+B.buildPaidE.toFixed(3),
              bi]);
  }
  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;
}
/* 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);
    }
  }
  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{
    const snap={
      v:1, at:Date.now(), reason:reason||'unknown',
      /* 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(),
    };
    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.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<unitHigh;i++) if(ualive[i]) killUnit(i,true);
    for(const B of blds) if(B.alive&&B.type!=='nest') B.alive=false;
    refreshBldLive();

    const bMap=new Map();
    for(const b of s.blds){
      const [type,team,x,y,hp,prog,lvl,paidM,paidE,oldBi]=b;
      try{
        const B=addBld(type,team,x,y,true);
        if(B){ B.hp=hp; B.prog=prog; B.lvl=lvl||1;
          if(paidM!=null)B.buildPaidM=paidM;if(paidE!=null)B.buildPaidE=paidE;
          if(oldBi!=null) bMap.set(oldBi,blds.length-1); }
      }catch(e){}
    }
    refreshBldLive();

    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);
      spawned.push(i);
      if(i<0) continue;
      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];
    }
    /* 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. */
    if(typeof resM!=='undefined'&&s.resM){ resM[0]=s.resM[0]; if(!s.wallets) resM[1]=s.resM[1]; }
    if(typeof resE!=='undefined'&&s.resE){ resE[0]=s.resE[0]; if(!s.wallets) resE[1]=s.resE[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(typeof matchClock!=='undefined') matchClock=s.clock||0;
    if(typeof AI!=='undefined'&&s.wave){ AI.wave=s.wave.n||1; AI.waveTimer=s.wave.timer||60; }
    /* 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;
      if(uteam[i]===0){ if(heroIdx<0) heroIdx=i; }
      else if(uteam[i]===1){ if(enemyHeroIdx<0) enemyHeroIdx=i; enemyHeroIdxs.push(i); }
    }
    if(heroIdx<0) return (sessClear(),false);   // nothing to resume into
    sessClear();
    return true;
  }catch(e){ 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='◈ &nbsp;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{
    /* 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<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(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;
    }
    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 audMusicEnterMatch==='function') audMusicEnterMatch();
    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(); }
}