CREATORJD commited on
Commit
cf96355
·
verified ·
1 Parent(s): b1a2ba2

v1.32.43 — KEEN voice, mailbox release notes, batch-0 fixes, terrain recovery

Browse files
src/account.js CHANGED
@@ -224,7 +224,12 @@ async function syncPull(){
224
  /* Never overwrite silently. Progress is compared on the one axis a player would
225
  compare it on themselves — how far the career has actually got — and the
226
  choice is theirs whenever both sides have something worth keeping. */
227
- function careerWeight(m){ return (m?(m.xp||0):0)+(m?(m.cores||0):0)*3+(m?(m.researchData||0):0)*8+(m?(m.matches||0):0)*40; }
 
 
 
 
 
228
  function applyIncoming(p,src){
229
  const mine=careerWeight(META), theirs=careerWeight(p.meta);
230
  const use=()=>{
 
224
  /* Never overwrite silently. Progress is compared on the one axis a player would
225
  compare it on themselves — how far the career has actually got — and the
226
  choice is theirs whenever both sides have something worth keeping. */
227
+ /* Cores are deliberately NOT weighed. They are the one term in this sum an
228
+ imported .mfsave can set to anything, and at x3 a forged balance outweighs
229
+ every honest axis combined — so the file that lies the hardest wins the merge
230
+ and overwrites the real career. XP, research and matches all cost time to
231
+ accumulate; a spendable currency is not evidence of progress. */
232
+ function careerWeight(m){ return (m?(m.xp||0):0)+(m?(m.researchData||0):0)*8+(m?(m.matches||0):0)*40; }
233
  function applyIncoming(p,src){
234
  const mine=careerWeight(META), theirs=careerWeight(p.meta);
235
  const use=()=>{
src/assetpack.js CHANGED
@@ -183,17 +183,20 @@ function packRenderBar(){
183
  el.classList.toggle('busy', PACK.state === 'downloading');
184
  if(PACK.state === 'downloading'){
185
  const pct = PACK.total ? Math.round(PACK.got / PACK.total * 100) : 0;
186
- txt.textContent = 'DOWNLOADING SOUNDTRACK';
187
  sub.textContent = packBytes(PACK.got) + ' of ' + packBytes(PACK.total) + ' · ' + pct + '%';
188
  bar.style.width = pct + '%'; btn.textContent = '…'; btn.disabled = true;
189
  el.style.display = '';
190
  } else if(PACK.state === 'offer'){
191
- txt.textContent = 'SOUNDTRACK AVAILABLE';
192
- sub.textContent = packBytes(PACK.total) + ' — faction music, downloaded once';
 
 
 
193
  bar.style.width = '0%'; btn.textContent = 'GET IT'; btn.disabled = false;
194
  el.style.display = '';
195
  } else if(PACK.state === 'error'){
196
- txt.textContent = 'SOUNDTRACK UNAVAILABLE';
197
  sub.textContent = PACK.err;
198
  bar.style.width = '0%'; btn.textContent = 'RETRY'; btn.disabled = false;
199
  el.style.display = '';
@@ -202,13 +205,33 @@ function packRenderBar(){
202
  }
203
  }
204
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  async function packStart(manual){
206
  packPanel();
207
  const idx = PACK.idx || await packLoadIndex();
208
- if(!idx || !idx.music){ PACK.state = 'idle'; packRenderBar(); return; }
209
- const miss = await packMissing('music');
210
- PACK.total = miss.bytes;
211
- if(!miss.files.length){
 
 
 
 
 
 
 
212
  PACK.state = 'ready'; packRenderBar();
213
  if(typeof audAttachPack === 'function') audAttachPack();
214
  return;
@@ -217,7 +240,8 @@ async function packStart(manual){
217
  try{ pref = localStorage.getItem(PACK_PREF) || 'ask'; }catch(e){}
218
  if(!manual && pref !== 'auto'){ PACK.state = 'offer'; packRenderBar(); return; }
219
  try{ localStorage.setItem(PACK_PREF, 'auto'); }catch(e){}
220
- const ok = await packDownload('music');
 
221
  if(ok && typeof audAttachPack === 'function') audAttachPack();
222
  }
223
 
 
183
  el.classList.toggle('busy', PACK.state === 'downloading');
184
  if(PACK.state === 'downloading'){
185
  const pct = PACK.total ? Math.round(PACK.got / PACK.total * 100) : 0;
186
+ txt.textContent = 'DOWNLOADING AUDIO PACK';
187
  sub.textContent = packBytes(PACK.got) + ' of ' + packBytes(PACK.total) + ' · ' + pct + '%';
188
  bar.style.width = pct + '%'; btn.textContent = '…'; btn.disabled = true;
189
  el.style.display = '';
190
  } else if(PACK.state === 'offer'){
191
+ /* No longer soundtrack-only: this panel now covers the voice bank too, and
192
+ telling a player they are downloading music when they are also getting
193
+ every spoken line is the kind of small lie that gets a refund request. */
194
+ txt.textContent = 'AUDIO PACK AVAILABLE';
195
+ sub.textContent = packBytes(PACK.total) + ' — faction music and voices, downloaded once';
196
  bar.style.width = '0%'; btn.textContent = 'GET IT'; btn.disabled = false;
197
  el.style.display = '';
198
  } else if(PACK.state === 'error'){
199
+ txt.textContent = 'AUDIO PACK UNAVAILABLE';
200
  sub.textContent = PACK.err;
201
  bar.style.width = '0%'; btn.textContent = 'RETRY'; btn.disabled = false;
202
  el.style.display = '';
 
205
  }
206
  }
207
 
208
+ /* Every pack this build knows how to consume, in the order it wants them.
209
+ `voice` was published on the channel and then never requested by anything —
210
+ packDownload's only call site asked for 'music' and packStart returned early
211
+ unless idx.music existed — so on a pack-only build the radio and KEEN banks
212
+ were never fetched, packURL('voice', …) always returned null, and voice was
213
+ silent on every device. Generalised over a list so the next pack is data. */
214
+ /* Voice FIRST. Music is 16.1 MB and voice is 5.2, so fetching in the old order
215
+ meant the download prompt quoted 21.3 MB and KEEN stayed mute until the whole
216
+ soundtrack had landed — on a phone, on mobile data, that is most of a session
217
+ of silence from the feature the update is for. Ordered by what the player
218
+ notices missing, not by what was written first. */
219
+ const PACK_WANT = ['voice', 'music'];
220
+
221
  async function packStart(manual){
222
  packPanel();
223
  const idx = PACK.idx || await packLoadIndex();
224
+ if(!idx){ PACK.state = 'idle'; packRenderBar(); return; }
225
+ const want = PACK_WANT.filter(p => idx[p]);
226
+ if(!want.length){ PACK.state = 'idle'; packRenderBar(); return; }
227
+ let bytes = 0;
228
+ const need = [];
229
+ for(const p of want){
230
+ const m = await packMissing(p);
231
+ if(m.files.length){ need.push(p); bytes += m.bytes; }
232
+ }
233
+ PACK.total = bytes;
234
+ if(!need.length){
235
  PACK.state = 'ready'; packRenderBar();
236
  if(typeof audAttachPack === 'function') audAttachPack();
237
  return;
 
240
  try{ pref = localStorage.getItem(PACK_PREF) || 'ask'; }catch(e){}
241
  if(!manual && pref !== 'auto'){ PACK.state = 'offer'; packRenderBar(); return; }
242
  try{ localStorage.setItem(PACK_PREF, 'auto'); }catch(e){}
243
+ let ok = true;
244
+ for(const p of need) ok = (await packDownload(p)) && ok;
245
  if(ok && typeof audAttachPack === 'function') audAttachPack();
246
  }
247
 
src/audio.js CHANGED
@@ -145,10 +145,12 @@ function audUnique(){
145
  async function audLoad(name){
146
  try{
147
  let url = AUD.base + name + '.' + AUD.ext;
148
- /* The radio bank ships as an on-demand pack, exactly like the soundtrack —
149
- 216 clips is not something every installer should carry. If the pack is
150
  stored, play it from IndexedDB; otherwise fall through to the packaged
151
- path, which is what a build whose APK already carries them uses. */
 
 
 
152
  if(name.lastIndexOf('voice/',0)===0 && typeof packURL==='function'){
153
  try{ const u = await packURL('voice', name.slice(6) + '.' + AUD.ext); if(u) url = u; }catch(e){}
154
  }
@@ -202,15 +204,21 @@ async function audLoadSlots(){
202
  }catch(e){}
203
  }
204
 
205
- /* The rendered radio bank. Each line becomes an ordinary AUD_MAP slot, so voice
206
- inherits the whole existing pipeline for free: format selection, decoding,
207
- the sfx bus and its volume slider, ducking, priority culling. AUD_CLEAR keeps
208
- them un-muffled by distance — a radio call arrives over the radio, not from
209
- wherever on the map the unit happens to be standing.
 
210
 
211
- Buffers are NOT fetched here. 108 clips is four factions' worth and a player
212
- only ever hears their own, so voPlay() loads a slot the first time it is
213
- actually asked for and voPrewarm() pulls the player's faction at match start. */
 
 
 
 
 
214
  let VOICE_BANK = null;
215
  async function audLoadVoiceBank(){
216
  try{
@@ -223,13 +231,28 @@ async function audLoadVoiceBank(){
223
  for(const action in j.lines[fac]){
224
  const slot = 'vo_' + fac + '_' + action;
225
  AUD_MAP[slot] = j.lines[fac][action].map(stem => 'voice/' + stem);
226
- AUD_MIX[slot] = { g: 0.9, gap: 1200, p: 1 };
 
 
 
 
227
  AUD_CLEAR.add(slot);
228
  AUD_DUCK.add(slot);
229
  }
230
  }
231
  }catch(e){ VOICE_BANK = null; }
232
  }
 
 
 
 
 
 
 
 
 
 
 
233
  /* True if the slot is playable right now. Kicks off the decode when it is not,
234
  so the very next order in that category speaks. */
235
  function voReady(fac, action){
@@ -240,10 +263,89 @@ function voReady(fac, action){
240
  if(typeof AC !== 'undefined' && AC) list.forEach(f => { if(!AUD.buf[f]) audLoad(f); });
241
  return null;
242
  }
243
- function voPlay(fac, action, wx, wy){
244
- const slot = voReady(fac, action);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  if(!slot) return false;
246
- try{ return sfx(slot, wx, wy, 1) !== false; }catch(e){ return false; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  }
248
  /* Called when a match starts: pull the one faction the player will actually
249
  hear, rather than paying for all four. */
@@ -265,7 +367,16 @@ async function initAudioSamples(){
265
  const sfxFirst = names.filter(n => !n.startsWith('mus_'));
266
  const music = names.filter(n => n.startsWith('mus_'));
267
  await Promise.all(sfxFirst.map(audLoad));
268
- AUD.ready = Object.keys(AUD.buf).length > 0;
 
 
 
 
 
 
 
 
 
269
  await audLoadPlaylists();
270
  /* The title/menu has no match state, so it cannot wait for combat's frame
271
  intensity to start the first track. Once a user gesture created the audio
@@ -277,9 +388,19 @@ async function initAudioSamples(){
277
  if(!audPlaylistFor()) music.forEach(audLoad);
278
  }
279
 
280
- function audPick(name){
 
 
 
 
281
  const list = AUD_MAP[name];
282
  if(!list) return null;
 
 
 
 
 
 
283
  const avail = list.filter(f => AUD.buf[f]);
284
  if(!avail.length) return null;
285
  return AUD.buf[avail[(Math.random() * avail.length) | 0]];
@@ -302,19 +423,48 @@ function audMakeRoom(priority){
302
  try{ low.src.stop(); }catch(e){}
303
  return true;
304
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
  function audSlotHasRoom(name){
306
  const cap=AUD_CAP[name]||3;
 
307
  let n=0;
308
- /* RADIO IS ONE CHANNEL. The cap was per-NAME, so three different vo_ lines
309
- could play at once — a burst of orders fired select, move and attack acks
310
- simultaneously and the result was unintelligible. Any vo_ voice now blocks
311
- every other vo_ voice; sfxSample treats a full slot as handled, so the
312
- extra line is dropped rather than queued or errored. */
313
- const vo=name.lastIndexOf('vo_',0)===0;
314
- for(const v of AUD.active)
315
- if(!v.done&&(vo?v.name.lastIndexOf('vo_',0)===0:v.name===name)&&++n>=(vo?1:cap)) return false;
316
  return true;
317
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  function audMusicGain(v){
319
  return v*audMusicLevel()*(performance.now()<AUD.duckUntil?0.58:1);
320
  }
@@ -397,9 +547,9 @@ function artilleryWorldAudio(kind,wx,wy,team,scale){
397
  }
398
 
399
  /* The replacement for hud.js's sfx(). Same signature, same call sites. */
400
- function sfxSample(name, wx, wy, scale){
401
  if(!AC || muted || (typeof sfxOn!=='undefined'&&!sfxOn) || !AUD.ready) return false;
402
- const buf = audPick(name);
403
  if(!buf) return false;
404
 
405
  const mix = AUD_MIX[name] || {g:0.6, gap:40, p:2};
@@ -411,6 +561,7 @@ function sfxSample(name, wx, wy, scale){
411
  const pan=spatial.pan,dist=spatial.gain;
412
  /* Cull in world space before touching the voice pool. An off-screen alarm
413
  must not evict an on-screen cannon simply because alarms have priority. */
 
414
  if(!audSlotHasRoom(name)) return true;
415
  if(!audMakeRoom(mix.p||2)) return true;
416
  AUD.lastAt[name] = now;
@@ -446,7 +597,14 @@ function sfxSample(name, wx, wy, scale){
446
  AUD.active.push(voice); AUD.voices++;
447
  src.onended = () => { audRetireVoice(voice); };
448
  try{ src.start(); }catch(e){ audRetireVoice(voice); return false; }
449
- if(AUD_DUCK.has(name)) AUD.duckUntil=Math.max(AUD.duckUntil,now+650);
 
 
 
 
 
 
 
450
  return true;
451
  }
452
 
@@ -490,8 +648,14 @@ function audMusicTick(dt){
490
  g.gain.setTargetAtTime(1, AC.currentTime, 1.6);
491
  audMusSrc = s; audMusName = want;
492
  }
 
 
 
 
 
493
  const target = audMusicGain(0.30 + (typeof musicInt === 'number' ? musicInt : 0) * 0.16);
494
- audMusBus.gain.setTargetAtTime(target, AC.currentTime, 0.8);
 
495
  }
496
 
497
  /* ---- TAKEOVER --------------------------------------------------------------
@@ -528,12 +692,18 @@ function initSampleAudio(){
528
  },{passive:true});
529
  }
530
 
531
- sfx = function(name, wx, wy, scale){
532
  /* The shipped bank is dual-codec, so every supported device has a sample
533
  path. Do not expose the old oscillator/chiptune bank while decoding or
534
  for a missing slot: a brief silence is preferable to retro arcade audio
535
- in a cinematic RTS mix. Keep the binding only for legacy diagnostics. */
536
- sfxSample(name, wx, wy, scale);
 
 
 
 
 
 
537
  };
538
 
539
  if(synthMusic){
 
145
  async function audLoad(name){
146
  try{
147
  let url = AUD.base + name + '.' + AUD.ext;
148
+ /* The voice bank also ships on the optional pack channel. If the pack is
 
149
  stored, play it from IndexedDB; otherwise fall through to the packaged
150
+ path under assets/audio/voice/, which is where the bank now lives in the
151
+ repo — so a build that never fetched a pack still speaks. Both formats are
152
+ reachable either way: AUD.ext is chosen by asking the browser what it can
153
+ decode, never by hard-coding one container. */
154
  if(name.lastIndexOf('voice/',0)===0 && typeof packURL==='function'){
155
  try{ const u = await packURL('voice', name.slice(6) + '.' + AUD.ext); if(u) url = u; }catch(e){}
156
  }
 
204
  }catch(e){}
205
  }
206
 
207
+ /* The rendered voice bank — four factions of unit radio plus KEEN, the training
208
+ liaison. Each line becomes an ordinary AUD_MAP slot, so voice inherits the
209
+ whole existing pipeline for free: format selection, decoding, the sfx bus and
210
+ its volume slider, ducking, priority culling. AUD_CLEAR keeps them un-muffled
211
+ by distance — a radio call arrives over the radio, not from wherever on the
212
+ map the unit happens to be standing.
213
 
214
+ Nothing here is speaker-specific: the loop walks whatever `lines` the manifest
215
+ declares, so adding a speaker is a data change. KEEN's 54 authored ids arrive
216
+ through exactly this path.
217
+
218
+ Buffers are NOT fetched here. 162 takes is four factions plus a narrator and a
219
+ player only ever hears one faction, so voPlay() loads a slot the first time it
220
+ is actually asked for and voPrewarm() pulls the player's faction at match
221
+ start. */
222
  let VOICE_BANK = null;
223
  async function audLoadVoiceBank(){
224
  try{
 
231
  for(const action in j.lines[fac]){
232
  const slot = 'vo_' + fac + '_' + action;
233
  AUD_MAP[slot] = j.lines[fac][action].map(stem => 'voice/' + stem);
234
+ /* Speech is the most information-dense thing in the mix and there are at
235
+ most two of it (see audVoiceChannel). Priority 1 put it at the bottom
236
+ of audMakeRoom's eviction order, so the moment a firefight filled the
237
+ 22-voice pool the tutorial narration was the first thing thrown out. */
238
+ AUD_MIX[slot] = { g: 0.9, gap: 1200, p: 5 };
239
  AUD_CLEAR.add(slot);
240
  AUD_DUCK.add(slot);
241
  }
242
  }
243
  }catch(e){ VOICE_BANK = null; }
244
  }
245
+ /* True if the MANIFEST carries a take for this line, decoded or not. Separate
246
+ from voReady on purpose: "is there a recording of this" and "can it start on
247
+ this exact millisecond" are different questions, and answering the first with
248
+ the second is what made every caller conclude there was no voice at all
249
+ during the whole of a cold start. */
250
+ function voHas(fac, action){
251
+ if(!fac || !action) return null;
252
+ const slot = 'vo_' + fac + '_' + action;
253
+ const list = AUD_MAP[slot];
254
+ return (list && list.length) ? slot : null;
255
+ }
256
  /* True if the slot is playable right now. Kicks off the decode when it is not,
257
  so the very next order in that category speaks. */
258
  function voReady(fac, action){
 
263
  if(typeof AC !== 'undefined' && AC) list.forEach(f => { if(!AUD.buf[f]) audLoad(f); });
264
  return null;
265
  }
266
+ /* Duration of the take a slot would play, in seconds, straight out of the
267
+ manifest — available before anything is decoded. Used to hold the music duck
268
+ open for the length of a narration line rather than a fixed 650 ms. */
269
+ function voSeconds(slot){
270
+ if(!VOICE_BANK || !VOICE_BANK.takes) return 0;
271
+ const list = AUD_MAP[slot];
272
+ if(!list || !list.length) return 0;
273
+ let s = 0;
274
+ for(const f of list){
275
+ const t = VOICE_BANK.takes[f.lastIndexOf('voice/',0)===0 ? f.slice(6) : f];
276
+ if(t && t.seconds > s) s = t.seconds;
277
+ }
278
+ return s;
279
+ }
280
+ /* KEEN alone is 345 seconds of speech. Decoded to float PCM at the context rate
281
+ that is ~66 MB resident if every line the tutorial plays is kept — the exact
282
+ arithmetic the playlist comment below refuses to pay for a soundtrack. Voice
283
+ buffers are therefore released once the bank grows past a working set; a
284
+ recall inside the window is still instant, and anything older simply decodes
285
+ again the next time it is asked for.
286
+
287
+ Budgeted in SECONDS, not files: a faction's whole radio vocabulary is 27 clips
288
+ of about two seconds and must stay resident, while three KEEN narration lines
289
+ are the same memory. 90 s is roughly 17 MB of mono float at 48 kHz. */
290
+ const VO_BUDGET_SEC = 90;
291
+ const voSeen = [];
292
+ function voFileSeconds(f){
293
+ if(!VOICE_BANK || !VOICE_BANK.takes) return 2;
294
+ const t = VOICE_BANK.takes[f.lastIndexOf('voice/',0)===0 ? f.slice(6) : f];
295
+ return (t && t.seconds) || 2;
296
+ }
297
+ function voTouch(files){
298
+ for(const f of files){
299
+ const i = voSeen.indexOf(f);
300
+ if(i >= 0) voSeen.splice(i, 1);
301
+ voSeen.push(f);
302
+ }
303
+ let held = 0;
304
+ for(const f of voSeen) held += voFileSeconds(f);
305
+ while(voSeen.length > 1 && held > VO_BUDGET_SEC){
306
+ const old = voSeen.shift();
307
+ if(AUD.active.some(v => !v.done && AUD_MAP[v.name] && AUD_MAP[v.name].indexOf(old) >= 0)){
308
+ voSeen.push(old); break; // still sounding: keep it, try later
309
+ }
310
+ held -= voFileSeconds(old);
311
+ delete AUD.buf[old];
312
+ }
313
+ }
314
+ /* Play a rendered line.
315
+
316
+ Returns TRUE when this call has taken responsibility for the line — either it
317
+ started, or the decode is in flight and playback/miss is scheduled. Returns
318
+ FALSE only when no take exists at all, which is the caller's cue to fall back
319
+ to synthesis IMMEDIATELY. `onMiss` fires when a take existed but could not be
320
+ produced (fetch 404, decode reject), which is the deferred version of the same
321
+ cue. Reporting false the instant a slot was merely undecoded is what pushed
322
+ every first airing — and for KEEN, every airing — onto speechSynthesis. */
323
+ function voPlay(fac, action, wx, wy, idx, onMiss){
324
+ const slot = voHas(fac, action);
325
  if(!slot) return false;
326
+ const list = AUD_MAP[slot];
327
+ const want = (typeof idx === 'number' && idx >= 0) ? list[(idx | 0) % list.length] : null;
328
+ if(want ? !!AUD.buf[want] : list.some(f => AUD.buf[f])){
329
+ voTouch(list.filter(f => AUD.buf[f]));
330
+ try{ if(sfx(slot, wx, wy, 1, idx) !== false) return true; }catch(e){}
331
+ return false;
332
+ }
333
+ if(typeof AC === 'undefined' || !AC) return false;
334
+ const t0 = performance.now();
335
+ Promise.all(list.map(f => AUD.buf[f] ? null : audLoad(f))).then(() => {
336
+ const got = list.filter(f => AUD.buf[f]);
337
+ /* A take that arrives after the bubble it belonged to has gone is worse than
338
+ silence — it talks over the next one. Past the deadline, drop it; the
339
+ buffer stays decoded so the same line is instant if it comes round again. */
340
+ if(!got.length || performance.now() - t0 > 6000){
341
+ if(!got.length && typeof onMiss === 'function') onMiss();
342
+ return;
343
+ }
344
+ voTouch(got);
345
+ try{ if(sfx(slot, wx, wy, 1, idx) === false && typeof onMiss === 'function') onMiss(); }
346
+ catch(e){ if(typeof onMiss === 'function') onMiss(); }
347
+ });
348
+ return true;
349
  }
350
  /* Called when a match starts: pull the one faction the player will actually
351
  hear, rather than paying for all four. */
 
367
  const sfxFirst = names.filter(n => !n.startsWith('mus_'));
368
  const music = names.filter(n => n.startsWith('mus_'));
369
  await Promise.all(sfxFirst.map(audLoad));
370
+ /* THE SFX BANK AND THE VOICE BANK SHIP SEPARATELY, so the engine must not be
371
+ declared dead when only one of them arrived. assets/audio/sfx.json and its
372
+ clips are not in this repo (see AUD_MAP's floor above); the voice bank is.
373
+ Gating readiness on "at least one SFX buffer decoded" therefore left
374
+ sfxSample() returning false on its very first line for every caller — and
375
+ voice runs through sfxSample, so a build with a complete voice bank sitting
376
+ on disk still spoke nothing at all. A decoded-on-demand voice bank is a
377
+ usable sample engine; slots with no file behind them still decline
378
+ individually at audPick(). */
379
+ AUD.ready = Object.keys(AUD.buf).length > 0 || !!(VOICE_BANK && VOICE_BANK.lines);
380
  await audLoadPlaylists();
381
  /* The title/menu has no match state, so it cannot wait for combat's frame
382
  intensity to start the first track. Once a user gesture created the audio
 
388
  if(!audPlaylistFor()) music.forEach(audLoad);
389
  }
390
 
391
+ /* `pickIdx` selects a SPECIFIC take instead of a random one. radioAck prints one
392
+ of three lines and then asked for a random recording of the three, so the
393
+ printed line and the spoken line disagreed about two times in three. Every
394
+ existing caller passes nothing and keeps the random behaviour byte for byte. */
395
+ function audPick(name, pickIdx){
396
  const list = AUD_MAP[name];
397
  if(!list) return null;
398
+ /* Indexed against the FULL list, not the decoded subset — indexing the subset
399
+ would silently shift which line you hear as buffers come and go. */
400
+ if(typeof pickIdx === 'number' && pickIdx >= 0){
401
+ const want = list[(pickIdx | 0) % list.length];
402
+ if(AUD.buf[want]) return AUD.buf[want];
403
+ }
404
  const avail = list.filter(f => AUD.buf[f]);
405
  if(!avail.length) return null;
406
  return AUD.buf[avail[(Math.random() * avail.length) | 0]];
 
423
  try{ low.src.stop(); }catch(e){}
424
  return true;
425
  }
426
+ function audVoiceChannel(name){
427
+ if(name.lastIndexOf('vo_',0)!==0) return null;
428
+ return name.lastIndexOf('vo_keen_',0)===0 ? 'keen' : 'radio';
429
+ }
430
+ /* TWO VOICE CHANNELS, each still capped at one.
431
+
432
+ The cap was originally per-NAME, so three different vo_ lines could play at
433
+ once — a burst of orders fired select, move and attack acks simultaneously and
434
+ the result was unintelligible. Collapsing every vo_ line onto a single channel
435
+ fixed that and created a worse bug: KEEN's narration is 3-15 seconds long and
436
+ unit acks fire on every order the tutorial is asking the player to perform, so
437
+ the tutorial voice lost that race by construction — and lost it SILENTLY,
438
+ because sfxSample treats a full slot as handled and returns true, which
439
+ suppresses the fallback too.
440
+
441
+ KEEN is a different character on a different net. Unit radio can no longer
442
+ block her and she can no longer block it; neither can stack on itself. */
443
  function audSlotHasRoom(name){
444
  const cap=AUD_CAP[name]||3;
445
+ const ch=audVoiceChannel(name);
446
  let n=0;
447
+ for(const v of AUD.active){
448
+ if(v.done) continue;
449
+ const hit = ch ? (audVoiceChannel(v.name)===ch) : (v.name===name);
450
+ if(hit && ++n >= (ch?1:cap)) return false;
451
+ }
 
 
 
452
  return true;
453
  }
454
+ /* A narrator interrupts herself; she does not queue. The KEEN channel holds one
455
+ line, her lines run up to 15 seconds, and the bubble advances every 2.4-6.5 s,
456
+ so without this the second and every subsequent line of a run is dropped and
457
+ the player watches text they never hear. The fallback path does exactly this
458
+ already — speakVoiceFallback calls speechSynthesis.cancel() first. Unit radio
459
+ keeps the old drop behaviour: a formation drag must not machine-gun acks. */
460
+ function audVoiceYield(name){
461
+ if(audVoiceChannel(name)!=='keen') return;
462
+ for(const v of AUD.active.slice()){
463
+ if(v.done||audVoiceChannel(v.name)!=='keen') continue;
464
+ audRetireVoice(v);
465
+ try{ v.src.stop(); }catch(e){}
466
+ }
467
+ }
468
  function audMusicGain(v){
469
  return v*audMusicLevel()*(performance.now()<AUD.duckUntil?0.58:1);
470
  }
 
547
  }
548
 
549
  /* The replacement for hud.js's sfx(). Same signature, same call sites. */
550
+ function sfxSample(name, wx, wy, scale, pickIdx){
551
  if(!AC || muted || (typeof sfxOn!=='undefined'&&!sfxOn) || !AUD.ready) return false;
552
+ const buf = audPick(name, pickIdx);
553
  if(!buf) return false;
554
 
555
  const mix = AUD_MIX[name] || {g:0.6, gap:40, p:2};
 
561
  const pan=spatial.pan,dist=spatial.gain;
562
  /* Cull in world space before touching the voice pool. An off-screen alarm
563
  must not evict an on-screen cannon simply because alarms have priority. */
564
+ audVoiceYield(name);
565
  if(!audSlotHasRoom(name)) return true;
566
  if(!audMakeRoom(mix.p||2)) return true;
567
  AUD.lastAt[name] = now;
 
597
  AUD.active.push(voice); AUD.voices++;
598
  src.onended = () => { audRetireVoice(voice); };
599
  try{ src.start(); }catch(e){ audRetireVoice(voice); return false; }
600
+ /* 650 ms is right for an explosion and wrong for speech: a KEEN line runs 3-15
601
+ seconds, so the music came back up over the top of her within the first
602
+ sentence. A voice line holds the duck open for as long as it is actually
603
+ talking, measured from the decoded buffer rather than assumed. */
604
+ if(AUD_DUCK.has(name)){
605
+ const hold = (name.lastIndexOf('vo_',0)===0 && buf.duration) ? buf.duration*1000+240 : 650;
606
+ AUD.duckUntil=Math.max(AUD.duckUntil,now+hold);
607
+ }
608
  return true;
609
  }
610
 
 
648
  g.gain.setTargetAtTime(1, AC.currentTime, 1.6);
649
  audMusSrc = s; audMusName = want;
650
  }
651
+ /* Same duck arithmetic as audPlaylistTick: at tau = 0.8 s the bus travels only
652
+ a fraction of the way to the ducked target before the duck expires, so the
653
+ dip never reaches the depth AUD_DUCK's 0.58 asks for. Fast attack while
654
+ ducked, slow release after — otherwise nothing cuts through the bed and the
655
+ bundled-bed build stays exactly as deaf as before. */
656
  const target = audMusicGain(0.30 + (typeof musicInt === 'number' ? musicInt : 0) * 0.16);
657
+ audMusBus.gain.setTargetAtTime(target, AC.currentTime,
658
+ performance.now() < AUD.duckUntil ? 0.06 : 0.8);
659
  }
660
 
661
  /* ---- TAKEOVER --------------------------------------------------------------
 
692
  },{passive:true});
693
  }
694
 
695
+ sfx = function(name, wx, wy, scale, pickIdx){
696
  /* The shipped bank is dual-codec, so every supported device has a sample
697
  path. Do not expose the old oscillator/chiptune bank while decoding or
698
  for a missing slot: a brief silence is preferable to retro arcade audio
699
+ in a cinematic RTS mix. Keep the binding only for legacy diagnostics.
700
+
701
+ RETURN THE RESULT. Dropping it made sfx() yield undefined for every
702
+ caller, and voPlay's `sfx(...) !== false` was therefore unconditionally
703
+ true — a declined or impossible voice line reported success and
704
+ suppressed its own fallback. voPlay is the only call site in the tree
705
+ that reads this value, so returning it changes nothing else. */
706
+ return sfxSample(name, wx, wy, scale, pickIdx);
707
  };
708
 
709
  if(synthMusic){
src/authportal.js CHANGED
@@ -196,9 +196,18 @@ function apErrorText(e){
196
 
197
  /* ---- account actions ---------------------------------------------------------- */
198
  function apSetSessionFrom(data, email){
199
- AP_SESSION = { token: data.token, email: (data.user && data.user.email) || email,
200
- username: (data.user && data.user.username) || null,
201
- ageOk: !!(data.user && data.user.ageOk),
 
 
 
 
 
 
 
 
 
202
  expiresAt: data.expiresAt || 0 };
203
  AP_SYNC_KIND = 'idle';
204
  AP_SYNC_MESSAGE = '';
 
196
 
197
  /* ---- account actions ---------------------------------------------------------- */
198
  function apSetSessionFrom(data, email){
199
+ const u = data.user || null;
200
+ AP_SESSION = { token: data.token, email: (u && u.email) || email,
201
+ username: (u && u.username) || null,
202
+ /* A MISSING ageOk means the server did not say — not that the
203
+ player answered and failed. Coercing absence to false is why
204
+ a perfectly good signed-in account meets the ONE-TIME AGE
205
+ CHECK panel on every single sign-in, and the CONFIRM button
206
+ behind it posts to a route the deployed worker does not
207
+ have. Only `false` should nag; `null` is "unknown, carry on".
208
+ Line 256 already gets this right with an `in` test — the two
209
+ paths into the same field disagreed. */
210
+ ageOk: (u && 'ageOk' in u) ? !!u.ageOk : null,
211
  expiresAt: data.expiresAt || 0 };
212
  AP_SYNC_KIND = 'idle';
213
  AP_SYNC_MESSAGE = '';
src/engine/terrain.js CHANGED
@@ -32,6 +32,18 @@ let waterMesh=null, waterVAO=null, waterVBO=null, waterIBO=null, waterIdxCount=0
32
  pathfinding, fog sensors, placement or deformation. */
33
  const TERR_EDGE_EXT=960;
34
  let terrEdgeVAO=null, terrEdgeVBO=null, terrEdgeIBO=null, terrEdgeIdxCount=0;
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
  /* World height in world units. Bilinear so slopes are smooth and the camera
37
  ray-march in s2w() converges instead of stair-stepping. */
 
32
  pathfinding, fog sensors, placement or deformation. */
33
  const TERR_EDGE_EXT=960;
34
  let terrEdgeVAO=null, terrEdgeVBO=null, terrEdgeIBO=null, terrEdgeIdxCount=0;
35
+ /* CONTEXT-LOSS RESET. Every builder above caches its VAO/VBO in a module
36
+ variable and takes an if(!vao) create-else-update branch. After a context
37
+ loss those handles still LOOK truthy but point at dead GL objects, so the
38
+ rebuild path poured fresh vertices into dead buffers and the ground simply
39
+ never drew again - the exact match-killing symptom on device: units,
40
+ structures and boundary intact, terrain a flat fog-coloured void. Recovery
41
+ must forget the handles so the builders genuinely re-create. */
42
+ function terrainGLReset(){
43
+ terrVAO=terrVBO=terrIBO=null;
44
+ terrEdgeVAO=terrEdgeVBO=terrEdgeIBO=null;
45
+ waterVAO=waterVBO=waterIBO=null;
46
+ }
47
 
48
  /* World height in world units. Bilinear so slopes are smooth and the camera
49
  ray-march in s2w() converges instead of stair-stepping. */
src/game/meta.js CHANGED
@@ -835,7 +835,7 @@ function mfSetTabs(root,key,focus){
835
  deployment plan in sync with the Weekly contract being inspected. */
836
  try{ root.dispatchEvent(new CustomEvent('mftabchange',{detail:{key:active,focus:!!focus}})); }catch(e){}
837
  if(focus){
838
- const scroller=root.querySelector('.settingsScroll,.profileScroll,.opsScroll,.dailyScroll');
839
  if(scroller) scroller.scrollTop=0;
840
  const b=tabs.find(x=>x.dataset.mfTab===active);
841
  if(b) try{ b.focus({preventScroll:true}); }catch(e){ b.focus(); }
@@ -1051,8 +1051,8 @@ function renderSettings(){
1051
  +cyc('menubg','Menu Backdrop',BGD[bg],BGL[bg],bg!=='off'));
1052
 
1053
  h+=group('command','COMMAND INTERFACE','Planning aids for formations, platoons, and patrol routes.',
1054
- tog('formationPreview','Formation Holograms','Show one placement marker for every selected unit before issuing a formation order')
1055
- +tog('orderPaths','Waypoint & Patrol Cues','Show numbered nodes, direction pulses, and committed patrol loops'));
1056
  h+='<section class="setGroup mfTabPanel" id="setExtras" role="tabpanel" aria-labelledby="setTab-system" data-mf-panel="system"><div class="setGroupHd">SERVICES & ACCESSIBILITY</div>'
1057
  +'<div class="setGroupDs">Tutorial, offline, connectivity, and optional world systems.</div><div id="setExtraRows"></div><div class="setEmpty">No additional services are configured.</div></section>';
1058
  list.innerHTML=h;
 
835
  deployment plan in sync with the Weekly contract being inspected. */
836
  try{ root.dispatchEvent(new CustomEvent('mftabchange',{detail:{key:active,focus:!!focus}})); }catch(e){}
837
  if(focus){
838
+ const scroller=root.querySelector('.settingsScroll,.profileScroll,.opsScroll,.dailyScroll,.inboxScroll');
839
  if(scroller) scroller.scrollTop=0;
840
  const b=tabs.find(x=>x.dataset.mfTab===active);
841
  if(b) try{ b.focus({preventScroll:true}); }catch(e){ b.focus(); }
 
1051
  +cyc('menubg','Menu Backdrop',BGD[bg],BGL[bg],bg!=='off'));
1052
 
1053
  h+=group('command','COMMAND INTERFACE','Planning aids for formations, platoons, and patrol routes.',
1054
+ tog('formationPreview','Formation Preview','Outline where the platoon will end up, on every move order, before it commits')
1055
+ +tog('orderPaths','Route Arrows & Patrol Cues','Flashing arrows along the real pathfinding route, plus numbered waypoints and committed patrol loops'));
1056
  h+='<section class="setGroup mfTabPanel" id="setExtras" role="tabpanel" aria-labelledby="setTab-system" data-mf-panel="system"><div class="setGroupHd">SERVICES & ACCESSIBILITY</div>'
1057
  +'<div class="setGroupDs">Tutorial, offline, connectivity, and optional world systems.</div><div id="setExtraRows"></div><div class="setEmpty">No additional services are configured.</div></section>';
1058
  list.innerHTML=h;
src/glrecover.js CHANGED
@@ -54,6 +54,13 @@ function glrQualityDown(){
54
  or stack a second set of deposits on top of the first. */
55
  function glrRebuildResources(){
56
  const step=(name,fn)=>{ try{ fn(); }catch(e){ console.warn('glrecover: '+name+' failed',e); throw e; } };
 
 
 
 
 
 
 
57
  step('initGL3D', ()=>initGL3D());
58
  step('buildMatAtlas', ()=>buildMatAtlas());
59
  step('initBillboards',()=>{ if(typeof initBillboards==='function') initBillboards(); });
 
54
  or stack a second set of deposits on top of the first. */
55
  function glrRebuildResources(){
56
  const step=(name,fn)=>{ try{ fn(); }catch(e){ console.warn('glrecover: '+name+' failed',e); throw e; } };
57
+ /* FORGET THE DEAD HANDLES FIRST. Builders that cache their VAO/texture in a
58
+ module variable branch on if(!handle) - after a loss the handle is truthy
59
+ but dead, so without this the terrain rebuild poured vertices into dead
60
+ buffers and the ground stayed a fog-coloured void for the rest of the
61
+ match (seen on device at 11:37: units and structures back, terrain gone). */
62
+ step('terrainGLReset',()=>{ if(typeof terrainGLReset==='function') terrainGLReset(); });
63
+ step('fogGLReset', ()=>{ if(typeof fogGLReset==='function') fogGLReset(); });
64
  step('initGL3D', ()=>initGL3D());
65
  step('buildMatAtlas', ()=>buildMatAtlas());
66
  step('initBillboards',()=>{ if(typeof initBillboards==='function') initBillboards(); });
src/main.js CHANGED
@@ -303,11 +303,15 @@ function newSkirmish(){
303
  const pxs=PS.x, pys=PS.y;
304
  matchLive=false;
305
  carrier.active=true; carrier.phase=0; carrier.alt=980; carrier.clearance=0;
306
- /* Nova is the current playable army. The query override is deliberately QA-
307
- only so every registered deployer can be inspected without implying that a
308
- not-yet-playable faction has been selected in progression. */
 
 
 
309
  const carrierFacQa=(location.search.match(/[?&]carrierfac=(nova|terran|frontline|federation|legion|ascendancy|red|syndicate|machine|coalition|horde|brood|swarm|infestation)/)||[])[1]||'';
310
- carrier.fac=typeof dropFactionKey==='function'?dropFactionKey(carrierFacQa||'nova'):'nova';
 
311
  carrier.x=pxs; carrier.y=pys; carrier.tx=pxs; carrier.ty=pys; carrier.ang=0; carrier.dust=0;
312
  // AI start (mirrored)
313
  /* The enemy fields its FACTION'S hero, not a mirror of yours. The Legion
@@ -319,6 +323,10 @@ function newSkirmish(){
319
  avoids keeping three unused structure libraries resident on phones. */
320
  if(typeof ensureBldFactionMeshes==='function'){
321
  ensureBldFactionMeshes(AI.fac);
 
 
 
 
322
  /* Neutral nests use Horde architecture even when another faction owns the
323
  enemy base. Upload it during the carrier/deployment phase, where a short
324
  allocation is invisible, instead of hitching the first frame a nest
@@ -420,7 +428,15 @@ function deployCarrier(){
420
  down yourself, and it should be yours from the first structure. The HQ
421
  generates enough on its own to fund that first move. */
422
  const hq=addBld('hq',0,cx2,cy2,true);
423
- heroIdx=spawnUnit(4,0,cx2+52,cy2+44);
 
 
 
 
 
 
 
 
424
  spawnUnit(UT_ENGINEER,0,cx2-52,cy2+44);
425
  AI.base.x=AI.base.x; // (AI base already placed)
426
  matchLive=true; stats.t=0; matchClock=timeLimit;
@@ -980,6 +996,12 @@ function nativeLayerVisible(id){
980
  function nativeBackTap(id){
981
  const el=$(id);if(!el)return false;
982
  if(typeof MF_POINTER_COMMIT!=='undefined') MF_POINTER_COMMIT=-1e9;
 
 
 
 
 
 
983
  el.click();
984
  return true;
985
  }
@@ -1251,10 +1273,18 @@ function wire(){
1251
  mfBindTap($('profBack'),()=>{
1252
  renderMetaHead(); sfx('ui'); showFrontScreen('startScreen');
1253
  });
1254
- $('profName').addEventListener('change',()=>{
 
 
 
 
 
 
1255
  const P=activeProf(), v=$('profName').value.trim();
1256
- if(v){ P.name=v.slice(0,14); profSave(); renderMetaHead(); renderProfile(); }
1257
- });
 
 
1258
  $('profNew').addEventListener('pointerdown',()=>{
1259
  if(PROFILES.list.length>=6){ toast('Profile limit reached (6)'); return; }
1260
  const id='p'+(++PROFILES.seq);
@@ -1464,7 +1494,14 @@ function boot(){
1464
  if(used.has(A.zone)) A.zone=START_ZONES.find(z=>!used.has(z.id)).id;
1465
  used.add(A.zone);
1466
  }
1467
- if(su.pf&&typeof FACTIONS!=='undefined'&&FACTIONS[su.pf]) playerFaction=su.pf;
 
 
 
 
 
 
 
1468
  if(su.df!=null) defenseFocus=su.df?1:0;
1469
  difficulty=Math.max(...activeAiSlots().map(A=>A.diff));
1470
  document.querySelectorAll('.glbtn').forEach(b=>b.classList.toggle('on',b.dataset.g===goalSel));
@@ -1512,24 +1549,12 @@ function boot(){
1512
  const showScr=id=>{ showFrontScreen(id); sfx('ui'); };
1513
  mfBindTap($('dailyBtn'),()=>{ renderDaily(); showScr('dailyScr'); });
1514
  mfBindTap($('dailyBack'),()=>{ showFrontScreen('startScreen'); renderMetaHead(); sfx('ui'); });
 
 
1515
  mfBindTap($('dossierBtn'),()=>{
1516
- renderDossier();
1517
  if(typeof renderCodex==='function') renderCodex();
1518
  showScr('dossierScr');
1519
  });
1520
- /* Dispatches and Factions share one screen; two panes, one tab row. */
1521
- document.querySelectorAll('#dossTabs .tabBtn').forEach(b=>{
1522
- mfBindTap(b,()=>{
1523
- document.querySelectorAll('#dossTabs .tabBtn').forEach(x=>x.classList.remove('on'));
1524
- b.classList.add('on');
1525
- const fac=b.dataset.dt==='fac';
1526
- const d=$('dossPaneDisp'), c=$('codexList');
1527
- if(d) d.style.display=fac?'none':'';
1528
- if(c) c.style.display=fac?'':'none';
1529
- const s=$('dossScroll'); if(s) s.scrollTop=0;
1530
- sfx('ui');
1531
- });
1532
- });
1533
  mfBindTap($('opsBtn'),()=>{ renderOps(); showScr('opsScr'); });
1534
  mfBindTap($('devBtn'),()=>{ renderDevelop(); showScr('devScr'); });
1535
  mfBindTap($('devBack'),()=>{ showFrontScreen('startScreen'); renderMetaHead(); sfx('ui'); });
@@ -1542,8 +1567,17 @@ function boot(){
1542
  if(wkB) wkB.addEventListener('pointerdown',()=>{ renderOps(); showScr('opsScr');
1543
  setTimeout(()=>{ const w=$('weeklyBox'); if(w) w.scrollIntoView({behavior:'smooth',block:'center'}); },120); });
1544
  mfBindTap($('dossBack'),()=>{ showFrontScreen('startScreen'); sfx('ui'); });
 
 
 
 
 
 
1545
  mfBindTap($('dispOk'),()=>{ $('dispatch').style.display='none';
1546
- if(typeof storyRefreshBadge==='function') storyRefreshBadge(); sfx('ui'); });
 
 
 
1547
  /* New subsystems. Each lives in its own module and registers its own UI, so
1548
  none of them need to touch index.html or this file beyond this line. */
1549
  for(const fn of ['initOffline','initSampleAudio','initAssetPacks','initAuthPortal','initTutorial','initAdBoards','initStoreUI','initResTree3D','initEconomyNet','initIntro'])
 
303
  const pxs=PS.x, pys=PS.y;
304
  matchLive=false;
305
  carrier.active=true; carrier.phase=0; carrier.alt=980; carrier.clearance=0;
306
+ /* The drop is the FIRST thing anyone sees of their own army, and it was
307
+ always Nova — "Nova is the current playable army" stopped being true once
308
+ the faction picker went into battle setup. It now arrives in the colours
309
+ and hardware of whatever the player actually chose. The query override
310
+ stays, ahead of the choice, so QA can still inspect any registered
311
+ deployer without touching progression. */
312
  const carrierFacQa=(location.search.match(/[?&]carrierfac=(nova|terran|frontline|federation|legion|ascendancy|red|syndicate|machine|coalition|horde|brood|swarm|infestation)/)||[])[1]||'';
313
+ const carrierFacOwn=(typeof playerKitKey==='function')?playerKitKey():'nova';
314
+ carrier.fac=typeof dropFactionKey==='function'?dropFactionKey(carrierFacQa||carrierFacOwn):'nova';
315
  carrier.x=pxs; carrier.y=pys; carrier.tx=pxs; carrier.ty=pys; carrier.ang=0; carrier.dust=0;
316
  // AI start (mirrored)
317
  /* The enemy fields its FACTION'S hero, not a mirror of yours. The Legion
 
323
  avoids keeping three unused structure libraries resident on phones. */
324
  if(typeof ensureBldFactionMeshes==='function'){
325
  ensureBldFactionMeshes(AI.fac);
326
+ /* The player's own architecture too. bldMeshFor() would fault it in lazily,
327
+ but that lands on the frame the first structure finishes rather than
328
+ here in the carrier phase where an allocation is invisible. */
329
+ if(typeof playerKitKey==='function') ensureBldFactionMeshes(playerKitKey());
330
  /* Neutral nests use Horde architecture even when another faction owns the
331
  enemy base. Upload it during the carrier/deployment phase, where a short
332
  allocation is invisible, instead of hitching the first frame a nest
 
428
  down yourself, and it should be yours from the first structure. The HQ
429
  generates enough on its own to fund that first move. */
430
  const hq=addBld('hq',0,cx2,cy2,true);
431
+ /* You field YOUR faction's hero, the same way the enemy fields theirs. The
432
+ Dominion walks Lord Darion Vex, the Syndicate a shielded hover platform,
433
+ the Brood its Sovereign — and Nova keeps the Commander. Until now only the
434
+ AI got this, so choosing a faction changed who you fought as everywhere
435
+ except the one unit you spend the whole match looking at.
436
+ No hero builds anything — `builder:1` belongs to the Constructor, which
437
+ lands beside you regardless — so this swaps a fighter, not an economy. */
438
+ const PH=(typeof FACTIONS!=='undefined'&&FACTIONS[playerFaction])||null;
439
+ heroIdx=spawnUnit(PH&&PH.hero!=null?PH.hero:4,0,cx2+52,cy2+44);
440
  spawnUnit(UT_ENGINEER,0,cx2-52,cy2+44);
441
  AI.base.x=AI.base.x; // (AI base already placed)
442
  matchLive=true; stats.t=0; matchClock=timeLimit;
 
996
  function nativeBackTap(id){
997
  const el=$(id);if(!el)return false;
998
  if(typeof MF_POINTER_COMMIT!=='undefined') MF_POINTER_COMMIT=-1e9;
999
+ /* Blur FIRST. Android Back used to navigate straight out of a screen with a
1000
+ text field still focused, so the field's commit handler never ran and
1001
+ whatever had been typed was discarded — silently, which is the worst way to
1002
+ lose a name someone chose. */
1003
+ const a=document.activeElement;
1004
+ if(a&&a!==document.body&&typeof a.blur==='function') a.blur();
1005
  el.click();
1006
  return true;
1007
  }
 
1273
  mfBindTap($('profBack'),()=>{
1274
  renderMetaHead(); sfx('ui'); showFrontScreen('startScreen');
1275
  });
1276
+ /* `change` alone loses the name. On a phone the field is committed by the
1277
+ player leaving the screen, and the two ways they actually do that — the
1278
+ hardware Back button, and a tap on BACK that fires from pointerdown before
1279
+ the input has blurred — neither produces a `change` event. So a commander
1280
+ typed their name, went back, and the game had forgotten it. Commit on
1281
+ `blur` as well, and see nativeBackTap() for the other half. */
1282
+ const profNameCommit=()=>{
1283
  const P=activeProf(), v=$('profName').value.trim();
1284
+ if(v&&P&&P.name!==v.slice(0,14)){ P.name=v.slice(0,14); profSave(); renderMetaHead(); renderProfile(); }
1285
+ };
1286
+ $('profName').addEventListener('change',profNameCommit);
1287
+ $('profName').addEventListener('blur',profNameCommit);
1288
  $('profNew').addEventListener('pointerdown',()=>{
1289
  if(PROFILES.list.length>=6){ toast('Profile limit reached (6)'); return; }
1290
  const id='p'+(++PROFILES.seq);
 
1494
  if(used.has(A.zone)) A.zone=START_ZONES.find(z=>!used.has(z.id)).id;
1495
  used.add(A.zone);
1496
  }
1497
+ /* Validate against the PICKER's own list, not against FACTIONS. FACTIONS has
1498
+ no 'nova' entry — it is the enemy roster — so validating a saved player
1499
+ faction against it silently rejects the one everybody starts on. It happens
1500
+ to land on 'nova' anyway because that is the declared default, which is
1501
+ exactly the kind of accident that stops being true the moment the default
1502
+ moves. */
1503
+ if(su.pf&&typeof playableFactions==='function'&&playableFactions().includes(su.pf)) playerFaction=su.pf;
1504
+ else if(su.pf&&typeof FACTIONS!=='undefined'&&FACTIONS[su.pf]) playerFaction=su.pf;
1505
  if(su.df!=null) defenseFocus=su.df?1:0;
1506
  difficulty=Math.max(...activeAiSlots().map(A=>A.diff));
1507
  document.querySelectorAll('.glbtn').forEach(b=>b.classList.toggle('on',b.dataset.g===goalSel));
 
1549
  const showScr=id=>{ showFrontScreen(id); sfx('ui'); };
1550
  mfBindTap($('dailyBtn'),()=>{ renderDaily(); showScr('dailyScr'); });
1551
  mfBindTap($('dailyBack'),()=>{ showFrontScreen('startScreen'); renderMetaHead(); sfx('ui'); });
1552
+ /* One purpose, so no tab row: the Dossier is the faction codex. Dispatches
1553
+ moved to the mailbox whole, sealed entries included (see renderInbox). */
1554
  mfBindTap($('dossierBtn'),()=>{
 
1555
  if(typeof renderCodex==='function') renderCodex();
1556
  showScr('dossierScr');
1557
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
1558
  mfBindTap($('opsBtn'),()=>{ renderOps(); showScr('opsScr'); });
1559
  mfBindTap($('devBtn'),()=>{ renderDevelop(); showScr('devScr'); });
1560
  mfBindTap($('devBack'),()=>{ showFrontScreen('startScreen'); renderMetaHead(); sfx('ui'); });
 
1567
  if(wkB) wkB.addEventListener('pointerdown',()=>{ renderOps(); showScr('opsScr');
1568
  setTimeout(()=>{ const w=$('weeklyBox'); if(w) w.scrollIntoView({behavior:'smooth',block:'center'}); },120); });
1569
  mfBindTap($('dossBack'),()=>{ showFrontScreen('startScreen'); sfx('ui'); });
1570
+ /* Dismissing a dispatch marks it read, but nothing used to re-render the list
1571
+ underneath — the corner badge cleared while the row it came from still
1572
+ showed a green dot and NEW until you left the screen and came back. Redraw
1573
+ the mailbox on dismiss (never from inside showDispatch, which would rebuild
1574
+ the list under a live modal). handleNativeBack routes #dispatch here too,
1575
+ so hardware Back gets the same fix. */
1576
  mfBindTap($('dispOk'),()=>{ $('dispatch').style.display='none';
1577
+ if(typeof storyRefreshBadge==='function') storyRefreshBadge();
1578
+ const ib=$('inboxScr');
1579
+ if(ib&&ib.style.display!=='none'&&typeof renderInbox==='function') renderInbox();
1580
+ sfx('ui'); });
1581
  /* New subsystems. Each lives in its own module and registers its own UI, so
1582
  none of them need to touch index.html or this file beyond this line. */
1583
  for(const fn of ['initOffline','initSampleAudio','initAssetPacks','initAuthPortal','initTutorial','initAdBoards','initStoreUI','initResTree3D','initEconomyNet','initIntro'])
src/storeui.js CHANGED
@@ -374,8 +374,14 @@ renderArmory=function(){
374
  ev.stopPropagation(); armTab='inventory'; sfx('ui'); renderArmory(); list.scrollTop=0;
375
  }));
376
 
 
 
 
 
 
 
377
  list.querySelectorAll('.armIt').forEach(el=>{
378
- el.addEventListener('pointerdown',()=>{
379
  const it=STORE.find(s=>s.id===el.dataset.id); if(!it) return;
380
  const t=META.owned[it.id]||0;
381
  if(t>=it.max){ toast(it.nm+' is fully upgraded'); return; }
@@ -388,7 +394,7 @@ renderArmory=function(){
388
  });
389
 
390
  list.querySelectorAll('.swatch').forEach(el=>{
391
- el.addEventListener('pointerdown',()=>{
392
  const key=el.dataset.col, C=COLORS[key];
393
  const owned=key==='azure'||META.owned['col_'+key];
394
  if(!owned){
 
374
  ev.stopPropagation(); armTab='inventory'; sfx('ui'); renderArmory(); list.scrollTop=0;
375
  }));
376
 
377
+ /* SPENDING ON FINGER-DOWN. These two were raw `pointerdown` handlers inside a
378
+ `touch-action:pan-y` list, so the act of SCROLLING the Armory bought
379
+ whatever was under your thumb — up to 1600 cores gone before the finger had
380
+ moved far enough for the browser to call it a scroll. mfBindTap already
381
+ exists for exactly this: it waits for the release and cancels past a 12px
382
+ drag. The cart is the structural fix; this is the bleeding one. */
383
  list.querySelectorAll('.armIt').forEach(el=>{
384
+ mfBindTap(el,()=>{
385
  const it=STORE.find(s=>s.id===el.dataset.id); if(!it) return;
386
  const t=META.owned[it.id]||0;
387
  if(t>=it.max){ toast(it.nm+' is fully upgraded'); return; }
 
394
  });
395
 
396
  list.querySelectorAll('.swatch').forEach(el=>{
397
+ mfBindTap(el,()=>{
398
  const key=el.dataset.col, C=COLORS[key];
399
  const owned=key==='azure'||META.owned['col_'+key];
400
  if(!owned){
src/story.js CHANGED
@@ -207,28 +207,52 @@ function storyPending(){
207
  }
208
  function storyMarkSeen(i){ storySeen()['d'+i]=1; metaSave(); storyRefreshBadge(); }
209
 
210
- /* Dispatches belong in the Dossier, not in a compulsory modal queue. The old
211
  post-match flow reopened the next unlocked dispatch 260 ms after every
212
  ACKNOWLEDGE. On devices where the card failed to composite, its fixed
213
  full-screen backdrop was invisible but still intercepted every menu tap.
214
  A visible badge and one toast preserve the narrative reward without ever
215
  taking control away from the player. */
216
- function storyRefreshBadge(){
217
- const pending=!!storyPending();
218
- const unread=storyUnreadCount();
219
- const b=document.getElementById('dossierBtn');
220
- if(b){
221
- b.classList.toggle('pendingDispatch',pending);
222
- b.setAttribute('aria-label',pending?'Dossier, new dispatch available':'Dossier');
 
 
 
 
 
 
 
 
 
 
 
223
  }
224
- /* The Inbox is the notification surface now, so it carries the dot. A toast
225
- that fires while the player is mid-match is a notification you can miss;
226
- a badge is not. */
 
 
 
 
 
227
  for(const id of ['inboxBtn','inboxHudBtn']){
228
  const i=document.getElementById(id); if(!i) continue;
229
  i.classList.toggle('pendingDispatch',unread>0);
230
- i.setAttribute('aria-label',unread?('Inbox, '+unread+' unread transmission'+(unread>1?'s':'')):'Inbox');
231
  }
 
 
 
 
 
 
 
 
232
  }
233
  /* Unlocked and not yet read. storyPending() stops at the first LOCKED dispatch
234
  because the arc is strictly sequential; the badge wants the true unread count
@@ -239,10 +263,19 @@ function storyUnreadCount(){
239
  return n;
240
  }
241
 
242
- /* ---------- INBOX ----------
243
- Everything addressed to the player, in one place. Dispatches are live today;
244
- friend requests and messages declare themselves as pending so the screen does
245
- not silently change shape when social ships. */
 
 
 
 
 
 
 
 
 
246
  function renderInbox(){
247
  const g=document.getElementById('inboxDispatches');
248
  if(g){
@@ -250,20 +283,38 @@ function renderInbox(){
250
  const rows=[];
251
  for(let i=0;i<STORY.length;i++){
252
  const d=STORY[i], readable=storyUnlocked(i), read=!!seen['d'+i];
253
- if(!readable && !read) continue; // locked: it lives in the Dossier
254
- rows.push({i,d,read});
255
  }
256
- rows.sort((a,b)=>(a.read?1:0)-(b.read?1:0) || b.i-a.i); // unread first, newest first
257
- g.innerHTML = rows.length
258
- ? rows.map(r=>'<button type="button" class="inboxItem'+(r.read?'':' unread')+'" data-i="'+r.i+'">'
259
- +'<span class="inboxDot" aria-hidden="true"></span>'
260
- +'<span class="inboxTx"><b>'+r.d.ttl+'</b><span>'+r.d.from+'</span></span>'
261
- +'<span class="inboxMeta">'+(r.read?'READ':'NEW')+'</span></button>').join('')
262
- : '<div class="inboxEmpty">No transmissions yet. Rank up to receive your first orders.</div>';
263
- g.querySelectorAll('.inboxItem').forEach(el=>{
 
 
 
 
 
 
 
 
 
 
264
  const go=()=>{ showDispatch(STORY[+el.dataset.i]); };
265
  if(typeof mfBindTap==='function') mfBindTap(el,go); else el.addEventListener('pointerdown',go);
266
  });
 
 
 
 
 
 
 
 
 
267
  }
268
  const f=document.getElementById('inboxFriends');
269
  if(f) f.innerHTML='<div class="inboxEmpty">Friend requests arrive here once the friends update ships.</div>';
@@ -272,29 +323,79 @@ function renderInbox(){
272
  renderInboxUpdates();
273
  renderInboxNews();
274
  storyRefreshBadge();
 
 
 
 
275
  }
276
  /* The update history this device actually received — written by initUpdater as
277
- each patch starts for the first time. A local log rather than a changelog
278
- fetched from the server, so it stays truthful offline and after a rollback. */
 
 
 
 
 
 
 
 
 
 
 
279
  function renderInboxUpdates(){
280
  const g=document.getElementById('inboxUpdates'); if(!g) return;
281
- let log=[];
282
- try{ log=JSON.parse(localStorage.getItem('mf_update_log')||'[]'); }catch(e){ log=[]; }
 
 
 
 
283
  const cur=(typeof APP_VERSION!=='undefined'&&APP_VERSION)||'';
284
- if(!log.length){
 
285
  g.innerHTML='<div class="inboxEmpty">No updates installed on this device yet'
286
  +(cur?' — running v'+cur:'')+'.</div>';
287
  return;
288
  }
289
- g.innerHTML=log.slice(0,12).map(e=>{
 
 
 
290
  const d=new Date(e.at||0);
291
- const when=isNaN(d.getTime())?'':d.toLocaleDateString(undefined,{month:'short',day:'numeric'});
292
- return '<div class="inboxItem inboxLog">'
 
 
 
 
 
 
293
  +'<span class="inboxDot" aria-hidden="true"></span>'
294
- +'<span class="inboxTx"><b>v'+e.version+(e.version===cur?' · running now':'')+'</b>'
295
- +'<span>'+(e.notes? String(e.notes).replace(/</g,'&lt;') : 'Update installed')+'</span></span>'
296
- +'<span class="inboxMeta">'+when+'</span></div>';
297
  }).join('');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
  }
299
  /* Newsletters are optional and served from the release channel. A build whose
300
  channel has no news.json simply says so — it never errors and never blocks. */
@@ -313,15 +414,24 @@ function renderInboxNews(){
313
  };
314
  draw();
315
  if(INBOX_NEWS!==null) return;
316
- INBOX_NEWS=[];
 
 
 
317
  if(typeof netAllowed==='function'&&!netAllowed()) return;
 
 
 
318
  try{
319
- const base=(typeof updEndpoint==='function'&&updEndpoint())||'';
320
- if(!base) return;
321
- fetch(base.replace(/\/[^/]*$/,'/')+'news.json',{cache:'no-store'})
322
- .then(r=>r.ok?r.json():null)
323
- .then(j=>{ if(j&&Array.isArray(j.items)){ INBOX_NEWS=j.items; draw(); } })
324
- .catch(()=>{});
 
 
 
325
  }catch(e){}
326
  }
327
 
@@ -332,6 +442,14 @@ function renderInboxNews(){
332
  Nova xenobiology report into a message spoken by the Brood Sovereign. */
333
  function storySpeakerVisual(d){
334
  const source=((d&&d.from)||'').toLowerCase(), title=((d&&d.ttl)||'').toLowerCase();
 
 
 
 
 
 
 
 
335
  let key='nova';
336
  /* Machine Ascendancy is the Coalition's doctrinal name, not the Red
337
  Ascendancy. Test it first so the shared word cannot select Vex. */
@@ -389,11 +507,16 @@ function showDispatch(d){
389
  document.getElementById('dispTtl').textContent=d.ttl;
390
  document.getElementById('dispFrom').textContent=d.from;
391
  document.getElementById('dispTxt').textContent=d.txt;
392
- document.getElementById('dispIdx').textContent='DISPATCH '+(STORY.indexOf(d)+1)+' / '+STORY.length;
 
 
 
 
 
393
  el.setAttribute('aria-label',speaker.commander+' — '+d.ttl);
394
  el.style.display='flex';
395
  el.classList.remove('in'); void el.offsetWidth; el.classList.add('in');
396
- storyMarkSeen(STORY.indexOf(d));
397
  if(typeof sfx==='function') sfx(tx.mode==='urgent'?'alarm':'level');
398
  }
399
  /* Show whatever is owed, one at a time — a player who ranks up twice in a
@@ -401,36 +524,12 @@ function showDispatch(d){
401
  function storyCheck(){
402
  const d=storyPending();
403
  storyRefreshBadge();
404
- if(d&&typeof toast==='function') toast('📡 NEW DISPATCH — open DOSSIER when ready');
405
  return d;
406
  }
407
- function renderDossier(){
408
- const g=document.getElementById('dossierList'); if(!g) return;
409
- let h='';
410
- STORY.forEach((d,i)=>{
411
- const open=!!storySeen()['d'+i] || storyUnlocked(i);
412
- const lock=d.trig? ('Reach '+RANKS[d.r].em+' '+RANKS[d.r].nm+' · '+d.hint)
413
- : ('Reach '+RANKS[d.r].em+' '+RANKS[d.r].nm);
414
- h+='<div class="dossItem'+(open?'':' locked')+'" data-i="'+i+'">'
415
- +'<div class="dossN">'+String(i+1).padStart(2,'0')+'</div>'
416
- +'<div class="dossTx"><b>'+(open?d.ttl:'CLASSIFIED')+'</b>'
417
- +'<span>'+(open?d.from:lock)+'</span></div>'
418
- +(open?'':'<div class="dossLock">🔒</div>')+'</div>';
419
- });
420
- g.innerHTML=h;
421
- g.querySelectorAll('.dossItem').forEach(el=>{
422
- el.addEventListener('pointerdown',()=>{
423
- const i=+el.dataset.i;
424
- if(!(storySeen()['d'+i]||storyUnlocked(i))){
425
- const d=STORY[i];
426
- toast('🔒 Unlocks at '+RANKS[d.r].em+' '+RANKS[d.r].nm+(d.trig?' — or '+d.hint:''));
427
- return;
428
- }
429
- showDispatch(STORY[i]);
430
- });
431
- });
432
- storyRefreshBadge();
433
- }
434
 
435
  /* ============================================================================
436
  CAMPAIGN PROLOGUE — a playable front door for first-time commanders
 
207
  }
208
  function storyMarkSeen(i){ storySeen()['d'+i]=1; metaSave(); storyRefreshBadge(); }
209
 
210
+ /* Dispatches belong in the mailbox, not in a compulsory modal queue. The old
211
  post-match flow reopened the next unlocked dispatch 260 ms after every
212
  ACKNOWLEDGE. On devices where the card failed to composite, its fixed
213
  full-screen backdrop was invisible but still intercepted every menu tap.
214
  A visible badge and one toast preserve the narrative reward without ever
215
  taking control away from the player. */
216
+ /* One number for the mailbox dot. Anything else that becomes unread mail —
217
+ release notes, friend requests — adds its term HERE and nowhere else, so no
218
+ two features ever have to edit the same line of storyRefreshBadge(). */
219
+ function inboxUnreadCount(){ return storyUnreadCount()+inboxUnreadUpdates(); }
220
+ /* The UPDATES tab has no count element: index.html only gave one to DISPATCHES.
221
+ Create it once here rather than asking for a shell edit — an OTA bundle is
222
+ scripts only (boot.js replaces sources, never index.html or the stylesheet),
223
+ so a badge that lives in the markup can never reach a device that has already
224
+ shipped, and one built in JS can. Same class as the dispatch badge, so it
225
+ inherits the styling that is already on disk. */
226
+ function inboxUpdTabCount(){
227
+ const t=document.getElementById('inboxTab-updates'); if(!t) return null;
228
+ let c=t.querySelector('.tabCount');
229
+ if(!c){
230
+ c=document.createElement('span');
231
+ c.className='tabCount'; c.id='inboxUpdTabCount';
232
+ c.setAttribute('aria-hidden','true');
233
+ t.appendChild(c);
234
  }
235
+ return c;
236
+ }
237
+ function storyRefreshBadge(){
238
+ /* The mailbox is the notification surface — the Dossier no longer carries a
239
+ dispatch badge because it no longer carries dispatches. A toast that fires
240
+ while the player is mid-match is a notification you can miss; a badge is
241
+ not. */
242
+ const unread=inboxUnreadCount();
243
  for(const id of ['inboxBtn','inboxHudBtn']){
244
  const i=document.getElementById(id); if(!i) continue;
245
  i.classList.toggle('pendingDispatch',unread>0);
246
+ i.setAttribute('aria-label',unread?('Mailbox, '+unread+' unread transmission'+(unread>1?'s':'')):'Mailbox');
247
  }
248
+ /* Per-tab counts name only their own pane. The tile above is the whole
249
+ mailbox so it carries the sum, but a DISPATCHES badge reading 13 over a
250
+ list of 12 dispatches and one release note is simply wrong — a badge has to
251
+ agree with what is under it. */
252
+ const setCount=(el,n)=>{ if(!el) return;
253
+ el.textContent=n>99?'99+':String(n); el.classList.toggle('on',n>0); };
254
+ setCount(document.getElementById('inboxTabCount'),storyUnreadCount());
255
+ setCount(inboxUpdTabCount(),inboxUnreadUpdates());
256
  }
257
  /* Unlocked and not yet read. storyPending() stops at the first LOCKED dispatch
258
  because the arc is strictly sequential; the badge wants the true unread count
 
263
  return n;
264
  }
265
 
266
+ /* The unlock condition, in the player's words. The mailbox archive is the only
267
+ caller now that the Dossier is a faction codex and nothing else. */
268
+ function storyLockHint(d){
269
+ const r=RANKS[d.r];
270
+ return 'Reach '+r.em+' '+r.nm+(d.trig&&d.hint? ' · '+d.hint : '');
271
+ }
272
+
273
+ /* ---------- MAILBOX ----------
274
+ Everything addressed to the player, in one place, including the sealed tail
275
+ of the arc. The mailbox owns dispatches outright: the whole 27-entry archive
276
+ lives here, unread first, then read, then the CLASSIFIED entries the player
277
+ has not earned yet. Friend requests and messages declare themselves as
278
+ pending so the screen does not silently change shape when social ships. */
279
  function renderInbox(){
280
  const g=document.getElementById('inboxDispatches');
281
  if(g){
 
283
  const rows=[];
284
  for(let i=0;i<STORY.length;i++){
285
  const d=STORY[i], readable=storyUnlocked(i), read=!!seen['d'+i];
286
+ rows.push({i,d,read,locked:!readable&&!read});
 
287
  }
288
+ /* Unread OLDEST first: this arc is authored sequentially (see the header of
289
+ this file) and the old `b.i-a.i` served the newest unread first, so a
290
+ player who banked four dispatches read the war backwards. Read mail is
291
+ newest-first like any mailbox; the sealed archive keeps canonical order. */
292
+ const bucket=r=>r.locked?2:r.read?1:0;
293
+ rows.sort((a,b)=>bucket(a)-bucket(b) || (bucket(a)===1 ? b.i-a.i : a.i-b.i));
294
+ g.innerHTML=rows.map(r=>r.locked
295
+ ? '<div class="inboxItem inboxLog locked">'
296
+ +'<span class="inboxDot" aria-hidden="true"></span>'
297
+ +'<span class="inboxTx"><b>CLASSIFIED</b><span>'+storyLockHint(r.d)+'</span></span>'
298
+ +'<span class="inboxMeta">🔒</span></div>'
299
+ : '<button type="button" class="inboxItem'+(r.read?'':' unread')+'" data-i="'+r.i+'">'
300
+ +'<span class="inboxDot" aria-hidden="true"></span>'
301
+ +'<span class="inboxTx"><b>'+r.d.ttl+'</b><span>'+r.d.from+'</span></span>'
302
+ +'<span class="inboxMeta">'+(r.read?'READ':'NEW')+'</span></button>').join('');
303
+ /* Locked rows carry no data-i and must not bind — a sealed dispatch is not
304
+ a tap target. */
305
+ g.querySelectorAll('.inboxItem[data-i]').forEach(el=>{
306
  const go=()=>{ showDispatch(STORY[+el.dataset.i]); };
307
  if(typeof mfBindTap==='function') mfBindTap(el,go); else el.addEventListener('pointerdown',go);
308
  });
309
+ /* Replaces the unreachable "No transmissions yet" empty state — three
310
+ dispatches are already unlocked at rank 0, so that string never showed.
311
+ This carries the progression the Dossier used to own. */
312
+ const hd=document.getElementById('inboxDispHead');
313
+ if(hd){
314
+ const open=rows.filter(r=>!r.locked).length, unread=storyUnreadCount();
315
+ hd.textContent='📡 '+open+' of '+STORY.length+' declassified'
316
+ +(unread? ' · '+unread+' unread' : '');
317
+ }
318
  }
319
  const f=document.getElementById('inboxFriends');
320
  if(f) f.innerHTML='<div class="inboxEmpty">Friend requests arrive here once the friends update ships.</div>';
 
323
  renderInboxUpdates();
324
  renderInboxNews();
325
  storyRefreshBadge();
326
+ /* mfBindTabs is idempotent — it guards every button with dataset.mfTapBound
327
+ and the root with dataset.mfTabsBound — so re-binding on every open is safe
328
+ and keeps working if the shell was replaced by an OTA patch. */
329
+ if(typeof mfBindTabs==='function') mfBindTabs(document.getElementById('inboxScr'),'dispatches');
330
  }
331
  /* The update history this device actually received — written by initUpdater as
332
+ each release starts for the first time. A local log rather than a changelog
333
+ fetched from the server, so it stays truthful offline and after a rollback.
334
+
335
+ Every row is a real mail item, not a caption. The notes body runs to ~900
336
+ characters and .inboxTx span clamps to one ellipsised line, so a row that
337
+ could not be opened was a release log you could see and not read. */
338
+ function updateLogRows(){ return (typeof updLogRead==='function')? updLogRead() : []; }
339
+ /* Strictly `read===false`, never `!read`. Entries written before this feature
340
+ existed carry no read flag at all, and treating those as unread would greet
341
+ every upgrading player with a badge full of releases they installed months
342
+ ago. An entry that was never announced is history, not news; only the writer
343
+ that can announce one marks it. */
344
+ function inboxUnreadUpdates(){ return updateLogRows().filter(e=>e.read===false).length; }
345
  function renderInboxUpdates(){
346
  const g=document.getElementById('inboxUpdates'); if(!g) return;
347
+ /* Notes are publisher copy, not player input, but they land in innerHTML and
348
+ an unescaped & or < in a changelog would silently eat the rest of a row. */
349
+ const esc=s=>String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
350
+ /* The version actually running: on a patched build the bundle carries its own
351
+ updater.js with the bumped APP_VERSION, and after a rollback APP_VERSION is
352
+ once again what is executing. Both true, so this needs no patch state. */
353
  const cur=(typeof APP_VERSION!=='undefined'&&APP_VERSION)||'';
354
+ const rows=updateLogRows();
355
+ if(!rows.length){
356
  g.innerHTML='<div class="inboxEmpty">No updates installed on this device yet'
357
  +(cur?' — running v'+cur:'')+'.</div>';
358
  return;
359
  }
360
+ /* No slice: the log is already capped at 40 by updLogPost, and rendering a
361
+ shorter window than the badge counts would put a number on the tab that
362
+ nothing under it explains. */
363
+ g.innerHTML=rows.map((e,i)=>{
364
  const d=new Date(e.at||0);
365
+ const when=(!e.at||isNaN(d.getTime()))?''
366
+ :d.toLocaleDateString(undefined,{month:'short',day:'numeric'});
367
+ /* Reverted beats running: a rolled-back entry cannot also be what is on the
368
+ device, and initUpdater clears the mark if that version is ever applied
369
+ again. */
370
+ const tag=e.rolledBack?' · reverted':(String(e.version)===cur?' · running now':'');
371
+ const isNew=e.read===false; // see inboxUnreadUpdates
372
+ return '<button type="button" class="inboxItem inboxUpd'+(isNew?' unread':'')+'" data-u="'+i+'">'
373
  +'<span class="inboxDot" aria-hidden="true"></span>'
374
+ +'<span class="inboxTx"><b>v'+esc(e.version)+tag+'</b>'
375
+ +'<span>'+esc(e.notes||'Update installed')+'</span></span>'
376
+ +'<span class="inboxMeta">'+(isNew?'NEW':when)+'</span></button>';
377
  }).join('');
378
+ g.querySelectorAll('.inboxUpd').forEach(el=>{
379
+ const go=()=>inboxOpenUpdate(rows[+el.dataset.u]);
380
+ if(typeof mfBindTap==='function') mfBindTap(el,go); else el.addEventListener('pointerdown',go);
381
+ });
382
+ }
383
+ /* A release note is mail, so it opens the same transmission card the dispatches
384
+ use — that card scrolls, which is the only thing on this screen that can show
385
+ 900 characters. The list underneath is deliberately NOT repainted here: the
386
+ dismiss handler in main.js already redraws the mailbox, and rebuilding a list
387
+ under a live modal is what that handler exists to avoid. */
388
+ function inboxOpenUpdate(e){
389
+ if(!e) return;
390
+ if(typeof updLogMark==='function') updLogMark(e.version,{read:true});
391
+ e.read=true;
392
+ /* from/ttl are FIXED strings and never the notes body: storySpeakerVisual and
393
+ storyTransmissionVisual keyword-match those two fields, and a changelog that
394
+ happens to mention the Syndicate must not arrive spoken by Broker Renn. */
395
+ showDispatch({ttl:'BUILD v'+e.version, idx:'RELEASE NOTES',
396
+ from:'MASSFRONT RELEASE CHANNEL · BROADCAST',
397
+ txt:e.notes||'This build was installed on this device.'});
398
+ storyRefreshBadge();
399
  }
400
  /* Newsletters are optional and served from the release channel. A build whose
401
  channel has no news.json simply says so — it never errors and never blocks. */
 
414
  };
415
  draw();
416
  if(INBOX_NEWS!==null) return;
417
+ /* Latch the cache ONLY once a request is actually issued. Setting it to []
418
+ above these guards meant a single Inbox open while offline — or before
419
+ updResolveEndpoint had finished — killed newsletters for the rest of the
420
+ session, with no retry when the player came back online. */
421
  if(typeof netAllowed==='function'&&!netAllowed()) return;
422
+ const base=(typeof updEndpoint==='function'&&updEndpoint())||'';
423
+ if(!base) return;
424
+ INBOX_NEWS=[];
425
  try{
426
+ /* netFetch, not fetch: it re-checks the offline gate and aborts at 12s. A
427
+ raw fetch hangs forever on a captive portal, which is the exact failure
428
+ src/offline.js exists to prevent. */
429
+ const url=base.replace(/\/[^/]*$/,'/')+'news.json';
430
+ const req=(typeof netFetch==='function')
431
+ ? netFetch(url,{cache:'no-store'}) : fetch(url,{cache:'no-store'});
432
+ req.then(r=>r.ok?r.json():null)
433
+ .then(j=>{ if(j&&Array.isArray(j.items)){ INBOX_NEWS=j.items; draw(); } })
434
+ .catch(()=>{});
435
  }catch(e){}
436
  }
437
 
 
442
  Nova xenobiology report into a message spoken by the Brood Sovereign. */
443
  function storySpeakerVisual(d){
444
  const source=((d&&d.from)||'').toLowerCase(), title=((d&&d.ttl)||'').toLowerCase();
445
+ /* A changelog is the only thing in this card that is not in-fiction. It must
446
+ not arrive signed by Captain Kai — which is what the default below would do
447
+ — but it also cannot invent a portrait key, because #dispPortrait has no
448
+ onerror and a missing faction image renders as a broken-image glyph. Keep
449
+ the Nova art as the channel avatar and say plainly who is talking. */
450
+ if(source.includes('release channel'))
451
+ return {key:'nova',name:'MASSFRONT',commander:'RELEASE CHANNEL',
452
+ src:'./assets/factions/nova_192.jpg',alt:'MASSFRONT release channel'};
453
  let key='nova';
454
  /* Machine Ascendancy is the Coalition's doctrinal name, not the Red
455
  Ascendancy. Test it first so the shared word cannot select Vex. */
 
507
  document.getElementById('dispTtl').textContent=d.ttl;
508
  document.getElementById('dispFrom').textContent=d.from;
509
  document.getElementById('dispTxt').textContent=d.txt;
510
+ /* This card now also shows release notes, which are not STORY entries. Without
511
+ the guard indexOf returns -1, the header reads "DISPATCH 0 / 27" and
512
+ storyMarkSeen(-1) writes a junk META.story['d-1'] key into the save. */
513
+ const si=STORY.indexOf(d);
514
+ document.getElementById('dispIdx').textContent = si>=0
515
+ ? 'DISPATCH '+(si+1)+' / '+STORY.length : (d.idx||'RELEASE NOTES');
516
  el.setAttribute('aria-label',speaker.commander+' — '+d.ttl);
517
  el.style.display='flex';
518
  el.classList.remove('in'); void el.offsetWidth; el.classList.add('in');
519
+ if(si>=0) storyMarkSeen(si);
520
  if(typeof sfx==='function') sfx(tx.mode==='urgent'?'alarm':'level');
521
  }
522
  /* Show whatever is owed, one at a time — a player who ranks up twice in a
 
524
  function storyCheck(){
525
  const d=storyPending();
526
  storyRefreshBadge();
527
+ if(d&&typeof toast==='function') toast('📡 NEW DISPATCH — check your MAILBOX ✉');
528
  return d;
529
  }
530
+ /* renderDossier() lived here. The Dossier is the faction codex now and nothing
531
+ else; the whole dispatch archive, sealed entries included, is renderInbox()
532
+ above. Its lock-hint string survives as storyLockHint(). */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
533
 
534
  /* ============================================================================
535
  CAMPAIGN PROLOGUE — a playable front door for first-time commanders
src/tutorial.js CHANGED
@@ -272,7 +272,7 @@ function sawQueuedUnit(){
272
  barks are not tied to a step: they interrupt, hold briefly, then hand back
273
  to whatever the resting text was (or hide, if the tutorial isn't running).
274
  --------------------------------------------------------------------------- */
275
- var queue=[], holdUntil=0, restingText='', restingTag='', lastText=null, domReady=false;
276
  var focusEls=[], mapCue=null;
277
 
278
  function buildDOM(){
@@ -314,7 +314,7 @@ function buildDOM(){
314
  its dim resting state — a quiet "say that again" without extra chrome. */
315
  document.getElementById('keelBar').addEventListener('pointerdown',function(ev){
316
  if(ev.target===skipBtn||ev.target===nextBtn) return;
317
- if(restingText) speak(restingText,4.2,'recall');
318
  });
319
  mapCue=cue;
320
  }
@@ -488,9 +488,12 @@ function syncFocus(){
488
  step the player is looking at right now matters more than a "done" bark
489
  for something they finished three actions ago. */
490
  var MAX_QUEUE=3;
491
- function speak(text,holdSec,kind){
 
 
 
492
  if(!text) return;
493
- queue.push({text:text,hold:holdSec||4.4,kind:kind||'bark'});
494
  while(queue.length>MAX_QUEUE) queue.shift();
495
  }
496
  function pump(){
@@ -506,7 +509,7 @@ function pump(){
506
  showWrap(true);
507
  /* Pass the line's identity through, so a rendered take can be found for it
508
  instead of every line silently falling through to synthesis. */
509
- speakVoice(m.text,'keen',keenLineId(m.kind,m.text));
510
  return;
511
  }
512
  if(TUT.active && restingText){
@@ -535,6 +538,11 @@ function pump(){
535
  function voiceOn(){
536
  try{
537
  if(!(typeof META!=='undefined' && META.settings && META.settings.tutorialVoice)) return false;
 
 
 
 
 
538
  if(typeof voReady==='function' && voReady('keen','greeting')) return true;
539
  return 'speechSynthesis' in window;
540
  }catch(e){ return false; }
@@ -542,12 +550,23 @@ function voiceOn(){
542
  /* A stable id per scripted line, so a rendered take can be looked up for it.
543
  KEEN's script is FIXED — every step's say/done, the greeting, the graduation
544
  and the reactive barks are constants — which is precisely what makes them
545
- renderable offline the same way the command acks already are. */
546
- function keenLineId(kind,text){
547
- if(!text) return null;
548
- var h=0, t=String(text);
549
- for(var k=0;k<t.length;k++){ h=(h*31+t.charCodeAt(k))>>>0; }
550
- return (kind||'bark')+'_'+h.toString(36);
 
 
 
 
 
 
 
 
 
 
 
551
  }
552
  /* CHARACTER VOICES.
553
 
@@ -565,7 +584,14 @@ function keenLineId(kind,text){
565
  function speakVoice(text,faction,action,idx,wx,wy){
566
  if(!voiceOn()) return;
567
  var fac=faction||'nova';
568
- if(action&&typeof voPlay==='function'&&voPlay(fac,action,wx,wy)) return;
 
 
 
 
 
 
 
569
  speakVoiceFallback(text,fac);
570
  }
571
  /* Chrome returns an EMPTY voice list on the first getVoices() call and fills it
@@ -649,7 +675,7 @@ function beginRun(){
649
  MATCH.cameraBase={yaw:yawTarget,pitch:pitchTarget,span:orthoSpan};
650
  MATCH.lastAbCool=(typeof abCool!=='undefined')?abCool.slice():[0,0,0,0];
651
  queue.length=0;
652
- speak(GREETING,5.5,'greeting');
653
  }
654
  function evalSteps(){
655
  if(!TUT.active) return;
@@ -659,7 +685,7 @@ function evalSteps(){
659
  try{ ok=!!STEPS[i].test(); }catch(e){ ok=false; }
660
  if(ok){
661
  TUT.doneFlags[i]=true;
662
- if(i===TUT.stepIdx) speak(STEPS[i].done,3.8,'done');
663
  }
664
  }
665
  while(TUT.stepIdx<STEPS.length && TUT.doneFlags[TUT.stepIdx]) TUT.stepIdx++;
@@ -673,8 +699,8 @@ function evalSteps(){
673
  TUT.graduated=true; TUT.active=false;
674
  var M=tutMeta(); M.done=true; M.skipped=false; M.version=GUIDE_VERSION;
675
  M.progress=STEPS.length;
676
- restingText=''; restingTag='';
677
- speak(GRADUATION,6.5,'graduation');
678
  if(typeof sfx==='function'){ try{ sfx('level'); }catch(e){} }
679
  if(TUT.trainingMode&&!TUT.finishTimer) TUT.finishTimer=setTimeout(finishTrainingMission,1300);
680
  else if(typeof metaSave==='function') metaSave();
@@ -683,10 +709,11 @@ function evalSteps(){
683
  }
684
  var cur=STEPS[TUT.stepIdx];
685
  restingText=cur.say;
 
686
  restingTag=(cur.icon||'◇')+' STEP '+(TUT.stepIdx+1)+' / '+STEPS.length;
687
  if(TUT.shownStepIdx!==TUT.stepIdx){
688
  TUT.shownStepIdx=TUT.stepIdx;
689
- speak(cur.say,5.5,'step');
690
  }
691
  }
692
  function tutSkip(){
@@ -695,14 +722,14 @@ function tutSkip(){
695
  var wasTraining=TUT.trainingMode;
696
  var M=tutMeta(); M.skipped=true; M.version=GUIDE_VERSION;
697
  queue.length=0;
698
- restingText=''; restingTag='';
699
  clearFocus();
700
  if(wasTraining){
701
  TUT.trainingMode=false; trainingLaunched=false; restoreTrainingConfig();
702
  if(typeof metaSave==='function') metaSave();
703
  if(typeof returnToMainMenu==='function') returnToMainMenu();
704
  } else if(typeof metaSave==='function') metaSave();
705
- speak(SKIP_LINE,4.0,'skip');
706
  if(typeof sfx==='function'){ try{ sfx('ui'); }catch(e){} }
707
  }
708
 
@@ -725,11 +752,16 @@ function checkReactive(){
725
  if(typeof matchLive==='undefined'||!matchLive) return;
726
  if(typeof alarmT!=='undefined' && alarmT>0 && alarmT!==REACT.lastAlarmT){
727
  REACT.lastAlarmT=alarmT;
728
- if(!REACT.baseAttack){ REACT.baseAttack=true; speak(pick(BASE_ATTACK_LINES),5.0,'reactive'); }
 
 
 
 
 
729
  }
730
  if(typeof stallE!=='undefined'){
731
  var nowStall=stallE>0;
732
- if(nowStall && !REACT.lastStallE && !REACT.lowPower){ REACT.lowPower=true; speak(LOW_POWER_LINE,4.6,'reactive'); }
733
  REACT.lastStallE=nowStall;
734
  }
735
  if(typeof HAZ!=='undefined'){
@@ -737,15 +769,22 @@ function checkReactive(){
737
  if(nowWarn && !REACT.lastHazWarn && !REACT.hazard){
738
  REACT.hazard=true;
739
  var key=(typeof curMap!=='undefined')?curMap:'';
740
- speak(HAZARD_LINES[key]||HAZARD_LINES._default,5.2,'reactive');
 
 
 
 
 
741
  }
742
  REACT.lastHazWarn=nowWarn;
743
  }
744
  if(typeof stats!=='undefined' && stats.kills && !REACT.unitLost && ((stats.kills[1]|0)+(stats.kills[2]|0))>0){
745
- REACT.unitLost=true; speak(pick(UNIT_LOST_LINES),4.2,'reactive');
 
 
746
  }
747
  if(typeof AI!=='undefined'){
748
- if(AI.warned && !REACT.lastWarned && !REACT.wave){ REACT.wave=true; speak(WAVE_LINE,5.0,'reactive'); }
749
  REACT.lastWarned=!!AI.warned;
750
  }
751
  }
@@ -822,7 +861,7 @@ function restoreTrainingConfig(){
822
  function cancelTrainingMission(){
823
  if(!TUT.trainingMode&&!trainingPrev) return;
824
  TUT.trainingMode=false; trainingLaunched=false; TUT.active=false; TUT.finishTimer=0;
825
- queue.length=0; restingText=''; restingTag=''; clearFocus(); hideWrap();
826
  document.body.classList.remove('trainingOperation');
827
  restoreTrainingConfig();
828
  updateTrainingEntry();
@@ -1125,6 +1164,11 @@ window.initTutorial=initTutorial;
1125
  wired for real players (no different from any other console-reachable
1126
  global in this codebase). */
1127
  window.__tutDebug=function(){ return {TUT:TUT,MATCH:MATCH,REACT:REACT,STEPS:STEPS,
1128
- needsTraining:needsTraining(),startTraining:startTrainingMission}; };
 
 
 
 
 
1129
 
1130
  })();
 
272
  barks are not tied to a step: they interrupt, hold briefly, then hand back
273
  to whatever the resting text was (or hide, if the tutorial isn't running).
274
  --------------------------------------------------------------------------- */
275
+ var queue=[], holdUntil=0, restingText='', restingTag='', restingId='', lastText=null, domReady=false;
276
  var focusEls=[], mapCue=null;
277
 
278
  function buildDOM(){
 
314
  its dim resting state — a quiet "say that again" without extra chrome. */
315
  document.getElementById('keelBar').addEventListener('pointerdown',function(ev){
316
  if(ev.target===skipBtn||ev.target===nextBtn) return;
317
+ if(restingText) speak(restingText,4.2,'recall',restingId);
318
  });
319
  mapCue=cue;
320
  }
 
488
  step the player is looking at right now matters more than a "done" bark
489
  for something they finished three actions ago. */
490
  var MAX_QUEUE=3;
491
+ /* `id` is the AUTHORED voice key for this line (see keenLineId). It travels with
492
+ the line through the queue instead of being reconstructed from the copy on the
493
+ far side — reconstructing it is what let the gate and the player disagree. */
494
+ function speak(text,holdSec,kind,id){
495
  if(!text) return;
496
+ queue.push({text:text,hold:holdSec||4.4,kind:kind||'bark',id:id||null});
497
  while(queue.length>MAX_QUEUE) queue.shift();
498
  }
499
  function pump(){
 
509
  showWrap(true);
510
  /* Pass the line's identity through, so a rendered take can be found for it
511
  instead of every line silently falling through to synthesis. */
512
+ speakVoice(m.text,'keen',keenLineId(m.kind,m.id));
513
  return;
514
  }
515
  if(TUT.active && restingText){
 
538
  function voiceOn(){
539
  try{
540
  if(!(typeof META!=='undefined' && META.settings && META.settings.tutorialVoice)) return false;
541
+ /* voHas asks the BANK MANIFEST; voReady asks whether the buffer is decoded
542
+ yet. Gating on voReady alone made the answer "no voice available" for the
543
+ whole of a cold start, because nothing is decoded until something asks for
544
+ it — and this gate is the thing that asks. */
545
+ if(typeof voHas==='function' && voHas('keen','greeting')) return true;
546
  if(typeof voReady==='function' && voReady('keen','greeting')) return true;
547
  return 'speechSynthesis' in window;
548
  }catch(e){ return false; }
 
550
  /* A stable id per scripted line, so a rendered take can be looked up for it.
551
  KEEN's script is FIXED — every step's say/done, the greeting, the graduation
552
  and the reactive barks are constants — which is precisely what makes them
553
+ renderable offline the same way the command acks already are.
554
+
555
+ THE ID IS AUTHORED AT THE CALL SITE. It must match an action key under
556
+ `lines.keen` in assets/audio/voice.json (step_camera, done_camera, greeting,
557
+ skip, graduation, react_low_power, react_hazard_default, …), which is what
558
+ verifykeen.mjs diffs. This used to hash the line's own TEXT, which meant the
559
+ gate at voiceOn() probed vo_keen_greeting while playback asked for
560
+ vo_keen_greeting_19hur50, no rendered take could ever match either, and
561
+ correcting a typo silently orphaned a take.
562
+
563
+ `kind` no longer contributes to the key — it survives only as the bubble's
564
+ hold/emphasis class. That is deliberate: it is what lets a 'recall' replay
565
+ resolve to the SAME take as the 'step' line it is repeating instead of
566
+ demanding a second recording of identical copy. */
567
+ function keenLineId(kind,id){
568
+ if(!id) return null;
569
+ return String(id).toLowerCase().replace(/[^a-z0-9_]/g,'');
570
  }
571
  /* CHARACTER VOICES.
572
 
 
584
  function speakVoice(text,faction,action,idx,wx,wy){
585
  if(!voiceOn()) return;
586
  var fac=faction||'nova';
587
+ if(action&&typeof voPlay==='function'){
588
+ /* A slot the player has not heard yet is not decoded yet, and the decode is
589
+ asynchronous. Reporting "no take" at that instant is what made the FIRST
590
+ airing of every line — which for KEEN is the only airing — fall through to
591
+ speech synthesis. voPlay now owns the wait and calls back only if the take
592
+ genuinely cannot be produced. */
593
+ if(voPlay(fac,action,wx,wy,idx,function(){ speakVoiceFallback(text,fac); })) return;
594
+ }
595
  speakVoiceFallback(text,fac);
596
  }
597
  /* Chrome returns an EMPTY voice list on the first getVoices() call and fills it
 
675
  MATCH.cameraBase={yaw:yawTarget,pitch:pitchTarget,span:orthoSpan};
676
  MATCH.lastAbCool=(typeof abCool!=='undefined')?abCool.slice():[0,0,0,0];
677
  queue.length=0;
678
+ speak(GREETING,5.5,'greeting','greeting');
679
  }
680
  function evalSteps(){
681
  if(!TUT.active) return;
 
685
  try{ ok=!!STEPS[i].test(); }catch(e){ ok=false; }
686
  if(ok){
687
  TUT.doneFlags[i]=true;
688
+ if(i===TUT.stepIdx) speak(STEPS[i].done,3.8,'done','done_'+STEPS[i].id);
689
  }
690
  }
691
  while(TUT.stepIdx<STEPS.length && TUT.doneFlags[TUT.stepIdx]) TUT.stepIdx++;
 
699
  TUT.graduated=true; TUT.active=false;
700
  var M=tutMeta(); M.done=true; M.skipped=false; M.version=GUIDE_VERSION;
701
  M.progress=STEPS.length;
702
+ restingText=''; restingTag=''; restingId='';
703
+ speak(GRADUATION,6.5,'graduation','graduation');
704
  if(typeof sfx==='function'){ try{ sfx('level'); }catch(e){} }
705
  if(TUT.trainingMode&&!TUT.finishTimer) TUT.finishTimer=setTimeout(finishTrainingMission,1300);
706
  else if(typeof metaSave==='function') metaSave();
 
709
  }
710
  var cur=STEPS[TUT.stepIdx];
711
  restingText=cur.say;
712
+ restingId='step_'+cur.id;
713
  restingTag=(cur.icon||'◇')+' STEP '+(TUT.stepIdx+1)+' / '+STEPS.length;
714
  if(TUT.shownStepIdx!==TUT.stepIdx){
715
  TUT.shownStepIdx=TUT.stepIdx;
716
+ speak(cur.say,5.5,'step',restingId);
717
  }
718
  }
719
  function tutSkip(){
 
722
  var wasTraining=TUT.trainingMode;
723
  var M=tutMeta(); M.skipped=true; M.version=GUIDE_VERSION;
724
  queue.length=0;
725
+ restingText=''; restingTag=''; restingId='';
726
  clearFocus();
727
  if(wasTraining){
728
  TUT.trainingMode=false; trainingLaunched=false; restoreTrainingConfig();
729
  if(typeof metaSave==='function') metaSave();
730
  if(typeof returnToMainMenu==='function') returnToMainMenu();
731
  } else if(typeof metaSave==='function') metaSave();
732
+ speak(SKIP_LINE,4.0,'skip','skip');
733
  if(typeof sfx==='function'){ try{ sfx('ui'); }catch(e){} }
734
  }
735
 
 
752
  if(typeof matchLive==='undefined'||!matchLive) return;
753
  if(typeof alarmT!=='undefined' && alarmT>0 && alarmT!==REACT.lastAlarmT){
754
  REACT.lastAlarmT=alarmT;
755
+ /* pick() throws the chosen index away, and the index is exactly what the
756
+ voice key needs — the two variants are two separate recordings. Indexed
757
+ inline rather than changing pick(), which has other callers. */
758
+ if(!REACT.baseAttack){ REACT.baseAttack=true;
759
+ var bi=(Math.random()*BASE_ATTACK_LINES.length)|0;
760
+ speak(BASE_ATTACK_LINES[bi],5.0,'reactive','react_base_attack'+bi); }
761
  }
762
  if(typeof stallE!=='undefined'){
763
  var nowStall=stallE>0;
764
+ if(nowStall && !REACT.lastStallE && !REACT.lowPower){ REACT.lowPower=true; speak(LOW_POWER_LINE,4.6,'reactive','react_low_power'); }
765
  REACT.lastStallE=nowStall;
766
  }
767
  if(typeof HAZ!=='undefined'){
 
769
  if(nowWarn && !REACT.lastHazWarn && !REACT.hazard){
770
  REACT.hazard=true;
771
  var key=(typeof curMap!=='undefined')?curMap:'';
772
+ /* HAZARD_LINES._default is keyed with a leading underscore so it cannot
773
+ collide with a map id; the rendered take is plain `react_hazard_default`.
774
+ Guard the underscore explicitly — a map literally named "_default" would
775
+ otherwise ask for a take that was never rendered. */
776
+ var hk=(key&&key.charAt(0)!=='_'&&HAZARD_LINES[key])?key:'default';
777
+ speak(HAZARD_LINES[key]||HAZARD_LINES._default,5.2,'reactive','react_hazard_'+hk);
778
  }
779
  REACT.lastHazWarn=nowWarn;
780
  }
781
  if(typeof stats!=='undefined' && stats.kills && !REACT.unitLost && ((stats.kills[1]|0)+(stats.kills[2]|0))>0){
782
+ REACT.unitLost=true;
783
+ var ui=(Math.random()*UNIT_LOST_LINES.length)|0;
784
+ speak(UNIT_LOST_LINES[ui],4.2,'reactive','react_unit_lost'+ui);
785
  }
786
  if(typeof AI!=='undefined'){
787
+ if(AI.warned && !REACT.lastWarned && !REACT.wave){ REACT.wave=true; speak(WAVE_LINE,5.0,'reactive','react_wave'); }
788
  REACT.lastWarned=!!AI.warned;
789
  }
790
  }
 
861
  function cancelTrainingMission(){
862
  if(!TUT.trainingMode&&!trainingPrev) return;
863
  TUT.trainingMode=false; trainingLaunched=false; TUT.active=false; TUT.finishTimer=0;
864
+ queue.length=0; restingText=''; restingTag=''; restingId=''; clearFocus(); hideWrap();
865
  document.body.classList.remove('trainingOperation');
866
  restoreTrainingConfig();
867
  updateTrainingEntry();
 
1164
  wired for real players (no different from any other console-reachable
1165
  global in this codebase). */
1166
  window.__tutDebug=function(){ return {TUT:TUT,MATCH:MATCH,REACT:REACT,STEPS:STEPS,
1167
+ needsTraining:needsTraining(),startTraining:startTrainingMission,
1168
+ /* The voice plumbing lives inside this IIFE, so verifykeen.mjs cannot reach
1169
+ speak()/keenLineId()/pump() any other way. Read-only handles, no state. */
1170
+ speak:speak,pump:pump,queue:queue,voiceOn:voiceOn,keenLineId:keenLineId,
1171
+ speakVoice:speakVoice,beginRun:beginRun,
1172
+ keenKey:function(kind,id){ return 'vo_keen_'+keenLineId(kind,id); }}; };
1173
 
1174
  })();
src/ui/hud.js CHANGED
@@ -78,6 +78,10 @@ const fogCanvas=document.createElement('canvas'); fogCanvas.width=FN; fogCanvas.
78
  const fogCtx=fogCanvas.getContext('2d');
79
  const fogImg=fogCtx.createImageData(FN,FN);
80
  let fogTex=null, fogDirty=true;
 
 
 
 
81
  function covAt(wx,wy){
82
  if(!fogOn) return 1;
83
  return fogCov[clamp(wy/MAP*FN|0,0,FN-1)*FN+clamp(wx/MAP*FN|0,0,FN-1)];
 
78
  const fogCtx=fogCanvas.getContext('2d');
79
  const fogImg=fogCtx.createImageData(FN,FN);
80
  let fogTex=null, fogDirty=true;
81
+ /* Context-loss reset: the fog texture handle survives the loss as a truthy
82
+ reference to a dead object; updateFog would then upload into it forever and
83
+ the explored map would never come back. Recovery calls this first. */
84
+ function fogGLReset(){ fogTex=null; }
85
  function covAt(wx,wy){
86
  if(!fogOn) return 1;
87
  return fogCov[clamp(wy/MAP*FN|0,0,FN-1)*FN+clamp(wx/MAP*FN|0,0,FN-1)];
src/updater.js CHANGED
@@ -29,7 +29,14 @@
29
  ============================================================================ */
30
 
31
  /* Bumped by the release script. Compared against the manifest's `version`. */
32
- const APP_VERSION = '1.32.42';
 
 
 
 
 
 
 
33
 
34
  /* The channel URL in update-config.json remains publisher-configurable, but a
35
  production checker also needs one known-good recovery path. More importantly,
@@ -231,6 +238,93 @@ async function updDel(key){
231
  });
232
  }
233
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  /* Compare dotted version strings numerically: '1.10.0' is newer than '1.9.9',
235
  which a string comparison gets backwards. */
236
  function verNewer(a,b){
@@ -306,6 +400,7 @@ async function updCheck(manual){
306
  UPD.manifest=m;
307
  UPD.checkedVersion=m.version;
308
  UPD.source=found.source;
 
309
  /* Compare against the version actually running. A live-patched client can
310
  be newer than its packaged APP_VERSION; comparing only to the package
311
  made it repeatedly offer an older patch. Also distinguish a stale
@@ -390,6 +485,9 @@ async function updDownload(){
390
  /* Commit only once every file is present and accounted for. */
391
  await updPut('pending',{version:m.version, notes:m.notes||'', at:Date.now(),
392
  order:m.files.map(f=>f.path), files:out});
 
 
 
393
  await updDel('applyFailure');
394
  updSet('ready',{pct:100,abort:null});
395
  }catch(e){
@@ -429,6 +527,12 @@ function updHardReload(){
429
  }catch(e){ location.reload(); }
430
  }
431
  async function updRollback(){
 
 
 
 
 
 
432
  await updDel('active'); await updDel('pending'); await updDel('probation');
433
  location.reload();
434
  }
@@ -585,21 +689,25 @@ async function initUpdater(){
585
  if(window.__MASSFRONT_PATCHED) document.body.classList.add('patched');
586
  const pend=await updGet('pending');
587
  const fail=await updGet('applyFailure');
588
- const installed=window.__MASSFRONT_PATCHED&&
589
- localStorage.getItem('mf_update_installed_notice')!==String(window.__MASSFRONT_PATCHED);
 
 
 
 
 
590
  if(installed){
591
- localStorage.setItem('mf_update_installed_notice',String(window.__MASSFRONT_PATCHED));
592
- /* Append to the local install log. This is the history the player actually
593
  received on THIS device — not a changelog fetched from the server — so it
594
- stays truthful offline and after a rollback. */
595
- try{
596
- const log=JSON.parse(localStorage.getItem('mf_update_log')||'[]');
597
- const v=String(window.__MASSFRONT_PATCHED);
598
- if(!log.some(e=>e.version===v)){
599
- log.unshift({version:v,at:Date.now(),notes:(UPD.manifest&&UPD.manifest.notes)||''});
600
- localStorage.setItem('mf_update_log',JSON.stringify(log.slice(0,40)));
601
- }
602
- }catch(e){}
603
  updSet('installed');
604
  } else if(fail&&fail.quarantined&&verNewer(fail.version,updVerShown)){
605
  updSet('error',{err:'v'+fail.version+' failed to start twice and was removed - check again to download a fresh copy'});
@@ -612,6 +720,21 @@ async function initUpdater(){
612
  } else if(!UPDATE_URL) updSet('unset');
613
  else renderUpdatePanel();
614
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
615
  /* Auto-check on arrival at the main menu. The dot goes amber while the request
616
  is in flight and settles green or amber-with-an-update, so the player never
617
  has to remember to press CHECK. Silent: no toast, no modal — a check that
 
29
  ============================================================================ */
30
 
31
  /* Bumped by the release script. Compared against the manifest's `version`. */
32
+ const APP_VERSION = '1.32.43';
33
+
34
+ /* Release notes for the PACKAGED build, bumped by the release script beside
35
+ APP_VERSION and PACKAGED_REV. A device that has never taken an OTA has no
36
+ download history to read notes from, and an offline device can never fetch
37
+ them, so the build carries its own copy — otherwise a fresh install shows a
38
+ permanently empty first entry in the mailbox. */
39
+ const APP_NOTES = "Your faction is now your army. Picking the Crimson Dominion or the Syndicate used to change your unit models and nothing else — your base, your build plates and the carrier you land in were always Nova. All three now follow your choice, and so does your commander: the Dominion walks Lord Darion Vex, the Syndicate fields Broker Lys Renn, the Brood its Sovereign. Salvage follows material. Machines leave scrap you can reclaim for mass; animals and soldiers leave biomass that pays energy only and rots fast, so an organic battlefield has to be harvested now while a mechanised one waits for you. Killing wildlife pays energy rather than mass, and a free-spawning swarm no longer mints mass out of nothing. KEEN has a voice — 162 recorded takes across her narration and all four factions' command radio, in place of the robotic fallback that was silent on Android entirely. Crystals are crystal: every phase-ore spire in the game was being drawn as riveted vehicle armour, and now transmits light like a mineral. Armour hits read properly too — a deflection throws sparks along the shot, a penetration throws hot spall, and five weapon types that were calculating the armour multiplier and discarding it now show it. Move orders draw the route your army will actually walk, around obstacles rather than through them, with a formation outline showing where the platoon ends up before you commit. Dispatches now live in your mailbox, with a sealed archive for the ones you have not unlocked, and every release posts its notes there — including this one. Fixes: the one-time age check no longer reappears on every sign-in; scrolling the Armory no longer buys whatever is under your thumb; Android Back no longer discards the commander name you just typed; and an imported save can no longer overwrite a real career by claiming a huge core balance.";
40
 
41
  /* The channel URL in update-config.json remains publisher-configurable, but a
42
  production checker also needs one known-good recovery path. More importantly,
 
238
  });
239
  }
240
 
241
+ /* ---- THE RELEASE LOG, AND THE NOTES THAT GO IN IT --------------------------
242
+ Every release the player actually receives posts one mail item, and the body
243
+ of that item is the publisher's notes for that exact version.
244
+
245
+ Two storage rules make that work on a phone:
246
+
247
+ * The notes are staged in localStorage at DOWNLOAD time, not read back out
248
+ of the bundle at install time. They travel with the bundle in the IndexedDB
249
+ `pending` record, but that record holds the whole payload — the live
250
+ channel ships one 26.7 MB source string — and deserialising 26 MB on the
251
+ boot path to recover 1 KB of text is not something a 360px phone should be
252
+ asked to do. localStorage survives the location.replace() that restarts the
253
+ document, which is the only thing the install path actually needs.
254
+
255
+ * The log is keyed by VERSION and appended in exactly one place, so a re-check,
256
+ a relaunch, a rollback or a re-apply can never post the same release twice.
257
+ Everything here is localStorage only: no network, so the whole path is
258
+ already offline-clean and stays that way. */
259
+ const UPD_LOG_KEY='mf_update_log';
260
+ const UPD_NOTES_KEY='mf_update_notes';
261
+ const UPD_NOTES_MAX=2000;
262
+
263
+ function updLogRead(){
264
+ try{
265
+ const a=JSON.parse(localStorage.getItem(UPD_LOG_KEY)||'[]');
266
+ return Array.isArray(a)? a.filter(e=>e&&e.version) : [];
267
+ }catch(e){ return []; }
268
+ }
269
+ function updLogWrite(log){
270
+ try{ localStorage.setItem(UPD_LOG_KEY,JSON.stringify(log.slice(0,40))); return true; }
271
+ catch(e){ return false; }
272
+ }
273
+ function updStageNotes(version,notes){
274
+ if(!version) return;
275
+ try{
276
+ let a=JSON.parse(localStorage.getItem(UPD_NOTES_KEY)||'[]');
277
+ if(!Array.isArray(a)) a=[];
278
+ a=a.filter(e=>e&&e.version!==String(version));
279
+ a.unshift({version:String(version),notes:String(notes||'').slice(0,UPD_NOTES_MAX)});
280
+ localStorage.setItem(UPD_NOTES_KEY,JSON.stringify(a.slice(0,6)));
281
+ }catch(e){}
282
+ }
283
+ function updNotesFor(version){
284
+ try{
285
+ const a=JSON.parse(localStorage.getItem(UPD_NOTES_KEY)||'[]');
286
+ const e=Array.isArray(a)&&a.find(x=>x&&x.version===String(version));
287
+ return (e&&e.notes)||'';
288
+ }catch(e){ return ''; }
289
+ }
290
+ /* The ONE place that appends to the log, so the install path, the packaged seed
291
+ and any future writer cannot disagree about an entry's shape. Version is the
292
+ dedupe key. Returns true only when an entry was really added. */
293
+ function updLogPost(version,notes,extra){
294
+ if(!version) return false;
295
+ const v=String(version), log=updLogRead();
296
+ if(log.some(e=>String(e.version)===v)) return false;
297
+ log.unshift(Object.assign({version:v,at:Date.now(),
298
+ notes:String(notes||'').slice(0,UPD_NOTES_MAX),read:false},extra||{}));
299
+ return updLogWrite(log);
300
+ }
301
+ /* Stamp fields onto an existing entry: read state, and the rollback marker.
302
+ Writes only when something actually changed, because this is called on every
303
+ launch of a patched build. */
304
+ function updLogMark(version,patch){
305
+ if(!version||!patch) return false;
306
+ const v=String(version), log=updLogRead();
307
+ const e=log.find(x=>String(x.version)===v);
308
+ if(!e) return false;
309
+ let dirty=false;
310
+ for(const k of Object.keys(patch)) if(e[k]!==patch[k]){ e[k]=patch[k]; dirty=true; }
311
+ return dirty? updLogWrite(log) : false;
312
+ }
313
+ /* An entry written by a build that predates staging has an empty body, and so
314
+ does one whose staged notes were evicted. A later check whose manifest is for
315
+ THAT EXACT version can fill it in. The equality guard matters: the channel is
316
+ normally ahead of the device, and pasting the next release's notes onto the
317
+ installed one is worse than leaving it blank. */
318
+ function updBackfillNotes(m){
319
+ if(!m||!m.notes||!m.version) return;
320
+ const log=updLogRead();
321
+ const e=log.find(x=>String(x.version)===String(m.version));
322
+ if(!e||e.notes) return;
323
+ e.notes=String(m.notes).slice(0,UPD_NOTES_MAX);
324
+ if(updLogWrite(log)&&typeof renderInboxUpdates==='function')
325
+ try{ renderInboxUpdates(); }catch(err){}
326
+ }
327
+
328
  /* Compare dotted version strings numerically: '1.10.0' is newer than '1.9.9',
329
  which a string comparison gets backwards. */
330
  function verNewer(a,b){
 
400
  UPD.manifest=m;
401
  UPD.checkedVersion=m.version;
402
  UPD.source=found.source;
403
+ updBackfillNotes(m); // repair a note-less log entry for THIS version only
404
  /* Compare against the version actually running. A live-patched client can
405
  be newer than its packaged APP_VERSION; comparing only to the package
406
  made it repeatedly offer an older patch. Also distinguish a stale
 
485
  /* Commit only once every file is present and accounted for. */
486
  await updPut('pending',{version:m.version, notes:m.notes||'', at:Date.now(),
487
  order:m.files.map(f=>f.path), files:out});
488
+ /* Stage the notes where the NEXT document can read them without touching
489
+ the megabytes of source sitting in the record above. */
490
+ updStageNotes(m.version,m.notes);
491
  await updDel('applyFailure');
492
  updSet('ready',{pct:100,abort:null});
493
  }catch(e){
 
527
  }catch(e){ location.reload(); }
528
  }
529
  async function updRollback(){
530
+ /* Mark the entry BEFORE the records are destroyed: one reload from now
531
+ __MASSFRONT_PATCHED is gone and nothing can tell which version was
532
+ reverted. The entry is not removed — the player really did run that build
533
+ on this device — it just stops implying it is still installed. */
534
+ const v=typeof window!=='undefined'?String(window.__MASSFRONT_PATCHED||''):'';
535
+ if(v) updLogMark(v,{rolledBack:true});
536
  await updDel('active'); await updDel('pending'); await updDel('probation');
537
  location.reload();
538
  }
 
689
  if(window.__MASSFRONT_PATCHED) document.body.classList.add('patched');
690
  const pend=await updGet('pending');
691
  const fail=await updGet('applyFailure');
692
+ const running=window.__MASSFRONT_PATCHED?String(window.__MASSFRONT_PATCHED):'';
693
+ const installed=running&&localStorage.getItem('mf_update_installed_notice')!==running;
694
+ /* This document IS the patch, so an entry still marked reverted is describing
695
+ the build the player is looking at. Re-applying a rolled-back version can
696
+ only be caught here: it does not re-enter the branch below, because
697
+ mf_update_installed_notice still holds that version from the first install. */
698
+ if(running) updLogMark(running,{rolledBack:false});
699
  if(installed){
700
+ localStorage.setItem('mf_update_installed_notice',running);
701
+ /* Post the mail for this release. This is the history the player actually
702
  received on THIS device — not a changelog fetched from the server — so it
703
+ stays truthful offline and after a rollback.
704
+
705
+ The body comes from the copy updDownload staged in localStorage. Reading
706
+ UPD.manifest here, as this did, always produced an empty body: this is a
707
+ fresh document, UPD is the literal declared above with manifest:null, and
708
+ the first updCheck that would fill it in is still 1.4 seconds away. */
709
+ if(updLogPost(running,updNotesFor(running))&&typeof storyRefreshBadge==='function')
710
+ try{ storyRefreshBadge(); }catch(e){}
 
711
  updSet('installed');
712
  } else if(fail&&fail.quarantined&&verNewer(fail.version,updVerShown)){
713
  updSet('error',{err:'v'+fail.version+' failed to start twice and was removed - check again to download a fresh copy'});
 
720
  } else if(!UPDATE_URL) updSet('unset');
721
  else renderUpdatePanel();
722
 
723
+ /* FRESH INSTALL, and native store upgrades. No OTA ran, so nothing above
724
+ posted anything, and the mailbox would read "no updates yet" on a build the
725
+ player installed five minutes ago. Log the packaged build from its own
726
+ APP_NOTES — entirely local, so it works on a device that has never had a
727
+ network. Silent (read:true) when the log was empty: a brand-new player has
728
+ no "what's new" to catch up on and should not be handed an unread badge on
729
+ first launch. A store upgrade over existing history is real news, so that
730
+ one arrives unread. Dedupe is by version, so a rollback to a packaged build
731
+ that is already logged posts nothing. */
732
+ if(!running&&APP_NOTES){
733
+ const empty=!updLogRead().length;
734
+ if(updLogPost(APP_VERSION,APP_NOTES,{packaged:true,read:empty})&&!empty&&
735
+ typeof storyRefreshBadge==='function') try{ storyRefreshBadge(); }catch(e){}
736
+ }
737
+
738
  /* Auto-check on arrival at the main menu. The dot goes amber while the request
739
  is in flight and settles green or amber-with-an-update, so the player never
740
  has to remember to press CHECK. Silent: no toast, no modal — a check that