mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-02 06:46:18 +08:00
Implements the ai-traffic roadmap item per docs/plans/2026-07-14-ai-traffic-architecture-design.md. Simulated other aircraft on the user's frequency — callsigns, ATC instructions, readbacks in their own stable voice, handovers — as pure scenery. It never touches radioBackend: the Python backend keeps owning the dialogue *with* the user, useAiTraffic owns the radio *around* the user. The two share only the speech queue (arbitration) and the log. Rules live as pure, seeded, framework-free modules under shared/utils/aiTraffic/ so they run in tsx --test without a browser: callsign collision rules, wake/in-trail separation, runway slots, the speed ladder, direct validation, the §3 decision table, and the gating chain. app/composables/useAiTraffic.ts wires them to Vue (1 Hz tick, spawner, scheduler). Gating is evaluated twice — before enqueue and again at playback, since seconds pass in between. Traffic never keys up while the user holds PTT, while their transmission is out at the backend, or inside the fresh readback window. Off by default; the toggle surfaces the feature's v1 limitations rather than burying them in a doc. Zero LLM calls: variance comes from seeded RNG over template variants. Deviations from the design, both documented in the design doc: - Adds SimAircraft.quietUntilSec. The design's rule table says "first matching row per tick" but never says an instruction must be allowed to take effect before the next one. Without it the planner re-derives the same unresolved condition every second and nags one aircraft with the same vector: 624 calls/30min measured, vs 90 with the cooldown. - Airline pool limited to the 14 designators DEFAULT_AIRLINE_TELEPHONY already knows; UAE/AUA/WZZ from the design would be spelled out letter by letter instead of spoken as airline names. Verified: 406 tests pass (176 new), no new typecheck errors, /live-atc compiles and serves. The manual in-session walkthrough (audible traffic, toggle mid-session) is NOT verified — it needs a login and the Python backend. The 30-minute deterministic integration run stands in for it and caught two of the three bugs found during development. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
189 lines
6.4 KiB
TypeScript
189 lines
6.4 KiB
TypeScript
import { computed, ref, watch } from 'vue'
|
|
import { normalizeRadioPhrase, DEFAULT_AIRLINE_TELEPHONY } from '../../shared/utils/radioSpeech'
|
|
import useCommunicationsEngine from '../../shared/utils/communicationsEngine'
|
|
import type { ReadbackFieldDetail } from '~/composables/useRadioBackend'
|
|
import { SCENARIOS, type Scenario } from '../../shared/constants/scenarios'
|
|
|
|
/**
|
|
* Reactive state of one /live-atc session: which screen we're on, the backend
|
|
* session identity, and the "last transmission" strip with its fault-reporting
|
|
* dialog.
|
|
*
|
|
* Split out from useLiveAtcSession (the controller) because almost every other
|
|
* composable needs some of this — useAtisPlayback wants currentScreen,
|
|
* usePttRecording wants backendSessionId, useRadioSpeech wants
|
|
* setLastTransmission — while the controller itself needs those composables to
|
|
* exist first. State is constructed first, the controller last.
|
|
*/
|
|
export function useSessionState(engine: ReturnType<typeof useCommunicationsEngine>) {
|
|
const { clearCommunicationLog, sessionId: engineSessionId, flags } = engine
|
|
|
|
const currentScreen = ref<'login' | 'flightselect' | 'scenario' | 'monitor' | 'complete'>('login')
|
|
const loading = ref(false)
|
|
const error = ref('')
|
|
const pilotInput = ref('')
|
|
|
|
// VATSIM flight selection
|
|
const vatsimId = ref('1857215')
|
|
const flightPlans = ref<any[]>([])
|
|
const selectedPlan = ref<any>(null)
|
|
|
|
/** The scenario the user just finished (used on the completion screen). */
|
|
const completedScenario = ref<Scenario | null>(null)
|
|
/** The scenario currently being flown. */
|
|
const activeScenario = ref<Scenario | null>(null)
|
|
|
|
const oppositeScenario = computed<Scenario | null>(() => {
|
|
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
|
|
})
|
|
|
|
// Overlay shown while a session is being created. For taxi flows the backend
|
|
// computes a real OSM taxi route synchronously, so the create call can take a
|
|
// few seconds — the spinner tells the user that work is happening.
|
|
const sessionStarting = ref(false)
|
|
const sessionStartingMessage = ref('Starting session…')
|
|
|
|
const backendSessionId = ref<string | null>(null)
|
|
// Last ATC utterance returned by the backend (pre-rendered, correct variables).
|
|
const lastControllerSay = ref<string | null>(null)
|
|
// When that utterance was scheduled — opens the readback window during which
|
|
// simulated background traffic must stay silent (useAiTraffic's gating chain).
|
|
const lastControllerSpeechAtMs = ref<number | null>(null)
|
|
// Authoritative expected pilot phrase from the backend — replaces local engine rendering.
|
|
const backendExpectedPhrase = ref<string | null>(null)
|
|
// Per-field readback diagnostic from the last transmission (STT debug panel).
|
|
const lastReadbackReport = ref<ReadbackFieldDetail[]>([])
|
|
const lastReadbackTranscript = ref<string>('')
|
|
// Toggle: show radio pronunciation (wun, tree, squawk niner…) vs plain text.
|
|
const showRadioPronunciation = ref(false)
|
|
|
|
function toRadioSpeech(text: string): string {
|
|
return normalizeRadioPhrase(text, {
|
|
expandCallsigns: true,
|
|
expandAirports: true,
|
|
airlineMap: DEFAULT_AIRLINE_TELEPHONY,
|
|
sidSuffixIcao: true,
|
|
})
|
|
}
|
|
|
|
// Display helpers — apply ICAO phonetics when the toggle is on.
|
|
const displayControllerSay = computed(() =>
|
|
lastControllerSay.value
|
|
? (showRadioPronunciation.value ? toRadioSpeech(lastControllerSay.value) : lastControllerSay.value)
|
|
: null,
|
|
)
|
|
const displayExpectedPhrase = computed(() =>
|
|
backendExpectedPhrase.value
|
|
? (showRadioPronunciation.value ? toRadioSpeech(backendExpectedPhrase.value) : backendExpectedPhrase.value)
|
|
: null,
|
|
)
|
|
|
|
const lastTransmission = ref('')
|
|
const lastTransmissionFaulty = ref(false)
|
|
const lastTransmissionFaultNote = ref('')
|
|
const showTransmissionIssueDialog = ref(false)
|
|
const transmissionIssueNote = ref('')
|
|
|
|
function setLastTransmission(text: string) {
|
|
lastTransmission.value = text
|
|
lastTransmissionFaulty.value = false
|
|
lastTransmissionFaultNote.value = ''
|
|
}
|
|
|
|
function clearLastTransmission() {
|
|
setLastTransmission('')
|
|
}
|
|
|
|
function markLastTransmissionFault(note: string) {
|
|
if (!lastTransmission.value) return
|
|
lastTransmissionFaulty.value = true
|
|
lastTransmissionFaultNote.value = note
|
|
}
|
|
|
|
function resetLastTransmissionFault() {
|
|
lastTransmissionFaulty.value = false
|
|
lastTransmissionFaultNote.value = ''
|
|
}
|
|
|
|
function startTransmissionIssueFlow() {
|
|
if (!lastTransmission.value) return
|
|
transmissionIssueNote.value = lastTransmissionFaultNote.value
|
|
showTransmissionIssueDialog.value = true
|
|
}
|
|
|
|
function confirmTransmissionIssue() {
|
|
if (!lastTransmission.value) {
|
|
showTransmissionIssueDialog.value = false
|
|
return
|
|
}
|
|
|
|
markLastTransmissionFault(transmissionIssueNote.value.trim())
|
|
showTransmissionIssueDialog.value = false
|
|
}
|
|
|
|
function removeTransmissionIssue() {
|
|
resetLastTransmissionFault()
|
|
showTransmissionIssueDialog.value = false
|
|
}
|
|
|
|
function cancelTransmissionIssue() {
|
|
showTransmissionIssueDialog.value = false
|
|
}
|
|
|
|
watch(showTransmissionIssueDialog, (open) => {
|
|
if (!open) {
|
|
transmissionIssueNote.value = ''
|
|
}
|
|
})
|
|
|
|
const clearLog = () => {
|
|
clearCommunicationLog()
|
|
clearLastTransmission()
|
|
}
|
|
|
|
const sessionLabel = computed(() => engineSessionId.value || flags.value.session_id || '-')
|
|
|
|
return {
|
|
currentScreen,
|
|
loading,
|
|
error,
|
|
pilotInput,
|
|
vatsimId,
|
|
flightPlans,
|
|
selectedPlan,
|
|
completedScenario,
|
|
activeScenario,
|
|
oppositeScenario,
|
|
sessionStarting,
|
|
sessionStartingMessage,
|
|
backendSessionId,
|
|
lastControllerSay,
|
|
lastControllerSpeechAtMs,
|
|
backendExpectedPhrase,
|
|
lastReadbackReport,
|
|
lastReadbackTranscript,
|
|
showRadioPronunciation,
|
|
displayControllerSay,
|
|
displayExpectedPhrase,
|
|
lastTransmission,
|
|
lastTransmissionFaulty,
|
|
lastTransmissionFaultNote,
|
|
showTransmissionIssueDialog,
|
|
transmissionIssueNote,
|
|
setLastTransmission,
|
|
clearLastTransmission,
|
|
resetLastTransmissionFault,
|
|
startTransmissionIssueFlow,
|
|
confirmTransmissionIssue,
|
|
removeTransmissionIssue,
|
|
cancelTransmissionIssue,
|
|
clearLog,
|
|
sessionLabel,
|
|
}
|
|
}
|