; ; /* ============================================================================ 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='UGA SHIP LIAISON · LANCE OF MORNING'; var GUIDE_VERSION=5; var TRAINING_COMPLETE_EVENT='massfront:protected-training-complete'; var TRAINING_SKIP_EVENT='massfront:protected-training-skipped'; var GREETING='KEEL online — UGA 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."; var BASIC_GRADUATION='Planetary basics complete. Camera, landing, Commander control, economy, production, army selection, orders and extraction are confirmed. '+ 'Next: choose your faction and receive that faction\'s Commander 1.'; /* The recorded KEEL takes are keyed to the authored lines below. Keep those lines for voice, but show one compact action at a time on the battlefield. This also gives players with voice disabled the complete control sequence. */ var GREETING_CUE='Follow the gold marker to your next control. SKIP is always available.'; var GRADUATION_CUE='Orientation complete. Choose your next operation at the War Table.'; var BASIC_GRADUATION_CUE='Basics complete. Choose a faction to commission its first Commander.'; /* Each step tests REAL state. `say` retains the authored voice take while STEP_CUES below supplies the concise visible prompt. `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/500, shared by your combat faction.', done:'Striker queued. Production burns mass and energy over the build time. Pop n/500 is live faction headcount — adding commanders does not multiply the cap.', 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; } } ]; /* Each objective is already a gated stage. The visible cue states only the action and the rule needed for that stage; the longer authored narration remains available through the existing voice bank without stale audio. */ var STEP_CUES={ camera:'Drag to pan. Pinch to zoom or twist; VIEW has ROT and TILT.', deploy:'Tap clear ground for a green landing signal, then DEPLOY BASE HERE.', commander:'Tap the Commander bar to select and center your hero. Keep it alive.', pickup:'Select Commander or Constructor, then tap beside the marked ◆ cache.', territory:'Blue grid = build zone. Builders extend it; Factories and Uplinks anchor it. Silos prevent overflow. Tap GOT IT.', mex:'BUILD → ECONOMY → Extractor. Drag it onto a ◆ deposit; tap ✓.', power:'BUILD → ECONOMY → Reactor. Place inside the blue grid; tap ✓.', fac:'BUILD → PRODUCTION → Factory. It trains units and extends territory.', queue:'Tap Factory, then Striker. Watch the queue and population counter (max 500).', train:'Wait for Striker. Tap ARMY to select your field units.', orders:'Single-tap ground: attack-move. Tap an enemy: focus attack. Double-tap open ground: retreat. Try retreat now.', intel:'Long-press any unit for counters. Kinetic beats light; Gauss beats heavy. Damage can swing 0.45–1.85×.', turret:'BUILD → DEFENCE → Sentinel. Place it on the likely approach.', platoon:'Select units. Open PLATOONS; hold P1 to save. Tap P1 to recall, double-tap to center.', formation:'Open ORDERS, tap formation, then drag its hologram onto the map.', fog:'Black fog hides enemies; grey is remembered. Move the marked SCOUT to reveal the signal.', attack:'Select ARMY. Single-tap unexplored ground to attack-move; double-tap to retreat. SPLASH hits grouped enemies.', tech:'BUILD → TECH → Research Complex. It unlocks studies and earns ◆ Data after battle.', ability:'Select Commander, tap BLAST, then a target. Abilities cost energy and have cooldowns.', objective:'Operations vary: destroy, hold, purge, or survive. Training is secure—tap CALL EXTRACTION.', cloud:'Career autosaves locally. ACCOUNT syncs cloud saves; PROFILE exports .mfsave backups. Tap FINISH TRAINING.' }; var STEP_ACKS={ queue:'Striker queued. Check mass, energy, and the shared 500-unit cap.', orders:'Retreat confirmed. Stop and Hold keep units firing in place; neither chases.', intel:'Counter card read. Weapon class matters; SONIC bypasses Bulwark shields.', turret:'Perimeter online. Long-range guns also have a minimum range.', attack:'Attack-move confirmed. Tap ground to fight forward; double-tap to disengage.' }; /* The first-career course is intentionally super-basic. The existing longer certification remains available from Operations/Settings, but a new player reaches faction commissioning after these ten essential actions instead of being held inside a twenty-one-objective systems tour. Reusing the authored step objects keeps every gate tied to real simulation state. */ var BASIC_STEP_IDS=['camera','deploy','commander','mex','power','fac','queue','train','orders','objective']; var BASIC_STEPS=BASIC_STEP_IDS.map(function(id){ 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 keelMapSpeakingUntil=0; var focusEls=[], mapCue=null; function syncTrainingChrome(){ var skipBtn=document.getElementById('keelSkip'); if(skipBtn){ skipBtn.classList.toggle('keelSkipEnd',!!TUT.trainingMode); skipBtn.textContent=TUT.trainingMode?(TUT.basicMode?'SKIP TRAINING':'END'):'SKIP'; skipBtn.setAttribute('aria-label',TUT.trainingMode?(TUT.basicMode?'Skip training and continue to faction commissioning':'End training operation'):'Skip tutorial guidance'); } var mode=document.getElementById('keelModeBadge'); if(mode){var b=mode.querySelector('b');if(b)b.textContent=TUT.basicMode?'PLANETARY BASICS':'TRAINING OPERATION';} } function buildDOM(){ if(domReady){syncTrainingChrome();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 OPERATIONUGA KEEL 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. */ syncTrainingChrome(); var nextBtn=document.getElementById('keelNext'); nextBtn.addEventListener('pointerdown',function(ev){ ev.preventDefault(); ev.stopPropagation(); var active=courseSteps(),S=TUT.active&&active[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 active=courseSteps(),at=Math.min(active.length,TUT.stepIdx),pct=active.length?at/active.length*100:0; pg.setAttribute('aria-valuemax',active.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=active.length) return; var S=active[TUT.stepIdx],next=document.getElementById('keelNext'); if(badge){ var bs=badge.querySelector('span'); if(bs) bs.textContent='OBJECTIVE '+(TUT.stepIdx+1)+' / '+active.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,display){ if(!text) return; queue.push({text:text,display:display||text,hold:holdSec||4.4,kind:kind||'bark',id:id||null}); while(queue.length>MAX_QUEUE) queue.shift(); } function keelBattleReceiverAvailable(){ if(typeof document==='undefined'||!document.getElementById('cmdrTx')||!document.getElementById('minimapWrap')) return false; if(typeof running!=='undefined'&&!running) return false; if(typeof matchLive!=='undefined'&&!matchLive) return false; if(typeof gameEnded!=='undefined'&&gameEnded) return false; var b=document.body; return !(b&&b.classList&&(b.classList.contains('mfMenuOpen')||b.classList.contains('menuMode'))); } /* KEEL owns the same in-match receiver as commanders, but never their faction identity. The cancelable event lets the HUD accept the line only when the minimap bay is idle; a busy receiver is retried instead of spawning a second talking portrait elsewhere on the battlefield. */ function keelPresentAtBattleMap(message,durationMs){ if(!keelBattleReceiverAvailable()||typeof window==='undefined'||typeof window.dispatchEvent!=='function') return 0; var detail={schema:'massfront.keel-hint.v1',hintId:'tutorial-'+(message.id||message.kind||'guidance'), context:'protected-training',surface:'battle-minimap',speaker:'KEEL',speakerId:'keel',affiliation:'uga', speakerRole:'UGA SHIP LIAISON',channel:'UGA TACTICAL LINK',voiceId:'keen', profileId:'uga-keel-expedition-guide',animationId:'keel-tactical-link',text:message.display||message.text, durationMs:durationMs,priority:message.kind==='step'?85:70,issuedAt:Date.now(),handled:false}; try{ var ev; if(typeof CustomEvent==='function') ev=new CustomEvent('massfront:keel-hint',{detail:detail,cancelable:true}); else if(document.createEvent){ev=document.createEvent('CustomEvent');ev.initCustomEvent('massfront:keel-hint',false,true,detail);} if(!ev) return 0; window.dispatchEvent(ev); return detail.handled?1:2; }catch(e){return 0;} } 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(); var active=courseSteps(); TUT.active=true; TUT.graduated=false; TUT.stepIdx=0; TUT.shownStepIdx=-1; TUT.doneFlags=active.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;keelMapSpeakingUntil=0; speak(GREETING,5.5,'greeting','greeting',GREETING_CUE); } function evalSteps(){ if(!TUT.active) return; var active=courseSteps(); for(var i=0;i(progressMeta[progressKey]|0)){ progressMeta[progressKey]=TUT.stepIdx; if(typeof metaSave==='function') metaSave(); } if(TUT.stepIdx>=active.length){ if(!TUT.graduated){ TUT.graduated=true; TUT.active=false; var M=tutMeta(); /* Dedicated Training commits completion in finishTrainingMission(), where persistence is checked before the commissioning event exists. Publishing basicDone here let the faction gate observe an in-memory success up to 1.3 seconds before the save transaction ran. */ if(!TUT.trainingMode){ M.skipped=false;M.version=GUIDE_VERSION; if(TUT.basicMode){M.basicDone=true;M.basicSkipped=false;M.basicProgress=BASIC_STEPS.length;} else{M.done=true;M.basicDone=true;M.progress=STEPS.length;M.basicProgress=BASIC_STEPS.length;} } restingText=''; restingTag=''; restingId=''; keelMapSpeakingUntil=0; speak(TUT.basicMode?BASIC_GRADUATION:GRADUATION,6.5,'graduation','graduation', TUT.basicMode?BASIC_GRADUATION_CUE:GRADUATION_CUE); 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=active[TUT.stepIdx]; restingText=STEP_CUES[cur.id]||cur.say; restingId='step_'+cur.id; restingTag=(cur.icon||'◇')+' STEP '+(TUT.stepIdx+1)+' / '+active.length; if(TUT.shownStepIdx!==TUT.stepIdx){ TUT.shownStepIdx=TUT.stepIdx; speak(cur.say,5.5,'step',restingId,restingText); } } /* 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(confirmedState){ if(!TUT.active) return; var liveTraining=TUT.trainingMode&&(typeof running!=='undefined'&&running)&& !(typeof gameEnded!=='undefined'&&gameEnded); if(liveTraining&&!tutSkipConfirmed&&typeof accConfirm==='function'){ var confirmState={trainingMode:!!TUT.trainingMode,basicMode:!!TUT.basicMode}; accConfirm(TUT.basicMode ?'Skip the remaining basics and continue to required faction commissioning? This operation grants no payout.' :'Leave training and return to the menu? This operation grants no payout.', function(){ tutSkipConfirmed=true; tutSkip(confirmState); tutSkipConfirmed=false; }); return; } TUT.active=false; var wasTraining=confirmedState?confirmedState.trainingMode:TUT.trainingMode; var wasBasic=confirmedState?confirmedState.basicMode:TUT.basicMode; var M=tutMeta(),skipSaved=false; M.skipped=true; M.version=GUIDE_VERSION; if(wasBasic){M.basicSkipped=true;M.basicDone=false;} queue.length=0; restingText=''; restingTag=''; restingId=''; keelMapSpeakingUntil=0; clearFocus(); if(wasTraining){ TUT.trainingMode=false; trainingLaunched=false; restoreTrainingConfig(); if(typeof metaSave==='function')try{skipSaved=metaSave()!==false;}catch(e){skipSaved=false;} if(wasBasic&&typeof window.__MF_RETURN_TO_BASE_FOR_COMMISSIONING__==='function'){ /* Galactic Command owns ordinary main-menu returns. Commissioning is the one protected base-document destination, so mark only this synchronous cleanup call and clear the marker before any later navigation can use it. */ window.__MF_COMMISSIONING_RETURN_ACTIVE__=true; try{window.__MF_RETURN_TO_BASE_FOR_COMMISSIONING__();} finally{delete window.__MF_COMMISSIONING_RETURN_ACTIVE__;} } else if(typeof returnToMainMenu==='function') returnToMainMenu(); } else if(typeof metaSave==='function')try{skipSaved=metaSave()!==false;}catch(e2){skipSaved=false;} speak(SKIP_LINE,4.0,'skip','skip'); if(typeof sfx==='function'){ try{ sfx('ui'); }catch(e){} } if(wasBasic&&skipSaved){ var detail={schema:'massfront.protected-training-skipped.v1',course:'basic', speaker:'KEEL',affiliation:'uga',nextStep:'required-faction-selection',at:Date.now()}; trainingSignal(TRAINING_SKIP_EVENT,detail); /* A player who changes their mind after landing chose to skip Training, not to abandon the new-career workflow. Converge on the same mandatory commissioning gate as the pre-drop skip path. */ setTimeout(function(){ try{ var gate=window.MFNewCareerFactionGate; if(gate&&typeof gate.openFromRoute==='function')gate.openFromRoute({choice:'skipped'}); }catch(e){} },0); }else if(wasBasic&&!skipSaved&&typeof toast==='function')toast('Training skip could not be saved — commissioning remains locked'); TUT.basicMode=false;TUT.course='certification'; syncTrainingChrome(); } /* --------------------------------------------------------------------------- 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&&performance.now()/1000>=keelMapSpeakingUntil) 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(){ /* Standard deploy (setupStart → newSkirmish → deployCarrier) must never auto-enter Training. activeWarMode defaults to 'standard' on the menu, so this guard is only the live Standard battle, not the War Room card. */ if(typeof running!=='undefined'&&running&&typeof matchLive!=='undefined'&&matchLive &&typeof activeWarMode!=='undefined'&&activeWarMode==='standard' &&!(typeof META!=='undefined'&&META.onboarding&&META.onboarding.choice==='training')){ toast('Training is a separate operation — return to the menu or choose Training'); return; } 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
' +'
'+(wasBasic ?'PLANETARY BASICS COMPLETE · FACTION COMMISSIONING AND COMMANDER 1 ARE NEXT' :'FIELD CERTIFICATION COMPLETE · 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){} } if(wasBasic&&saved){ trainingSignal(TRAINING_COMPLETE_EVENT,{schema:'massfront.protected-training-complete.v1', course:'basic',objectives:active.length,speaker:'KEEL',affiliation:'uga', nextStep:'required-faction-selection',at:Date.now()}); }else if(wasBasic&&!saved&&typeof toast==='function'){ toast('Training finished, but career progress could not be saved'); } TUT.basicMode=false;TUT.course='certification'; syncTrainingChrome(); updateTrainingEntry(); } function needsTraining(){ var M=tutMeta(); return !M.skipped&&!M.basicSkipped&&!M.basicDone&&!M.done; } function trainingUiState(){ var M=tutMeta(),certified=!!M.done&&(M.version|0)>=GUIDE_VERSION, basicDone=!!M.basicDone||certified,done=basicDone, active=!!TUT.trainingMode&&!!TUT.active&&typeof running!=='undefined'&&!!running, course=active?courseName():(certified?'certification':'basic'), total=active?courseSteps().length:(certified?STEPS.length:BASIC_STEPS.length), progress=active?TUT.stepIdx:certified?STEPS.length:basicDone?BASIC_STEPS.length: Math.min(BASIC_STEPS.length,M.basicProgress|0), interrupted=!done&&!active&&(progress>0||!!M.skipped||!!M.basicSkipped),rewarded=(M.rewardedVersion|0)>=GUIDE_VERSION; return {done:done,basicDone:basicDone,certified:certified,active:active,course:course,total:total, interrupted:interrupted,progress:progress,rewarded:rewarded, state:active?(TUT.basicMode?'PLANETARY BASICS ACTIVE':'FIELD CERTIFICATION ACTIVE'): certified?'FIELD CERTIFIED · REPLAYABLE':basicDone?'BASICS COMPLETE · CERTIFICATION AVAILABLE': interrupted?'INCOMPLETE · RESTARTABLE':'RECOMMENDED · SHORT PROTECTED DROP', action:active?'▶ RESUME TRAINING':certified?'↻ REPLAY CERTIFICATION':basicDone?'▶ START FIELD CERTIFICATION': interrupted?'↻ RESTART BASICS':'▶ START PLANETARY BASICS'}; } 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. The old 350 ms poll rebuilt `#warGrid` unconditionally; if that landed between a card's pointerdown and pointerup, mfBindTap's press record disappeared and the card silently did nothing. Refresh only when its rendered state changed, preserving the live control and its accessibility semantics through ordinary taps. */ var wr=document.getElementById('warScr'),grid=document.getElementById('warGrid'); var warSig=[S.done,S.certified,S.active,S.course,S.total,S.interrupted,S.progress,S.rewarded,S.state,S.action].join(':'); if(wr&&wr.style.display&&wr.style.display!=='none'&&typeof renderWarRoom==='function' &&(!grid||grid.dataset.mfTrainingSig!==warSig)) renderWarRoom(); var op=document.getElementById('keelTrainingOp'),sig=[S.done,S.certified,S.active,S.course,S.total,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,'weekly',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(){ /* Operations was deliberately consolidated to one deployable-operation pane. The old Threat pane no longer exists; targeting it silently removed KEEL's unique Training entry from both orientations. Keep an old-shell fallback while the current shell inserts Training beside the weekly operation. */ var pane=document.getElementById('opsPane-weekly')||document.getElementById('opsPane-threat'); if(!pane) return; var old=document.getElementById('keelTrainingOp'); if(old) old.remove(); var S=trainingUiState(),displayDone=!S.active&&S.done,progressPct=Math.round(S.progress/Math.max(1,S.total)*100), topicHtml=S.course==='basic' ?'◇ CAMERA⚓ LANDING★ COMMANDER⬡ ECONOMY▣ PRODUCTION➤ ORDERS◎ EXTRACTION' :'⌁ LOGISTICS⬢ COUNTERS⛨ DEFENCEⅣ PLATOONS⌾ SCOUTING⌬ TECH✦ POWERS', rewardText=S.rewarded?'FIRST-CLEAR REWARD CLAIMED':'FIRST CLEAR · +'+TRAINING_REWARD+' ⬡ CORES'; var d=document.createElement('article'); d.id='keelTrainingOp'; d.className='keelTrainingOp'+(displayDone?' done':'')+(S.active?' active':''); d.dataset.stateSig=[S.done,S.certified,S.active,S.course,S.total,S.interrupted,S.progress,S.rewarded].join(':'); d.dataset.progress=String(S.progress); d.innerHTML='
'+S.state+''+rewardText+'
' +'
' +'
UGA TRAINING OPERATION 01'+(S.course==='basic'?'PLANETARY BASICS':'FIELD CERTIFICATION')+'' +'KEEL · UGA SHIP LIAISON' +'

'+(S.course==='basic'?'Short faction-neutral orientation. Learn the essentials before faction and Commander 1 commissioning.':'Optional advanced course. Continue into counters, logistics, defence, formations, scouting and technology.')+'

' +'
'+topicHtml+'
' +'
OBJECTIVE PROGRESS'+S.progress+' / '+S.total+'
' +'
' +''+(S.active?'Current lesson: '+((courseSteps()[Math.min(TUT.stepIdx,courseSteps().length-1)]||{}).id||'field systems').toUpperCase(): S.done?'Orientation certified · replay does not repeat the first-clear reward': S.interrupted?'Previous best retained · a new drop restarts at camera control':S.total+' guided objectives · progress is saved to this profile')+'
' +'
'+S.total+'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(),S=trainingUiState(); var status=TUT.active? ('Training in progress — step '+(TUT.stepIdx+1)+' of '+courseSteps().length) : S.certified? 'Field certification completed' : S.basicDone? 'Planetary basics completed · advanced certification available' : M.skipped||M.basicSkipped? 'Skipped' : 'Not started yet'; var label=S.active?'▶ RESUME':S.certified?'↺ REPLAY':S.basicDone?'▶ ADVANCED':(!M.skipped&&!M.basicSkipped&&!TUT.active)?'▶ START':'↺ RESTART'; 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+' — protected planetary mission with real-state objectives
' +'
'+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 and War Room navigation own application-level entry/exit transactions. This file is intentionally scoped in an IIFE, so expose the small public bridge explicitly: otherwise meta.js' defensive typeof checks see no Training handlers at all and the card silently does nothing. */ window.cancelTrainingMission=cancelTrainingMission; window.trainingMissionActive=function(){return !!(TUT.trainingMode||trainingPrev);}; window.trainingUiState=trainingUiState; window.resumeTrainingMission=resumeTrainingMission; window.needsTraining=needsTraining; /* commander.js legacyVoiceSpeak checks window.speakVoice before voPlay. __tutDebug alone is not enough — the global must exist for battle comms. */ window.speakVoice=speakVoice; /* 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,BASIC_STEPS:BASIC_STEPS, activeSteps:courseSteps(),trainingCompleteEvent:TRAINING_COMPLETE_EVENT,trainingSkipEvent:TRAINING_SKIP_EVENT, 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); }}; }; })();