mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-01 22:26:04 +08:00
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>
435 lines
13 KiB
TypeScript
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,
|
|
}
|
|
}
|