mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-04 16:22:48 +08:00
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:
@@ -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
|
||||
}
|
||||
|
||||
89
shared/utils/registration.ts
Normal file
89
shared/utils/registration.ts
Normal 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, 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<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 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<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)}` }
|
||||
}
|
||||
73
tests/shared/registration.test.ts
Normal file
73
tests/shared/registration.test.ts
Normal 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 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}`)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user