fix(live-atc): arm the silence timer on readback states too

The timer was only armed for states carrying auto_advance_on_silence. Readback
states carry none — they wait for the pilot — so nothing was armed and ATC
never asked again when the readback did not arrive.

Which window applies to a state is now a pure function: the flow-authored one
for an auto-advance state, the much shorter server-published one for a readback
state. That also makes both cases testable instead of inline in the composable.
This commit is contained in:
itsrubberduck
2026-07-27 00:30:59 +02:00
parent 6830a28687
commit eb7fd82ad4
3 changed files with 146 additions and 11 deletions

View File

@@ -9,6 +9,7 @@ import type { useRadioSpeech } from '~/composables/useRadioSpeech'
import useCommunicationsEngine from '../../shared/utils/communicationsEngine'
import { generateGermanRegistration } from '../../shared/utils/registration'
import { gateTransmission } from '../../shared/utils/transmissionGate'
import { silenceWindowFor } from '../../shared/utils/silenceTimer'
import {
isSimControlMatch,
isSimControlRejection,
@@ -100,14 +101,16 @@ export function useLiveAtcSession(
const transmitInFlightCount = ref(0)
const transmitInFlight = computed(() => transmitInFlightCount.value > 0)
// --- Silence auto-advance ----------------------------------------------------
// Some pilot states let ATC continue on its own when the pilot stays quiet
// (e.g. the takeoff roll in tower-v1: no report needed, Tower hands off after a
// while anyway). Such states carry auto_advance_on_silence +
// auto_advance_timeout_ms in the runtime tree; whenever the session lands on
// one, arm a timer that fires the backend /timeout endpoint. Any pilot
// transmission or telemetry-fired advance re-arms or clears it via
// applyBackendDecision.
// --- Silence timers ----------------------------------------------------------
// Two kinds of wait fire the backend /timeout endpoint, both armed here (see
// silenceWindowFor for which applies where):
// auto-advance — the pilot owes nothing and ATC carries on by itself (the
// takeoff roll in tower-v1), on the flow-authored window.
// readback — the pilot owes a mandatory readback and has not given it,
// so ATC asks again on the much shorter server-published window.
// Any pilot transmission or telemetry-fired advance re-arms or clears the
// timer via applyBackendDecision, and a fire is dropped if the session or the
// state moved on while it was pending.
let silenceTimer: ReturnType<typeof setTimeout> | null = null
// Consecutive silence-timeouts fired for the SAME state. If the backend keeps
@@ -132,7 +135,9 @@ export function useLiveAtcSession(
function armSilenceTimer() {
clearSilenceTimer()
const state = currentState.value as any
if (!state?.auto_advance_on_silence || !backendSessionId.value) return
if (!backendSessionId.value) return
const window = silenceWindowFor(state)
if (!window) return
if (silenceFireCount.stateId !== state.id) {
silenceFireCount = { stateId: state.id, count: 0 }
@@ -142,10 +147,10 @@ export function useLiveAtcSession(
return
}
const ms = Math.max(1000, Number(state.auto_advance_timeout_ms ?? 30000))
const ms = window.ms
const sessionAtArm = backendSessionId.value
const stateAtArm = state.id
pmLog.debug('SILENCE TIMER armed', { state: stateAtArm, ms })
pmLog.debug('SILENCE TIMER armed', { state: stateAtArm, ms, kind: window.kind })
const fire = async () => {
silenceTimer = null
// Never fire stale: the session ended or the state moved on while waiting.

View File

@@ -0,0 +1,60 @@
/**
* Which silence timer, if any, applies to the state the session is resting on.
*
* Two different waits look the same from the outside but mean opposite things:
*
* - **auto-advance** — the pilot owes nothing. The takeoff roll, a climb to the
* cleared level: ATC carries on by itself once enough time has passed, and the
* window is long because nothing is wrong.
*
* - **readback** — the pilot owes a mandatory readback and has not given it. A
* controller chases that within seconds, so the window is short. These states
* carry no `auto_advance_timeout_ms` (they wait for the pilot rather than
* advancing on their own), which is why the timer was never armed for them at
* all and the re-request never fired.
*/
export type SilenceTimerKind = 'auto_advance' | 'readback'
export interface SilenceTimerState {
auto_advance_on_silence?: boolean
auto_advance_timeout_ms?: number
readback_required?: string[]
/** Server-side policy, published on readback states by the runtime tree. */
readback_silence_ms?: number
}
export interface SilenceTimerWindow {
kind: SilenceTimerKind
ms: number
}
/** Never fire faster than this, whatever the flow or server says. */
const MIN_WINDOW_MS = 1000
export const DEFAULT_AUTO_ADVANCE_MS = 30_000
/** Only used when the backend published no window (older server). */
export const DEFAULT_READBACK_SILENCE_MS = 12_000
export function silenceWindowFor(
state: SilenceTimerState | null | undefined,
): SilenceTimerWindow | null {
if (!state) return null
// Auto-advance wins: a state carrying it has an explicit flow-authored window.
if (state.auto_advance_on_silence) {
return {
kind: 'auto_advance',
ms: Math.max(MIN_WINDOW_MS, Number(state.auto_advance_timeout_ms ?? DEFAULT_AUTO_ADVANCE_MS)),
}
}
if (state.readback_required?.length) {
return {
kind: 'readback',
ms: Math.max(MIN_WINDOW_MS, Number(state.readback_silence_ms ?? DEFAULT_READBACK_SILENCE_MS)),
}
}
return null
}

View File

@@ -0,0 +1,70 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import {
DEFAULT_READBACK_SILENCE_MS,
silenceWindowFor,
} from '~~/shared/utils/silenceTimer'
describe('silenceWindowFor', () => {
it('arms the flow-authored window on an auto-advance state', () => {
const window = silenceWindowFor({
auto_advance_on_silence: true,
auto_advance_timeout_ms: 45000,
})
assert.deepEqual(window, { kind: 'auto_advance', ms: 45000 })
})
// The bug: a readback state carries no auto_advance_timeout_ms, so nothing
// was ever armed and ATC never asked again when the readback did not come.
it('arms a readback state, which has no auto-advance window of its own', () => {
const window = silenceWindowFor({
readback_required: ['squawk', 'sid'],
readback_silence_ms: 12000,
})
assert.deepEqual(window, { kind: 'readback', ms: 12000 })
})
it('chases a missing readback far sooner than an auto-advance', () => {
const readback = silenceWindowFor({
readback_required: ['runway'],
readback_silence_ms: 12000,
})!
const autoAdvance = silenceWindowFor({
auto_advance_on_silence: true,
auto_advance_timeout_ms: 45000,
})!
assert.ok(readback.ms < autoAdvance.ms)
assert.ok(readback.ms >= 10_000 && readback.ms <= 15_000, `${readback.ms}ms`)
})
it('falls back to the built-in window when the server published none', () => {
const window = silenceWindowFor({ readback_required: ['runway'] })
assert.equal(window?.ms, DEFAULT_READBACK_SILENCE_MS)
})
it('arms nothing where the pilot holds the next transmission', () => {
assert.equal(silenceWindowFor({}), null)
assert.equal(silenceWindowFor({ readback_required: [] }), null)
assert.equal(silenceWindowFor(null), null)
assert.equal(silenceWindowFor(undefined), null)
})
it('prefers the auto-advance window when a state somehow has both', () => {
const window = silenceWindowFor({
auto_advance_on_silence: true,
auto_advance_timeout_ms: 45000,
readback_required: ['runway'],
readback_silence_ms: 12000,
})
assert.equal(window?.kind, 'auto_advance')
})
it('never arms a timer that would fire immediately', () => {
assert.equal(silenceWindowFor({ readback_required: ['x'], readback_silence_ms: 0 })?.ms, 1000)
assert.equal(
silenceWindowFor({ auto_advance_on_silence: true, auto_advance_timeout_ms: -5 })?.ms,
1000,
)
})
})