From 59e35aa087658358ef4f8e32d192c07fc3ffcc48 Mon Sep 17 00:00:00 2001 From: leubeem Date: Mon, 8 Jun 2026 13:03:55 +0200 Subject: [PATCH] feat: scenario picker, flow chaining UX, reliable frequency checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scenario picker & completion: - Login → scenario selection screen with complete chains + individual phases - Completion screen with "fly again / try opposite / back to scenarios" - Scenario.airport ('dep'|'arr') drives which airport frequencies to fetch; arrival scenarios (vfr-arrival, circuit-landing, taxi-in) use arr ICAO Backend session integration: - createSession forwards no_chain; response carries active_flow + session_complete - Pass all six airport frequency variables to session so every chained flow has the real airport values from creation - fetchAirportFrequencies now runs before session creation so resolved frequencies are included in backendVariables Wrong-frequency check: - airportFreqMap computed (from airportFrequencies, always up-to-date) used as primary source in expectedFrequencyForState — immune to flow snapshot switches - setActiveFlow called when response.active_flow changes so local engine cursor moves to the correct flow's states after a chain - Wrong-freq ATC reply appended to communication log (offSchema entry) Engine fixes (communicationsEngine.ts): - patchVariables / patchFlags: write directly to the internal reactive store, bypassing readonly(ref) which silently blocked all (vars as any) .value[k] = v mutations - appendLogEntry: push ATC speech (and wrong-freq replies) into comm log - ATC controller_say_rendered appended to comm log after every transmission --- app/composables/useRadioBackend.ts | 6 +- app/pages/pm.vue | 402 ++++++++++++++++++++++++--- shared/utils/communicationsEngine.ts | 42 +++ 3 files changed, 405 insertions(+), 45 deletions(-) diff --git a/app/composables/useRadioBackend.ts b/app/composables/useRadioBackend.ts index c6235e6..886a3a7 100644 --- a/app/composables/useRadioBackend.ts +++ b/app/composables/useRadioBackend.ts @@ -10,6 +10,7 @@ export interface RadioSessionResponse { export interface RadioTransmitResponse { session_id: string next_state_id: string + active_flow: string controller_say_template: string | null controller_say_rendered: string | null expected_pilot_template: string | null @@ -19,6 +20,8 @@ export interface RadioTransmitResponse { fallback_used: boolean fallback_reason: string | null auto_advanced_states?: string[] + /** True when the full chain is done — show the completion screen. */ + session_complete?: boolean } export function useRadioBackend() { @@ -31,10 +34,11 @@ export function useRadioBackend() { async function createSession( flowSlug: string, variables?: Record, + noChain: boolean = false, ): Promise { return await $fetch(`${baseUrl()}/api/radio/session`, { method: 'POST', - body: { flow_slug: flowSlug, variables: variables ?? null }, + body: { flow_slug: flowSlug, variables: variables ?? null, no_chain: noChain }, }) } diff --git a/app/pages/pm.vue b/app/pages/pm.vue index 3aaf6c4..ecd0447 100644 --- a/app/pages/pm.vue +++ b/app/pages/pm.vue @@ -87,7 +87,7 @@ v-for="plan in flightPlans" :key="plan.id" class="bg-white/5 border border-white/10 backdrop-blur transition hover:border-cyan-400/60 cursor-pointer" - @click="startMonitoring(plan)" + @click="selectedPlan = plan; currentScreen = 'scenario'" >
@@ -113,6 +113,101 @@
+ +
+ +
+ + mdi-arrow-left + +
+

Choose your scenario

+

+ {{ selectedPlan.callsign }} · {{ selectedPlan.dep }} → {{ selectedPlan.arr }} +

+
+
+ + +
+

+ Complete scenarios +

+
+ + + +
{{ s.name }}
+
{{ s.subtitle }}
+
+
+
+
+ + +
+

+ Practice a single phase +

+
+ +
+
+
+ + +
+
+ mdi-check-circle-outline +

+ {{ completedScenario?.name ?? 'Session' }} complete +

+

+ {{ selectedPlan?.callsign }} · + {{ selectedPlan?.dep }} → {{ selectedPlan?.arr }} +

