fix(live-atc): match the D-registration to the aircraft class

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 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-07-26 20:05:04 +02:00
parent 9780ced052
commit 7f0443f70e
3 changed files with 168 additions and 5 deletions

View File

@@ -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
}

View File

@@ -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, 25.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<Exclude<RegistrationPrefix, 'E'>, ReadonlySet<string>> = {
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 25.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<RegistrationPrefix, readonly string[]> = {
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)}` }
}

View File

@@ -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 25.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}`)
}
})