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 { normalizedFrequencyValue, type useFrequencyPresets } from '~/composables/useFrequencyPresets' import type { useSessionState } from '~/composables/useSessionState' import { useApi } from '~/composables/useApi' import type { useRadioSpeech } from '~/composables/useRadioSpeech' import useCommunicationsEngine from '../../shared/utils/communicationsEngine' 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 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, 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, extractAtisRunway, fetchAirportFrequencies, } = 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 auto-advance ---------------------------------------------------- // Some pilot states let ATC continue on its own when the pilot stays quiet // (e.g. the takeoff roll in tower-v1: no report needed, Tower hands off after a // while anyway). Such states carry auto_advance_on_silence + // auto_advance_timeout_ms in the runtime tree; whenever the session lands on // one, arm a timer that fires the backend /timeout endpoint. Any pilot // transmission or telemetry-fired advance re-arms or clears it via // applyBackendDecision. 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 (!state?.auto_advance_on_silence || !backendSessionId.value) 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 = Math.max(1000, Number(state.auto_advance_timeout_ms ?? 30000)) const sessionAtArm = backendSessionId.value const stateAtArm = state.id pmLog.debug('SILENCE TIMER armed', { state: stateAtArm, ms }) 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) } // Guards the ATC reply (log entry + TTS) against being applied twice for the // same decision — applyBackendDecision can be reached from four sources // (transmit reply, telemetry tick, silence timeout, bug-report restore) 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' } // Re-arm (or clear) the silence auto-advance for whatever state we're now on. armSilenceTimer() } // --- 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() // Ignore empty or content-free transmissions: silence, a stray PTT tap, or // Whisper hallucinating punctuation on near-silent audio. A genuine short call // ("roger", "wilco") still contains letters/digits and passes. if (!transcript || !/[a-z0-9]/i.test(transcript)) return // STT MINIMUM-WORD GATE — search here if a spoken transmission was ignored. // Voice (PTT) only: drop transcripts shorter than the configured minimum. // Whisper hallucinates short real words ("Test", "Thank you", "Okay") on // near-silent or noisy audio; left unfiltered those reach the backend as a // (wrong) readback attempt — counting toward the 3x-skip — and now also // trigger a paid LLM-router call. Text input is exempt so deliberate short // commands still work. Threshold is the NUXT_PUBLIC_PTT_MIN_WORDS env var // (default 2); set it to 1 to effectively disable the gate. const minPttWords = Number(config.public.pttMinWords ?? 2) if (source === 'ptt' && transcript.split(/\s+/).filter(Boolean).length < minPttWords) { pmLog.info(`IGNORED short PTT transcript (<${minPttWords} words):`, 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) // Runway in use from live ATIS (the flight plan carries no runway). Falls // back to the engine-generated one when no ATIS text mentions a runway. const atisRunway = extractAtisRunway(scenario.airport === 'arr' ? 'arr' : 'dep') if (atisRunway) { patchVariables({ runway: atisRunway }) pmLog.info('Runway from ATIS:', atisRunway) } // 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: Record = { callsign: v.callsign || flightPlan.callsign || 'UNKNOWN', information: v.atis_code || 'K', destination: v.dest || flightPlan.arr || flightPlan.arrival || 'Unknown', stand: v.stand || 'A1', sid: v.sid || 'UNKNOWN1A', initial_altitude: String(v.initial_altitude_ft ?? 5000), squawk: String(v.squawk ?? '2000'), // Shared / arrival variables. The engine generates these, but they were not // being forwarded — so arrival flows (and taxi/tower on departure) fell back // to YAML defaults and ignored the selected flight. Names are mapped to the // backend flow conventions (qnh_hpa→qnh, acf_type→aircraft_type, …). runway: atisRunway || v.runway || '25R', qnh: String(v.qnh_hpa ?? '1013'), surface_wind: v.surface_wind || '250/08', // taxi_route is intentionally NOT sent: the backend computes the real OSM // taxi route (and crossings) from airport_icao + stand/runway, and falls // back to the flow's YAML default on its own. Sending a placeholder here // would count as a caller override and suppress that computation. aircraft_type: v.acf_type || 'A320', cruise_level: v.cruise_flight_level || 'FL360', assigned_squawk: String(v.squawk ?? '2000'), // All airport frequencies — available to every flow in the chain. delivery_freq: v.delivery_freq || '121.950', ground_freq: v.ground_freq || '121.800', tower_freq: v.tower_freq || '118.700', departure_freq: v.departure_freq || '120.000', approach_freq: v.approach_freq || '119.000', handoff_freq: v.handoff_freq || '131.150', } // 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. if (scenario.startFlow.startsWith('vfr') || scenario.startFlow.startsWith('info')) { const pool = ['D-EMIL', 'D-EKLM', 'D-ENNY', 'D-ELLA', 'D-EOMT', 'D-ELPC', 'D-EMTO', 'D-EBRA'] const reg = pool[Math.floor(Math.random() * pool.length)] const short = `D-${reg.replace(/^D-/, '').slice(-2)}` backendVariables.callsign = reg backendVariables.callsign_short = short patchVariables({ callsign: reg, 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' } /** 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 = () => { 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') } /** * Restore a /live-atc session from a saved bug-report snapshot (admin link * `/live-atc?restoreBugReport=`). The Python backend has no "resume mid-session" * endpoint, so we recreate a real, working session for the SAME flight and * scenario via startMonitoring(), then overlay the saved variables/flags and * the captured conversation so the admin can reproduce and try out the bug. */ async function restoreBugReportState(restoreId: string) { try { const report = await api.get(`/api/admin/bug-reports/${restoreId}`) const state = report?.pmState if (!state) { error.value = 'Bug-Report enthält keinen gespeicherten State.' return } // Locate the scenario the report was captured in. const scenario = SCENARIOS.find(s => s.id === state.scenarioId) || SCENARIOS.find(s => s.startFlow === state.flowSlug) if (!scenario) { error.value = `Bug-Report-Restore: Szenario "${state.scenarioId || state.flowSlug || '?'}" nicht gefunden.` return } // Reconstruct a flight plan from the snapshot so startMonitoring resolves the // correct airport/frequencies and creates a backend session for the same flight. const v = state.variables || {} const fc = state.flightContext || {} const dep = v.dep || fc.dep const dest = v.dest || fc.dest const flightPlan: Record = { callsign: v.callsign || fc.callsign || 'UNKNOWN', aircraft: v.acf_type || fc.acf_type || 'A320', dep, departure: dep, arr: dest, arrival: dest, route: fc.route || v.route || '', assignedsquawk: v.squawk, } // Spin up a real session (loads tree, fetches frequencies, creates backend session). await startMonitoring(flightPlan, scenario) // startMonitoring bails out on error without entering the monitor screen. if (currentScreen.value !== 'monitor') return // Overlay the exact saved values over the freshly generated ones (stand, SID, …). if (state.variables && Object.keys(state.variables).length) patchVariables(state.variables) if (state.flags && Object.keys(state.flags).length) patchFlags(state.flags) // Restore the captured conversation for context. clearCommunicationLog?.() if (Array.isArray(state.communicationLog)) { for (const e of state.communicationLog) { if (!e?.message) continue appendLogEntry(e.speaker || 'system', e.message, e.state || '', { frequency: e.frequency, flow: e.flow, radioCheck: e.radioCheck, offSchema: e.offSchema, }) } } // A fresh backend session always starts at the flow's start state, so we // can't fake the local cursor onto the captured mid-flow state without // desyncing transmits. Tell the admin where the bug was captured instead. error.value = `Bug-Report wiederhergestellt: ${scenario.name} · ${flightPlan.callsign} (${dep || '?'}→${dest || '?'}). ` + `Erfasster State: ${state.currentStateId || '?'} (Flow ${state.flowSlug || '?'}).` } catch (err) { console.warn('[PM] Bug report restore failed', err) error.value = 'Bug-Report konnte nicht wiederhergestellt werden.' } } onUnmounted(clearSilenceTimer) return { clearSilenceTimer, transmitInFlight, applyBackendDecision, handlePilotTransmission, handleSimControlResult, loadFlightPlans, startMonitoring, startDemoFlight, launchScenario, flyAgain, backToSetup, sendPilotText, restoreBugReportState, } }