diff --git a/shared/learn/scenario.ts b/shared/learn/scenario.ts index 750628a..18edb3b 100644 --- a/shared/learn/scenario.ts +++ b/shared/learn/scenario.ts @@ -1,4 +1,5 @@ import type { AirlineData, AirportData, Frequency, FrequencyType, Scenario } from './types' +import { generateSquawk } from '../utils/transponder' const natoMap: Record = { A: 'Alpha', @@ -214,14 +215,6 @@ function codeToPhonetic(value: string): string { .trim() } -function generateSquawk(): string { - let code = '' - for (let i = 0; i < 4; i++) { - code += String(randInt(0, 7)) - } - return code -} - export function digitsToWords(value: string): string { return value .split('') diff --git a/shared/utils/communicationsEngine.ts b/shared/utils/communicationsEngine.ts index 3db5401..efdc387 100644 --- a/shared/utils/communicationsEngine.ts +++ b/shared/utils/communicationsEngine.ts @@ -9,6 +9,7 @@ import type { } from '../types/decision' import type { FlowActivationInstruction, FlowActivationMode, LLMDecisionTrace } from '../types/llm' import { normalizeRadioPhrase, DEFAULT_AIRLINE_TELEPHONY } from './radioSpeech' +import { generateSquawk } from './transponder' // --- DecisionTree runtime types --- type Role = 'pilot' | 'atc' | 'system' @@ -783,7 +784,7 @@ export default function useCommunicationsEngine() { dest: fpl.arr || fpl.arrival || 'EDDM', stand: genStand(), runway: genRunway(), - squawk: fpl.assignedsquawk || genSquawk(), + squawk: fpl.assignedsquawk || generateSquawk(), atis_code: genATIS(), sid: genSID(fpl.route || ''), transition: 'DCT', @@ -1509,10 +1510,6 @@ export default function useCommunicationsEngine() { return arr[Math.floor(Math.random() * arr.length)] } - function genSquawk() { - return String(Math.floor(Math.random() * 8000 + 1000)).padStart(4, '0') - } - function genATIS() { const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' return letters[Math.floor(Math.random() * letters.length)] diff --git a/shared/utils/transponder.ts b/shared/utils/transponder.ts new file mode 100644 index 0000000..8628f9d --- /dev/null +++ b/shared/utils/transponder.ts @@ -0,0 +1,39 @@ +/** + * Transponder (SSR) code generation. + * + * A squawk is an *octal* four-digit code — every digit is 0–7, because each + * digit is encoded in three pulse positions. A code containing 8 or 9 cannot be + * dialled into a real transponder, so it must never reach a clearance. + */ + +/** + * Codes ATC will not assign as a discrete code. + * + * 7500/7600/7700 are the ICAO emergency codes (unlawful interference, radio + * failure, general emergency); 7000 is the European VFR conspicuity code and + * 1200 its US equivalent; 2000 is the code for entering from a non-SSR area; + * 0000 is a non-code used to indicate a transponder fault. + */ +export const RESERVED_SQUAWKS = new Set([ + '7500', '7600', '7700', '7000', '2000', '1200', '0000', +]) + +/** True when `code` is a well-formed, assignable discrete squawk. */ +export function isValidSquawk(code: string): boolean { + if (!/^[0-7]{4}$/.test(code)) return false + return !RESERVED_SQUAWKS.has(code) +} + +/** A random assignable discrete squawk: four octal digits, no reserved code. */ +export function generateSquawk(): string { + // The reserved set is tiny next to the 4096-code space, so re-rolling + // terminates immediately in practice; the bound just makes that guaranteed. + for (let attempt = 0; attempt < 20; attempt++) { + let code = '' + for (let digit = 0; digit < 4; digit++) { + code += String(Math.floor(Math.random() * 8)) + } + if (isValidSquawk(code)) return code + } + return '1000' +} diff --git a/tests/shared/transponder.test.ts b/tests/shared/transponder.test.ts new file mode 100644 index 0000000..0760bdf --- /dev/null +++ b/tests/shared/transponder.test.ts @@ -0,0 +1,49 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { RESERVED_SQUAWKS, generateSquawk, isValidSquawk } from '../../shared/utils/transponder.ts' + +// Enough draws that a decimal generator (which would emit 8/9 roughly a quarter +// of the time per digit) could not pass by chance. +const DRAWS = 5000 + +test('generateSquawk only ever emits octal digits', () => { + for (let i = 0; i < DRAWS; i++) { + const code = generateSquawk() + assert.match(code, /^[0-7]{4}$/, `non-octal squawk generated: ${code}`) + } +}) + +test('generateSquawk never emits a reserved code', () => { + for (let i = 0; i < DRAWS; i++) { + const code = generateSquawk() + assert.ok(!RESERVED_SQUAWKS.has(code), `reserved squawk generated: ${code}`) + } +}) + +test('generateSquawk covers the code space rather than returning a constant', () => { + const seen = new Set() + for (let i = 0; i < DRAWS; i++) seen.add(generateSquawk()) + assert.ok(seen.size > 1000, `expected a spread of codes, got ${seen.size} distinct`) +}) + +test('isValidSquawk rejects the digits a transponder cannot display', () => { + assert.equal(isValidSquawk('2891'), false) + assert.equal(isValidSquawk('4592'), false) + assert.equal(isValidSquawk('1234'), true) +}) + +test('isValidSquawk rejects reserved codes', () => { + assert.equal(isValidSquawk('7500'), false) + assert.equal(isValidSquawk('7600'), false) + assert.equal(isValidSquawk('7700'), false) + assert.equal(isValidSquawk('7000'), false) + assert.equal(isValidSquawk('2000'), false) + assert.equal(isValidSquawk('0000'), false) +}) + +test('isValidSquawk rejects malformed input', () => { + assert.equal(isValidSquawk(''), false) + assert.equal(isValidSquawk('123'), false) + assert.equal(isValidSquawk('12345'), false) + assert.equal(isValidSquawk('12A4'), false) +})