feat(live-atc): send the filed route so the clearance can grant it

The route reached the local engine already — genSID() takes it and throws it
away — but never reached the backend, so the clearance could only issue a SID.

The payload mapping moves to shared/utils so it can be tested at all: app/
composables are outside the test glob, and the fallback chains and string
coercion in it were entirely uncovered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-07-27 11:12:43 +02:00
parent a27bd8a07b
commit ed8fe7366d
3 changed files with 131 additions and 30 deletions

View File

@@ -12,6 +12,7 @@ import { generateGermanRegistration } from '../../shared/utils/registration'
import { gateTransmission } from '../../shared/utils/transmissionGate'
import { silenceWindowFor } from '../../shared/utils/silenceTimer'
import { createAutoTuneScheduler } from '../../shared/utils/autoTune'
import { buildBackendVariables } from '../../shared/utils/backendVariables'
import {
isSimControlMatch,
isSimControlRejection,
@@ -670,36 +671,7 @@ export function useLiveAtcSession(
// departure_freq for tower-v1, etc.) are already populated with real airport
// values when the session advances to those flows.
const v = (vars as any).value
const backendVariables: Record<string, any> = {
callsign: v.callsign || flightPlan.callsign || 'UNKNOWN',
information: v.atis_code || 'K',
destination: v.dest || flightPlan.arr || flightPlan.arrival || 'Unknown',
stand: v.stand || 'A1',
sid: v.sid || 'UNKNOWN1A',
initial_altitude: String(v.initial_altitude_ft ?? 5000),
squawk: String(v.squawk ?? '2000'),
// Shared / arrival variables. The engine generates these, but they were not
// being forwarded — so arrival flows (and taxi/tower on departure) fell back
// to YAML defaults and ignored the selected flight. Names are mapped to the
// backend flow conventions (qnh_hpa→qnh, acf_type→aircraft_type, …).
runway: atisRunway || v.runway || '25R',
qnh: String(v.qnh_hpa ?? '1013'),
surface_wind: v.surface_wind || '250/08',
// taxi_route is intentionally NOT sent: the backend computes the real OSM
// taxi route (and crossings) from airport_icao + stand/runway, and falls
// back to the flow's YAML default on its own. Sending a placeholder here
// would count as a caller override and suppress that computation.
aircraft_type: v.acf_type || 'A320',
cruise_level: v.cruise_flight_level || 'FL360',
assigned_squawk: String(v.squawk ?? '2000'),
// All airport frequencies — available to every flow in the chain.
delivery_freq: v.delivery_freq || '121.950',
ground_freq: v.ground_freq || '121.800',
tower_freq: v.tower_freq || '118.700',
departure_freq: v.departure_freq || '120.000',
approach_freq: v.approach_freq || '119.000',
handoff_freq: v.handoff_freq || '131.150',
}
const backendVariables = buildBackendVariables({ flightPlan, vars: v, atisRunway })
// Special scenarios. Only sent when switched on: the backend treats the
// variable's absence as "leave it to chance", and sending false would pin

View File

@@ -0,0 +1,61 @@
/**
* Build the variable payload the Python backend session is created with.
*
* The backend stores every key, so frequencies declared in downstream chained
* flows (tower_freq for taxi-v1, departure_freq for tower-v1, …) are already
* populated with real airport values by the time the session advances to them.
*
* Pure on purpose: the mapping is the part with real logic — fallback chains,
* ATIS overrides, string coercion — and keeping it out of the composable is
* what makes it testable.
*/
export type BackendVariableInputs = {
/** The selected flight plan (VATSIM, demo, manual, or a restored snapshot). */
flightPlan: Record<string, any>
/** The local engine's variables, after initializeFlight() and the ATIS patch. */
vars: Record<string, any>
/** Runway from the resolved ATIS, when the airport publishes runway data. */
atisRunway?: string | null
}
export function buildBackendVariables(
{ flightPlan, vars, atisRunway }: BackendVariableInputs
): Record<string, any> {
const v = vars
return {
callsign: v.callsign || flightPlan.callsign || 'UNKNOWN',
information: v.atis_code || 'K',
destination: v.dest || flightPlan.arr || flightPlan.arrival || 'Unknown',
stand: v.stand || 'A1',
sid: v.sid || 'UNKNOWN1A',
initial_altitude: String(v.initial_altitude_ft ?? 5000),
squawk: String(v.squawk ?? '2000'),
// The filed enroute route. The backend derives the point where the SID
// hands over to the enroute structure and grants it in the clearance;
// demo and manual flights file none, and the flow keeps its no-route
// wording. Always present so the payload shape does not vary by source.
route: flightPlan.route || '',
// Shared / arrival variables. The engine generates these, but they were not
// being forwarded — so arrival flows (and taxi/tower on departure) fell back
// to YAML defaults and ignored the selected flight. Names are mapped to the
// backend flow conventions (qnh_hpa→qnh, acf_type→aircraft_type, …).
runway: atisRunway || v.runway || '25R',
qnh: String(v.qnh_hpa ?? '1013'),
surface_wind: v.surface_wind || '250/08',
// taxi_route is intentionally NOT sent: the backend computes the real OSM
// taxi route (and crossings) from airport_icao + stand/runway, and falls
// back to the flow's YAML default on its own. Sending a placeholder here
// would count as a caller override and suppress that computation.
aircraft_type: v.acf_type || 'A320',
cruise_level: v.cruise_flight_level || 'FL360',
assigned_squawk: String(v.squawk ?? '2000'),
// All airport frequencies — available to every flow in the chain.
delivery_freq: v.delivery_freq || '121.950',
ground_freq: v.ground_freq || '121.800',
tower_freq: v.tower_freq || '118.700',
departure_freq: v.departure_freq || '120.000',
approach_freq: v.approach_freq || '119.000',
handoff_freq: v.handoff_freq || '131.150',
}
}

View File

@@ -0,0 +1,68 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { buildBackendVariables } from '~~/shared/utils/backendVariables'
/** A VATSIM flight plan and the engine variables a session starts from. */
const build = (
flightPlan: Record<string, any> = {},
vars: Record<string, any> = {},
atisRunway: string | null = null
) =>
buildBackendVariables({
flightPlan: { callsign: 'DLH39A', dep: 'EDDF', arr: 'EDDM', ...flightPlan },
vars: { callsign: 'DLH39A', dest: 'EDDM', sid: 'BIBAX1N', ...vars },
atisRunway,
})
describe('buildBackendVariables — the filed route', () => {
it('forwards the route so the clearance can grant it', () => {
// Without this the backend never sees the route and the clearance stops
// at the SID.
assert.equal(
build({ route: 'SULUS5S SULUS Y101 ARMUT' }).route,
'SULUS5S SULUS Y101 ARMUT'
)
})
it('sends an empty route for a flight that filed none', () => {
// Demo and manual flights. The backend keeps the flow defaults, which are
// the wording without a route.
assert.equal(build().route, '')
})
it('sends an empty route rather than dropping the key', () => {
// A missing key and an empty one are the same to the backend, but the key
// being present keeps the payload shape stable across flight sources.
assert.ok('route' in build())
})
})
describe('buildBackendVariables — flight plan mapping', () => {
it('prefers the engine callsign over the plan', () => {
assert.equal(build({ callsign: 'DLH1' }, { callsign: 'DLH39A' }).callsign, 'DLH39A')
})
it('falls back to the plan callsign when the engine has none', () => {
assert.equal(build({ callsign: 'DLH1' }, { callsign: '' }).callsign, 'DLH1')
})
it('uses the ATIS runway over the engine one', () => {
assert.equal(build({}, { runway: '07C' }, '25R').runway, '25R')
})
it('falls back to the engine runway when the ATIS gives none', () => {
assert.equal(build({}, { runway: '07C' }).runway, '07C')
})
it('sends numeric values as strings', () => {
const payload = build({}, { squawk: 2341, initial_altitude_ft: 6000, qnh_hpa: 1015 })
assert.equal(payload.squawk, '2341')
assert.equal(payload.initial_altitude, '6000')
assert.equal(payload.qnh, '1015')
})
it('leaves taxi_route out so the backend computes the real one', () => {
assert.ok(!('taxi_route' in build()))
})
})