import { computed, nextTick, onUnmounted, ref } from 'vue' import type { Ref } from 'vue' import { pmLog } from '../../shared/utils/pmLog' import { SCENARIOS, type Scenario } from '../../shared/constants/scenarios' import { type useFrequencyPresets } from '~/composables/useFrequencyPresets' import { normalizedFrequencyValue } from '../../shared/utils/frequency' import type { useSessionState } from '~/composables/useSessionState' import { useApi } from '~/composables/useApi' import type { useRadioSpeech } from '~/composables/useRadioSpeech' import useCommunicationsEngine from '../../shared/utils/communicationsEngine' import { generateGermanRegistration } from '../../shared/utils/registration' import { gateTransmission } from '../../shared/utils/transmissionGate' import { silenceWindowFor } from '../../shared/utils/silenceTimer' import { createAutoTuneScheduler } from '../../shared/utils/autoTune' import { buildBackendVariables } from '../../shared/utils/backendVariables' import { isSimControlMatch, isSimControlRejection, parseSimControl, simControlRejectionSpeech, simControlResultSpeech, type SimControlCommand, type SimControlCommandResult, } from '../../shared/utils/simControl' export interface LiveAtcSessionDeps { state: ReturnType freq: ReturnType speech: ReturnType radioBackend: ReturnType api: ReturnType /** NUXT_PUBLIC_* runtime config (radioBackendUrl, pttMinWords). */ config: ReturnType /** Pre-generate ATIS audio once the airport is known (useAtisPlayback). */ prefetchAtisAudio: () => void /** True while the pilot holds PTT — the silence timer must not talk over them. */ isRecording: Ref /** Settings toggle: dial in the new frequency automatically after a handoff. */ autoTuneEnabled: Ref /** * Special-scenario switches. Read once when the session is created, so * toggling one mid-flight applies to the next flight — which is how an * instructor sets one up without the pilot seeing it coming. */ forceRto: Ref forceGoAround: Ref bridgeConnected: Ref bridgePosition: Ref<{ lat: number; lon: number } | null> /** ?token=… from the route — auths the frequency-sim-control channel (design §4). */ bridgeToken: Ref persistSelectedPlan: (plan: any | null) => void maybeShowFirstRunHelp: () => void } /** * The session controller: everything that talks to the Python backend and moves * the user between screens. * * Constructed last, after every composable it calls into. The composables that * in turn need handlePilotTransmission/applyBackendDecision (speech, PTT, * bridge) receive them as thin forwarding closures from the page, which is what * breaks the cycle — see useSessionState for the other half of the split. */ export function useLiveAtcSession( engine: ReturnType, deps: LiveAtcSessionDeps, ) { const { currentState, activeFlow, setActiveFlow, moveToSilent, patchVariables, patchFlags, appendLogEntry, initializeFlight, fetchRuntimeTree, isReady: engineReady, collectAtcStatesUntilPilotTurn, variables: vars, clearCommunicationLog, } = engine const { state, freq, speech, radioBackend, api, config, prefetchAtisAudio, isRecording, autoTuneEnabled, forceRto, forceGoAround, bridgeConnected, bridgePosition, bridgeToken, persistSelectedPlan, maybeShowFirstRunHelp, } = deps const { currentScreen, loading, error, pilotInput, vatsimId, flightPlans, selectedPlan, completedScenario, activeScenario, sessionStarting, sessionStartingMessage, backendSessionId, lastControllerSay, backendExpectedPhrase, lastReadbackReport, lastReadbackTranscript, setLastTransmission, clearLastTransmission, } = state const { frequencies, airportFrequencies, frequencySources, activeAirportIcao, expectedFrequencyForState, acceptedFrequenciesForState, runwayInUse, informationLetter, atisReport, fetchAirportFrequencies, setActiveFrequencyFromList, fetchDestinationFrequencies, applyDerivedPosition, } = freq const { RADIO_CHECK_RE, SAY_AGAIN_RE, FAREWELL_RE, scheduleControllerSpeech, speakPilotReadback, answerRadioCheck, } = speech const { readbackEnabled } = speech // Monotonic counter so a newer pilot transmission supersedes an older one still // awaiting its backend response — only the latest result is applied (#16). let transmitGeneration = 0 // True while a pilot transmission is out at the backend. Read by useAiTraffic's // gating chain: simulated traffic must not key up in the window between the // user finishing their call and ATC answering it, or the traffic call reads as // the reply. A counter rather than a boolean, so two overlapping transmissions // can't have the first one's finally clear the flag out from under the second. const transmitInFlightCount = ref(0) const transmitInFlight = computed(() => transmitInFlightCount.value > 0) // --- Silence timers ---------------------------------------------------------- // Two kinds of wait fire the backend /timeout endpoint, both armed here (see // silenceWindowFor for which applies where): // auto-advance — the pilot owes nothing and ATC carries on by itself (the // takeoff roll in tower-v1), on the flow-authored window. // readback — the pilot owes a mandatory readback and has not given it, // so ATC asks again on the much shorter server-published window. // Any pilot transmission or telemetry-fired advance re-arms or clears the // timer via applyBackendDecision, and a fire is dropped if the session or the // state moved on while it was pending. let silenceTimer: ReturnType | null = null // Consecutive silence-timeouts fired for the SAME state. If the backend keeps // re-prompting the identical auto_advance_on_silence state (e.g. it re-asks the // same read-back-less confirmation every timeout window), re-arming forever // reads as ATC repeating itself on a loop. Reset on any pilot transmission or // once the decision actually lands on a different state. let silenceFireCount: { stateId: string | null; count: number } = { stateId: null, count: 0 } const MAX_CONSECUTIVE_SILENCE_FIRES = 2 function clearSilenceTimer() { if (silenceTimer) { clearTimeout(silenceTimer) silenceTimer = null } } function resetSilenceFireCount() { silenceFireCount = { stateId: null, count: 0 } } function armSilenceTimer() { clearSilenceTimer() const state = currentState.value as any if (!backendSessionId.value) return const window = silenceWindowFor(state) if (!window) return if (silenceFireCount.stateId !== state.id) { silenceFireCount = { stateId: state.id, count: 0 } } if (silenceFireCount.count >= MAX_CONSECUTIVE_SILENCE_FIRES) { pmLog.warn('SILENCE TIMER not re-armed — state fired', silenceFireCount.count, 'times already:', state.id) return } const ms = window.ms const sessionAtArm = backendSessionId.value const stateAtArm = state.id pmLog.debug('SILENCE TIMER armed', { state: stateAtArm, ms, kind: window.kind }) const fire = async () => { silenceTimer = null // Never fire stale: the session ended or the state moved on while waiting. if (backendSessionId.value !== sessionAtArm) return if ((currentState.value as any)?.id !== stateAtArm) return // Don't talk over the pilot mid-PTT — check again shortly. if (isRecording.value) { silenceTimer = setTimeout(fire, 5000) return } try { pmLog.info('SILENCE TIMEOUT fired →', stateAtArm) silenceFireCount = { stateId: stateAtArm, count: (silenceFireCount.stateId === stateAtArm ? silenceFireCount.count : 0) + 1, } const response = await radioBackend.timeout(sessionAtArm) applyBackendDecision(response) } catch (e: any) { // 422 = the state changed under us (race with a transmission) — harmless. const status = e?.status ?? e?.response?.status if (status !== 422) pmLog.warn('SILENCE TIMEOUT failed', e) } } silenceTimer = setTimeout(fire, ms) } // --- Auto-tune --------------------------------------------------------------- // Tuning is manual, so after a handoff nothing the pilot says goes through // until they dial the new frequency in. With the setting on, the radio does it // for them a few seconds after the handoff was accepted, announcing it first. // // Whether a change is due is decided from state, not from an event (see // planAutoTune): the two cases that must NOT tune — a frequency readback that // was wrong, and one not yet given — both leave the session on a state that // still expects the frequency already dialled in, so nothing is due. const autoTune = createAutoTuneScheduler({ announce: (text) => { // Announce before changing, never after: the pilot has to be able to // follow what their own radio just did. scheduleControllerSpeech(text) appendLogEntry('system', text, currentState.value?.id ?? '', { frequency: frequencies.value.active, }) }, tune: (frequency) => { // Prefer the airport's own entry so the label and the engine's notion of // the position come along; fall back to a bare entry for an invented one. const known = airportFrequencies.value.find( (entry: any) => normalizedFrequencyValue(entry.frequency) === normalizedFrequencyValue(frequency), ) pmLog.info('AUTO-TUNE tuning →', frequency) setActiveFrequencyFromList(known ?? { type: '', label: '', frequency, source: 'openaip', } as any) }, currentSessionId: () => backendSessionId.value || null, currentActive: () => frequencies.value.active, onCancelled: (reason) => pmLog.info('AUTO-TUNE dropped —', reason), }) function clearAutoTune() { autoTune.cancel() } function scheduleAutoTune() { const plan = autoTune.schedule({ enabled: autoTuneEnabled.value, active: frequencies.value.active, expected: expectedFrequencyForState(), accepted: acceptedFrequenciesForState(), }) if (plan) pmLog.info('AUTO-TUNE armed →', plan.frequency, `in ${plan.delayMs}ms`) } // Guards the ATC reply (log entry + TTS) against being applied twice for the // same decision — applyBackendDecision can be reached from three sources // (transmit reply, telemetry tick and silence timeout), and // two of them can legitimately fire back-to-back for the same outcome (e.g. // a silence timeout racing an in-flight transmit reply), which otherwise // spoke/logged the same confirmation 2-3x (design doc WP1 Fix 2). let lastAppliedSay: { text: string; stateId: string; atMs: number } | null = null const APPLIED_SAY_DEDUPE_MS = 15_000 // Apply a backend decision (from a pilot transmission, a telemetry tick, or a // silence timeout) to the local engine + UI: sync the // flow/cursor/variables/flags, speak the controller reply, and surface // completion. Shared so all backend-driven ATC lands through one code path. const applyBackendDecision = ( response: import('./useRadioBackend').RadioTransmitResponse, opts: { transcript?: string } = {}, ) => { // If the backend has chained to a different flow, switch the local engine // first so moveToSilent can find the new states and expectedFrequencyForState() // returns the correct frequency_name. if (response.active_flow && response.active_flow !== activeFlow.value) { pmLog.info('FLOW CHANGE local:', activeFlow.value, '→ backend:', response.active_flow) try { setActiveFlow(response.active_flow) } catch (e) { pmLog.warn('setActiveFlow failed for', response.active_flow, e) } } // Advance local cursor through every state the backend auto-walked, then the // final state. moveToSilent updates current_unit, actions and handoffs // without scheduling further auto-transitions. suppressSay: the ATC line for // this decision is logged exactly once below, from the backend-rendered // sayText — logging each walked state's own say_tpl too would duplicate it // (2-3x on chained flows with multiple auto-advanced ATC/system states). // TTS is unaffected either way: only scheduleControllerSpeech below plays audio. for (const stateId of response.auto_advanced_states ?? []) { pmLog.debug('moveToSilent ← auto_advanced:', stateId) moveToSilent(stateId, { suppressSay: true }) } pmLog.debug('moveToSilent ← next_state_id:', response.next_state_id) moveToSilent(response.next_state_id, { suppressSay: true }) // Capture the per-field readback diagnostic for the STT debug panel. lastReadbackReport.value = response.readback_report ?? [] if (opts.transcript !== undefined) lastReadbackTranscript.value = opts.transcript // Update expected phrase AFTER cursor moves so any local-engine reactive // updates from moveToSilent have already settled. backendExpectedPhrase.value = response.expected_pilot_template ?? null // Sync variables + routing flags from backend so the local renderer stays in step. if (response.variables && Object.keys(response.variables).length) { patchVariables(response.variables) pmLog.debug('variables synced:', response.variables) } if (response.flags && Object.keys(response.flags).length) { patchFlags(response.flags) pmLog.debug('flags synced:', response.flags) } // TTS: use the pre-rendered string from the backend (correct variable values). let sayText = response.controller_say_rendered || response.controller_say_template const rb = response.readback_report ?? [] const readbackPassed = rb.length > 0 && rb.every((r: any) => r.matched) if (sayText && readbackPassed && !/^\s*(readback correct|correct[,.\s]|negative|i say again)/i.test(sayText)) { sayText = `Readback correct. ${sayText}` } if (sayText) { const isDuplicate = lastAppliedSay && lastAppliedSay.text === sayText && lastAppliedSay.stateId === response.next_state_id && Date.now() - lastAppliedSay.atMs < APPLIED_SAY_DEDUPE_MS if (isDuplicate) { pmLog.info('TTS SKIPPED (duplicate of last-applied decision) →', sayText) } else { pmLog.info('TTS →', sayText) lastControllerSay.value = sayText scheduleControllerSpeech(sayText) appendLogEntry('atc', sayText, response.next_state_id, { flow: response.active_flow, frequency: frequencies.value.active, }) lastAppliedSay = { text: sayText, stateId: response.next_state_id, atMs: Date.now() } } } if (response.fallback_used) { pmLog.warn('FALLBACK USED:', response.fallback_reason) console.warn('[Backend] Fallback used:', response.fallback_reason) } if (response.session_complete) { pmLog.info('SESSION COMPLETE — showing completion screen') completedScenario.value = activeScenario.value currentScreen.value = 'complete' } // Position-derived distances, used to work out which ground stations are // still within VHF range. Absent without a bridge — then nothing is ranged. applyDerivedPosition((response as any).derived_position) // Re-arm (or clear) the silence auto-advance for whatever state we're now on. armSilenceTimer() // And dial in the new frequency if this decision handed us to one. scheduleAutoTune() } // --- Frequency-sim-control (design doc §4) ------------------------------------ // A matched command from handlePilotTransmission is queued server-side for the // bridge to execute; the result comes back asynchronously via the /api/bridge/live // poll (see handleSimControlResult below), so this call itself stays silent on // success — only a network/enqueue failure gets an immediate ATC reply. const sendSimControlCommand = async (command: SimControlCommand) => { try { await $fetch('/api/bridge/command', { method: 'POST', headers: { 'x-bridge-token': bridgeToken.value }, body: { command }, }) } catch (e) { pmLog.warn('SIM CONTROL enqueue failed', e) const reply = 'unable to reach bridge, say again' scheduleControllerSpeech(reply) appendLogEntry('atc', reply, currentState.value?.id ?? '', { frequency: frequencies.value.active }) } } /** Speak the outcome of a bridge command surfaced by useSimBridgeSync's telemetry poll. */ const handleSimControlResult = (result: SimControlCommandResult) => { const reply = simControlResultSpeech(result) scheduleControllerSpeech(reply) appendLogEntry('atc', reply, currentState.value?.id ?? '', { frequency: frequencies.value.active }) } const handlePilotTransmission = async (message: string, source: 'text' | 'ptt' = 'text') => { const transcript = message.trim() // STT CONTENT GATE — search here if a spoken transmission was ignored. // Drops what push-to-talk produces when nothing was actually said: a stray // tap, punctuation from near-silent audio, a clipped syllable, or one of the // phrases Whisper hallucinates on silence. Each of those would otherwise be // graded as a wrong readback, count toward the 3x-skip, and cost an LLM // router call. Standard short calls ("roger", "wilco") are recognised as // phraseology and pass regardless of length; typed input is never gated. // Threshold is the NUXT_PUBLIC_PTT_MIN_WORDS env var (default 2); set it to // 1 to disable the length rule. const gate = gateTransmission(transcript, { source, minPttWords: Number(config.public.pttMinWords ?? 2), readbackRequired: currentState.value?.readback_required, }) if (!gate.accept) { pmLog.info( `IGNORED ${source} transcript (${gate.reason}) at state ${currentState.value?.id ?? '—'}:`, JSON.stringify(transcript), ) return } // Mark this as the latest transmission; a newer one started before the backend // replies supersedes it (the stale reply is dropped below). const myGen = ++transmitGeneration const prefix = source === 'ptt' ? 'Pilot (PTT)' : 'Pilot' setLastTransmission(`${prefix}: ${transcript}`) // Log the pilot's call with the ACTUAL tuned frequency (the engine's own // notion of "active frequency" is unit-derived and can lag what's tuned). appendLogEntry('pilot', transcript, currentState.value?.id ?? '', { frequency: frequencies.value.active, }) if (readbackEnabled.value) { speakPilotReadback(transcript) } // --- Radio check --- // A service call, valid on ANY frequency and answered by whoever owns it. // Handled entirely locally: it must bypass the wrong-frequency gate (its // purpose is verifying the tuned frequency) and never reach the backend // flow engine (the training state stays untouched). if (RADIO_CHECK_RE.test(transcript)) { answerRadioCheck(transcript) return } // --- Say again --- // Pilot asks ATC to repeat. Handled locally: re-speak the last controller // transmission without advancing the flow. if (SAY_AGAIN_RE.test(transcript) && lastControllerSay.value) { const reply = `I say again. ${lastControllerSay.value}` scheduleControllerSpeech(reply) appendLogEntry('atc', reply, currentState.value?.id ?? '', { frequency: frequencies.value.active }) return } // --- Sign-off --- // A bare goodbye / thank-you (no readback or request alongside): acknowledge // politely instead of evaluating it as a transmission. Requires a farewell // with no digits, no readback/acknowledgement words, and only a few words, so // it never swallows a real call like "wilco, thanks" or a frequency readback. const wordCount = transcript.split(/\s+/).filter(Boolean).length const hasActionWord = /\b(wilco|roger|cleared|clear|runway|contact|ready|request|squawk|descend|climb|taxi|line\s?up|holding|hold short|approved|affirm|going|switching|push|startup)\b/i.test(transcript) if (FAREWELL_RE.test(transcript) && !/\d/.test(transcript) && !hasActionWord && wordCount <= 6) { const cs = (vars as any).value?.callsign ?? '' const reply = cs ? `Good day, ${cs}.` : 'Good day.' scheduleControllerSpeech(reply) appendLogEntry('atc', reply, currentState.value?.id ?? '', { frequency: frequencies.value.active }) return } // --- Sim control (frequency-driven simulator command) --- // A meta channel: the pilot talking to their OWN simulator, not ATC — so it // runs before the frequency gate (like radio check) and, unlike every other // transmission, never reaches radioBackend.transmit()/the decision flow. // Only live while a bridge is actually connected (design doc §4); parseSimControl's // anchors already keep it from ever matching real ICAO phraseology either way. if (bridgeConnected.value) { const simResult = parseSimControl(transcript) if (isSimControlMatch(simResult)) { void sendSimControlCommand(simResult.command) return } // A rejection with a reason means the pilot clearly addressed their sim but // got something wrong — tell them. 'no_intent' just means this was ordinary // radio traffic, so it falls through to the flow untouched. if (isSimControlRejection(simResult) && simResult.reason !== 'no_intent') { const reply = simControlRejectionSpeech(simResult.reason) scheduleControllerSpeech(reply) appendLogEntry('atc', reply, currentState.value?.id ?? '', { frequency: frequencies.value.active }) return } } // --- Frequency check --- // Reject the transmission if the pilot is on the wrong frequency. A position // can publish several valid frequencies (e.g. two Tower freqs); accept any of // them, and only fall back to the single expected value when no list resolves. const expectedFreq = expectedFrequencyForState() const acceptedFreqs = acceptedFrequenciesForState() const activeNorm = normalizedFrequencyValue(frequencies.value.active) const onValidFreq = acceptedFreqs.length ? acceptedFreqs.includes(activeNorm) : frequencies.value.active === expectedFreq if (expectedFreq && !onValidFreq) { const callsign = (vars as any).value?.callsign ?? '' const freqName = (currentState.value as any)?.frequency_name as string | undefined // Tell the pilot exactly which frequency to switch to (and the position), // since tuning is manual and the call won't go through until they do. const target = freqName ? `${freqName} on ${expectedFreq}` : expectedFreq const reply = callsign ? `${callsign}, you are on the wrong frequency. Contact ${target}.` : `Station calling, wrong frequency. Contact ${target}.` pmLog.warn('WRONG FREQUENCY', { active: frequencies.value.active, expected: expectedFreq }) lastControllerSay.value = reply scheduleControllerSpeech(reply) // Add the ATC "wrong frequency" reply to the communication log. appendLogEntry('atc', reply, currentState.value?.id ?? '', { offSchema: true, frequency: frequencies.value.active, }) return } if (!backendSessionId.value) { pmLog.error('TRANSMIT BLOCKED — no backend session', { transcript, source }) console.error('No backend session — cannot transmit') setLastTransmission(`${prefix}: ${transcript} (no session)`) return } pmLog.group(`▶ TRANSMIT [${source}] session=${backendSessionId.value.slice(0,8)}`, () => { pmLog.info('utterance :', transcript) pmLog.info('local state :', currentState.value?.id ?? '—') pmLog.info('session_id :', backendSessionId.value) }) transmitInFlightCount.value++ try { // The pilot spoke — a pending silence auto-advance no longer applies, and // a live transmission means the loop-guard's job is done for this state. // The response re-arms it (fresh count) if the next state also allows silence. clearSilenceTimer() resetSilenceFireCount() const response = await radioBackend.transmit(backendSessionId.value, transcript) // Drop a stale reply if the pilot already transmitted again while this call // was in flight — only the newest transmission's result is applied (#16). if (myGen !== transmitGeneration) { pmLog.info('Discarded stale transmission reply (superseded by a newer transmission)') return } pmLog.group(`✓ RESPONSE ${currentState.value?.id ?? '?'} → ${response.next_state_id}`, () => { pmLog.info('next_state :', response.next_state_id) pmLog.info('auto_advanced :', response.auto_advanced_states ?? []) pmLog.info('fallback_used :', response.fallback_used, response.fallback_reason ?? '') pmLog.info('say_template :', response.controller_say_template ?? '—') pmLog.info('say_rendered :', response.controller_say_rendered ?? '—') pmLog.info('expected_pilot :', response.expected_pilot_template ?? '—') pmLog.info('variables :', response.variables) pmLog.info('flags :', response.flags) if (response.trace?.length) { pmLog.debug('trace:') response.trace.forEach(t => pmLog.debug(` [${t.type}]`, t.message)) } }) applyBackendDecision(response, { transcript }) } catch (e: any) { const status = e?.status ?? e?.response?.status if (status === 404) { // Session expired (idle timeout) or backend restarted without the // persisted session — reset to scenario selection for a clean restart. pmLog.error('SESSION GONE (404) — resetting to scenario selection', { session: backendSessionId.value }) backendSessionId.value = null error.value = 'Session expired — please start the scenario again.' currentScreen.value = 'scenario' return } const isTimeout = e?.name === 'TimeoutError' || e?.name === 'AbortError' || e?.cause?.name === 'TimeoutError' || e?.cause?.name === 'AbortError' pmLog.error(isTimeout ? 'TRANSMIT TIMED OUT' : 'TRANSMIT FAILED', { transcript, session: backendSessionId.value, error: e }) console.error('Backend transmission failed', e) setLastTransmission(`${prefix}: ${transcript} (${isTimeout ? 'backend timeout' : 'backend failed'})`) } finally { transmitInFlightCount.value = Math.max(0, transmitInFlightCount.value - 1) } } // VATSIM Integration const loadFlightPlans = async () => { if (!vatsimId.value) return loading.value = true error.value = '' try { const response = await api.get('/api/vatsim/flightplans', { query: { cid: vatsimId.value } }) if (Array.isArray(response) && response.length > 0) { flightPlans.value = response.slice(0, 10) currentScreen.value = 'flightselect' } else { error.value = 'No flight plans found for this VATSIM ID' } } catch (err) { console.error('Error loading flight plans:', err) error.value = 'Error loading flight plans. Please check the VATSIM ID.' } finally { loading.value = false } } const startMonitoring = async (flightPlan: any, scenario: Scenario) => { activeScenario.value = scenario sessionStarting.value = true sessionStartingMessage.value = /^taxi/.test(scenario.startFlow) ? 'Calculating taxi route…' : 'Starting session…' // 1. Ensure the local tree is loaded from the Python backend (same source as session) try { if (!engineReady.value) { await fetchRuntimeTree(scenario.startFlow, config.public.radioBackendUrl as string) } } catch (err) { console.error('Failed to prepare decision engine', err) error.value = 'Entscheidungsbaum konnte nicht geladen werden.' sessionStarting.value = false return } // 2. Initialize the local flight engine first so generated values (stand, SID, squawk) // are computed and available in vars.value before we create the backend session. initializeFlight(flightPlan) // 3a. Resolve which airport to use for frequencies. // Departure scenarios use the dep airport; arrival scenarios use arr. const scenarioIcao: string | undefined = scenario.airport === 'arr' ? (flightPlan.arr || flightPlan.arrival || flightPlan.dep || flightPlan.departure) : (flightPlan.dep || flightPlan.departure || flightPlan.arr || flightPlan.arrival) activeAirportIcao.value = scenarioIcao // Fetch real airport frequencies BEFORE building backendVariables so that // all freq vars are resolved from live VATSIM/OpenAIP data. await fetchAirportFrequencies(scenarioIcao) // The other end of the flight, so its stations can be dialled ahead of time. // Best-effort and not awaited on the critical path: the flight is entirely // flyable without them. const otherIcao = scenario.airport === 'arr' ? (flightPlan.dep || flightPlan.departure) : (flightPlan.arr || flightPlan.arrival) void fetchDestinationFrequencies(otherIcao) // Runway and information letter from the resolved ATIS (the flight plan // carries neither). The report always has a letter, and has a runway // whenever the airport publishes runway data — so the pilot's initial call // matches the broadcast they just listened to. const atisKind = scenario.airport === 'arr' ? 'arr' : 'dep' const atisRunway = runwayInUse(atisKind) const atisLetter = informationLetter(atisKind) const atisPatch: Record = {} if (atisRunway) atisPatch.runway = atisRunway if (atisLetter) atisPatch.atis_code = atisLetter // Quote the observed pressure and wind rather than the engine's placeholders, // so the controller does not contradict the ATIS the pilot just heard. if (atisReport.value?.qnhHpa) atisPatch.qnh_hpa = atisReport.value.qnhHpa if (atisReport.value?.surfaceWind) atisPatch.surface_wind = atisReport.value.surfaceWind if (Object.keys(atisPatch).length) { patchVariables(atisPatch) pmLog.info('From ATIS report:', { ...atisPatch, source: atisReport.value?.source }) } // Pre-generate the ATIS TTS audio now so the first tune-in plays instantly // instead of waiting for generation. Fire-and-forget. prefetchAtisAudio() // 3b. Build the backend variable payload. The backend stores ALL keys so that // frequencies declared in downstream chained flows (tower_freq for taxi-v1, // departure_freq for tower-v1, etc.) are already populated with real airport // values when the session advances to those flows. const v = (vars as any).value const backendVariables = buildBackendVariables({ flightPlan, vars: v, atisRunway }) // Special scenarios. Only sent when switched on: the backend treats the // variable's absence as "leave it to chance", and sending false would pin // the roll off rather than leaving it alone. if (forceRto.value) backendVariables.force_rto = true if (forceGoAround.value) backendVariables.force_go_around = true // VFR flights identify by aircraft registration, not an airline callsign. // Assign a German D-registration and its abbreviated form (D-EMIL -> D-IL, // first letter + last two), which ATC uses after the first call. Mirror it // into the local engine vars and HUD so the display matches the radio. // The class letter has to match the aircraft — D-E is single-engine // pistons, so an A320 gets a D-A registration, not "D-ETMO". if (scenario.startFlow.startsWith('vfr') || scenario.startFlow.startsWith('info')) { const { registration, short } = generateGermanRegistration(backendVariables.aircraft_type) backendVariables.callsign = registration backendVariables.callsign_short = short patchVariables({ callsign: registration, callsign_short: short }) } else { backendVariables.callsign_short = backendVariables.callsign } pmLog.info('backend variables payload:', backendVariables) // 4. Create a backend session with the flight-plan-derived variables try { error.value = '' const session = await radioBackend.createSession( scenario.startFlow, backendVariables, scenario.noChain, scenarioIcao, v.dest || flightPlan.arr || flightPlan.arrival, // Live aircraft position (bridge connected): backend starts the taxi // route at the real parking position instead of a random stand. bridgeConnected.value ? bridgePosition.value : null, ) backendSessionId.value = session.session_id pmLog.group(`SESSION CREATED id=${session.session_id.slice(0, 8)}`, () => { pmLog.info('flow :', session.flow_slug) pmLog.info('start state :', session.current_state) pmLog.info('variables :', session.variables) pmLog.info('flags :', session.flags) }) // Sync cursor to wherever the backend initialised (usually start_state) moveToSilent(session.current_state) pmLog.debug('moveToSilent ← session start_state:', session.current_state) // Sync session variables back so the local renderer stays in step. if (session.variables && Object.keys(session.variables).length) { patchVariables(session.variables) } // Sync boolean routing flags from session if (session.flags && Object.keys(session.flags).length) { patchFlags(session.flags) } // Seed the expected pilot phrase from the start state backendExpectedPhrase.value = session.expected_pilot_template ?? null pmLog.info('expected_pilot (session start):', backendExpectedPhrase.value ?? '—') } catch (err) { pmLog.error('SESSION CREATE FAILED', err) console.error('Failed to create backend session', err) error.value = 'Verbindung zum Training-Backend fehlgeschlagen.' return } finally { // The slow part (route computation) is done once createSession resolves. sessionStarting.value = false } error.value = '' selectedPlan.value = flightPlan currentScreen.value = 'monitor' persistSelectedPlan(flightPlan) maybeShowFirstRunHelp() // Start from a known baseline; the first controller's frequency is tuned in // automatically below, once the initial state-walk tells us which pilot state // the scenario opens on. frequencies.value.active = '121.900' frequencies.value.standby = '118.100' // 4. Walk the initial ATC/system states locally (deterministic, no LLM). // Safe because we loaded the tree from the same Python backend, so the // walk is identical to what the backend will do on the first transmission. try { await nextTick() const startMessages = collectAtcStatesUntilPilotTurn() for (const msg of startMessages) { scheduleControllerSpeech(msg.say_tpl) } } catch (err) { console.warn('Initial state advance failed:', err) } // Pre-tune COM1 active to the frequency the opening pilot call is expected on, // so the very first transmission isn't met with a confusing 'wrong frequency' // rejection. Later handoffs stay manual — tuning to the next controller // remains part of the exercise. await nextTick() const firstFreq = expectedFrequencyForState() if (firstFreq) { frequencies.value.active = firstFreq pmLog.info('Pre-tuned COM1 active to first frequency:', firstFreq) } } const startDemoFlight = () => { const demoFlight = { callsign: 'DLH39A', aircraft: 'A320/L', dep: 'EDDF', arr: 'EDDM', altitude: '36000', } selectedPlan.value = demoFlight currentScreen.value = 'scenario' } /** Start from a manually entered flight instead of VATSIM / demo. */ const startManualFlight = (plan: { callsign: string dep: string arr: string aircraft?: string altitude?: string }) => { selectedPlan.value = { callsign: plan.callsign.trim().toUpperCase(), aircraft: plan.aircraft?.trim().toUpperCase() || 'A320/L', dep: plan.dep.trim().toUpperCase(), arr: plan.arr.trim().toUpperCase(), altitude: plan.altitude?.trim() || '36000', } currentScreen.value = 'scenario' } /** Launch a specific scenario with the current flight plan. */ const launchScenario = async (scenario: Scenario) => { if (!selectedPlan.value) return await startMonitoring(selectedPlan.value, scenario) } /** Re-fly the same scenario that was just completed. */ const flyAgain = async () => { if (!completedScenario.value || !selectedPlan.value) { currentScreen.value = 'scenario' return } await startMonitoring(selectedPlan.value, completedScenario.value) } const backToSetup = () => { // Leaving the flight: a pending frequency change belongs to a session that // no longer exists. clearSilenceTimer() clearAutoTune() currentScreen.value = 'login' selectedPlan.value = null persistSelectedPlan(null) clearLastTransmission() airportFrequencies.value = [] frequencySources.value = { vatsim: false, openaip: false } } const sendPilotText = async () => { const text = pilotInput.value.trim() if (!text) return pilotInput.value = '' await handlePilotTransmission(text, 'text') } onUnmounted(() => { clearSilenceTimer() clearAutoTune() }) return { clearSilenceTimer, transmitInFlight, applyBackendDecision, handlePilotTransmission, handleSimControlResult, loadFlightPlans, startMonitoring, startDemoFlight, startManualFlight, launchScenario, flyAgain, backToSetup, sendPilotText, } }