import {startMission,act,checkpoints,checks,radioInstruction,expectedReadback} from './airside-state.mjs'; const $=id=>document.getElementById(id); let state=startMission(),scene,recognition,voiceGeneration=0,listening=false; const reducedMotion=matchMedia('(prefers-reduced-motion: reduce)').matches; const Recognition=window.SpeechRecognition||window.webkitSpeechRecognition; const copy={ inspection:['01 / PRE-DEPARTURE','Is this vehicle ready?','Work through the four inspection groups. These prompts start a discussion; use the approved vehicle checklist for the actual checks.','A vehicle that starts is not necessarily ready for airside use. Finding an issue in the bay gives you time to resolve it before it becomes a problem near people or aircraft.'], brake:['02 / BRAKE-CHECK AREA','Can you stop reliably?','The vehicle is at our imaginary designated test area. Describe the approved test, check the area is safe, then run the scenario. No test speed or technique is prescribed here.','The brake check tests a function you depend on throughout the route. It belongs in a suitable area using the vehicle and site procedure. If the response is abnormal, stop and report it; do not continue to find out whether it improves.'], defect:['02 / DEFECT FOUND','Stop. Withdraw. Report.','The scenario vehicle pulled to one side. Record the location and observed behaviour for the instructor.','Explain what you observed, where the vehicle is, and what you have done to keep it out of use. A useful report helps the responsible team locate the vehicle and investigate the defect. Reporting does not repair it.'], withdrawn:['02 / VEHICLE OUT OF USE','The safe outcome is to stop.','The practice report is saved in this run. Export it for discussion, or start a fresh run with a different vehicle scenario.','Completing the route is not the objective when the vehicle has a defect. Recognising the problem and withdrawing the vehicle is the learning outcome for this branch. The instructor decides the next practical activity.'], crossing:['03 / PEOPLE FIRST','Who is using the crossing?','People are walking across the practice road. Try your decision, then explain it to the class.','People may not see you, and vehicle blind spots can hide someone behind another person. Stop before the crossing, give them time and recheck the route. A horn or a hand gesture is not a substitute for a clear path.'], radio:['04 / RADIO HOLD','Listen. Read back. Remain stopped.','This exercise uses a fictional road point, Point Alpha. It is not a runway clearance or a Mthatha radio procedure.','A readback lets the other party check what you understood. Repeating a holding instruction keeps the restriction in force. If you are unsure of the call sign, point or instruction, ask again and remain stopped.'], release:['04 / TRAINER RELEASE','New instruction. New decision.','The trainer has issued a separate exercise cue to continue on the practice road. Check the route is clear before moving to the training stand.','The restriction changed only when the trainer gave a new scenario instruction. A previous readback, another vehicle moving, or a phrase-matching result could not release your vehicle.'], complete:['05 / DEBRIEF','Explain your decisions.','Review the record together. Explain the checks, the crossing decision and why the radio hold required a separate release.','The useful result is understanding what changed your decision at each point. This run records practice actions and corrections. It does not assess practical driving ability or award a permit.'] }; function stopAudio(){voiceGeneration++;window.speechSynthesis?.cancel();$('voice-status').textContent='Device narration stopped.';} function stopMic(){listening=false;const previous=recognition;recognition=null;try{previous?.abort();}catch{}const b=$('record');if(b)b.textContent='Record one readback';} function speak(text){stopMic();stopAudio();if(!window.speechSynthesis||!window.SpeechSynthesisUtterance){$('voice-status').textContent='Device speech is unavailable. Read the on-screen briefing.';return;}const generation=voiceGeneration;const u=new SpeechSynthesisUtterance(text);u.lang='en-ZA';u.rate=.94;u.onend=()=>{if(generation===voiceGeneration)$('voice-status').textContent='Prof has finished. Instructor leads discussion.';};u.onerror=()=>{if(generation===voiceGeneration)$('voice-status').textContent='Speech unavailable. The full explanation is on screen.';};$('voice-status').textContent='Speaking this point only…';speechSynthesis.speak(u);} const intentLabels={acknowledge_hold:'Acknowledges the hold',request_clarification:'Requests clarification',request_movement:'Requests movement',report_defect:'Reports a defect',unsupported:'Unsupported phrase',needs_instructor_review:'Needs instructor review'}; async function checkDecisionEngines(){ try{ const response=await fetch('/api/node/status',{cache:'no-store'});if(!response.ok)throw new Error('node');const data=await response.json(),engines=data.decision_engines; if(!engines)throw new Error('old-node'); const needle=engines.needle3.installed?(engines.needle3.loaded?'Needle3 loaded':'Needle3 installed; first use loads its local weights'):'Needle3 not installed'; const jev=engines.jev.configured?'Jev configured':'Jev awaiting TYPESAFE_API_KEY';$('engine-status').textContent=needle+' · '+jev;$('classify-phrase').disabled=false; }catch{$('classify-phrase').disabled=true;$('engine-status').textContent='Connect to the AM Risk local training node to use the decision helper. The public demo remains deterministic.';} } async function classifyPhrase(){ const phrase=$('engine-phrase').value.trim(),provider=$('engine-provider').value,result=$('engine-result'); if(!phrase){result.textContent='Enter a learner phrase first.';result.className='engine-result warn';return;} result.textContent=provider==='jev'?'Checking with Jev…':'Checking locally with Needle3…';result.className='engine-result';$('classify-phrase').disabled=true; try{ const response=await fetch('/api/decision/classify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:phrase,scenario_state:state.step,provider,cloud_consent:$('jev-consent').checked})}); const contentType=response.headers.get('content-type')||'';const data=contentType.includes('application/json')?await response.json():null;if(!data)throw new Error('Local decision service unavailable');if(!response.ok||!data.ok)throw new Error(data.error||'Decision service unavailable'); const confidence=data.confidence==null?'not available':Math.round(data.confidence*100)+'%'; result.textContent=`${data.provider==='needle3'?'Needle3':'Jev'} suggestion: ${intentLabels[data.intent]||data.intent}. Confidence ${confidence}. Instructor review required; vehicle remains stationary.`; result.className='engine-result '+(data.accepted_for_suggestion?'good':'warn'); }catch(error){result.textContent=(error.message||'Decision service unavailable')+'. Nothing changed in the exercise.';result.className='engine-result warn';} finally{if($('engine-status').textContent.startsWith('Needle3'))$('classify-phrase').disabled=false;} } function dispatch(event){const before=state.step;state=act(state,event);if(state.step!==before){stopMic();stopAudio();}render();if(event.type==='CHECK')$('check-'+event.id)?.focus();} function button(label,action,parent){const b=document.createElement('button');b.textContent=label;b.onclick=action;b.disabled=state.paused;parent.append(b);return b;} function render(){ const [phase,title,brief,why]=copy[state.step];$('phase').textContent=phase;$('mission-title').textContent=title;$('brief').textContent=brief;$('why').textContent=why;$('feedback').textContent=state.feedback;$('position').textContent=checkpoints[state.position]; $('status').textContent=state.paused?'● INSTRUCTOR PAUSED':'● PRACTICE SESSION';$('pause').textContent=state.paused?'Resume exercise':'Pause exercise';$('route').replaceChildren(); checkpoints.forEach((label,i)=>{const li=document.createElement('li');li.textContent=label;if(i===state.position)li.setAttribute('aria-current','step');$('route').append(li);}); scene?.moveVehicle(state.position);scene?.setPedestrians(state.pedestrians);scene?.setPaused(state.paused||reducedMotion); const activity=$('activity');activity.replaceChildren(); if(state.step==='inspection')checks.forEach(([id,title,note])=>{const row=document.createElement('div');row.className='inspection-row';const input=document.createElement('input');input.type='checkbox';input.id='check-'+id;input.checked=state.checks.includes(id);input.disabled=state.paused;input.onchange=()=>dispatch({type:'CHECK',id});const label=document.createElement('label');label.htmlFor=input.id;label.textContent=title;const small=document.createElement('small');small.textContent=note;label.append(small);row.append(input,label);activity.append(row);}); if(state.step==='brake')button(state.brakeChecked?'Repeat scenario brake check':'Run scenario brake check',()=>dispatch({type:'BRAKE_TEST'}),activity); if(state.step==='crossing')button(state.pedestrians?'Stop and wait for the pedestrians':'Pedestrians have cleared the road',()=>dispatch({type:'WAIT'}),activity).disabled=state.paused||!state.pedestrians; if(state.step==='defect'){ const form=document.createElement('form');form.innerHTML=''; form.onsubmit=e=>{e.preventDefault();dispatch({type:'REPORT',location:$('defect-location').value,symptom:$('defect-symptom').value});};form.querySelectorAll('input,textarea,button').forEach(e=>e.disabled=state.paused);activity.append(form); } if(state.step==='radio')renderRadio(activity); if(state.step==='withdrawn'){const p=document.createElement('p');p.textContent=state.reports.map(r=>r.vehicle+' · '+r.location+' · '+r.symptom).join('\n');activity.append(p);} if(state.step==='complete'){ const stats=document.createElement('div');stats.className='debrief-stats';for(const [n,label]of[[state.checks.length,'check groups discussed'],[state.events.filter(e=>!e.accepted).length,'actions to revisit'],[state.position+1,'route points reached']]){const d=document.createElement('div'),b=document.createElement('b');b.textContent=n;d.append(b,document.createTextNode(label));stats.append(d);}activity.append(stats); const p=document.createElement('p');p.textContent='Instructor prompts: What would stop you leaving the bay? Where does the approved brake-test procedure come from? What did the holding readback authorise?';activity.append(p); } $('move').hidden=['complete','withdrawn','defect'].includes(state.step);$('move').disabled=state.paused;$('move').textContent=state.step==='radio'?'Try to proceed →':'Move to next point →'; $('trainer-radio').hidden=state.step!=='radio';$('review').disabled=state.paused||!state.readbackMatch;$('review').checked=state.reviewed;$('release').disabled=state.paused||!state.reviewed; $('history').replaceChildren();state.events.slice(-30).reverse().forEach(e=>{const li=document.createElement('li');li.textContent=e.action+' — '+e.message;$('history').append(li);}); } function renderRadio(parent){ const panel=document.createElement('div');panel.className='radio-message';const label=document.createElement('small');label.textContent='PRACTICE CONTROLLER';const p=document.createElement('p');p.textContent=radioInstruction;panel.append(label,p);button('Listen / say again',()=>speak(radioInstruction),panel);parent.append(panel); const form=document.createElement('form');form.innerHTML='

Microphone off. Recording fills the text box only.

Show this exercise’s example

Limited phrase comparison only. The trainer evaluates understanding and pronunciation.
'; parent.append(form);$('readback').value=state.readback;$('example').textContent=expectedReadback;form.querySelectorAll('button,input,textarea').forEach(el=>el.disabled=state.paused); const invalidateDraft=()=>{state={...state,readback:$('readback').value,readbackMatch:false,reviewed:false};$('review').checked=false;$('review').disabled=true;$('release').disabled=true;}; $('readback').oninput=invalidateDraft; if(!Recognition){$('record').disabled=true;$('mic-status').textContent='Speech recognition unavailable in this browser. Type your readback.';} form.onsubmit=e=>{e.preventDefault();const text=$('readback').value;stopMic();dispatch({type:'READBACK',text});}; $('record').onclick=()=>{ if(listening){stopMic();$('mic-status').textContent='Microphone stopped.';return;} if(!$('mic-consent').checked){$('mic-status').textContent='Confirm microphone use first, or type your answer.';return;} stopAudio();recognition=new Recognition();const current=recognition;current.lang='en-ZA';current.interimResults=false;current.continuous=false;listening=true;$('record').textContent='Stop recording';$('mic-status').textContent='Listening for one readback…'; current.onresult=e=>{if(!listening||recognition!==current||state.step!=='radio'||state.paused)return;$('readback').value=e.results[0][0].transcript;invalidateDraft();$('mic-status').textContent='Review the recognised words, then press Check. Nothing has moved.';}; current.onerror=e=>{if(recognition===current&&$('mic-status'))$('mic-status').textContent='Microphone unavailable ('+e.error+'). Type your readback.';}; current.onend=()=>{if(recognition!==current)return;listening=false;if($('record'))$('record').textContent='Record one readback';};try{current.start();}catch{stopMic();$('mic-status').textContent='Could not start recognition. Type your readback.';} }; } $('move').onclick=()=>dispatch({type:'MOVE'}); $('pause').onclick=()=>{stopMic();stopAudio();dispatch({type:'PAUSE'});}; $('explain').onclick=()=>speak(copy[state.step][3]);$('stop-voice').onclick=stopAudio; $('review').onchange=()=>{if($('review').checked)dispatch({type:'REVIEW'});else{state={...state,reviewed:false};render();}}; $('release').onclick=()=>{dispatch({type:'RELEASE'});if(state.step==='release')speak(state.feedback);}; $('restart').onclick=()=>{stopMic();stopAudio();state=startMission($('scenario').value);render();}; $('engine-provider').onchange=()=>{$('jev-consent-row').hidden=$('engine-provider').value!=='jev';$('jev-consent').checked=false;$('engine-result').textContent='The result is advice for the instructor. It can never move the vehicle.';$('engine-result').className='engine-result';}; $('classify-phrase').onclick=classifyPhrase; $('export').onclick=()=>{const report={schema:'AMR_AIRSIDE_PRACTICE_V1',exportedAt:new Date().toISOString(),airport:'Generic illustration; FAUT validation pending',competencyDecision:null,...state};const url=URL.createObjectURL(new Blob([JSON.stringify(report,null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=url;a.download='am-risk-airside-practice.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}; document.querySelectorAll('[data-view]').forEach(b=>b.onclick=()=>scene?.view(b.dataset.view)); document.addEventListener('visibilitychange',()=>{if(document.hidden){stopMic();stopAudio();}});window.addEventListener('pagehide',()=>{stopMic();stopAudio();}); render(); checkDecisionEngines(); try{const {createAirfield}=await import('./scene.js?v=airside-mission-2');scene=createAirfield($('airside-scene'),()=>{},{fire:false,driving:true,route:[[-24,0,16],[-14,0,16],[-8,0,16],[5,0,16],[17,0,16]]});scene.view('road');render();}catch(error){$('fallback').hidden=false;console.error('Airside 3D unavailable',error);}