+
+ +
+ + mdi-refresh + Fly again + + + + {{ oppositeScenario.icon }} + Try {{ oppositeScenario.name }} + + + + Back to scenarios + +
+
+ @@ -1163,6 +1258,9 @@ const { activeFlow, sessionId: engineSessionId, lastDecisionTrace, + appendLogEntry, + patchVariables, + patchFlags, initializeFlight, updateFrequencyVariables, fetchRuntimeTree, @@ -1170,6 +1268,7 @@ const { processPilotTransmission, buildLLMContext, applyLLMDecision, + setActiveFlow, moveTo: forceMove, moveToSilent, normalizeATCText, @@ -1220,11 +1319,29 @@ const FREQ_NAME_TO_VAR: Record = { 'radar': 'handoff_freq', } +// Derived from the raw airport frequency list — always reflects the real airport +// data regardless of which flow snapshot is active. Used by expectedFrequencyForState +// so the wrong-frequency check is reliable across all flow transitions. +const airportFreqMap = computed>(() => { + const result: Record = {} + for (const entry of airportFrequencies.value) { + const key = frequencyTypeMap[entry.type] + if (key && entry.frequency && !result[key]) { + result[key] = entry.frequency + } + } + return result +}) + function expectedFrequencyForState(): string | null { const freqName = (currentState.value as any)?.frequency_name as string | undefined if (!freqName) return null const varKey = FREQ_NAME_TO_VAR[freqName.toLowerCase()] - return varKey ? ((vars as any).value[varKey] as string ?? null) : null + if (!varKey) return null + // airportFreqMap is the authoritative source — it comes from the live airport + // data and is unaffected by flow-snapshot switches. Fall back to the engine + // variable store for any edge-case where airport data is missing. + return airportFreqMap.value[varKey] ?? ((vars as any).value[varKey] as string ?? null) } const lastTransmission = ref('') @@ -1566,8 +1683,151 @@ function describeElimination(entry: any): string { return entry.reason } +// --------------------------------------------------------------------------- +// Scenario definitions +// --------------------------------------------------------------------------- + +interface Scenario { + id: string + name: string + subtitle: string + icon: string + startFlow: string + /** Display-only chain label, e.g. "Clearance → Taxi → Tower → Departure" */ + chain: string + isComplete: boolean + /** When true, backend will NOT follow next_flow links (single-phase practice). */ + noChain: boolean + /** + * Which airport from the flight plan to use for frequency lookups. + * 'dep' = departure airport, 'arr' = arrival/destination airport. + */ + airport: 'dep' | 'arr' +} + +const SCENARIOS: Scenario[] = [ + // ── Complete chains ────────────────────────────────────────────────────── + { + id: 'ifr-departure', + name: 'IFR Departure', + subtitle: 'Clearance → Taxi → Tower → Departure', + chain: 'clearance-v1 → taxi-v1 → tower-v1 → departure-v1', + icon: 'mdi-airplane-takeoff', + startFlow: 'clearance-v1', + isComplete: true, + noChain: false, + airport: 'dep', + }, + { + id: 'vfr-arrival', + name: 'VFR Arrival', + subtitle: 'Approach → Circuit → Landing → Taxi-in', + chain: 'vfr-arrival-v1 → vfr-circuit-landing-v1 → taxi-in-v1', + icon: 'mdi-airplane-landing', + startFlow: 'vfr-arrival-v1', + isComplete: true, + noChain: false, + airport: 'arr', + }, + // ── Individual phases ──────────────────────────────────────────────────── + { + id: 'clearance', + name: 'Clearance', + subtitle: 'Request IFR clearance', + chain: 'clearance-v1', + icon: 'mdi-file-document-outline', + startFlow: 'clearance-v1', + isComplete: false, + noChain: true, + airport: 'dep', + }, + { + id: 'taxi', + name: 'Startup & Taxi', + subtitle: 'Startup → Pushback → Taxi', + chain: 'taxi-v1', + icon: 'mdi-car-side', + startFlow: 'taxi-v1', + isComplete: false, + noChain: true, + airport: 'dep', + }, + { + id: 'tower', + name: 'Tower', + subtitle: 'Line-up and takeoff', + chain: 'tower-v1', + icon: 'mdi-tower-fire', + startFlow: 'tower-v1', + isComplete: false, + noChain: true, + airport: 'dep', + }, + { + id: 'departure', + name: 'Departure', + subtitle: 'Initial climb check-in', + chain: 'departure-v1', + icon: 'mdi-radar', + startFlow: 'departure-v1', + isComplete: false, + noChain: true, + airport: 'dep', + }, + { + id: 'vfr-approach', + name: 'VFR Approach', + subtitle: 'Initial contact & joining', + chain: 'vfr-arrival-v1', + icon: 'mdi-binoculars', + startFlow: 'vfr-arrival-v1', + isComplete: false, + noChain: true, + airport: 'arr', + }, + { + id: 'circuit-landing', + name: 'Circuit & Landing', + subtitle: 'Traffic circuit and landing', + chain: 'vfr-circuit-landing-v1', + icon: 'mdi-airport', + startFlow: 'vfr-circuit-landing-v1', + isComplete: false, + noChain: true, + airport: 'arr', + }, + { + id: 'taxi-in', + name: 'Taxi-in', + subtitle: 'Post-landing ground movement', + chain: 'taxi-in-v1', + icon: 'mdi-parking', + startFlow: 'taxi-in-v1', + isComplete: false, + noChain: true, + airport: 'arr', + }, +] + +const completeScenarios = SCENARIOS.filter(s => s.isComplete) +const individualScenarios = SCENARIOS.filter(s => !s.isComplete) + +/** The scenario the user just finished (used on the completion screen). */ +const completedScenario = ref(null) +/** The scenario currently being flown. */ +const activeScenario = ref(null) + +const oppositeScenario = computed(() => { + if (!completedScenario.value) return null + if (completedScenario.value.id === 'ifr-departure') + return SCENARIOS.find(s => s.id === 'vfr-arrival') ?? null + if (completedScenario.value.id === 'vfr-arrival') + return SCENARIOS.find(s => s.id === 'ifr-departure') ?? null + return null +}) + // UI State -const currentScreen = ref<'login' | 'flightselect' | 'monitor'>('login') +const currentScreen = ref<'login' | 'flightselect' | 'scenario' | 'monitor' | 'complete'>('login') const loading = ref(false) const error = ref('') const pilotInput = ref('') @@ -1678,7 +1938,8 @@ onMounted(async () => { if (storedPlanRaw) { try { const parsedPlan = JSON.parse(storedPlanRaw) - await startMonitoring(parsedPlan) + selectedPlan.value = parsedPlan + currentScreen.value = 'scenario' } catch (err) { console.warn('Failed to restore stored flight plan', err) window.localStorage.removeItem(STORAGE_KEYS.selectedPlan) @@ -2284,6 +2545,8 @@ const handlePilotTransmission = async (message: string, source: 'text' | 'ptt' = 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 }) return } @@ -2318,6 +2581,18 @@ const handlePilotTransmission = async (message: string, source: 'text' | 'ptt' = } }) + // If the backend has chained to a different flow, switch the local engine + // to that flow 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, handoffs, // and the communication log without scheduling further auto-transitions. @@ -2333,28 +2608,17 @@ const handlePilotTransmission = async (message: string, source: 'text' | 'ptt' = backendExpectedPhrase.value = response.expected_pilot_template ?? null // Sync variables from backend — keeps local renderer in step with backend state. - // Without this, {{squawk}} / {{sid}} etc. render from stale frontend defaults. - const changedVars: Record = {} - for (const [k, v] of Object.entries(response.variables ?? {})) { - if ((vars as any).value[k] !== v) { - ;(vars as any).value[k] = v - changedVars[k] = v - } - } - if (Object.keys(changedVars).length) { - pmLog.debug('variables synced:', changedVars) + // Uses patchVariables() which writes directly to the engine's reactive store, + // bypassing the readonly(ref) wrapper that silently blocks (vars as any).value[k] = v. + if (response.variables && Object.keys(response.variables).length) { + patchVariables(response.variables) + pmLog.debug('variables synced:', response.variables) } // Sync boolean routing flags (in_air, emergency_active, etc.) from backend. - const changedFlags: Record = {} - for (const [k, v] of Object.entries(response.flags ?? {})) { - if (typeof v === 'boolean') { - (flags as any).value[k] = v - changedFlags[k] = v - } - } - if (Object.keys(changedFlags).length) { - pmLog.debug('flags synced:', changedFlags) + 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). @@ -2364,12 +2628,23 @@ const handlePilotTransmission = async (message: string, source: 'text' | 'ptt' = pmLog.info('TTS →', sayText) lastControllerSay.value = sayText scheduleControllerSpeech(sayText) + // Add ATC speech to the communication log so it appears alongside pilot entries. + appendLogEntry('atc', sayText, response.next_state_id, { + flow: response.active_flow, + }) } if (response.fallback_used) { pmLog.warn('FALLBACK USED:', response.fallback_reason) console.warn('[Backend] Fallback used:', response.fallback_reason) } + + // Session complete → show completion screen + if (response.session_complete) { + pmLog.info('SESSION COMPLETE — showing completion screen') + completedScenario.value = activeScenario.value + currentScreen.value = 'complete' + } } catch (e) { pmLog.error('TRANSMIT FAILED', { transcript, session: backendSessionId.value, error: e }) console.error('Backend transmission failed', e) @@ -2403,11 +2678,13 @@ const loadFlightPlans = async () => { } } -const startMonitoring = async (flightPlan: any) => { +const startMonitoring = async (flightPlan: any, scenario: Scenario) => { + activeScenario.value = scenario + // 1. Ensure the local tree is loaded from the Python backend (same source as session) try { if (!engineReady.value) { - await fetchRuntimeTree('clearance', config.public.radioBackendUrl as string) + await fetchRuntimeTree(scenario.startFlow, config.public.radioBackendUrl as string) } } catch (err) { console.error('Failed to prepare decision engine', err) @@ -2419,24 +2696,47 @@ const startMonitoring = async (flightPlan: any) => { // are computed and available in vars.value before we create the backend session. initializeFlight(flightPlan) - // 3. Build the backend variable payload — maps frontend variable names to the - // names declared in clearance-v1.yaml. Unknown keys are silently ignored by - // the backend so it's safe to pass extras. + // 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) + + // Fetch real airport frequencies BEFORE building backendVariables so that + // all freq vars are resolved from live VATSIM/OpenAIP data. + await fetchAirportFrequencies(scenarioIcao) + + // 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', + 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'), + 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', } pmLog.info('backend variables payload:', backendVariables) // 4. Create a backend session with the flight-plan-derived variables try { - const session = await radioBackend.createSession('clearance', backendVariables) + const session = await radioBackend.createSession( + scenario.startFlow, + backendVariables, + scenario.noChain, + ) backendSessionId.value = session.session_id pmLog.group(`SESSION CREATED id=${session.session_id.slice(0, 8)}`, () => { pmLog.info('flow :', session.flow_slug) @@ -2447,13 +2747,13 @@ const startMonitoring = async (flightPlan: any) => { // 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 - for (const [k, v] of Object.entries(session.variables ?? {})) { - ;(vars as any).value[k] = v + // 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 - for (const [k, v] of Object.entries(session.flags ?? {})) { - if (typeof v === 'boolean') (flags as any).value[k] = v + 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 @@ -2470,13 +2770,11 @@ const startMonitoring = async (flightPlan: any) => { currentScreen.value = 'monitor' persistSelectedPlan(flightPlan) - if (flightPlan.dep === 'EDDF') { + if (scenarioIcao === 'EDDF') { frequencies.value.active = '121.900' frequencies.value.standby = '121.700' } - await fetchAirportFrequencies(flightPlan.dep || flightPlan.departure) - // 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. @@ -2499,7 +2797,23 @@ const startDemoFlight = () => { arr: 'EDDM', altitude: '36000', } - void startMonitoring(demoFlight) + 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 = () => { diff --git a/shared/utils/communicationsEngine.ts b/shared/utils/communicationsEngine.ts index 57903fa..6ca89dc 100644 --- a/shared/utils/communicationsEngine.ts +++ b/shared/utils/communicationsEngine.ts @@ -1391,6 +1391,45 @@ export default function useCommunicationsEngine() { communicationLog.value.push(entry) } + /** + * Patch arbitrary variables directly on the active flow's reactive variable + * store. Use this from outside the engine instead of trying to mutate + * `readonly(variables).value` (which Vue 3 silently blocks). + */ + function patchVariables(updates: Record) { + for (const [key, value] of Object.entries(updates)) { + variables.value[key] = value + } + } + + function patchFlags(updates: Record) { + for (const [key, value] of Object.entries(updates)) { + if (typeof value === 'boolean') { + (flags.value as Record)[key] = value + } + } + } + + function appendLogEntry( + speaker: Role, + message: string, + stateId: string, + options: { frequency?: string; flow?: string; radioCheck?: boolean; offSchema?: boolean } = {}, + ) { + const entry: EngineLog = { + timestamp: new Date(), + frequency: options.frequency ?? activeFrequency.value, + speaker, + message, + normalized: normalizeATCText(message, exposeCtxFlat()), + state: stateId, + flow: options.flow ?? (activeFlowSlug.value || undefined), + radioCheck: options.radioCheck, + offSchema: options.offSchema, + } + communicationLog.value.push(entry) + } + function renderATCMessage(tpl: string) { return renderTpl(tpl, exposeCtx()) } @@ -1511,6 +1550,9 @@ export default function useCommunicationsEngine() { // Utilities normalizeATCText, renderATCMessage, + appendLogEntry, + patchVariables, + patchFlags, getStateDetails, updateTelemetry, }