feat(live-atc): add simulated AI background traffic on the tuned frequency

Implements the ai-traffic roadmap item per
docs/plans/2026-07-14-ai-traffic-architecture-design.md.

Simulated other aircraft on the user's frequency — callsigns, ATC
instructions, readbacks in their own stable voice, handovers — as pure
scenery. It never touches radioBackend: the Python backend keeps owning
the dialogue *with* the user, useAiTraffic owns the radio *around* the
user. The two share only the speech queue (arbitration) and the log.

Rules live as pure, seeded, framework-free modules under
shared/utils/aiTraffic/ so they run in tsx --test without a browser:
callsign collision rules, wake/in-trail separation, runway slots, the
speed ladder, direct validation, the §3 decision table, and the gating
chain. app/composables/useAiTraffic.ts wires them to Vue (1 Hz tick,
spawner, scheduler).

Gating is evaluated twice — before enqueue and again at playback, since
seconds pass in between. Traffic never keys up while the user holds PTT,
while their transmission is out at the backend, or inside the fresh
readback window. Off by default; the toggle surfaces the feature's v1
limitations rather than burying them in a doc.

Zero LLM calls: variance comes from seeded RNG over template variants.

Deviations from the design, both documented in the design doc:
- Adds SimAircraft.quietUntilSec. The design's rule table says "first
  matching row per tick" but never says an instruction must be allowed to
  take effect before the next one. Without it the planner re-derives the
  same unresolved condition every second and nags one aircraft with the
  same vector: 624 calls/30min measured, vs 90 with the cooldown.
- Airline pool limited to the 14 designators DEFAULT_AIRLINE_TELEPHONY
  already knows; UAE/AUA/WZZ from the design would be spelled out letter
  by letter instead of spoken as airline names.

Verified: 406 tests pass (176 new), no new typecheck errors, /live-atc
compiles and serves. The manual in-session walkthrough (audible traffic,
toggle mid-session) is NOT verified — it needs a login and the Python
backend. The 30-minute deterministic integration run stands in for it and
caught two of the three bugs found during development.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-07-16 17:46:52 +02:00
parent 3fe0ea5f8a
commit 7d49f18cc6
25 changed files with 3968 additions and 3 deletions

View File

@@ -0,0 +1,194 @@
/**
* Callsign + type generation for simulated traffic, collision-free against the
* user's own callsign (architecture design § 1).
*
* "Collision-free" here means three escalating rules, because the failure mode
* is acoustic, not logical: the user must never think a call to someone else was
* meant for them.
* 1. exact — never the user's callsign (long or short form)
* 2. prefix — never the user's airline designator at all
* 3. phonetic— the spoken digit/letter tail must differ in at least two
* positions, so DLH39A never shares a frequency with BAW39A
*/
import { DEFAULT_AIRLINE_TELEPHONY, normalizeRadioPhrase } from '../radioSpeech'
import {
simAircraftTypesByClass,
type SimAircraftClass,
type SimAircraftType,
} from '../../data/simAircraftTypes'
import type { TrafficTier } from '../../data/trafficTiers'
import type { Rng } from './rng'
/**
* Only designators the radiotelephony normalizer already knows — an unknown one
* would be spelled out letter by letter instead of spoken as an airline name.
*/
export const TRAFFIC_AIRLINES: readonly string[] = Object.keys(DEFAULT_AIRLINE_TELEPHONY)
/**
* German GA registrations used by the VFR flows in useLiveAtcSession — always
* blocked, since the user may be flying any of them.
*/
export const VFR_REGISTRATION_POOL: readonly string[] = [
'D-EMIL', 'D-EKLM', 'D-ENNY', 'D-ELLA', 'D-EOMT', 'D-ELPC', 'D-EMTO', 'D-EBRA',
]
const GA_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
/** Type mix per tier — the weights are the design's § 1 percentages. */
const TYPE_MIX: Readonly<Record<TrafficTier, readonly { value: SimAircraftClass; weight: number }[]>> = {
major: [{ value: 'narrowbody', weight: 70 }, { value: 'widebody', weight: 20 }, { value: 'regional', weight: 10 }],
regional: [{ value: 'narrowbody', weight: 60 }, { value: 'regional', weight: 30 }, { value: 'ga', weight: 10 }],
ga: [{ value: 'ga', weight: 80 }, { value: 'regional', weight: 20 }],
}
/** Levenshtein distance — small strings only, the naive DP is plenty. */
export function levenshtein(a: string, b: string): number {
if (a === b) return 0
if (!a.length) return b.length
if (!b.length) return a.length
let prev = Array.from({ length: b.length + 1 }, (_, i) => i)
for (let i = 1; i <= a.length; i++) {
const row = [i]
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1
row[j] = Math.min(row[j - 1]! + 1, prev[j]! + 1, prev[j - 1]! + cost)
}
prev = row
}
return prev[b.length]!
}
/** The part a listener actually confuses: everything after the airline designator. */
export function callsignTail(callsign: string): string {
const cs = (callsign || '').toUpperCase().replace(/[\s-]/g, '')
const airlineMatch = /^([A-Z]{3})([0-9].*)$/.exec(cs)
if (airlineMatch) return airlineMatch[2]!
// Registration (D-EMIL, N123AB): the tail is everything after the country prefix.
const regMatch = /^([A-Z])([A-Z0-9]+)$/.exec(cs)
if (regMatch) return regMatch[2]!
return cs
}
/** The airline designator, or '' for a registration. */
export function callsignPrefix(callsign: string): string {
const cs = (callsign || '').toUpperCase().replace(/[\s-]/g, '')
const match = /^([A-Z]{3})[0-9]/.exec(cs)
return match ? match[1]! : ''
}
export interface CallsignBlocklist {
/** The user's callsign and short form, plus anything already spawned. */
callsigns: readonly string[]
}
/**
* Is `candidate` safe to put on the same frequency as everything in `blocked`?
* Implements the three rules above; exported so the rule itself is testable
* without driving the whole factory.
*/
export function isCallsignDistinct(candidate: string, blocked: readonly string[]): boolean {
const cand = (candidate || '').toUpperCase().replace(/[\s-]/g, '')
if (!cand) return false
const candPrefix = callsignPrefix(cand)
const candTail = callsignTail(cand)
for (const raw of blocked) {
const other = (raw || '').toUpperCase().replace(/[\s-]/g, '')
if (!other) continue
// 1. exact
if (cand === other) return false
// 2. prefix — no other aircraft from the user's airline at all
const otherPrefix = callsignPrefix(other)
if (candPrefix && otherPrefix && candPrefix === otherPrefix) return false
// 3. phonetic — the spoken tail must differ in at least two positions
if (levenshtein(candTail, callsignTail(other)) < 2) return false
}
return true
}
export interface CallsignFactoryOptions {
rng: Rng
tier: TrafficTier
/** The user's callsign and short form — always blocked. */
userCallsigns: readonly string[]
}
export interface GeneratedCallsign {
callsign: string
callsignSpoken: string
type: SimAircraftType
}
/** Spoken form: 'DLH472' + heavy → 'Lufthansa four seven two heavy'. */
export function spokenCallsign(callsign: string, type: SimAircraftType): string {
const suffix = type.heavyCallsign ? ` ${type.heavyCallsign}` : ''
return normalizeRadioPhrase(`${callsign}${suffix}`, {
expandCallsigns: true,
airlineMap: DEFAULT_AIRLINE_TELEPHONY,
}).trim()
}
/**
* Generates callsign+type pairs that never collide with the user or with each
* other. Keeps its own issued list, so a factory instance is the pool's memory.
*/
export function createCallsignFactory(options: CallsignFactoryOptions) {
const { rng, tier, userCallsigns } = options
const issued: string[] = []
const permanentlyBlocked = [
...userCallsigns,
// Any VFR registration the user could have been assigned.
...VFR_REGISTRATION_POOL,
].filter(Boolean)
const blockedNow = () => [...permanentlyBlocked, ...issued]
const rollType = (): SimAircraftType => {
const cls = rng.weighted(TYPE_MIX[tier])
const candidates = simAircraftTypesByClass(cls)
return rng.pick(candidates)
}
const rollAirlineCallsign = (): string => {
const airline = rng.pick(TRAFFIC_AIRLINES)
const digits = rng.int(1, 4)
let number = String(rng.int(1, 9))
for (let i = 1; i < digits; i++) number += String(rng.int(0, 9))
// A letter suffix on ~20% of flights, the way real schedules look.
const suffix = rng.chance(0.2) ? rng.pick(GA_LETTERS.split('')) : ''
return `${airline}${number}${suffix}`
}
const rollRegistration = (): string => {
let letters = ''
for (let i = 0; i < 3; i++) letters += rng.pick(GA_LETTERS.split(''))
return `D-E${letters}`
}
/**
* Returns null if no distinct callsign turned up within the attempt budget —
* the caller simply doesn't spawn this tick rather than risking a confusable
* one. With a 14-airline pool that effectively never happens.
*/
const next = (): GeneratedCallsign | null => {
for (let attempt = 0; attempt < 60; attempt++) {
const type = rollType()
const callsign = type.class === 'ga' ? rollRegistration() : rollAirlineCallsign()
if (!isCallsignDistinct(callsign, blockedNow())) continue
issued.push(callsign)
return { callsign, callsignSpoken: spokenCallsign(callsign, type), type }
}
return null
}
const release = (callsign: string) => {
const index = issued.indexOf(callsign)
if (index >= 0) issued.splice(index, 1)
}
return { next, release, issued: () => [...issued] }
}

