Spaces:
Running
Running
v1.32.43 — KEEN voice, mailbox release notes, batch-0 fixes, terrain recovery
Browse files- src/account.js +6 -1
- src/assetpack.js +33 -9
- src/audio.js +202 -32
- src/authportal.js +12 -3
- src/engine/terrain.js +12 -0
- src/game/meta.js +3 -3
- src/glrecover.js +7 -0
- src/main.js +58 -24
- src/storeui.js +8 -2
- src/story.js +173 -74
- src/tutorial.js +70 -26
- src/ui/hud.js +4 -0
- src/updater.js +137 -14
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 192 |
-
|
|
|
|
|
|
|
|
|
|
| 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 = '
|
| 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
|
| 209 |
-
const
|
| 210 |
-
PACK.
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
|
|
|
| 210 |
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
if(!slot) return false;
|
| 246 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 200 |
-
|
| 201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 1055 |
-
+tog('orderPaths','
|
| 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 |
-
/*
|
| 307 |
-
|
| 308 |
-
|
|
|
|
|
|
|
|
|
|
| 309 |
const carrierFacQa=(location.search.match(/[?&]carrierfac=(nova|terran|frontline|federation|legion|ascendancy|red|syndicate|machine|coalition|horde|brood|swarm|infestation)/)||[])[1]||'';
|
| 310 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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();
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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
|
| 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 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
}
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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?('
|
| 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 |
-
/*
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 254 |
-
rows.push({i,d,read});
|
| 255 |
}
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 282 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
const cur=(typeof APP_VERSION!=='undefined'&&APP_VERSION)||'';
|
| 284 |
-
|
|
|
|
| 285 |
g.innerHTML='<div class="inboxEmpty">No updates installed on this device yet'
|
| 286 |
+(cur?' — running v'+cur:'')+'.</div>';
|
| 287 |
return;
|
| 288 |
}
|
| 289 |
-
|
|
|
|
|
|
|
|
|
|
| 290 |
const d=new Date(e.at||0);
|
| 291 |
-
const when=isNaN(d.getTime())?''
|
| 292 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
+'<span class="inboxDot" aria-hidden="true"></span>'
|
| 294 |
-
+'<span class="inboxTx"><b>v'+
|
| 295 |
-
+'<span>'+(e.notes
|
| 296 |
-
+'<span class="inboxMeta">'+when+'</span></
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 317 |
if(typeof netAllowed==='function'&&!netAllowed()) return;
|
|
|
|
|
|
|
|
|
|
| 318 |
try{
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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 —
|
| 405 |
return d;
|
| 406 |
}
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|
|
|
|
|
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 589 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 590 |
if(installed){
|
| 591 |
-
localStorage.setItem('mf_update_installed_notice',
|
| 592 |
-
/*
|
| 593 |
received on THIS device — not a changelog fetched from the server — so it
|
| 594 |
-
stays truthful offline and after a rollback.
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 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
|