diff --git a/app/composables/useLiveAtcSession.ts b/app/composables/useLiveAtcSession.ts index 2c90420..0cdd28e 100644 --- a/app/composables/useLiveAtcSession.ts +++ b/app/composables/useLiveAtcSession.ts @@ -8,6 +8,8 @@ import { useApi } from '~/composables/useApi' import type { useRadioSpeech } from '~/composables/useRadioSpeech' import useCommunicationsEngine from '../../shared/utils/communicationsEngine' import { + isSimControlMatch, + isSimControlRejection, parseSimControl, simControlRejectionSpeech, simControlResultSpeech, @@ -331,11 +333,14 @@ export function useLiveAtcSession( // anchors already keep it from ever matching real ICAO phraseology either way. if (bridgeConnected.value) { const simResult = parseSimControl(transcript) - if (simResult.matched) { + if (isSimControlMatch(simResult)) { void sendSimControlCommand(simResult.command) return } - if (simResult.reason !== 'no_intent') { + // 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 }) diff --git a/shared/utils/simControl.ts b/shared/utils/simControl.ts index 7099ec9..b2b6660 100644 --- a/shared/utils/simControl.ts +++ b/shared/utils/simControl.ts @@ -38,9 +38,26 @@ export type SimControlNoMatchReason = | 'missing_runway' | 'missing_airport' -export type SimControlParseResult = - | { matched: true; command: SimControlCommand; text: string } - | { matched: false; reason: SimControlNoMatchReason; text: string } +export type SimControlMatch = { matched: true; command: SimControlCommand; text: string } +export type SimControlRejection = { matched: false; reason: SimControlNoMatchReason; text: string } + +export type SimControlParseResult = SimControlMatch | SimControlRejection + +/** + * Narrowing helpers. This union is discriminated by a boolean, and the project + * builds with `strict: false` (nuxt.config.ts) — so `strictNullChecks` is off, + * and without it TypeScript does not treat `true`/`false` literal types as + * discriminants. `if (result.matched) … else result.reason` therefore does NOT + * narrow and fails to compile. An explicit type predicate narrows regardless, + * which is why callers must go through these rather than testing `.matched`. + */ +export function isSimControlMatch(result: SimControlParseResult): result is SimControlMatch { + return result.matched +} + +export function isSimControlRejection(result: SimControlParseResult): result is SimControlRejection { + return !result.matched +} /** Hard value ranges; anything outside is a refusal, never a clamp. */ export const SIM_CONTROL_LIMITS = {