; ; /* ============================================================================ TUTORIAL — KEEL, ship liaison intelligence, Lance of Morning ---------------------------------------------------------------------------- Two jobs live in this file: 1. A first-drop tutorial, narrated in character rather than read off a card. Every step is gated on the SAME state the rest of the game already tracks — bldLive, unit arrays, ability cooldowns, kill counts — polled from a private setInterval. Nothing here edits the frame loop, and nothing here can desync from what actually happened: if the player builds a Factory before a Reactor, or wins a fight before they ever open the build menu, the step order just adapts around them instead of stalling on a script it expected. 2. A small set of reactive barks that outlive the tutorial — first base attack, first loss, a hazard telegraph, a wave massing, the grid running dry. Each fires at most once per match. The mechanical coach in hud.js already tells you WHAT to fix; KEEL never repeats that, it only ever reacts to what it means. KEEL is written the way the dispatches are written: short, declarative, never a pep talk. It respects that the player can read a build menu; it just narrates the war while they do. File ownership: this module speaks to the rest of the game entirely through globals that already exist (toast, sfx, $, META, metaSave, bldLive, TYPES, BT, the unit arrays) and extends exactly one function it does not own — renderSettings() — using the same wrap-and-call-through pattern src/game/commander.js and src/game/meta.js already use on econTick() and heroXP(). Nothing here is imported; it is one more script in the shared boot scope, loaded after meta.js and before main.js. ============================================================================ */ (function(){ /* --------------------------------------------------------------------------- THE SCRIPT Every line KEEL says lives here, in one place, so the voice stays one voice. Step copy is fixed (a lesson should say the same thing every time); reactive lines get two or three variants because a player who finishes a dozen matches will hear the same trigger a dozen times. --------------------------------------------------------------------------- */ var KEEL_NAME='KEEL'; var KEEL_TAG='SHIP LIAISON · LANCE OF MORNING'; var GUIDE_VERSION=4; var GREETING='KEEL online — ship liaison, Lance of Morning. Follow the gold signal: it marks the exact control for your next action. '+ 'Tap SKIP any time; replay this guide from Settings.'; var SKIP_LINE='Understood. Find me again under Settings → Tutorial if you change your mind.'; var GRADUATION='Field orientation complete. Camera, economy, orders, logistics, formations, scouting, technology and extraction are certified. '+ 'Real operations are chosen at the WAR TABLE: system, planet, region, then a site. '+ "I'll go quiet now — unless something's on fire."; /* Each step tests REAL state. `say` is shown the moment a step becomes current; `done` fires once, the moment its test flips true — but only if it was the step on screen when that happened, so a player who does things out of order gets fast-forwarded in silence instead of congratulated for a step they finished five minutes ago. */ var STEPS=[ { id:'camera', icon:'◇', say:'DRAG the battlefield to pan. PINCH to zoom and twist, or use VIEW → ROT / TILT. Move the camera once to confirm control.', done:'Command view responding. The minimap can jump across the whole battlefield.', test:function(){ return MATCH.sawCamera; } }, { id:'deploy', icon:'⚓', say:'Tap open ground to fly the carrier there. When the landing signal turns green, tap DEPLOY BASE HERE.', done:"Hull's down, engines cold. That was the easy part.", test:function(){ return typeof matchLive!=='undefined' && !!matchLive; } }, { id:'commander', icon:'★', say:'Your COMMANDER is the marked hero and a mobile builder. Tap the Commander bar at top-left to select and center it.', done:'Commander identified. Keep it alive: losing every Commander loses the match.', test:function(){ return typeof heroIdx==='number'&&heroIdx>=0&&!!usel[heroIdx]; } }, { id:'pickup', icon:'◆', say:'A gold SUPPLY CACHE is marked nearby. Select the Commander or Constructor, then tap beside the cache so the unit collects it.', done:'Cache secured. Pickups can grant resources, repairs, scans, veterancy or rare strike codes.', test:function(){ return MATCH.sawPickup; } }, /* Moved ahead of the economy steps. `power` tells the player to place a Reactor "inside the blue grid" — and the step that explains what the blue grid is used to sit three objectives further down. */ { id:'territory', icon:'⌁', manual:'territoryAck', action:'GOT IT', say:'LOGISTICS: blue grid is legal build territory. Builders extend a mobile zone; Factories and Uplinks anchor it. Silos prevent resource overflow. Tap GOT IT.', done:'Logistics and territory rules logged.', test:function(){ return MATCH.territoryAck; } }, { id:'mex', icon:'⛏', say:'Mass buys units and structures. Tap BUILD → ECONOMY → Extractor, drag its hologram onto a ◆ deposit, then tap ✓.', done:"Extractor placed. That's your first income line.", test:function(){ return hasPlayerBld('mex'); } }, { id:'power', icon:'⚡', say:'Production also needs energy. Tap BUILD → ECONOMY → Reactor, place it on open ground inside the blue grid, then tap ✓.', done:'Reactor placed. Mass and energy must both stay positive while building.', test:function(){ return hasPlayerBld('pgen')||hasPlayerBld('geo'); } }, { id:'fac', icon:'▣', say:"Build a Factory under BUILD → PRODUCTION. It is this army's barracks and vehicle plant, and expands construction territory.", done:'Factory placed. Open it to train the first field unit.', test:function(){ return hasPlayerBld('fac'); } }, { id:'queue', icon:'▤', say:'Tap the Factory on the map, then tap the Striker unit card. The queue line shows what is being built. Each unit also costs population — the HUD counter reads pop n/1K, and 1K is this commander\'s cap.', done:'Striker queued. Production burns mass and energy over the build time. Pop n/1K is live headcount against the 1K seat cap — theatre size adds more seats, it does not raise yours.', test:function(){ return MATCH.sawQueue; } }, { id:'train', icon:'⚔', say:'Keep the Factory supplied until the Striker rolls out. Tap ARMY when it appears to select every field unit.', done:'Combat unit trained and selected. Unit cards show role, range, armor and ammunition.', test:function(){ return MATCH.sawCombatSel&&combatCount()>MATCH.startCombat; } }, /* ORDERS ARE A GESTURE LANGUAGE AND NOTHING ON THE HUD SAYS SO. Ground tap, enemy tap and double-tap ground write three different unit states in orderMove()/orderAttack() (src/ui/input.js). Two of them a player discovers by accident within a minute; the double-tap retreat is the one nobody finds, and it is the one that saves an army — which is why this step gates on the retreat specifically rather than on any order at all. */ { id:'orders', icon:'➤', say:'Orders are gestures. SINGLE-TAP GROUND is attack-move: units advance and fight what they meet. TAP AN ENEMY and they chase it. DOUBLE-TAP OPEN GROUND to RETREAT: they drop the target and reposition without stopping to fight. Break contact once now.', done:'Retreat confirmed — double-tap is how an army leaves a fight it is losing. Single-tap remains attack-move. Stop in the command row and Hold under ORDERS both plant units where they stand: they fire at whatever enters weapon range and never chase it out. Stop is the panic button, Hold is the same order given on purpose.', test:function(){ return MATCH.sawRetreat; } }, /* THE ONE STEP THAT TEACHES THE GAME RATHER THAN THE BUTTONS. Every other objective here is "tap X to make Y happen", and the training match is deliberately combat-free — trainingSafety() parks the wave timers and floors resources — so a player can complete all of it without ever making a wrong-weapon decision. Meanwhile the counter card already renders the whole 0.45x-1.85x chip grid, splash, minimum range and shield-pierce prose, and it opens on a 520 ms long-press that nothing ever asks for. This step exists to make the player perform that gesture once. */ { id:'intel', icon:'⬢', say:'Long-press any unit — yours or the enemy\'s — to open its counter card. Weapon class against armour class swings damage from 0.45x to 1.85x: KINETIC shreds LIGHT and dents HEAVY, GAUSS is the exact reverse.', done:'Counter card read. The wrong weapon against the wrong plate is a quarter of the damage you thought you had — and SONIC is the only thing in the roster that ignores Bulwark shields.', test:function(){ return shown(document.getElementById('unitCard')); } }, { id:'turret', icon:'⛨', say:'Protect the economy before the first push. Tap BUILD → DEFENCE → Sentinel and place it on the likely approach.', done:'Basic perimeter online. Towers buy your army time; they do not replace it. Long-ranged guns — Bastions, Thumpers, Bombards — also have a MIN RANGE, and an enemy inside it is an enemy they cannot shoot.', test:function(){ return hasPlayerBld('turret'); } }, { id:'platoon', icon:'Ⅳ', say:'With combat units selected, open PLATOONS and HOLD P1 to save them. Tap P1 later to recall; double-tap focuses the camera.', done:'Platoon P1 stored. Saved groups remember membership and formation.', test:function(){ return MATCH.sawPlatoon; } }, { id:'formation', icon:'⌃', say:'Open ORDERS, tap the formation button, then drag the hologram on the map and release. Every marker is one unit slot.', done:'Formation placed. Spacing keeps mixed units readable and prevents stacking.', test:function(){ return MATCH.sawFormation; } }, { id:'fog', icon:'⌾', say:'Black fog hides enemies; grey terrain is remembered, not currently visible. Move the marked SCOUT toward the signal to reveal the sector.', done:'Sensor contact expanded. Enemy positions remain hidden until a friendly sensor sees them.', test:function(){ return MATCH.sawScout; } }, { id:'attack', icon:'➜', say:'Leave A-MOVE active and SINGLE-TAP unexplored ground. That is attack-move: units advance and engage contacts. DOUBLE-TAP is still retreat. Watch what actually does the killing: a unit with SPLASH on its card hits everything inside the radius, which beats raw damage against anything bunched up.', done:'Attack-move confirmed — single-tap ground fights on the way; double-tap ground breaks contact. Long-press a unit or structure for its full counter card.', test:function(){ return MATCH.sawAttackMove; } }, { id:'tech', icon:'⌬', say:'Field rank 3 granted for training. Build BUILD → TECH → Research Complex; it unlocks studies and banks account-level ◆ Data after battle.', done:'Research Complex online. Its shield buffer protects studies from a sudden rush.', test:function(){ return hasPlayerBld('techlab'); } }, { id:'ability', icon:'✦', say:'Select the Commander, then tap BLAST in the action bar above the selection panel. Abilities cost energy and have cooldowns.', done:'Special ability confirmed. Research and Commander levels unlock additional powers.', test:function(){ return MATCH.usedAbility; } }, { id:'objective', icon:'◎', manual:'objectiveAck', action:'CALL EXTRACTION', say:'Normal operations end by their selected objective: destroy Commanders, hold territory, purge hives or survive. Training is secure—tap CALL EXTRACTION.', done:'Extraction acknowledged. Match resources, rewards and research are summarized after every operation.', test:function(){ return MATCH.objectiveAck; } }, { id:'cloud', icon:'☁', manual:'cloudAck', action:'FINISH TRAINING', say:'Your career autosaves locally. After extraction, ACCOUNT can compare and sync a cloud save; PROFILE can export a portable .mfsave backup. Tap FINISH TRAINING.', done:'Career storage confirmed.', test:function(){ return MATCH.cloudAck; } } ]; var BASE_ATTACK_LINES=[ 'Contact on the perimeter — tap the ⚠ alert, top of screen, and go see for yourself.', "Something's testing your walls. The ⚠ alert will take you straight there." ]; var UNIT_LOST_LINES=[ "That one's not getting up. Filed under acceptable losses — keep the file short.", 'Lost one. It happens to everyone who actually fights. Keep moving.' ]; var LOW_POWER_LINE="Grid's in the red. Whatever's queued behind it just stopped moving."; var WAVE_LINE="Enemy's massing for a real push — you'll feel this one land. Get your army somewhere useful."; var HAZARD_LINES={ vanguard:'Dust front inbound — sight and speed go with it. Get exposed units under cover.', highland:"The slope's about to become the valley floor. Clear the marked ground.", isles:"Storm's earthing through whatever's biggest and made of metal. Spread out.", crater:"The relic's stirring. The basin's about to go dark — don't be standing in it.", _default:"Something's inbound that isn't the enemy. Read the warning; it isn't decoration." }; /* --------------------------------------------------------------------------- STATE --------------------------------------------------------------------------- */ var TUT={ active:false, stepIdx:0, doneFlags:[], shownStepIdx:-1, graduated:false, forceNext:false, trainingMode:false, finishTimer:0 }; var trainingPrev=null, trainingLaunched=false; /* Set only while re-entering tutSkip from its own confirmation callback, so the dialog cannot ask twice. */ var tutSkipConfirmed=false; /* Facts about THIS match, tracked whether or not the tutorial is running, so a mid-match "replay" (from Settings) can credit things the player already demonstrably knows how to do instead of waiting for them to happen again. */ var MATCH={ sawSel:false, sawCombatSel:false, sawMove:false, sawQueue:false, sawCamera:false, sawPickup:false, sawPlatoon:false, sawFormation:false, sawAttackMove:false,sawScout:false, sawRetreat:false, territoryAck:false,objectiveAck:false,cloudAck:false,usedAbility:false, startCombat:0,cameraBase:null,pickup:null,scoutIdx:-1,scoutStart:null,lastAbCool:[0,0,0,0] }; /* Reactive one-per-match latches. */ var REACT={ baseAttack:false, lowPower:false, hazard:false, unitLost:false, wave:false, lastAlarmT:0, lastStallE:false, lastHazWarn:false, lastWarned:false }; var prevDropping=false; function tutMeta(){ if(typeof META==='undefined'||!META) return {done:false,skipped:false,version:0}; META.tutorial=META.tutorial||{done:false,skipped:false,version:0}; /* `progress` is additive save metadata: old profiles load exactly as before, while the briefing can distinguish new, interrupted and completed runs. It is a high-water mark only; the live-state gates below remain the authority whenever a mission is running. */ if(typeof META.tutorial.progress!=='number') META.tutorial.progress=META.tutorial.done?STEPS.length:0; META.tutorial.progress=Math.max(0,Math.min(STEPS.length,META.tutorial.progress|0)); return META.tutorial; } function pick(arr){ return arr[(Math.random()*arr.length)|0]; } function hasPlayerBld(type){ if(typeof bldLive==='undefined') return false; for(var i=0;i=1.1s for the cheapest unit — see TYPES[0].bt) and is only shifted off on completion, so a non-empty queue is never a one-frame flicker: it is guaranteed to be visible across many polls of this timer, with no need for a population backstop that would have to guess at a baseline. */ for(var i=0;i0 && (B.type==='fac'||B.type==='tgate'||B.type==='harbor'||B.type==='airfield')) return true; } return false; } /* --------------------------------------------------------------------------- THE BUBBLE — one element, two moods. "Speaking" (a fresh line just landed, full emphasis, brief pulse) settles into "resting" (the current step's objective, held at lower emphasis) once its hold time elapses. Reactive barks are not tied to a step: they interrupt, hold briefly, then hand back to whatever the resting text was (or hide, if the tutorial isn't running). --------------------------------------------------------------------------- */ var queue=[], holdUntil=0, restingText='', restingTag='', restingId='', lastText=null, domReady=false; var focusEls=[], mapCue=null; function buildDOM(){ if(domReady) return; domReady=true; var wrap=document.createElement('div'); wrap.id='keelWrap'; wrap.setAttribute('aria-live','polite'); wrap.innerHTML= '
'+ ''+ '
'+ '
'+KEEL_NAME+'
'+ '
'+ '
'+ ''+ '
'+ ''+ '
'; document.body.appendChild(wrap); var cue=document.createElement('div'); cue.id='keelMapCue'; cue.setAttribute('aria-hidden','true'); cue.innerHTML=''; document.body.appendChild(cue); var mode=document.createElement('div'); mode.id='keelModeBadge'; mode.setAttribute('aria-live','polite'); mode.innerHTML='TRAINING OPERATIONKEEL GUIDANCE ACTIVE'; document.body.appendChild(mode); var skipBtn=document.getElementById('keelSkip'); skipBtn.addEventListener('pointerdown',function(ev){ ev.stopPropagation(); tutSkip(); }); /* A control that abandons a match should not be styled as a hint. In a training match the label becomes END and the button takes the warning treatment, so the destructive one is never the quiet one. */ skipBtn.classList.toggle('keelSkipEnd',!!TUT.trainingMode); skipBtn.textContent=TUT.trainingMode?'END':'SKIP'; var nextBtn=document.getElementById('keelNext'); nextBtn.addEventListener('pointerdown',function(ev){ ev.preventDefault(); ev.stopPropagation(); var S=TUT.active&&STEPS[TUT.stepIdx]; if(!S||!S.manual) return; MATCH[S.manual]=true; if(typeof sfx==='function'){ try{ sfx('ui'); }catch(e){} } }); /* Tapping the body re-shows the full-strength card if it has settled into its dim resting state — a quiet "say that again" without extra chrome. */ document.getElementById('keelBar').addEventListener('pointerdown',function(ev){ if(ev.target===skipBtn||ev.target===nextBtn) return; if(restingText) speak(restingText,4.2,'recall',restingId); }); mapCue=cue; } function renderBubble(text,tag){ if(!domReady) return; if(text!==lastText){ var t=document.getElementById('keelTxt'); if(t) t.textContent=text; lastText=text; } var tg=document.getElementById('keelStepTag'); if(tg) tg.textContent=tag||''; var pg=document.getElementById('keelProgress'); if(pg){ var at=Math.min(STEPS.length,TUT.stepIdx),pct=STEPS.length?at/STEPS.length*100:0; pg.setAttribute('aria-valuemax',STEPS.length);pg.setAttribute('aria-valuenow',at); var fill=pg.querySelector('i');if(fill)fill.style.width=pct+'%'; } } function showWrap(fresh){ if(!domReady) return; var w=document.getElementById('keelWrap'); if(w) w.classList.add('show'); var bar=document.getElementById('keelBar'); if(bar){ bar.classList.toggle('resting',!fresh); if(fresh){ bar.classList.remove('pulse'); void bar.offsetWidth; bar.classList.add('pulse'); } } } function hideWrap(){ if(!domReady) return; var w=document.getElementById('keelWrap'); if(w) w.classList.remove('show'); } /* --------------------------------------------------------------------------- CONTEXT SIGNAL — the copy names a control and this signal marks that exact control. It is recalculated every poll because BUILD changes from button → tab → card → world placement without changing tutorial steps. The signal is purely visual (pointer-events:none), so it can never become an input shield. --------------------------------------------------------------------------- */ function shown(el){ if(!el||!el.isConnected) return false; var s=getComputedStyle(el),r=el.getBoundingClientRect(); return s.display!=='none'&&s.visibility!=='hidden'&&r.width>1&&r.height>1; } function byText(rootId,selector,text){ var root=document.getElementById(rootId); if(!root) return null; var list=root.querySelectorAll(selector),want=String(text).toUpperCase(); for(var i=0;i=0) return list[i]; return null; } function clearFocus(){ for(var i=0;i=0) return; el.classList.add('keelFocus'); el.setAttribute('aria-describedby','keelTxt'); focusEls.push(el); } function cueAt(x,y,label){ if(!mapCue) return; var pad=62,bot=typeof VH==='number'?Math.max(210,VH*.25):220; x=Math.max(pad,Math.min((typeof VW==='number'?VW:innerWidth)-pad,x)); y=Math.max(170,Math.min((typeof VH==='number'?VH:innerHeight)-bot,y)); mapCue.style.left=x+'px'; mapCue.style.top=y+'px'; var t=mapCue.querySelector('span'); if(t) t.textContent=label||'TAP MAP'; mapCue.classList.add('show'); } function cueWorld(x,y,label){ try{ var p=w2s(x,y); cueAt(p[0],p[1],label); } catch(e){ cueAt((typeof VW==='number'?VW:innerWidth)*.5,(typeof VH==='number'?VH:innerHeight)*.5,label); } } function buildFocus(cat,name,type){ if(typeof placing!=='undefined'&&placing&&placing.type===type){ var valid=false; try{ valid=!!placementValid(); }catch(e){} if(valid) focusEl(document.getElementById('placeOk')); if(type==='mex'&&!valid&&typeof deposits!=='undefined'){ var best=null,bd=Infinity; for(var i=0;i=STEPS.length) return; var S=STEPS[TUT.stepIdx],next=document.getElementById('keelNext'); if(badge){ var bs=badge.querySelector('span'); if(bs) bs.textContent='OBJECTIVE '+(TUT.stepIdx+1)+' / '+STEPS.length+' · '+S.id.toUpperCase(); } if(next){ next.style.display=S.manual?'inline-flex':'none';next.textContent=S.action||'GOT IT'; next.setAttribute('aria-label',(S.action||'Confirm')+' — '+S.id+' lesson'); } if(S.id==='camera'){ focusEl(document.querySelector('.hudDeckBtn[data-deck="view"]')); focusEl(document.getElementById('rotL'));focusEl(document.getElementById('zoomIn'));focusEl(document.getElementById('tiltBtn'));focusEl(document.getElementById('zoomOut'));focusEl(document.getElementById('rotR')); cueAt((typeof VW==='number'?VW:innerWidth)*.5,(typeof VH==='number'?VH:innerHeight)*.52,'DRAG / PINCH'); } else if(S.id==='deploy'){ var dep=document.getElementById('deployBtn'); if(shown(dep)) focusEl(dep); else cueAt((typeof VW==='number'?VW:innerWidth)*.5,(typeof VH==='number'?VH:innerHeight)*.52,'TAP LANDING SITE'); } else if(S.id==='commander'){ focusEl(document.getElementById('heroBar')); } else if(S.id==='pickup'){ if(MATCH.pickup&&crates.indexOf(MATCH.pickup)>=0)cueWorld(MATCH.pickup.x,MATCH.pickup.y,'◆ COLLECT CACHE'); focusEl(document.getElementById('heroBar')); } else if(S.id==='mex') buildFocus('ECONOMY','Extractor','mex'); else if(S.id==='power') buildFocus('ECONOMY','Reactor','pgen'); else if(S.id==='fac') buildFocus('PRODUCTION','Factory','fac'); else if(S.id==='territory'){ focusEl(document.getElementById('buildBtn')); var H=playerBld('hq'); if(H) cueWorld(H.x,H.y,'BLUE BUILD GRID'); } else if(S.id==='queue'){ focusEl(document.getElementById('unitRes')); var pm=document.getElementById('prodMenu'); if(shown(pm)) focusEl(byText('prodGrid','.bcard','Striker')||pm.querySelector('.bcard')); else { var F=playerBld('fac'); if(F) cueWorld(F.x,F.y,'TAP FACTORY'); } } else if(S.id==='train'){ if(hasCombatUnit()) focusEl(document.getElementById('armyBtn')); else { var F2=playerBld('fac'); if(F2) cueWorld(F2.x,F2.y,'UNIT BUILDING'); } } else if(S.id==='orders'){ /* No button teaches this one — the cue has to sit on the ground the player is being asked to double-tap, offset clear of the units so the marker is not sitting under the very gesture it is asking for. */ var ox=0,oy=0,on=0; if(typeof unitHigh==='number') for(var q=0;q=0&&ualive[MATCH.scoutIdx])cueWorld(ux[MATCH.scoutIdx]+190,uy[MATCH.scoutIdx]-150,'SCOUT THIS SECTOR'); } else if(S.id==='tech') buildFocus('TECH','Research Complex','techlab'); else if(S.id==='ability'){ focusEl(document.getElementById('heroBar')); if(typeof heroIdx==='number'&&heroIdx>=0&&ualive[heroIdx])cueWorld(ux[heroIdx]+105,uy[heroIdx]-70,'BLAST TARGET'); } else if(S.id==='objective'||S.id==='cloud'){ focusEl(next); } } /* Capped so a burst of state changes (several buildings finishing within the same poll, or a mid-match replay that silently back-fills a lot of already-true steps) can never queue up a minute of stale dialogue. Once the backlog is deep, older lines are dropped in favour of newer ones — the step the player is looking at right now matters more than a "done" bark for something they finished three actions ago. */ var MAX_QUEUE=3; /* `id` is the AUTHORED voice key for this line (see keenLineId). It travels with the line through the queue instead of being reconstructed from the copy on the far side — reconstructing it is what let the gate and the player disagree. */ function speak(text,holdSec,kind,id){ if(!text) return; queue.push({text:text,hold:holdSec||4.4,kind:kind||'bark',id:id||null}); while(queue.length>MAX_QUEUE) queue.shift(); } function pump(){ var now=performance.now()/1000; if(now=0;})|| voices.find(function(q){return /^en/i.test(q.lang);}); if(chosen)u.voice=chosen; } window.speechSynthesis.speak(u); }catch(e){ /* degrade silently — the radio panel already carries the message */ } } /* --------------------------------------------------------------------------- MATCH-LEVEL SIGNAL TRACKING — runs every tick regardless of whether the tutorial is active, so replaying it mid-match can credit what the player already demonstrably did. --------------------------------------------------------------------------- */ function trackMatchSignals(){ if(sawQueuedUnit()) MATCH.sawQueue=true; if(MATCH.cameraBase){ var C=MATCH.cameraBase; if((typeof camUserT==='number'&&camUserT>0)||Math.abs(yawTarget-C.yaw)>.06|| Math.abs(pitchTarget-C.pitch)>.035||Math.abs(orthoSpan-C.span)>24)MATCH.sawCamera=true; } if(typeof unitHigh==='number' && (!MATCH.sawSel||!MATCH.sawCombatSel)){ for(var i=0;i=0&&ctrlGroups[activePlatoon]&&groupLive(ctrlGroups[activePlatoon]).length) MATCH.sawPlatoon=true; if(typeof orderConfirm!=='undefined'&&orderConfirm)MATCH.sawFormation=true; if(MATCH.scoutIdx>=0&&MATCH.scoutStart&&ualive[MATCH.scoutIdx]&& Math.hypot(ux[MATCH.scoutIdx]-MATCH.scoutStart.x,uy[MATCH.scoutIdx]-MATCH.scoutStart.y)>115)MATCH.sawScout=true; if(typeof abCool!=='undefined'){ for(var k=0;k(MATCH.lastAbCool[k]||0)+0.01) MATCH.usedAbility=true; MATCH.lastAbCool[k]=abCool[k]; } } } /* --------------------------------------------------------------------------- TUTORIAL ENGINE --------------------------------------------------------------------------- */ function beginRun(){ buildDOM(); TUT.active=true; TUT.graduated=false; TUT.stepIdx=0; TUT.shownStepIdx=-1; TUT.doneFlags=STEPS.map(function(){ return false; }); MATCH.sawSel=false;MATCH.sawCombatSel=false;MATCH.sawMove=false;MATCH.sawQueue=false;MATCH.sawCamera=false; MATCH.sawPickup=false;MATCH.sawPlatoon=false;MATCH.sawFormation=false;MATCH.sawAttackMove=false;MATCH.sawScout=false;MATCH.sawRetreat=false; MATCH.territoryAck=false;MATCH.objectiveAck=false;MATCH.cloudAck=false;MATCH.usedAbility=false; MATCH.startCombat=combatCount();MATCH.pickup=null;MATCH.scoutIdx=-1;MATCH.scoutStart=null; MATCH.cameraBase={yaw:yawTarget,pitch:pitchTarget,span:orthoSpan}; MATCH.lastAbCool=(typeof abCool!=='undefined')?abCool.slice():[0,0,0,0]; queue.length=0; speak(GREETING,5.5,'greeting','greeting'); } function evalSteps(){ if(!TUT.active) return; for(var i=0;i(progressMeta.progress|0)){ progressMeta.progress=TUT.stepIdx; if(typeof metaSave==='function') metaSave(); } if(TUT.stepIdx>=STEPS.length){ if(!TUT.graduated){ TUT.graduated=true; TUT.active=false; var M=tutMeta(); M.done=true; M.skipped=false; M.version=GUIDE_VERSION; M.progress=STEPS.length; restingText=''; restingTag=''; restingId=''; speak(GRADUATION,6.5,'graduation','graduation'); if(typeof sfx==='function'){ try{ sfx('level'); }catch(e){} } if(TUT.trainingMode&&!TUT.finishTimer) TUT.finishTimer=setTimeout(finishTrainingMission,1300); else if(typeof metaSave==='function') metaSave(); } return; } var cur=STEPS[TUT.stepIdx]; restingText=cur.say; restingId='step_'+cur.id; restingTag=(cur.icon||'◇')+' STEP '+(TUT.stepIdx+1)+' / '+STEPS.length; if(TUT.shownStepIdx!==TUT.stepIdx){ TUT.shownStepIdx=TUT.stepIdx; speak(cur.say,5.5,'step',restingId); } } /* SKIP ends GUIDANCE. In a training MATCH it also ends the match — and it used to do that on a single tap of a 9px transparent label sitting on the guidance bar for the whole session, with no confirmation and no undo. The deliberate way out (pause > ABANDON MATCH) has always asked first; the accidental way out did not. Same question, same wording, before anything is thrown away. Guidance-only skips stay instant: nothing is lost. */ function tutSkip(){ if(!TUT.active) return; var liveTraining=TUT.trainingMode&&(typeof running!=='undefined'&&running)&& !(typeof gameEnded!=='undefined'&&gameEnded); if(liveTraining&&!tutSkipConfirmed&&typeof accConfirm==='function'){ accConfirm('Leave training and return to the menu? This operation grants no payout.', function(){ tutSkipConfirmed=true; tutSkip(); tutSkipConfirmed=false; }); return; } TUT.active=false; var wasTraining=TUT.trainingMode; var M=tutMeta(); M.skipped=true; M.version=GUIDE_VERSION; queue.length=0; restingText=''; restingTag=''; restingId=''; clearFocus(); if(wasTraining){ TUT.trainingMode=false; trainingLaunched=false; restoreTrainingConfig(); if(typeof metaSave==='function') metaSave(); if(typeof returnToMainMenu==='function') returnToMainMenu(); } else if(typeof metaSave==='function') metaSave(); speak(SKIP_LINE,4.0,'skip','skip'); if(typeof sfx==='function'){ try{ sfx('ui'); }catch(e){} } } /* --------------------------------------------------------------------------- REACTIVE NARRATION — beyond the tutorial. Sparse on purpose: each of these fires at most once per match. The mechanical coach in hud.js already tells the player WHAT to fix (low energy, low mass); these only ever react to what just happened, and never repeat that advice. --------------------------------------------------------------------------- */ function checkReactive(){ /* KEEL is the Tutorial guide, not the selected battlefield commander. Standard/Campaign radio belongs to the chosen faction commander. */ if(!TUT.trainingMode) return; if(typeof running==='undefined'||!running) return; if(typeof demoMode!=='undefined'&&demoMode) return; /* Nothing here is meaningful before the HQ is actually down — there is no base to attack, no grid to stall, no wave to mass against. Gating on matchLive (rather than just `running`) also means any state left over from the tail of a PREVIOUS match — a grid still reading "stalled" in the last instant before defeat — has already decayed by the time it could matter again, instead of firing a premature bark during the next drop before the player has even landed. */ if(typeof matchLive==='undefined'||!matchLive) return; if(typeof alarmT!=='undefined' && alarmT>0 && alarmT!==REACT.lastAlarmT){ REACT.lastAlarmT=alarmT; /* pick() throws the chosen index away, and the index is exactly what the voice key needs — the two variants are two separate recordings. Indexed inline rather than changing pick(), which has other callers. */ if(!REACT.baseAttack){ REACT.baseAttack=true; var bi=(Math.random()*BASE_ATTACK_LINES.length)|0; speak(BASE_ATTACK_LINES[bi],5.0,'reactive','react_base_attack'+bi); } } if(typeof stallE!=='undefined'){ var nowStall=stallE>0; if(nowStall && !REACT.lastStallE && !REACT.lowPower){ REACT.lowPower=true; speak(LOW_POWER_LINE,4.6,'reactive','react_low_power'); } REACT.lastStallE=nowStall; } if(typeof HAZ!=='undefined'){ var nowWarn=HAZ.warn>0; if(nowWarn && !REACT.lastHazWarn && !REACT.hazard){ REACT.hazard=true; var key=(typeof curMap!=='undefined')?curMap:''; /* HAZARD_LINES._default is keyed with a leading underscore so it cannot collide with a map id; the rendered take is plain `react_hazard_default`. Guard the underscore explicitly — a map literally named "_default" would otherwise ask for a take that was never rendered. */ var hk=(key&&key.charAt(0)!=='_'&&HAZARD_LINES[key])?key:'default'; speak(HAZARD_LINES[key]||HAZARD_LINES._default,5.2,'reactive','react_hazard_'+hk); } REACT.lastHazWarn=nowWarn; } if(typeof stats!=='undefined' && stats.kills && !REACT.unitLost && ((stats.kills[1]|0)+(stats.kills[2]|0))>0){ REACT.unitLost=true; var ui=(Math.random()*UNIT_LOST_LINES.length)|0; speak(UNIT_LOST_LINES[ui],4.2,'reactive','react_unit_lost'+ui); } if(typeof AI!=='undefined'){ if(AI.warned && !REACT.lastWarned && !REACT.wave){ REACT.wave=true; speak(WAVE_LINE,5.0,'reactive','react_wave'); } REACT.lastWarned=!!AI.warned; } } /* --------------------------------------------------------------------------- MATCH-START DETECTION — the carrier goes active/phase 0 exactly once per drop (see newSkirmish() in src/main.js), so that edge is what arms a fresh run and resets every once-per-match latch. Polled, not hooked: this file never touches newSkirmish itself. */ function onNewMatchBegin(){ REACT.baseAttack=false; REACT.lowPower=false; REACT.hazard=false; REACT.unitLost=false; REACT.wave=false; REACT.lastAlarmT=(typeof alarmT!=='undefined')?alarmT:0; REACT.lastStallE=false; REACT.lastHazWarn=false; REACT.lastWarned=false; MATCH.sawSel=false;MATCH.sawCombatSel=false;MATCH.sawMove=false;MATCH.sawQueue=false;MATCH.sawCamera=false; MATCH.sawPickup=false;MATCH.sawPlatoon=false;MATCH.sawFormation=false;MATCH.sawAttackMove=false;MATCH.sawScout=false;MATCH.sawRetreat=false; MATCH.territoryAck=false;MATCH.objectiveAck=false;MATCH.cloudAck=false;MATCH.usedAbility=false; MATCH.startCombat=combatCount();MATCH.pickup=null;MATCH.scoutIdx=-1;MATCH.scoutStart=null; MATCH.cameraBase={yaw:yawTarget,pitch:pitchTarget,span:orthoSpan}; MATCH.lastAbCool=(typeof abCool!=='undefined')?abCool.slice():[0,0,0,0]; if(TUT.trainingMode&&!TUT.active){ TUT.forceNext=false; beginRun(); } } function detectMatchEdge(){ var dropping=(typeof carrier!=='undefined')&&carrier.active&&carrier.phase===0; if(dropping&&!prevDropping) onNewMatchBegin(); prevDropping=dropping; } function tutTick(){ try{ detectMatchEdge(); trackMatchSignals(); trainingSafety(); if(TUT.active) evalSteps(); checkReactive(); pump(); /* A carrier/menu transition can hide KEEL while its speech queue is still holding the current line. TUT.active is the authoritative mission state, so never leave an active training objective visually absent just because the voice pump is between messages. */ if(TUT.active&&restingText) showWrap(false); syncFocus(); updateTrainingEntry(); /* A carrier transition, a modal, or opening an interface briefly changes `running` in some mobile builds. Treating that as an abort was the reason a tap in training could silently throw the player to the menu. Training now ends only through Skip, completion, or the explicit return to main-menu transaction below. */ }catch(e){ if(window.console) console.error('tutorial tick',e); } } /* --------------------------------------------------------------------------- TRAINING OPERATION — an explicit, safe mission. Normal PLAY never inherits these rules: every changed session field is snapshotted before launch and restored on completion, skip, or an early return to the main menu. --------------------------------------------------------------------------- */ var TRAINING_REWARD=150; function saveTrainingConfig(){ trainingPrev={difficulty:difficulty,defenseFocus:defenseFocus,infestationOn:infestationOn, wcChoice:wcChoice,goalSel:goalSel,timeLimit:timeLimit,resPace:resPace,crateRate:crateRate, curMap:curMap,curTheme:curTheme,aiFactionSel:aiFactionSel,playerStartZone:playerStartZone, activeWarMode:typeof activeWarMode==='string'?activeWarMode:'standard', aiSlots:aiSlots.map(function(A){return {on:!!A.on,diff:A.diff|0,zone:A.zone,ally:!!A.ally,behavior:A.behavior||'balanced'};}), opmods:Object.assign({},META.opmods||{}),threatSel:META.threatSel}; } function restoreTrainingConfig(){ if(!trainingPrev) return; difficulty=trainingPrev.difficulty; defenseFocus=trainingPrev.defenseFocus; infestationOn=trainingPrev.infestationOn; wcChoice=trainingPrev.wcChoice; goalSel=trainingPrev.goalSel; timeLimit=trainingPrev.timeLimit; resPace=trainingPrev.resPace; crateRate=trainingPrev.crateRate; curMap=trainingPrev.curMap; curTheme=trainingPrev.curTheme; aiFactionSel=trainingPrev.aiFactionSel; playerStartZone=trainingPrev.playerStartZone; if(typeof activeWarMode!=='undefined') activeWarMode=trainingPrev.activeWarMode||'standard'; for(var i=0;i=0))return; var H=playerBld('hq');if(!H||typeof spawnCrate!=='function')return; var C=spawnCrate(H.x+155,H.y-55,'supply'); C.alt=0;C.seen=true;C.t=0;C.site=true;C.siteName='TRAINING CACHE';C.announced=true; MATCH.pickup=C; if(typeof mmPing==='function')mmPing(C.x,C.y); } function ensureTrainingScout(){ if(MATCH.scoutIdx>=0&&ualive[MATCH.scoutIdx])return; var H=playerBld('hq'),ty=-1;if(!H)return; for(var i=0;i=0&&ualive[heroIdx]) uhp[heroIdx]=Math.max(uhp[heroIdx],uhpm[heroIdx]*.72); var H=playerBld('hq'); if(H) H.hp=Math.max(H.hp,H.hpm*.75); var step=trainingStepId(); if(step==='pickup')ensureTrainingPickup(); if(step==='fog')ensureTrainingScout(); if(step==='tech'&&typeof heroLvl==='number')heroLvl=Math.max(heroLvl,3); } function startTrainingMission(){ if(typeof running!=='undefined'&&running){ toast('Leave the current battle before starting training'); return; } if(!trainingPrev) saveTrainingConfig(); difficulty=0; defenseFocus=0; infestationOn=false; wcChoice=0; goalSel='annihilate'; timeLimit=0; resPace=1; crateRate=1; curMap='vanguard'; curTheme='verdant'; aiFactionSel='legion'; playerStartZone='sw'; aiSlots[0].on=true; aiSlots[0].diff=0; aiSlots[0].zone='ne';aiSlots[0].ally=false;aiSlots[0].behavior='balanced'; for(var i=1;iOBJECTIVES' +'
'+((stats&&stats.built&&stats.built[0])|0)+'STRUCTURES BUILT
' +'
'+((stats&&stats.t)|0)+'sTRAINING TIME
' +'
READYFIELD STATUS
'; var rw=document.getElementById('goRewards'); if(rw) rw.innerHTML='

TRAINING PAYOUT

' +'
+'+reward+'⬡ CORES
COMPLETEKEEL ORIENTATION
' +'
NORMAL SKIRMISHES ARE NOW READY · REPLAY ANY TIME FROM SETTINGS OR OPERATIONS
'; if(typeof drawMatchChart==='function') drawMatchChart(); var go=document.getElementById('gameOver'); if(go) go.style.display='flex'; if(typeof sfx==='function'){ try{sfx('level');}catch(e){} } updateTrainingEntry(); } function needsTraining(){ var M=tutMeta(); return !M.skipped&&(!M.done||(M.version|0)=GUIDE_VERSION, active=!!TUT.trainingMode&&!!TUT.active&&typeof running!=='undefined'&&!!running, progress=done?STEPS.length:(active?TUT.stepIdx:Math.min(STEPS.length-1,M.progress|0)), interrupted=!done&&!active&&(progress>0||!!M.skipped),rewarded=(M.rewardedVersion|0)>=GUIDE_VERSION; return {done:done,active:active,interrupted:interrupted,progress:progress,rewarded:rewarded, state:done?'COMPLETED · REPLAYABLE':active?'TRAINING PAUSED · RESUMABLE':interrupted?'INCOMPLETE · RESTARTABLE':'RECOMMENDED · SKIPS WAR TABLE', action:done?'↻ REPLAY TRAINING':active?'▶ RESUME TRAINING':interrupted?'↻ RESTART TRAINING':'▶ START TRAINING'}; } function updateTrainingEntry(){ var S=trainingUiState(); /* The training card's label and state line come from trainingUiState(), the same source the Operations card reads, so the two can never disagree. If the War Room is on screen, refresh it in place. */ var wr=document.getElementById('warScr'); if(wr&&wr.style.display&&wr.style.display!=='none'&&typeof renderWarRoom==='function') renderWarRoom(); var op=document.getElementById('keelTrainingOp'),sig=[S.done,S.active,S.interrupted,S.progress,S.rewarded].join(':'); if(op&&op.dataset.stateSig!==sig) appendTrainingOperation(); } function openTrainingBrief(){ if(typeof renderOps==='function') renderOps(); if(typeof showFrontScreen==='function') showFrontScreen('opsScr'); var ops=document.getElementById('opsScr'); if(ops&&typeof mfSetTabs==='function') mfSetTabs(ops,'threat',false); var sc=ops&&ops.querySelector('.opsScroll'); if(sc) sc.scrollTop=0; if(typeof sfx==='function'){ try{ sfx('ui'); }catch(e){} } } function resumeTrainingMission(){ var S=trainingUiState(); if(!S.active){ startTrainingMission(); return; } if(typeof hideFrontScreens==='function') hideFrontScreens(); var po=document.getElementById('pauseOverlay'); if(po) po.style.display='none'; if(typeof paused!=='undefined') paused=false; if(typeof sfx==='function'){ try{ sfx('ui'); }catch(e){} } } function ensureTrainingEntry(){ /* The old entry was an identity CARD inserted BEFORE #startBtn, so the first thing on the menu was a training advert and PLAY got pushed down the screen behind it. Training is one destination, not a banner: it is now a single dedicated button sitting directly UNDER PLAY, styled as a peer of it (.mbtn.alt) so the two read as one primary + one secondary action. openTrainingBrief() is still reachable from Operations and Settings. */ /* Training is a WAR ROOM card now, not a menu button — the menu is back to a single primary control. Both of this function's older entries (the identity callout above PLAY, and the standalone #trainBtn below it) are torn down here so an OTA patch landing on either shell converges on the same menu. */ var stale=document.getElementById('keelTrainingCallout'); if(stale) stale.remove(); var oldBtn=document.getElementById('trainBtn'); if(oldBtn) oldBtn.remove(); updateTrainingEntry(); } function appendTrainingOperation(){ var pane=document.getElementById('opsPane-threat'); if(!pane) return; var old=document.getElementById('keelTrainingOp'); if(old) old.remove(); var S=trainingUiState(),nova=typeof facArt==='function'?facArt('nova'):null, commander=(nova&&nova.cdr)||'Captain Elara Kai',progressPct=Math.round(S.progress/STEPS.length*100), rewardText=S.rewarded?'FIRST-CLEAR REWARD CLAIMED':'FIRST CLEAR · +'+TRAINING_REWARD+' ⬡ CORES'; var d=document.createElement('article'); d.id='keelTrainingOp'; d.className='keelTrainingOp'+(S.done?' done':'')+(S.active?' active':''); d.dataset.stateSig=[S.done,S.active,S.interrupted,S.progress,S.rewarded].join(':'); d.dataset.progress=String(S.progress); d.innerHTML='
'+S.state+''+rewardText+'
' +'
'+commander+', Nova commander' +''+(typeof facIcon==='function'?facIcon('nova',38,'ktoCrestImg'):'✦')+'
' +'
TRAINING OPERATION 01FIELD ORIENTATION' +''+commander+' · KEEL tactical guidance' +'

A protected live-fire drop. Skips the galaxy war table and lands on a fixed training map with no early enemy rush.

' +'
◇ CAMERA⬡ ECONOMY▣ PRODUCTION' +'➤ ORDERSn/1K POP⛨ DEFENCEⅣ PLATOONS⌾ SCOUTING' +'⌬ TECH✦ POWERS☁ SAVES
' +'
OBJECTIVE PROGRESS'+S.progress+' / '+STEPS.length+'
' +'
' +''+(S.done?'Orientation certified · replay does not repeat the first-clear reward': S.active?'Current lesson: '+(STEPS[Math.min(TUT.stepIdx,STEPS.length-1)].id||'field systems').toUpperCase(): S.interrupted?'Previous best retained · a new drop restarts at camera control':STEPS.length+' guided objectives · progress is saved to this profile')+'
' +'
'+STEPS.length+'OBJECTIVES
EASYFIXED OPPONENT
' +'
SAFENO INFESTATION
' +''; pane.insertBefore(d,pane.firstChild); var b=d.querySelector('button'); var act=S.active?resumeTrainingMission:startTrainingMission; if(typeof mfBindTap==='function') mfBindTap(b,act); else b.addEventListener('click',act); } /* --------------------------------------------------------------------------- SETTINGS — a "Tutorial" row and a voice toggle, appended to #setList after renderSettings() (src/game/meta.js) rebuilds it. That function replaces #setList's entire innerHTML on every call — including every time a player flips ANY setting — so appending once at boot would vanish on the first unrelated toggle. Wrapping the function, the same way commander.js wraps econTick() and meta.js wraps heroXP(), means our row survives every rebuild without ever editing meta.js. */ function tutSettingsAction(){ if(trainingUiState().active){ resumeTrainingMission(); return; } var midMatch=typeof running!=='undefined'&&running; if(midMatch){ toast('Training is a separate operation — return to the main menu to replay it'); } else { startTrainingMission(); } if(typeof sfx==='function'){ try{ sfx('ui'); }catch(e){} } if(midMatch&&typeof renderSettings==='function') renderSettings(); } function appendTutorialSettingsRow(){ var list=document.getElementById('setList'); if(!list) return; var M=tutMeta(); var status=TUT.active? ('Training in progress — step '+(TUT.stepIdx+1)+' of '+STEPS.length) : M.done? 'Completed' : M.skipped? 'Skipped' : 'Not started yet'; var label=trainingUiState().active?'▶ RESUME':(!M.done&&!M.skipped&&!TUT.active)? '▶ START' : '↺ REPLAY'; var row=document.createElement('div'); row.className='sItem setRow'; row.id='keelSetRow'; row.setAttribute('role','button'); row.setAttribute('tabindex','0'); row.innerHTML='
🎓 Training Operation
'+status+' — skips the war table; protected mission with guided controls
' +'
'+label+'
'; var act=function(ev){ if(ev){ ev.preventDefault(); } tutSettingsAction(); }; row.addEventListener('pointerdown',act); row.addEventListener('keydown',function(ev){ if(ev.key==='Enter'||ev.key===' ') act(ev); }); list.appendChild(row); var vOn=!!(typeof META!=='undefined'&&META.settings&&META.settings.tutorialVoice); var vrow=document.createElement('div'); vrow.className='sItem setRow'; vrow.setAttribute('role','button'); vrow.setAttribute('tabindex','0'); vrow.innerHTML='
🔊 KEEL Voice
Speak tutorial lines aloud, if your device supports it
' +'
'+(vOn?'ON':'OFF')+'
'; var vact=function(ev){ if(ev){ ev.preventDefault(); } if(typeof META==='undefined'||!META.settings) return; META.settings.tutorialVoice=!META.settings.tutorialVoice; if(typeof metaSave==='function') metaSave(); if(typeof sfx==='function'){ try{ sfx('ui'); }catch(e){} } if(typeof renderSettings==='function') renderSettings(); }; vrow.addEventListener('pointerdown',vact); vrow.addEventListener('keydown',function(ev){ if(ev.key==='Enter'||ev.key===' ') vact(ev); }); list.appendChild(vrow); } if(typeof renderSettings==='function'){ var _keelRenderSettings=renderSettings; renderSettings=function(){ _keelRenderSettings(); appendTutorialSettingsRow(); }; } /* --------------------------------------------------------------------------- BOOT --------------------------------------------------------------------------- */ function initTutorial(){ if(window.__keelInit) return; window.__keelInit=true; buildDOM(); ensureTrainingEntry(); if(typeof renderOps==='function'&&!window.__keelOpsWrapped){ window.__keelOpsWrapped=true; var _keelRenderOps=renderOps; renderOps=function(){ _keelRenderOps(); appendTrainingOperation(); }; appendTrainingOperation(); } if(typeof orderMove==='function'&&!window.__keelMoveWrapped){ window.__keelMoveWrapped=true; var _keelOrderMove=orderMove; orderMove=function(wx,wy,patrol,retreat){ var combat=hasSelectedCombatUnit(),formationDone=MATCH.sawFormation,attackMode=typeof moveMode==='undefined'||!moveMode, ok=_keelOrderMove(wx,wy,patrol,retreat); /* Retreat is the one order the player cannot reach from a button, so it is credited from any live selection rather than a combat-only one — the Commander breaking off counts as having learned it. */ if(ok&&retreat&&!patrol) MATCH.sawRetreat=true; if(ok&&combat&&!patrol){ MATCH.sawMove=true; /* The formation release itself also calls orderMove. Requiring a formation observed on an earlier poll ensures the next deliberate map tap teaches attack-move instead of silently auto-completing. */ if(formationDone&&attackMode&&!retreat)MATCH.sawAttackMove=true; } return ok; }; } if(typeof applyCrate==='function'&&!window.__keelPickupWrapped){ window.__keelPickupWrapped=true; var _keelApplyCrate=applyCrate; applyCrate=function(k,x,y){ var out=_keelApplyCrate(k,x,y); if(TUT.trainingMode)MATCH.sawPickup=true; return out; }; } setInterval(tutTick,350); } window.initTutorial=initTutorial; /* Main-menu navigation owns the application-level exit transaction. Export the training cleanup explicitly so returnToMainMenu() can actually call it; this file is intentionally scoped in an IIFE, so a bare function declaration was never global and the old typeof guard silently did nothing. */ window.cancelTrainingMission=cancelTrainingMission; window.trainingMissionActive=function(){return !!(TUT.trainingMode||trainingPrev);}; /* Inspection hook for automated verification — read-only, harmless to leave wired for real players (no different from any other console-reachable global in this codebase). */ window.__tutDebug=function(){ return {TUT:TUT,MATCH:MATCH,REACT:REACT,STEPS:STEPS, needsTraining:needsTraining(),startTraining:startTrainingMission, /* The voice plumbing lives inside this IIFE, so verifykeen.mjs cannot reach speak()/keenLineId()/pump() any other way. Read-only handles, no state. */ speak:speak,pump:pump,queue:queue,voiceOn:voiceOn,keenLineId:keenLineId, speakVoice:speakVoice,beginRun:beginRun, keenKey:function(kind,id){ return 'vo_keen_'+keenLineId(kind,id); }}; }; })();