mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 08:55:54 +08:00
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:
194
shared/utils/aiTraffic/callsign.ts
Normal file
194
shared/utils/aiTraffic/callsign.ts
Normal 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] }
|
||||
}
|
||||
99
shared/utils/aiTraffic/gating.ts
Normal file
99
shared/utils/aiTraffic/gating.ts
Normal 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
|
||||
}
|
||||
549
shared/utils/aiTraffic/instructions.ts
Normal file
549
shared/utils/aiTraffic/instructions.ts
Normal 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 (45–90 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,
|
||||
}
|
||||
}
|
||||
72
shared/utils/aiTraffic/rng.ts
Normal file
72
shared/utils/aiTraffic/rng.ts
Normal 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}`
|
||||
}
|
||||
137
shared/utils/aiTraffic/separation.ts
Normal file
137
shared/utils/aiTraffic/separation.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
213
shared/utils/aiTraffic/sim.ts
Normal file
213
shared/utils/aiTraffic/sim.ts
Normal 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
|
||||
}
|
||||
50
shared/utils/aiTraffic/types.ts
Normal file
50
shared/utils/aiTraffic/types.ts
Normal 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
|
||||
}
|
||||
@@ -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
68
shared/utils/voicePool.ts
Normal 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, [])
|
||||
}
|
||||
Reference in New Issue
Block a user