View File

@@ -0,0 +1,99 @@
/**
* The gating chain (architecture design § 3, Frage 10) — the hard "traffic never
* talks over the user" guarantee, as one pure function.
*
* Evaluated at TWO points: before enqueuing a traffic pair, and again inside the
* speech task when it actually starts playing. Seconds can pass between the two,
* and a gate that was open at enqueue time means nothing at playback time.
*
* "Only one transmitter at a time" is handled structurally elsewhere — traffic
* runs through the same serial speech queue as real ATC. This chain answers the
* different question of *whether the frequency belongs to the user right now*.
*/
/**
* How long after an ATC instruction the frequency stays reserved for the user's
* readback. Taken literally, `backendExpectedPhrase` is set almost permanently
* (the flow alternates ATC/pilot states), which would mute traffic forever and
* make the feature pointless. So the lock protects the *fresh* readback window
* instead: absolute silence from the moment ATC speaks until the user answers,
* but at least this long. After it, ambient chatter may resume — a real
* frequency also keeps working when a pilot dawdles, and the flow's own silence
* timer runs independently of this.
*
* Set it to Infinity for the literal-strict reading: same chain, one parameter,
* no second code path.
*/
export const DEFAULT_READBACK_PROTECTION_MS = 12_000
export interface ReadbackWindow {
/** Role of the flow state the session is currently on. */
currentStateRole: 'pilot' | 'atc' | 'system' | undefined
/** The backend's authoritative expected pilot phrase, if it wants one. */
backendExpectedPhrase: string | null
/** When the ATC instruction that opened this window was spoken (epoch ms). */
lastControllerSpeechAtMs: number | null
nowMs: number
readbackProtectionMs?: number
}
/**
* True while ATC is still owed a fresh readback from the user.
*
* Both base signals must hold — the flow is on a pilot state AND the backend
* actually named a phrase it expects — and the protection window must still be
* open. Once the window lapses the user is simply taking their time, which is
* not a reason to keep the whole frequency silent.
*/
export function readbackPending(win: ReadbackWindow): boolean {
if (win.currentStateRole !== 'pilot') return false
if (!win.backendExpectedPhrase) return false
if (win.lastControllerSpeechAtMs === null) return false
const protectionMs = win.readbackProtectionMs ?? DEFAULT_READBACK_PROTECTION_MS
if (protectionMs === Number.POSITIVE_INFINITY) return true
return win.nowMs - win.lastControllerSpeechAtMs < protectionMs
}
export interface GateInput {
/** The settings toggle. */
aiTrafficEnabled: boolean
/** The user is holding PTT — absolute, no window, no exception. */
isRecording: boolean
/** A user transmission is waiting on the backend — equally absolute. */
transmitInFlight: boolean
/** True while the session exists and the monitor screen is up. */
sessionActive: boolean
readback: ReadbackWindow
}
export type GateBlockReason =
| 'disabled'
| 'recording'
| 'transmit_in_flight'
| 'readback_pending'
| 'session_inactive'
export interface GateResult {
open: boolean
/** Why it's shut — surfaced in the debug log, and what the tests assert on. */
reason?: GateBlockReason
}
/**
* The chain itself. Ordered so the reason reported is the most specific one:
* a disabled toggle outranks everything, then the two absolute user-owns-the-
* frequency signals, then the softer readback window.
*/
export function evaluateGate(input: GateInput): GateResult {
if (!input.aiTrafficEnabled) return { open: false, reason: 'disabled' }
if (input.isRecording) return { open: false, reason: 'recording' }
if (input.transmitInFlight) return { open: false, reason: 'transmit_in_flight' }
if (!input.sessionActive) return { open: false, reason: 'session_inactive' }
if (readbackPending(input.readback)) return { open: false, reason: 'readback_pending' }
return { open: true }
}
export function gateOpen(input: GateInput): boolean {
return evaluateGate(input).open
}

View File

