From 7f0443f70e447152db7d97caa28f6a0e5ece4ada Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Sun, 26 Jul 2026 20:05:04 +0200 Subject: [PATCH] fix(live-atc): match the D-registration to the aircraft class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VFR scenarios drew from a fixed pool of D-E** registrations regardless of type, so an A320 was assigned "D-ETMO". The letter after "D-" is assigned by weight and type class (LuftVZO Anlage 1): D-E is single-engine pistons up to 2 t, so an airliner has to be D-A. Pick the class from the ICAO type designator — D-A for airliners, D-I for light twins, D-H for helicopters, D-E for light singles and anything unrecognised, since VFR scenarios are overwhelmingly light aircraft. Co-Authored-By: Claude Opus 5 --- app/composables/useLiveAtcSession.ts | 11 ++-- shared/utils/registration.ts | 89 ++++++++++++++++++++++++++++ tests/shared/registration.test.ts | 73 +++++++++++++++++++++++ 3 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 shared/utils/registration.ts create mode 100644 tests/shared/registration.test.ts diff --git a/app/composables/useLiveAtcSession.ts b/app/composables/useLiveAtcSession.ts index 3363666..5c50a3d 100644 --- a/app/composables/useLiveAtcSession.ts +++ b/app/composables/useLiveAtcSession.ts @@ -7,6 +7,7 @@ import type { useSessionState } from '~/composables/useSessionState' import { useApi } from '~/composables/useApi' import type { useRadioSpeech } from '~/composables/useRadioSpeech' import useCommunicationsEngine from '../../shared/utils/communicationsEngine' +import { generateGermanRegistration } from '../../shared/utils/registration' import { isSimControlMatch, isSimControlRejection, @@ -620,13 +621,13 @@ export function useLiveAtcSession( // Assign a German D-registration and its abbreviated form (D-EMIL -> D-IL, // first letter + last two), which ATC uses after the first call. Mirror it // into the local engine vars and HUD so the display matches the radio. + // The class letter has to match the aircraft — D-E is single-engine + // pistons, so an A320 gets a D-A registration, not "D-ETMO". if (scenario.startFlow.startsWith('vfr') || scenario.startFlow.startsWith('info')) { - const pool = ['D-EMIL', 'D-EKLM', 'D-ENNY', 'D-ELLA', 'D-EOMT', 'D-ELPC', 'D-EMTO', 'D-EBRA'] - const reg = pool[Math.floor(Math.random() * pool.length)] - const short = `D-${reg.replace(/^D-/, '').slice(-2)}` - backendVariables.callsign = reg + const { registration, short } = generateGermanRegistration(backendVariables.aircraft_type) + backendVariables.callsign = registration backendVariables.callsign_short = short - patchVariables({ callsign: reg, callsign_short: short }) + patchVariables({ callsign: registration, callsign_short: short }) } else { backendVariables.callsign_short = backendVariables.callsign } diff --git a/shared/utils/registration.ts b/shared/utils/registration.ts new file mode 100644 index 0000000..7db346a --- /dev/null +++ b/shared/utils/registration.ts @@ -0,0 +1,89 @@ +/** + * German aircraft registrations (D-…). + * + * The letter after "D-" is not decorative: LuftVZO Anlage 1 assigns it by + * weight and type class, so a registration has to match the aircraft. D-E is + * reserved for single-engine pistons up to 2 t — an A320 wearing one is as + * wrong as an airliner with a glider's callsign. + * + * D-A… above 20 t (airliners) + * D-I… multi-engine piston, 2–5.7 t + * D-E… single-engine piston, up to 2 t + * D-H… helicopters + */ + +export type RegistrationPrefix = 'A' | 'I' | 'E' | 'H' + +/** ICAO type designators that are not light singles, by registration class. */ +const TYPES_BY_PREFIX: Record, ReadonlySet> = { + A: new Set([ + // Narrowbody / widebody / regional jets — all comfortably above 20 t. + 'A318', 'A319', 'A320', 'A321', 'A20N', 'A21N', + 'A332', 'A333', 'A339', 'A342', 'A343', 'A345', 'A346', + 'A359', 'A35K', 'A388', + 'B733', 'B734', 'B735', 'B736', 'B737', 'B738', 'B739', + 'B37M', 'B38M', 'B39M', + 'B752', 'B753', 'B762', 'B763', 'B764', + 'B772', 'B773', 'B77L', 'B77W', 'B788', 'B789', 'B78X', + 'B742', 'B744', 'B748', + 'E170', 'E175', 'E190', 'E195', 'E290', 'E295', + 'CRJ2', 'CRJ7', 'CRJ9', 'CRJX', + 'AT72', 'AT76', 'DH8D', 'BCS1', 'BCS3', 'SU95', + ]), + I: new Set([ + // Multi-engine pistons in the 2–5.7 t band. + 'BE58', 'BE55', 'BE76', 'BE60', 'BE95', + 'PA34', 'PA31', 'PA44', 'PA23', + 'C310', 'C320', 'C337', 'C340', 'C402', 'C404', 'C414', 'C421', + 'DA62', 'P68', 'AC11', 'AC50', + ]), + H: new Set([ + 'R22', 'R44', 'R66', 'EC20', 'EC30', 'EC35', 'EC45', 'EC55', + 'H125', 'H130', 'H135', 'H145', 'AS50', 'AS55', 'AS65', + 'B06', 'B06T', 'B407', 'B429', 'S76', 'A109', 'A139', 'MD90', + ]), +} + +/** + * Pools of pronounceable registrations per class. Real German registrations + * read as words on the radio, which matters because these are spoken by TTS + * and transcribed back from the pilot. + */ +const REGISTRATION_POOLS: Record = { + A: ['D-AIZA', 'D-AIBL', 'D-ABYT', 'D-AIMA', 'D-AINK', 'D-ABKM', 'D-AIDF', 'D-ACNL'], + I: ['D-IBSL', 'D-IAAB', 'D-IHAG', 'D-IFOX', 'D-IKMR', 'D-IWAL'], + E: ['D-EMIL', 'D-EKLM', 'D-ENNY', 'D-ELLA', 'D-EOMT', 'D-ELPC', 'D-EMTO', 'D-EBRA'], + H: ['D-HAFH', 'D-HBKD', 'D-HELI', 'D-HMUC', 'D-HAXE', 'D-HRTG'], +} + +/** + * Registration class letter for an ICAO type designator. + * + * Unknown types fall back to the light-single class: these registrations are + * assigned in VFR scenarios, which are overwhelmingly light aircraft. + */ +export function germanRegistrationPrefix(aircraftType: string): RegistrationPrefix { + // Flight plans carry things like "A320/M" or "C172 " — keep the designator. + const type = (aircraftType || '').trim().toUpperCase().split(/[/\s-]/)[0] || '' + if (!type) return 'E' + + for (const prefix of ['A', 'I', 'H'] as const) { + if (TYPES_BY_PREFIX[prefix].has(type)) return prefix + } + return 'E' +} + +export interface GermanRegistration { + /** Full registration, e.g. "D-EMIL". */ + registration: string + /** Abbreviated form German ATC uses after first contact, e.g. "D-IL". */ + short: string +} + +/** A registration whose class matches the aircraft type. */ +export function generateGermanRegistration(aircraftType: string): GermanRegistration { + const pool = REGISTRATION_POOLS[germanRegistrationPrefix(aircraftType)] + const registration = pool[Math.floor(Math.random() * pool.length)]! + // ATC abbreviates to the first character and the last two: D-EMIL → D-IL. + return { registration, short: `D-${registration.slice(-2)}` } +} diff --git a/tests/shared/registration.test.ts b/tests/shared/registration.test.ts new file mode 100644 index 0000000..e690926 --- /dev/null +++ b/tests/shared/registration.test.ts @@ -0,0 +1,73 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + generateGermanRegistration, + germanRegistrationPrefix, +} from '../../shared/utils/registration.ts' + +// German registrations encode a weight/type class in the first letter after +// "D-" (LuftVZO Anlage 1): D-A above 20 t, D-I multi-engine 2–5.7 t, +// D-E single-engine piston up to 2 t, D-H helicopters. + +test('airliners get a D-A registration', () => { + for (const type of ['A320', 'A319', 'A321', 'B738', 'B39M', 'A333', 'A359', 'B77W', 'B788', 'B744', 'A388', 'E190', 'CRJ9']) { + assert.equal(germanRegistrationPrefix(type), 'A', `${type} should be a D-A registration`) + } +}) + +test('single-engine light aircraft get a D-E registration', () => { + for (const type of ['C172', 'PA28', 'DA40', 'C152', 'SR22', 'DR40', 'P28A']) { + assert.equal(germanRegistrationPrefix(type), 'E', `${type} should be a D-E registration`) + } +}) + +test('light twins get a D-I registration', () => { + for (const type of ['BE58', 'PA34', 'C310', 'BE76']) { + assert.equal(germanRegistrationPrefix(type), 'I', `${type} should be a D-I registration`) + } +}) + +test('helicopters get a D-H registration', () => { + for (const type of ['R44', 'R22', 'EC35', 'H125', 'AS50']) { + assert.equal(germanRegistrationPrefix(type), 'H', `${type} should be a D-H registration`) + } +}) + +test('an unknown type falls back to the light single class', () => { + // VFR scenarios are overwhelmingly light aircraft, so that is the safe guess. + assert.equal(germanRegistrationPrefix('ZZZZ'), 'E') + assert.equal(germanRegistrationPrefix(''), 'E') + assert.equal(germanRegistrationPrefix(undefined as unknown as string), 'E') +}) + +test('the type is matched case-insensitively and ignores a trailing variant', () => { + assert.equal(germanRegistrationPrefix('a320'), 'A') + assert.equal(germanRegistrationPrefix('A320/M'), 'A') + assert.equal(germanRegistrationPrefix(' c172 '), 'E') +}) + +test('generateGermanRegistration produces a well-formed registration for the class', () => { + for (let i = 0; i < 200; i++) { + const { registration } = generateGermanRegistration('A320') + assert.match(registration, /^D-A[A-Z]{3}$/, registration) + } + for (let i = 0; i < 200; i++) { + const { registration } = generateGermanRegistration('C172') + assert.match(registration, /^D-E[A-Z]{3}$/, registration) + } +}) + +test('the abbreviated callsign is the first letter plus the last two', () => { + // German ATC abbreviates D-EMIL to "D-IL" after first contact. + const { registration, short } = generateGermanRegistration('C172') + const letters = registration.slice(2) + assert.equal(short, `D-${letters.slice(-2)}`) + assert.match(short, /^D-[A-Z]{2}$/) +}) + +test('an A320 never gets the light-aircraft registration that prompted this', () => { + for (let i = 0; i < 500; i++) { + const { registration } = generateGermanRegistration('A320') + assert.ok(!registration.startsWith('D-E'), `A320 got ${registration}`) + } +})