Files
OpenSquawk/app/composables/useDebugSimulation.ts
itsrubberduck 39a3650b4e refactor(live-atc): extract FlightInfoSheet, SettingsSheet, BugReportDialog, DebugPanel
DebugPanel now owns the FAB, the drawer, and useDebugSimulation, and the page
mounts it behind `v-if="debugMode"` — so on the normal cockpit path the whole
simulation machinery is never constructed. That required moving `debugMode`
out of useDebugSimulation and into the page: a flag cannot both live inside the
thing it gates and be the gate. The settings sheet is the one place it is
toggled. runFullSimulation's `debugMode = true` line went with it; nothing
called that function, so the write was already unreachable.

BugReportDialog takes the useBugReport handle as a single prop rather than a
dozen individual ones. The composable stays owned by the page because the HUD's
"Report issue" button starts the screenshot capture before the dialog exists.

SettingsSheet carries its own copy of the .pm-seg segmented-control styles and
routes the theme choice back through the page, so usePmTheme.setPreference
still persists it to the cookie.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:35:34 +02:00

435 lines
13 KiB
TypeScript

import { computed, nextTick, ref } from 'vue'
import useCommunicationsEngine from '../../shared/utils/communicationsEngine'
export interface DebugSimulationDeps {
setLastTransmission: (text: string) => void
clearLog: () => void
startDemoFlight: () => void
}
/**
* Debug drawer state: the scripted end-to-end flow simulation, the decision
* trace the panel renders, and the current/next-state inspectors.
*
* Only the debug panel constructs this — nothing on the normal cockpit path
* depends on it.
*/
export function useDebugSimulation(
engine: ReturnType<typeof useCommunicationsEngine>,
deps: DebugSimulationDeps,
) {
const {
currentState, nextCandidates, variables: vars, flags, lastDecisionTrace,
getStateDetails, moveTo: forceMove, normalizeATCText, renderATCMessage,
processPilotTransmission, buildLLMContext, applyLLMDecision,
} = engine
const { setLastTransmission, clearLog, startDemoFlight } = deps
const simulationRunning = ref(false)
const simulationTrace = ref<SimulationTraceEntry[]>([])
const simulationError = ref('')
type SimulationDecisionTemplate = {
next: string
controllerSayState?: string
controllerSayTpl?: string
updates?: Record<string, any>
note?: string
}
type SimulationTraceEntry = {
kind: 'info' | 'pilot' | 'atc' | 'llm-input' | 'llm-output'
id: string
label: string
payload?: any
}
const completedPilotSteps = computed(() => simulationTrace.value.filter(entry => entry.kind === 'pilot').length)
const simulationPilotSteps = [
'CD_CHECK_ATIS',
'CD_VERIFY_READBACK',
'GRD_READY_FOR_PUSH',
'GRD_TAXI_REQUEST',
'GRD_TAXI_READBACK',
'TWR_LINEUP_REQ',
'TWR_TAKEOFF_READBACK',
'DEP_IDENT',
'DEP_CLIMB_READBACK',
'DES_READBACK',
'APP_ESTABLISHED',
'TWR_LAND_READBACK',
'GRD_TAXI_IN_REQ',
'GRD_TAXI_IN_READBACK'
] as const
type SimulationPilotState = typeof simulationPilotSteps[number]
const simulationDecisions: Record<SimulationPilotState, SimulationDecisionTemplate> = {
CD_CHECK_ATIS: {
next: 'CD_ISSUE_CLR',
controllerSayState: 'CD_ISSUE_CLR',
updates: {
push_available: true,
runway_occupied: false,
pilot_able: true,
runway_available: true,
push_delay_min: 0,
surface_wind: '220/05',
speed_restriction: '210 knots',
emergency_heading: '180'
}
},
CD_VERIFY_READBACK: {
next: 'CD_READBACK_CHECK'
},
GRD_READY_FOR_PUSH: {
next: 'GRD_PUSH_APPROVE',
controllerSayState: 'GRD_PUSH_APPROVE'
},
GRD_TAXI_REQUEST: {
next: 'GRD_TAXI_INSTR',
controllerSayState: 'GRD_TAXI_INSTR'
},
GRD_TAXI_READBACK: {
next: 'TWR_CONTACT',
controllerSayState: 'TWR_CONTACT'
},
TWR_LINEUP_REQ: {
next: 'TWR_TAKEOFF_CLR',
controllerSayState: 'TWR_TAKEOFF_CLR'
},
TWR_TAKEOFF_READBACK: {
next: 'DEP_CONTACT',
controllerSayState: 'DEP_CONTACT'
},
DEP_IDENT: {
next: 'DEP_CLIMB_INSTR',
controllerSayState: 'DEP_CLIMB_INSTR'
},
DEP_CLIMB_READBACK: {
next: 'ENR_HANDOFF',
controllerSayState: 'ENR_HANDOFF'
},
DES_READBACK: {
next: 'APP_HANDOFF',
controllerSayState: 'APP_HANDOFF',
updates: {
speed_restriction: '180 knots'
}
},
APP_ESTABLISHED: {
next: 'TWR_LAND_CONTACT',
controllerSayState: 'TWR_LAND_CONTACT',
updates: {
runway_available: true,
surface_wind: '210/06'
}
},
TWR_LAND_READBACK: {
next: 'TWR_VACATE',
controllerSayState: 'TWR_VACATE'
},
GRD_TAXI_IN_REQ: {
next: 'GRD_TAXI_INSTR_IN',
controllerSayState: 'GRD_TAXI_INSTR_IN'
},
GRD_TAXI_IN_READBACK: {
next: 'FLOW_COMPLETE'
}
}
const recordedAtcStates = new Set<string>()
const simulationStepCount = simulationPilotSteps.length
const wait = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
const decisionTrace = computed(() => lastDecisionTrace.value)
const timelineSteps = computed(() => decisionTrace.value?.candidateTimeline?.steps ?? [])
const timelineUsedFallback = computed(() => Boolean(decisionTrace.value?.candidateTimeline?.fallbackUsed))
const traceAutoSelection = computed(() => decisionTrace.value?.autoSelection ?? null)
const traceFallback = computed(() => decisionTrace.value?.fallback ?? null)
const cloneForTrace = <T>(value: T): T => {
if (value === undefined || value === null) {
return value
}
try {
return JSON.parse(JSON.stringify(value))
} catch (err) {
console.warn('Failed to clone trace payload, returning original value.', err)
return value
}
}
function describeElimination(entry: any): string {
if (!entry || typeof entry !== 'object') {
return ''
}
if (entry.kind === 'regex' && entry.context?.patterns?.length) {
const patterns = entry.context.patterns
.map((pattern: any) => pattern?.pattern)
.filter((value: string | undefined) => Boolean(value))
.join(', ')
return patterns ? `Patterns: ${patterns}` : entry.reason
}
if (entry.kind === 'condition' && entry.context?.condition) {
const condition = entry.context.condition
if (condition.type === 'regex' || condition.type === 'regex_not') {
const flag = condition.pattern ? `/${condition.pattern}/${condition.patternFlags || 'i'}` : ''
return flag ? `Condition: ${condition.type} ${flag}` : entry.reason
}
const variable = condition.variable || 'value'
const operator = condition.operator || '=='
const expected = entry.context?.expectedValue ?? condition.value ?? '—'
const actual = entry.context?.actualValue ?? '—'
return `${variable} ${operator} ${expected} (actual: ${actual})`
}
return entry.reason
}
const debugState = computed(() => {
if (!currentState.value) return null
const sayTpl = currentState.value.say_tpl
return {
id: currentState.value.id,
phase: currentState.value.phase,
role: currentState.value.role,
frequencyName: currentState.value.frequencyName,
sayPlain: sayTpl ? renderATCMessage(sayTpl) : '',
sayNormalized: sayTpl ? normalizeATCText(sayTpl, { ...vars.value, ...flags.value }) : ''
}
})
const debugNextStates = computed(() => {
return nextCandidates.value.map(id => {
const state = getStateDetails(id)
if (!state) {
return { id, role: '', phase: '', frequencyName: undefined, sayPlain: '', sayNormalized: '' }
}
const sayPlain = state.say_tpl ? renderATCMessage(state.say_tpl) : ''
const sayNormalized = state.say_tpl ? normalizeATCText(state.say_tpl, { ...vars.value, ...flags.value }) : ''
return {
id,
role: state.role,
phase: state.phase,
frequencyName: state.frequencyName,
sayPlain,
sayNormalized
}
})
})
const formatTracePayload = (payload: any): string => {
if (payload === null || payload === undefined) return ''
if (typeof payload === 'string') return payload
try {
return JSON.stringify(payload, null, 2)
} catch (err) {
return String(payload)
}
}
const recordCurrentAtcMessage = () => {
const state = currentState.value
if (!state?.id || recordedAtcStates.has(state.id) || !state.say_tpl) {
return
}
const plain = renderATCMessage(state.say_tpl)
const normalized = normalizeATCText(state.say_tpl, { ...vars.value, ...flags.value })
simulationTrace.value.push({
kind: 'atc',
id: state.id,
label: `ATC • ${state.phase}`,
payload: { text: plain, normalized }
})
setLastTransmission(`ATC: ${plain}`)
recordedAtcStates.add(state.id)
}
const pickNextStateId = (state: ReturnType<typeof getStateDetails> | null): string | null => {
if (!state) return null
const chains: Array<Array<{ to: string }>> = []
if (state.ok_next?.length) chains.push(state.ok_next.map(({ to }) => ({ to })))
if (state.next?.length) chains.push(state.next.map(({ to }) => ({ to })))
if (state.bad_next?.length) chains.push(state.bad_next.map(({ to }) => ({ to })))
if (state.timer_next?.length) chains.push(state.timer_next.map(({ to }) => ({ to })))
for (const list of chains) {
for (const entry of list) {
if (entry?.to) return entry.to
}
}
return null
}
const advanceAutomaticStates = async () => {
let guard = 0
while (guard++ < 50) {
const state = currentState.value
if (!state?.id) break
if (state.auto === 'end') break
const autoMode = Boolean(state.auto && state.auto !== 'end')
if (!autoMode && state.role === 'pilot') break
const nextId = pickNextStateId(state)
if (!nextId || nextId === state.id) break
forceMove(nextId)
await nextTick()
recordCurrentAtcMessage()
}
}
const runFullSimulation = async () => {
if (simulationRunning.value) return
simulationRunning.value = true
simulationTrace.value = []
simulationError.value = ''
recordedAtcStates.clear()
try {
simulationTrace.value.push({
kind: 'info',
id: 'init',
label: 'Simulation Start',
payload: { timestamp: new Date().toISOString(), steps: simulationStepCount }
})
startDemoFlight()
await nextTick()
clearLog()
recordedAtcStates.clear()
if (currentState.value?.id !== 'CD_CHECK_ATIS') {
forceMove('CD_CHECK_ATIS')
await nextTick()
}
for (const stepId of simulationPilotSteps) {
const state = getStateDetails(stepId)
if (!state?.utterance_tpl) {
throw new Error(`Missing pilot utterance for ${stepId}`)
}
if (currentState.value?.id !== stepId) {
forceMove(stepId)
await nextTick()
}
await wait(120)
const pilotText = renderATCMessage(state.utterance_tpl)
const pilotNormalized = normalizeATCText(state.utterance_tpl, { ...vars.value, ...flags.value })
simulationTrace.value.push({
kind: 'pilot',
id: stepId,
label: `Pilot • ${state.phase}`,
payload: { text: pilotText, normalized: pilotNormalized }
})
setLastTransmission(`Pilot: ${pilotText}`)
const quickResponse = processPilotTransmission(pilotText)
if (quickResponse) {
simulationTrace.value.push({
kind: 'info',
id: `${stepId}-quick`,
label: 'Quick Response',
payload: { text: quickResponse }
})
await nextTick()
}
const ctx = buildLLMContext(pilotText)
simulationTrace.value.push({
kind: 'llm-input',
id: stepId,
label: 'LLM Request',
payload: cloneForTrace(ctx)
})
const template = simulationDecisions[stepId]
if (!template) {
throw new Error(`Missing simulation decision for ${stepId}`)
}
const decision: any = { next_state: template.next }
if (template.controllerSayTpl) {
decision.controller_say_tpl = template.controllerSayTpl
} else if (template.controllerSayState) {
const sayState = getStateDetails(template.controllerSayState)
if (sayState?.say_tpl) {
decision.controller_say_tpl = sayState.say_tpl
}
}
if (template.updates) {
decision.updates = template.updates
}
simulationTrace.value.push({
kind: 'llm-output',
id: stepId,
label: `LLM Response → ${decision.next_state}`,
payload: cloneForTrace(decision)
})
applyLLMDecision(decision)
await nextTick()
recordCurrentAtcMessage()
await advanceAutomaticStates()
await wait(200)
}
simulationTrace.value.push({
kind: 'info',
id: 'complete',
label: 'Simulation Complete',
payload: {
timestamp: new Date().toISOString(),
finalState: currentState.value?.id,
pilotSteps: completedPilotSteps.value
}
})
} catch (err: any) {
const message = err?.message || String(err)
simulationError.value = message
simulationTrace.value.push({
kind: 'info',
id: 'error',
label: 'Simulation aborted',
payload: { error: message }
})
} finally {
simulationRunning.value = false
}
}
return {
simulationRunning,
simulationTrace,
simulationError,
simulationStepCount,
completedPilotSteps,
decisionTrace,
timelineSteps,
timelineUsedFallback,
traceAutoSelection,
traceFallback,
debugState,
debugNextStates,
describeElimination,
formatTracePayload,
runFullSimulation,
}
}