@@ -0,0 +1,549 @@
/**
* The rule-based instruction planner (architecture design § 3, Fragen 4 + the
* decision table). Zero LLM calls: every instruction the simulated traffic ever
* receives is a finite, parameterised template — variance comes from the seeded
* RNG picking between template variants, not from generation.
*/
import type { SimAircraftType } from '../../data/simAircraftTypes'
import type { Rng } from './rng'
import {
DIRECT_HEADROOM_FACTOR,
TERMINAL_STANDARD_NM,
assessInTrail,
departureWakeDelaySec,
slotIsFree,
} from './separation'
import type { SimAircraft, SimPhase } from './types'
/**
* Discrete speed steps a controller actually assigns, fastest first. The final
* rung is the type's own approach speed ("reduce to final approach speed"), so
* the usable ladder depends on the aircraft — see `speedLadderFor`.
*/
export const SPEED_LADDER: readonly number[] = [250, 220, 210, 190, 180, 170, 160]
/** Below this altitude 250 kt IAS is the ceiling for jets. */
export const SPEED_LIMIT_ALTITUDE_FT = 10000
export const SPEED_LIMIT_BELOW_KTS = 250
/** Phases in which an approach speed reduction makes any sense. */
const SPEED_CONTROL_PHASES: readonly SimPhase[] = ['inbound', 'approach']
/** Vectoring delay booked on the timeline instead of flying the turn geometrically. */
export const VECTOR_DELAY_MIN_SEC = 60
export const VECTOR_DELAY_MAX_SEC = 120
/**
* The rungs available for one type: the fixed ladder down to its approach
* speed, which is itself the last rung. A type whose Vref sits above a rung
* simply never sees that rung.
*/
export function speedLadderFor(type: SimAircraftType): number[] {
return [...SPEED_LADDER.filter(step => step > type.approachKts), type.approachKts]
}
/**
* The next speed a controller may assign, or null when there is nothing legal
* and useful left to say. Implements all four rules from the design at once:
* one step per instruction, never above 250 below 10,000 ft, never below the
* type's Vref, and never in a phase where it would be nonsense.
*/
export function nextSpeedStep(
aircraft: Pick<SimAircraft, 'phase' | 'altitudeFt' | 'iasKts' | 'assignedSpeedKts' | 'type'>,
): number | null {
// A Cessna gets no jet speed callouts at all — see slowestPracticalSpeech().
if (aircraft.type.wake === 'L') return null
if (!SPEED_CONTROL_PHASES.includes(aircraft.phase)) return null
const current = aircraft.assignedSpeedKts ?? aircraft.iasKts
const ceiling = aircraft.altitudeFt < SPEED_LIMIT_ALTITUDE_FT ? SPEED_LIMIT_BELOW_KTS : Infinity
const candidate = speedLadderFor(aircraft.type).find(step => step < current && step <= ceiling)
return candidate ?? null
}
/** True once speed control has nothing left to give and only a vector remains. */
export function isAtMinimumSpeed(
aircraft: Pick<SimAircraft, 'phase' | 'altitudeFt' | 'iasKts' | 'assignedSpeedKts' | 'type'>,
): boolean {
return nextSpeedStep(aircraft) === null
}
export interface DirectValidation {
valid: boolean
/** Index of the target fix in the remaining route (only when valid). */
fixIndex?: number
fix?: string
/** NM the shortcut saves — used to keep distanceToFieldNm consistent. */
savedNm?: number
reason?: 'behind' | 'not_on_route' | 'no_headroom' | 'wrong_phase' | 'gap_conflict'
}
export interface DirectContext {
/** Gap to the aircraft ahead, in NM. Infinity when there is nobody ahead. */
gapNm: number
requiredNm: number
/** How much distance each skipped fix removes from the remaining route. */
nmPerFix: number
}
/**
* A direct is only ever a *reward* for a quiet picture, never a conflict
* resolver — so it must be strictly ahead on the route, and the shortened
* timeline must still respect the in-trail minimum afterwards. Failing either,
* it is simply not generated.
*/
export function validateDirect(
aircraft: Pick<SimAircraft, 'phase' | 'routeFixes'>,
fix: string,
ctx: DirectContext,
): DirectValidation {
if (aircraft.phase !== 'inbound' && aircraft.phase !== 'approach') {
return { valid: false, reason: 'wrong_phase' }
}
const index = aircraft.routeFixes.indexOf(fix)
if (index < 0) return { valid: false, reason: 'not_on_route' }
// Index 0 is the fix the aircraft is already proceeding to — "direct" to it is
// a no-op, and anything at a lower index has been passed.
if (index === 0) return { valid: false, reason: 'behind' }
const savedNm = index * ctx.nmPerFix
if (ctx.gapNm < ctx.requiredNm * DIRECT_HEADROOM_FACTOR) {
return { valid: false, reason: 'no_headroom' }
}
// The shortcut eats into the gap to whoever is ahead — never issue one that
// busts the minimum the moment it is read back.
if (ctx.gapNm - savedNm < ctx.requiredNm) {
return { valid: false, reason: 'gap_conflict' }
}
return { valid: true, fixIndex: index, fix, savedNm }
}
/** Apply an accepted direct to the aircraft state, so later phraseology stays consistent. */
export function applyDirect(aircraft: SimAircraft, validation: DirectValidation): void {
if (!validation.valid || validation.fixIndex === undefined) return
aircraft.routeFixes.splice(0, validation.fixIndex)
aircraft.distanceToFieldNm = Math.max(0, aircraft.distanceToFieldNm - (validation.savedNm ?? 0))
}
// ── The decision table ────────────────────────────────────────────────────────
export type InstructionKind =
| 'phase'
| 'slot_hold'
| 'wake_hold'
| 'speed'
| 'vector'
| 'direct'
| 'handover'
| 'ambient'
export interface InstructionPlan {
kind: InstructionKind
callsign: string
/** Speed instructions only. */
speedKts?: number
/** Vector instructions only. */
headingDeg?: number
vectorDelaySec?: number
/** Direct instructions only. */
direct?: DirectValidation
/** Handover instructions only. */
handoverStation?: string
handoverFrequency?: string
/** Wake/slot holds only — how long the aircraft is held. */
holdSec?: number
}
/**
* How long an aircraft is left alone after each kind of instruction — roughly
* how long the instruction takes to actually change the picture. The one source
* of truth for `SimAircraft.quietUntilSec`, shared by the scheduler and tests.
*/
export function cooldownSecFor(plan: InstructionPlan): number {
switch (plan.kind) {
// Let the aircraft decelerate before judging the gap again — IAS moves at
// ~1 kt/s, so a 20 kt step needs ~20 s before it means anything.
case 'speed': return 30
// Fly the vector out, then a beat before "resume own navigation".
case 'vector': return (plan.vectorDelaySec ?? 90) + 30
// A treat, not a habit.
case 'direct': return 120
case 'wake_hold': return plan.holdSec ?? 60
case 'slot_hold': return 45
// It is leaving the frequency; there is nothing more to say to it.
case 'handover': return Number.POSITIVE_INFINITY
case 'ambient': return 90
// The phase timeline (nextEventAtSec) already paces these.
case 'phase': return 15
default: return 30
}
}
export interface PlannerContext {
nowSec: number
rng: Rng
/** The aircraft immediately ahead on the approach, if any. */
leader: Pick<SimAircraft, 'distanceToFieldNm' | 'type'> | null
/** Everything currently reserving the runway, including the user's slot. */
occupiedSlots: readonly { fromSec: number; toSec: number }[]
/** The departure that most recently used the runway, for the wake timer. */
lastDeparture: { type: SimAircraftType; atSec: number } | null
/** Where this aircraft is handed to next, if it has reached a sector boundary. */
handover: { station: string; frequency: string } | null
/** NM each remaining fix represents — used for direct headroom. */
nmPerFix: number
/** How long the tuned frequency has been silent, for ambient chatter. */
silentForSec: number
/** Seeded threshold (4590 s) above which ambient chatter may fire. */
ambientAfterSec: number
}
function inTrailGap(aircraft: SimAircraft, ctx: PlannerContext) {
if (!ctx.leader) {
return {
gapNm: Number.POSITIVE_INFINITY,
requiredNm: TERMINAL_STANDARD_NM,
needsAction: false,
violated: false,
gapSec: Number.POSITIVE_INFINITY,
}
}
return assessInTrail({
leaderDistanceNm: ctx.leader.distanceToFieldNm,
leaderWake: ctx.leader.type.wake,
followerDistanceNm: aircraft.distanceToFieldNm,
followerWake: aircraft.type.wake,
followerKts: aircraft.iasKts,
})
}
/**
* The design's rule table, evaluated per tick — first matching row wins.
* Returns null when this aircraft has nothing to say right now.
*/
export function planInstruction(aircraft: SimAircraft, ctx: PlannerContext): InstructionPlan | null {
const { rng } = ctx
// One thing at a time. Most rows below test a condition that stays true until
// the instruction has had time to work (a vector is still being flown, a wake
// timer is still running), so without this the same row fires every tick and
// the controller nags one aircraft into the ground.
if (ctx.nowSec < aircraft.quietUntilSec) return null
// Row 7 (sector boundary) is checked first among the event-driven rows: a
// handover is the reason the phase timer fired in the first place.
if (ctx.handover && aircraft.nextEventAtSec <= ctx.nowSec) {
return {
kind: 'handover',
callsign: aircraft.callsign,
handoverStation: ctx.handover.station,
handoverFrequency: ctx.handover.frequency,
}
}
// Row 3 — departure behind a heavy: the wake timer outranks the runway slot,
// because it is the reason the slot has to move.
if (aircraft.phase === 'lineup' && ctx.lastDeparture) {
const required = departureWakeDelaySec(ctx.lastDeparture.type.wake, aircraft.type.wake)
const elapsed = ctx.nowSec - ctx.lastDeparture.atSec
if (required > 0 && elapsed < required) {
return { kind: 'wake_hold', callsign: aircraft.callsign, holdSec: Math.ceil(required - elapsed) }
}
}
// Row 2 — the runway slot collides with the user's reservation or another
// aircraft's. Simulated traffic always yields.
//
// The aircraft's own reservation is in ctx.occupiedSlots (the caller passes the
// whole runway timeline), so it has to come out first — otherwise every
// aircraft conflicts with itself, forever, and holds instead of ever flying.
if (aircraft.runwaySlot) {
const others = ctx.occupiedSlots.filter(slot => slot !== aircraft.runwaySlot)
if (!slotIsFree(aircraft.runwaySlot, others)) {
return { kind: 'slot_hold', callsign: aircraft.callsign }
}
}
// Row 1 — a phase change came due.
if (aircraft.nextEventAtSec <= ctx.nowSec && aircraft.phase !== 'handed_off') {
return { kind: 'phase', callsign: aircraft.callsign }
}
const gap = inTrailGap(aircraft, ctx)
// Row 4 — closing up, but speed control still has a step left.
if (gap.needsAction) {
const step = nextSpeedStep(aircraft)
if (step !== null) {
return { kind: 'speed', callsign: aircraft.callsign, speedKts: step }
}
// Row 5 — minimum busted and already at the slowest practical speed.
if (gap.violated) {
return {
kind: 'vector',
callsign: aircraft.callsign,
headingDeg: rng.int(1, 36) * 10,
vectorDelaySec: rng.int(VECTOR_DELAY_MIN_SEC, VECTOR_DELAY_MAX_SEC),
}
}
}
// Row 6 — lots of air, a valid fix ahead, and the dice agree (rarely).
if (aircraft.routeFixes.length > 1 && rng.chance(0.04)) {
const fix = aircraft.routeFixes[aircraft.routeFixes.length - 1]!
const direct = validateDirect(aircraft, fix, {
gapNm: gap.gapNm,
requiredNm: gap.requiredNm,
nmPerFix: ctx.nmPerFix,
})
if (direct.valid) {
return { kind: 'direct', callsign: aircraft.callsign, direct }
}
}
// Row 8 — nothing to do and the frequency has been quiet for a while.
if (ctx.silentForSec >= ctx.ambientAfterSec) {
return { kind: 'ambient', callsign: aircraft.callsign }
}
return null
}
// ── Phraseology ───────────────────────────────────────────────────────────────
export interface RadioEvent {
kind: InstructionKind
callsign: string
/** What the controller says. */
atcText: string
/** What the simulated pilot reads back — spoken in that aircraft's own voice. */
pilotReadbackText: string
/**
* Who keys first. Every instruction is ATC-first; an ambient check-in is the
* one event the aircraft initiates, and playing it the other way round would
* sound like the controller talking to nobody.
*/
order: 'atc_first' | 'pilot_first'
/** State mutation to apply once the pair has actually been spoken. */
plan: InstructionPlan
}
export interface RenderContext {
rng: Rng
/** e.g. 'Frankfurt Approach' — the station the traffic is talking to. */
station: string
runway: string
}
const pick = (rng: Rng, variants: readonly string[]) => rng.pick(variants)
/** GA aircraft never get a numeric jet speed — this is what they get instead. */
function slowestPracticalSpeech(callsign: string, rng: Rng): { atc: string; readback: string } {
return {
atc: pick(rng, [
`${callsign}, reduce to slowest practical speed`,
`${callsign}, make your approach speed as slow as practical`,
]),
readback: `Slowest practical speed, ${callsign}`,
}
}
function phaseSpeech(aircraft: SimAircraft, ctx: RenderContext): { atc: string; readback: string } {
const { rng, runway } = ctx
const cs = aircraft.callsign
const miles = Math.max(1, Math.round(aircraft.distanceToFieldNm))
switch (aircraft.phase) {
case 'inbound':
return {
atc: pick(rng, [
`${cs}, descend to altitude 4000 feet, QNH 1013`,
`${cs}, descend altitude 5000 feet, expect ILS approach runway ${runway}`,
]),
readback: `Descend 4000 feet, ${cs}`,
}
case 'approach':
return {
atc: pick(rng, [
`${cs}, ${miles} miles from touchdown, cleared ILS approach runway ${runway}`,
`${cs}, turn right heading 250, cleared ILS approach runway ${runway}`,
]),
readback: `Cleared ILS approach runway ${runway}, ${cs}`,
}
case 'final':
return {
atc: pick(rng, [
`${cs}, wind 250 degrees 8 knots, runway ${runway}, cleared to land`,
`${cs}, runway ${runway}, cleared to land, wind 240 degrees 6 knots`,
]),
readback: `Cleared to land runway ${runway}, ${cs}`,
}
case 'rollout':
return {
atc: pick(rng, [
`${cs}, vacate via taxiway November, contact Ground 121.800`,
`${cs}, turn right when able, contact Ground 121.800`,
]),
readback: `Ground 121.800, ${cs}`,
}
case 'taxi_out':
return {
atc: pick(rng, [
`${cs}, taxi to holding point runway ${runway} via taxiway November`,
`${cs}, taxi holding point runway ${runway}, give way to the A320 from your right`,
]),
readback: `Taxi holding point runway ${runway}, ${cs}`,
}
case 'lineup':
return {
atc: pick(rng, [
`${cs}, runway ${runway}, line up and wait`,
`${cs}, behind the landing traffic, line up and wait runway ${runway}`,
]),
readback: `Line up and wait runway ${runway}, ${cs}`,
}
case 'takeoff':
return {
atc: pick(rng, [
`${cs}, wind 250 degrees 8 knots, runway ${runway}, cleared for takeoff`,
`${cs}, runway ${runway}, cleared for takeoff, wind 240 degrees 7 knots`,
]),
readback: `Cleared for takeoff runway ${runway}, ${cs}`,
}
case 'climbout':
return {
atc: pick(rng, [
`${cs}, climb to altitude 6000 feet`,
`${cs}, continue climb altitude 5000 feet, resume own navigation`,
]),
readback: `Climb 6000 feet, ${cs}`,
}
default:
return { atc: `${cs}, roger`, readback: `${cs}` }
}
}
function ambientSpeech(aircraft: SimAircraft, ctx: RenderContext): { atc: string; readback: string } {
const { rng, station } = ctx
const cs = aircraft.callsign
const miles = Math.max(1, Math.round(aircraft.distanceToFieldNm))
// Row 8: the aircraft checks in and the controller answers — the pair is
// emitted pilot-first (see RadioEvent.order).
return rng.pick([
{
readback: `${station}, ${cs}, passing altitude 7000 feet, information Kilo`,
atc: `${cs}, ${station}, radar contact, continue as cleared`,
},
{
readback: `${station}, ${cs}, ${miles} miles to run`,
atc: `${cs}, ${station}, roger, no reported traffic ahead`,
},
{
readback: `${station}, ${cs}, with you, descending altitude 5000 feet`,
atc: `${cs}, ${station}, identified, expect vectors ILS runway ${ctx.runway}`,
},
])
}
/** Turn a plan into the ATC call + the simulated pilot's readback. */
export function renderInstruction(
aircraft: SimAircraft,
plan: InstructionPlan,
ctx: RenderContext,
): RadioEvent {
const { rng, station, runway } = ctx
const cs = aircraft.callsign
let atcText: string
let pilotReadbackText: string
switch (plan.kind) {
case 'speed': {
if (aircraft.type.wake === 'L') {
const speech = slowestPracticalSpeech(cs, rng)
atcText = speech.atc
pilotReadbackText = speech.readback
break
}
atcText = pick(rng, [
`${cs}, reduce speed to ${plan.speedKts} knots`,
`${cs}, for spacing reduce speed to ${plan.speedKts} knots`,
`${cs}, speed ${plan.speedKts} knots or less`,
])
pilotReadbackText = `Speed ${plan.speedKts} knots, ${cs}`
break
}
case 'vector': {
atcText = pick(rng, [
`${cs}, turn left heading ${plan.headingDeg}, vectors for spacing`,
`${cs}, turn right heading ${plan.headingDeg}, vectors for sequencing`,
])
pilotReadbackText = `Heading ${plan.headingDeg}, ${cs}`
break
}
case 'direct': {
const fix = plan.direct?.fix ?? aircraft.routeFixes[0] ?? 'the field'
atcText = pick(rng, [
`${cs}, proceed direct ${fix}`,
`${cs}, when ready proceed direct ${fix}`,
])
pilotReadbackText = `Direct ${fix}, ${cs}`
break
}
case 'handover': {
atcText = pick(rng, [
`${cs}, contact ${plan.handoverStation} ${plan.handoverFrequency}, good day`,
`${cs}, contact ${plan.handoverStation} on ${plan.handoverFrequency}, bye bye`,
])
pilotReadbackText = `${plan.handoverFrequency}, ${cs}, good day`
break
}
case 'wake_hold': {
atcText = pick(rng, [
`${cs}, hold position, wake turbulence delay`,
`${cs}, hold short runway ${runway}, wake turbulence separation`,
])
pilotReadbackText = `Holding, ${cs}`
break
}
case 'slot_hold': {
const arriving = aircraft.phase === 'inbound' || aircraft.phase === 'approach' || aircraft.phase === 'final'
if (arriving) {
atcText = pick(rng, [
`${cs}, continue approach, expect late landing clearance`,
`${cs}, continue approach runway ${runway}, landing clearance to follow`,
])
pilotReadbackText = `Continue approach, ${cs}`
} else {
atcText = pick(rng, [
`${cs}, hold position, traffic on the runway`,
`${cs}, hold short runway ${runway}, one landing ahead of you`,
])
pilotReadbackText = `Holding short runway ${runway}, ${cs}`
}
break
}
case 'ambient': {
const speech = ambientSpeech(aircraft, ctx)
atcText = speech.atc
pilotReadbackText = speech.readback
break
}
case 'phase':
default: {
const speech = phaseSpeech(aircraft, ctx)
atcText = speech.atc
pilotReadbackText = speech.readback
break
}
}
return {
kind: plan.kind,
callsign: cs,
atcText,
pilotReadbackText,
order: plan.kind === 'ambient' ? 'pilot_first' : 'atc_first',
plan,
}
}

View File

@@ -0,0 +1,72 @@
/**
* Seeded RNG for the simulated background traffic. Everything random about the
* traffic — callsigns, type mix, timing jitter, template variants — runs through
* one of these, so a session is reproducible from its seed alone. That keeps bug
* reports actionable and lets the sim core be tested without mocking anything
* (architecture design § 0).
*/
import { fnv1a } from '../voicePool'
export interface Rng {
/** Uniform in [0, 1). */
next(): number
/** Uniform integer in [min, max] (inclusive). */
int(min: number, max: number): number
/** Uniform float in [min, max). */
float(min: number, max: number): number
/** Uniform element. Throws on an empty list. */
pick<T>(items: readonly T[]): T
/** True with probability `p`. */
chance(p: number): boolean
/**
* Element picked by relative weight. Weights must be non-negative and sum > 0.
*/
weighted<T>(entries: readonly { value: T; weight: number }[]): T
}
/** mulberry32 — 32-bit state, good distribution, five lines. */
export function createRng(seed: string | number): Rng {
let state = (typeof seed === 'number' ? seed >>> 0 : fnv1a(seed)) >>> 0
// A zero state degenerates mulberry32 into a constant stream.
if (state === 0) state = 0x9e3779b9
const next = (): number => {
state = (state + 0x6d2b79f5) >>> 0
let t = state
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
const rng: Rng = {
next,
int: (min, max) => Math.floor(next() * (max - min + 1)) + min,
float: (min, max) => min + next() * (max - min),
pick: <T>(items: readonly T[]): T => {
if (!items.length) throw new Error('Rng.pick: empty list')
return items[Math.floor(next() * items.length)]!
},
chance: p => next() < p,
weighted: <T>(entries: readonly { value: T; weight: number }[]): T => {
const total = entries.reduce((sum, e) => sum + Math.max(0, e.weight), 0)
if (!entries.length || total <= 0) throw new Error('Rng.weighted: no positive weights')
let roll = next() * total
for (const entry of entries) {
roll -= Math.max(0, entry.weight)
if (roll < 0) return entry.value
}
return entries[entries.length - 1]!.value
},
}
return rng
}
/**
* The session seed: session id + calendar day. Same session on the same day
* replays identically; a new session gets fresh traffic.
*/
export function trafficSeed(sessionId: string, date: Date = new Date()): string {
const day = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
return `${sessionId}|${day}`
}

View File

@@ -0,0 +1,137 @@
/**
* Separation rules for the simulated traffic (architecture design § 3, Frage 2).
*
* Two invariants, checked every tick:
* - in-trail gap on a shared approach path: max(terminal standard, wake matrix)
* - runway occupancy: never two clearances with overlapping slots
*
* The user's aircraft is never a simulated position — it is a slot reservation
* that simulated traffic always yields to. A conflict therefore always resolves
* by slowing, vectoring or holding the *simulated* aircraft.
*/
import type { WakeCategory } from '../../data/simAircraftTypes'
import type { RunwaySlot } from './types'
/** Radar separation in the terminal area, before any wake supplement. */
export const TERMINAL_STANDARD_NM = 3
/** Buffer on top of the requirement at which the planner starts acting. */
export const SPACING_TRIGGER_FACTOR = 1.2
/** Gap above which a direct becomes a plausible reward rather than a risk. */
export const DIRECT_HEADROOM_FACTOR = 2
/**
* Required in-trail distance, leader → follower, on the same approach track.
* These are absolute minima in NM (not supplements): a Medium behind a Heavy
* needs 5 NM rather than the standard 3. Pairs with no entry are covered by the
* terminal standard alone.
*/
const WAKE_MATRIX: Readonly<Partial<Record<WakeCategory, Partial<Record<WakeCategory, number>>>>> = {
J: { H: 6, M: 7, L: 8 },
H: { H: 4, M: 5, L: 6 },
M: { L: 5 },
L: {},
}
/** The wake-derived minimum for a pair, or 0 when the category needs no supplement. */
export function wakeSeparationNm(leader: WakeCategory, follower: WakeCategory): number {
return WAKE_MATRIX[leader]?.[follower] ?? 0
}
/** What the follower actually has to hold: the stricter of standard and wake. */
export function requiredGapNm(leader: WakeCategory, follower: WakeCategory): number {
return Math.max(TERMINAL_STANDARD_NM, wakeSeparationNm(leader, follower))
}
/** Convert a distance gap into the time it buys at the follower's speed. */
export function gapToTimeSec(gapNm: number, groundspeedKts: number): number {
if (groundspeedKts <= 0) return Number.POSITIVE_INFINITY
return (gapNm / groundspeedKts) * 3600
}
/** Act before the minimum is actually busted — the 20% buffer from the design. */
export function needsSpacing(gapNm: number, required: number): boolean {
return gapNm < required * SPACING_TRIGGER_FACTOR
}
/** The minimum is (or is about to be) busted and speed alone won't fix it. */
export function isGapViolated(gapNm: number, required: number): boolean {
return gapNm < required
}
/**
* Time-based departure wake separation: a Light/Medium behind a Heavy/Super
* waits ~3 minutes, a Heavy behind a Heavy ~2. Same-category non-heavy pairs are
* separated by runway occupancy alone, not by time.
*/
export function departureWakeDelaySec(leader: WakeCategory, follower: WakeCategory): number {
const leaderIsHeavy = leader === 'H' || leader === 'J'
if (!leaderIsHeavy) return 0
const followerIsHeavy = follower === 'H' || follower === 'J'
return followerIsHeavy ? 120 : 180
}
export function slotsOverlap(a: RunwaySlot, b: RunwaySlot): boolean {
return a.fromSec < b.toSec && b.fromSec < a.toSec
}
export function slotIsFree(candidate: RunwaySlot, occupied: readonly RunwaySlot[]): boolean {
return !occupied.some(slot => slotsOverlap(candidate, slot))
}
/**
* Push `desired` later until it clears everything in `occupied`, preserving its
* duration. Simulated traffic always yields, so this only ever moves forward.
*/
export function nextFreeSlot(desired: RunwaySlot, occupied: readonly RunwaySlot[]): RunwaySlot {
const duration = desired.toSec - desired.fromSec
let from = desired.fromSec
// Each pass can only be blocked by a slot that ends later than the last one we
// cleared, so the loop is bounded by the number of occupied slots.
for (let pass = 0; pass <= occupied.length; pass++) {
const candidate = { fromSec: from, toSec: from + duration }
const blocker = occupied.find(slot => slotsOverlap(candidate, slot))
if (!blocker) return candidate
from = blocker.toSec
}
return { fromSec: from, toSec: from + duration }
}
export interface InTrailPair {
/** Distance to the field of the aircraft ahead, in NM. */
leaderDistanceNm: number
leaderWake: WakeCategory
followerDistanceNm: number
followerWake: WakeCategory
/** Follower's current speed — turns the distance gap into a closing rate. */
followerKts: number
}
export interface InTrailAssessment {
gapNm: number
requiredNm: number
/** Within the 20% buffer: start reducing speed. */
needsAction: boolean
/** Minimum busted: speed is no longer enough. */
violated: boolean
/** How long the current gap lasts at the follower's speed. */
gapSec: number
}
/**
* The one place the in-trail invariant is evaluated. Distances are 1D along the
* approach: the follower is further from the field than the leader.
*/
export function assessInTrail(pair: InTrailPair): InTrailAssessment {
const gapNm = Math.max(0, pair.followerDistanceNm - pair.leaderDistanceNm)
const requiredNm = requiredGapNm(pair.leaderWake, pair.followerWake)
return {
gapNm,
requiredNm,
needsAction: needsSpacing(gapNm, requiredNm),
violated: isGapViolated(gapNm, requiredNm),
gapSec: gapToTimeSec(gapNm, pair.followerKts),
}
}

View File

@@ -0,0 +1,213 @@
/**
* The traffic simulation itself: spawning aircraft and advancing them on a 1 Hz
* tick (architecture design § 2).
*
* Deliberately 1-dimensional — distance along the route, altitude, speed. There
* is no 2D radar picture because nothing needs one: positions only ever surface
* in phraseology ("six miles final") and in the in-trail gap. A vector is booked
* as time on the timeline rather than flown as a turn — acoustically identical,
* an order of magnitude less code.
*/
import type { Rng } from './rng'
import type { GeneratedCallsign } from './callsign'
import { pilotVoiceFor } from '../voicePool'
import type { SimAircraft, SimPhase } from './types'
/** How fast IAS follows an assigned speed — roughly what a jet actually does. */
export const SPEED_DRIFT_KTS_PER_SEC = 1
/** Distance-to-field at which an arrival is handed from approach to tower. */
export const FINAL_HANDOVER_NM = 8
/** Where arrivals enter the picture. */
export const SPAWN_DISTANCE_MIN_NM = 25
export const SPAWN_DISTANCE_MAX_NM = 60
/** How much of the remaining route one fix represents. */
export const NM_PER_FIX = 8
const FIX_CONSONANTS = 'BCDFGKLMNPRSTVXZ'
const FIX_VOWELS = 'AEIOU'
/**
* A small pool of invented but plausible-sounding 5-letter fixes per airport.
* v1 does not load real procedure data — directs here are ear realism, not
* navigation, and real SID/STAR fixes would be their own data project.
*/
export function generateFixPool(rng: Rng, count = 8): string[] {
const fixes = new Set<string>()
// Bounded so a pathological RNG can't spin here; the pool just ends up smaller.
for (let attempt = 0; attempt < count * 10 && fixes.size < count; attempt++) {
let name = ''
for (let i = 0; i < 5; i++) {
name += i % 2 === 0 ? rng.pick(FIX_CONSONANTS.split('')) : rng.pick(FIX_VOWELS.split(''))
}
fixes.add(name)
}
return [...fixes]
}
export type SpawnKind = 'arrival' | 'departure'
export interface SpawnOptions {
rng: Rng
nowSec: number
frequency: string
fixPool: readonly string[]
}
/** Arrivals start out on the STAR; departures start at the holding point. */
export function createSimAircraft(
generated: GeneratedCallsign,
kind: SpawnKind,
opts: SpawnOptions,
): SimAircraft {
const { rng, nowSec, frequency, fixPool } = opts
const { callsign, callsignSpoken, type } = generated
const routeFixCount = Math.min(fixPool.length, rng.int(2, 4))
const routeFixes = Array.from({ length: routeFixCount }, () => rng.pick(fixPool))
if (kind === 'departure') {
return {
callsign,
callsignSpoken,
type,
voiceId: pilotVoiceFor(callsign),
phase: 'taxi_out',
frequency,
routeFixes,
distanceToFieldNm: 0,
altitudeFt: 0,
iasKts: 0,
assignedSpeedKts: null,
vectorDelaySec: 0,
runwaySlot: null,
nextEventAtSec: nowSec + rng.int(20, 60),
quietUntilSec: 0,
}
}
const distance = rng.float(SPAWN_DISTANCE_MIN_NM, SPAWN_DISTANCE_MAX_NM)
return {
callsign,
callsignSpoken,
type,
voiceId: pilotVoiceFor(callsign),
phase: 'inbound',
frequency,
routeFixes,
distanceToFieldNm: distance,
altitudeFt: rng.int(7, 12) * 1000,
// Inbound and already speed-limited below 10,000 ft.
iasKts: Math.max(type.approachKts, Math.min(250, type.cruiseKts * 0.55)),
assignedSpeedKts: null,
vectorDelaySec: 0,
runwaySlot: null,
nextEventAtSec: nowSec + rng.int(15, 45),
quietUntilSec: 0,
}
}
/** The linear phase order per flight kind. */
const ARRIVAL_PHASES: readonly SimPhase[] = ['inbound', 'approach', 'final', 'rollout', 'handed_off']
const DEPARTURE_PHASES: readonly SimPhase[] = ['taxi_out', 'lineup', 'takeoff', 'climbout', 'handed_off']
export function nextPhase(phase: SimPhase): SimPhase {
const chain = ARRIVAL_PHASES.includes(phase) ? ARRIVAL_PHASES : DEPARTURE_PHASES
const index = chain.indexOf(phase)
if (index < 0 || index >= chain.length - 1) return 'handed_off'
return chain[index + 1]!
}
export function isArrival(aircraft: Pick<SimAircraft, 'phase'>): boolean {
return ARRIVAL_PHASES.includes(aircraft.phase)
}
/** Aircraft that have left the sector — the spawner reclaims their callsigns. */
export function isDespawnable(aircraft: Pick<SimAircraft, 'phase'>): boolean {
return aircraft.phase === 'handed_off'
}
/**
* One tick of 1D kinematics. Mutates in place — the pool owns these objects and
* a tick runs every second, so copying them buys nothing.
*/
export function advanceAircraft(aircraft: SimAircraft, dtSec: number): void {
if (dtSec <= 0) return
// A vector is time, not geometry: while it burns down the aircraft holds its
// distance to the field instead of closing.
if (aircraft.vectorDelaySec > 0) {
aircraft.vectorDelaySec = Math.max(0, aircraft.vectorDelaySec - dtSec)
return
}
// IAS chases the assigned speed at ~1 kt/s, so later phraseology
// ("12 miles, speed 180") stays consistent with the model.
if (aircraft.assignedSpeedKts !== null) {
const delta = aircraft.assignedSpeedKts - aircraft.iasKts
const step = Math.min(Math.abs(delta), SPEED_DRIFT_KTS_PER_SEC * dtSec)
aircraft.iasKts += Math.sign(delta) * step
}
switch (aircraft.phase) {
case 'inbound':
case 'approach':
case 'final':
aircraft.distanceToFieldNm = Math.max(0, aircraft.distanceToFieldNm - (aircraft.iasKts * dtSec) / 3600)
aircraft.altitudeFt = Math.max(0, aircraft.altitudeFt - (aircraft.type.descentFpm * dtSec) / 60)
break
case 'takeoff':
case 'climbout':
aircraft.distanceToFieldNm += (aircraft.iasKts * dtSec) / 3600
aircraft.altitudeFt += (aircraft.type.climbFpm * dtSec) / 60
break
case 'rollout':
aircraft.iasKts = Math.max(0, aircraft.iasKts - 5 * dtSec)
break
default:
break
}
}
/** Move an aircraft to its next phase and schedule the following event. */
export function advancePhase(aircraft: SimAircraft, rng: Rng, nowSec: number): void {
aircraft.phase = nextPhase(aircraft.phase)
switch (aircraft.phase) {
case 'takeoff':
aircraft.iasKts = aircraft.type.approachKts + 20
aircraft.nextEventAtSec = nowSec + rng.int(30, 60)
break
case 'climbout':
aircraft.assignedSpeedKts = null
aircraft.nextEventAtSec = nowSec + rng.int(60, 120)
break
case 'final':
aircraft.assignedSpeedKts = aircraft.type.approachKts
aircraft.nextEventAtSec = nowSec + rng.int(60, 120)
break
case 'handed_off':
aircraft.nextEventAtSec = Number.POSITIVE_INFINITY
break
default:
aircraft.nextEventAtSec = nowSec + rng.int(45, 90)
break
}
}
/**
* The aircraft immediately ahead of `follower` on the same approach: the closest
* one that is nearer to the field. Returns null when the follower is leading.
*/
export function findLeader(follower: SimAircraft, pool: readonly SimAircraft[]): SimAircraft | null {
let leader: SimAircraft | null = null
for (const other of pool) {
if (other === follower || other.callsign === follower.callsign) continue
if (!isArrival(other)) continue
if (other.distanceToFieldNm >= follower.distanceToFieldNm) continue
if (!leader || other.distanceToFieldNm > leader.distanceToFieldNm) leader = other
}
return leader
}

View File

@@ -0,0 +1,50 @@
import type { SimAircraftType } from '../../data/simAircraftTypes'
/**
* Where a simulated aircraft is in its (deliberately 1-dimensional) life:
* distance along a route, altitude and speed. No 2D radar picture — positions
* only ever surface in phraseology ("six miles final") and in the in-trail gap
* calculation, and that needs nothing more.
*/
export type SimPhase =
| 'inbound' | 'approach' | 'final' | 'rollout'
| 'taxi_out' | 'lineup' | 'takeoff' | 'climbout' | 'handed_off'
/** Runway timeline reservation, in sim seconds. */
export interface RunwaySlot {
fromSec: number
toSec: number
}
export interface SimAircraft {
/** ICAO callsign as written, e.g. 'DLH472' or 'D-EKLM'. */
callsign: string
/** Full radiotelephony form, e.g. 'Lufthansa four seven two heavy'. */
callsignSpoken: string
type: SimAircraftType
/** Stable across the session — hashed from the callsign, never persisted. */
voiceId: string
phase: SimPhase
/** The frequency this aircraft is currently on; it is only audible on the tuned one. */
frequency: string
/** Remaining route; [0] is the next waypoint. */
routeFixes: string[]
distanceToFieldNm: number
altitudeFt: number
iasKts: number
/** Last speed instruction issued, if any. */
assignedSpeedKts: number | null
/** Accumulated vectoring delay — a vector is booked as time, not flown geometrically. */
vectorDelaySec: number
runwaySlot: RunwaySlot | null
/** Sim time of this aircraft's next planned radio event. */
nextEventAtSec: number
/**
* No further instruction to this aircraft before this sim time. A controller
* issues a clearance and then lets it take effect; without this the planner
* re-derives the same unresolved condition every tick and nags the same
* aircraft once a second.
*/
quietUntilSec: number
}

View File

@@ -97,6 +97,12 @@ export interface EngineLog {
radioCheck?: boolean
offSchema?: boolean
flow?: string
/**
* Simulated background traffic (useAiTraffic), not part of the user's own
* exchange with ATC. Purely cosmetic — it lets the log tone these lines down
* so they read as scenery rather than as something to act on.
*/
traffic?: boolean
}
interface FlowSnapshot {
@@ -1423,7 +1429,7 @@ export default function useCommunicationsEngine() {
speaker: Role,
message: string,
stateId: string,
options: { frequency?: string; flow?: string; radioCheck?: boolean; offSchema?: boolean } = {},
options: { frequency?: string; flow?: string; radioCheck?: boolean; offSchema?: boolean; traffic?: boolean } = {},
) {
const entry: EngineLog = {
timestamp: new Date(),
@@ -1435,6 +1441,7 @@ export default function useCommunicationsEngine() {
flow: options.flow ?? (activeFlowSlug.value || undefined),
radioCheck: options.radioCheck,
offSchema: options.offSchema,
traffic: options.traffic,
}
communicationLog.value.push(entry)
}

68
shared/utils/voicePool.ts Normal file
View File

@@ -0,0 +1,68 @@
/**
* The one voice pool for the whole product — shared infrastructure for both
* `ai-traffic` (a stable voice per simulated aircraft) and the `multi-voice`
* roadmap item (a rotating voice per ATC position). Two disjoint partitions, one
* assignment function; deliberately not two systems (architecture design § 7).
*
* Assignment is a pure hash of the callsign / position, so the same "DLH472"
* sounds the same all session — and across sessions — without persisting any
* assignment state anywhere.
*/
/** Voices for ATC positions. `alloy` is today's hard-coded controller voice. */
export const CONTROLLER_VOICES: readonly string[] = ['alloy', 'echo', 'onyx', 'sage']
/** Voices for simulated pilots' readbacks. Disjoint from CONTROLLER_VOICES. */
export const PILOT_VOICES: readonly string[] = ['ash', 'ballad', 'coral', 'fable', 'nova', 'shimmer']
/**
* Never hand these to a simulated pilot: `verse` already belongs to the user's
* own readback (`speakPilotReadback`), and the live controller voice must stay
* distinguishable from the traffic around it.
*/
export const RESERVED_VOICES: readonly string[] = ['verse', 'alloy']
/** FNV-1a, 32-bit. Small, stable, and dependency-free — good enough to bucket strings. */
export function fnv1a(input: string): number {
let hash = 0x811c9dc5
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i)
// hash * 16777619, kept in 32-bit unsigned range without BigInt.
hash = Math.imul(hash, 0x01000193) >>> 0
}
return hash >>> 0
}
/**
* Pick a voice from `pool` for `key`, skipping anything in `reserved`.
* Walks forward from the hashed index so a collision with a reserved voice
* degrades to the next pool entry instead of failing.
*/
export function voiceFromPool(
key: string,
pool: readonly string[],
reserved: readonly string[] = RESERVED_VOICES,
): string {
const usable = pool.filter(v => !reserved.includes(v))
const candidates = usable.length ? usable : pool
if (!candidates.length) throw new Error('voiceFromPool: empty voice pool')
return candidates[fnv1a(key) % candidates.length]!
}
/**
* The stable voice of a simulated aircraft. `reserved` lets a caller exclude the
* controller voice that is live right now (relevant once `multi-voice` rotates
* it), on top of the permanently reserved ones.
*/
export function pilotVoiceFor(callsign: string, reserved: readonly string[] = RESERVED_VOICES): string {
return voiceFromPool(callsign.toUpperCase(), PILOT_VOICES, reserved)
}
/**
* The voice of an ATC position (`multi-voice`). Traffic doesn't call this yet —
* it exists so the future feature reuses this pool rather than inventing a
* second assignment path.
*/
export function controllerVoiceFor(position: string): string {
return voiceFromPool(position.toUpperCase(), CONTROLLER_VOICES, [])
}