mdi-dice-multiple
@@ -1326,6 +1347,7 @@ import {computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, shallowRe
import {useRoute, useRouter} from '#imports'
import {useApi} from '~/composables/useApi'
import {useAuthStore} from '~/stores/auth'
+import DecisionExercise from '~/components/learn/DecisionExercise.vue'
import {createDefaultLearnConfig} from '~~/shared/learn/config'
import type {LearnConfig, LearnProgress, LearnState} from '~~/shared/learn/config'
import {learnModules, learnTracks, seedFullFlightScenario} from '~~/shared/data/learnModules'
@@ -1337,7 +1359,8 @@ import {
altitudeToWords,
minutesToWords
} from '~~/shared/learn/scenario'
-import type {BlankWidth, Frequency, Lesson, LessonField, ModuleDef, Scenario, TrackDef} from '~~/shared/learn/types'
+import type {DecisionLesson, DecisionScenario} from '~~/shared/learn/decision-types'
+import type {BlankWidth, Frequency, Lesson, LessonField, ModuleDef, ModuleLesson, Scenario, TrackDef} from '~~/shared/learn/types'
import {loadPizzicatoLite} from '~~/shared/utils/pizzicatoLite'
import type {PizzicatoLite} from '~~/shared/utils/pizzicatoLite'
import {createNoiseGenerators, getReadabilityProfile} from '~~/shared/utils/radioEffects'
@@ -1432,12 +1455,20 @@ function tightenedThreshold(length: number, base: number): number {
return Math.max(base, 0.97)
}
+function isClozeLesson(lesson: ModuleLesson | null | undefined): lesson is Lesson {
+ return Boolean(lesson && 'fields' in lesson && 'readback' in lesson)
+}
+
+function isDecisionLesson(lesson: ModuleLesson | null | undefined): lesson is DecisionLesson {
+ return Boolean(lesson && !('fields' in lesson))
+}
+
const tracks = shallowRef
(learnTracks)
const modules = computed(() => tracks.value.flatMap(t => t.modules))
type LessonSearchHit = {
module: ModuleDef
- lesson: Lesson
+ lesson: ModuleLesson
score: number
}
@@ -1632,11 +1663,16 @@ type ManualForm = {
const panel = ref<'hub' | 'module'>('hub')
const current = ref(null)
-const activeLesson = ref(null)
+const activeLesson = ref(null)
const scenario = ref(null)
+const decisionScenario = ref(null)
+const decisionResult = ref<{ score: number; safety: number; correctness: number; efficiency: number } | null>(null)
const moduleStage = ref<'lessons' | 'setup' | 'briefing'>('lessons')
const pendingLessonId = ref(null)
+const activeClozeLesson = computed(() => (isClozeLesson(activeLesson.value) ? activeLesson.value : null))
+const activeDecisionLesson = computed(() => (isDecisionLesson(activeLesson.value) ? activeLesson.value : null))
+
function displayCallsign(value?: string | null, source?: CallsignContext | null): string {
if (!value) return ''
const context = source ?? scenario.value
@@ -2570,7 +2606,7 @@ function restartCurrentMission() {
function startLessonsForCurrent() {
if (!current.value) return
const mod = current.value
- let next: Lesson | undefined
+ let next: ModuleLesson | undefined
if (pendingLessonId.value) {
next = mod.lessons.find(lesson => lesson.id === pendingLessonId.value)
}
@@ -3102,8 +3138,8 @@ onBeforeUnmount(() => {
const fieldMap = computed>(() => {
const map: Record = {}
- if (activeLesson.value) {
- for (const field of activeLesson.value.fields) {
+ if (activeClozeLesson.value) {
+ for (const field of activeClozeLesson.value.fields) {
map[field.key] = field
}
}
@@ -3111,7 +3147,7 @@ const fieldMap = computed>(() => {
})
const firstReadbackFieldKey = computed(() => {
- const lesson = activeLesson.value
+ const lesson = activeClozeLesson.value
if (!lesson) return null
const firstField = lesson.readback.find(segment => segment.type === 'field')
if (!firstField || firstField.type !== 'field') return null
@@ -3145,8 +3181,8 @@ function focusFirstReadbackField() {
const fieldStates = computed>(() => {
const map: Record = {}
- if (!activeLesson.value || !scenario.value) return map
- for (const field of activeLesson.value.fields) {
+ if (!activeClozeLesson.value || !scenario.value) return map
+ for (const field of activeClozeLesson.value.fields) {
const expected = field.expected(scenario.value).trim()
const answer = (userAnswers[field.key] ?? '').trim()
const alternatives = field.alternatives?.(scenario.value) ?? []
@@ -3247,10 +3283,10 @@ function fieldExpectedValue(key: string): string {
}
const targetPhrase = computed(() => {
- if (!activeLesson.value || !scenario.value) return ''
- return displayCallsign(activeLesson.value.phrase(scenario.value), scenario.value)
+ if (!activeClozeLesson.value || !scenario.value) return ''
+ return displayCallsign(activeClozeLesson.value.phrase(scenario.value), scenario.value)
})
-const lessonInfo = computed(() => (activeLesson.value && scenario.value ? activeLesson.value.info(scenario.value) : []))
+const lessonInfo = computed(() => (activeClozeLesson.value && scenario.value ? activeClozeLesson.value.info(scenario.value) : []))
const showScenarioPracticeHint = computed(() => {
if (!current.value || !activeLesson.value) return false
const index = modules.value.findIndex(module => module.id === current.value?.id)
@@ -3327,11 +3363,16 @@ const nextActionLabel = computed(() => {
})
const lessonHasInput = computed(() => {
- if (!activeLesson.value) return false
- return activeLesson.value.fields.some(field => {
- const value = userAnswers[field.key]
- return typeof value === 'string' && value.trim().length > 0
- })
+ if (activeClozeLesson.value) {
+ return activeClozeLesson.value.fields.some(field => {
+ const value = userAnswers[field.key]
+ return typeof value === 'string' && value.trim().length > 0
+ })
+ }
+ if (activeDecisionLesson.value) {
+ return Boolean(decisionResult.value)
+ }
+ return false
})
const canAdvanceLesson = computed(() => Boolean(nextLessonMeta.value || nextMissionMeta.value))
@@ -3340,7 +3381,17 @@ const missionFooterNoop = () => {
}
const missionFooterPrimary = computed(() => {
- if (lessonHasInput.value && !result.value) {
+ if (activeDecisionLesson.value && !decisionResult.value) {
+ return {
+ label: 'Complete decision',
+ icon: 'mdi-traffic-light-outline',
+ disabled: true,
+ action: missionFooterNoop,
+ mode: 'is-check'
+ }
+ }
+
+ if (activeClozeLesson.value && lessonHasInput.value && !result.value) {
return {
label: evaluating.value ? 'Checking…' : 'Check lesson',
icon: 'mdi-check',
@@ -3371,8 +3422,8 @@ const missionFooterPrimary = computed(() => {
})
const lessonAnswerSignature = computed(() => {
- if (!activeLesson.value) return ''
- return activeLesson.value.fields
+ if (!activeClozeLesson.value) return ''
+ return activeClozeLesson.value.fields
.map(field => (userAnswers[field.key] ?? '').trim())
.join('|')
})
@@ -3399,6 +3450,8 @@ watch(activeLesson, lesson => {
} else {
stopAudio()
scenario.value = null
+ decisionScenario.value = null
+ decisionResult.value = null
}
})
@@ -3480,8 +3533,23 @@ function rollScenario(clear = false) {
if (!activeLesson.value) return
pendingAutoSay.value = false
stopAudio()
+ decisionResult.value = null
+
+ if (isDecisionLesson(activeLesson.value)) {
+ decisionScenario.value = activeLesson.value.generate()
+ scenario.value = null
+ activeFrequency.value = null
+ resetAnswers(true)
+ resetAudioReveal()
+ if (clear) {
+ result.value = null
+ }
+ return
+ }
+
const generated = activeLesson.value.generate()
scenario.value = generated
+ decisionScenario.value = null
const defaultType = activeLesson.value.defaultFrequency
activeFrequency.value = generated.frequencies.find(freq => freq.type === (defaultType || 'DEL')) || generated.frequencies[0] || null
resetAnswers(true)
@@ -3565,8 +3633,16 @@ function setActiveFrequency(freq: Frequency) {
}
function resetAnswers(clearResult = false) {
- if (!activeLesson.value) return
- const keys = activeLesson.value.fields.map(field => field.key)
+ if (!activeClozeLesson.value) {
+ Object.keys(userAnswers).forEach(key => {
+ delete userAnswers[key]
+ })
+ if (clearResult) {
+ result.value = null
+ }
+ return
+ }
+ const keys = activeClozeLesson.value.fields.map(field => field.key)
Object.keys(userAnswers).forEach(key => {
if (!keys.includes(key)) {
delete userAnswers[key]
@@ -3585,15 +3661,15 @@ function clearAnswers() {
}
function fillSolution() {
- if (!activeLesson.value || !scenario.value) return
- for (const field of activeLesson.value.fields) {
+ if (!activeClozeLesson.value || !scenario.value) return
+ for (const field of activeClozeLesson.value.fields) {
userAnswers[field.key] = field.expected(scenario.value)
}
}
function computeScore(): ScoreResult | null {
- if (!activeLesson.value) return null
- const details = activeLesson.value.fields
+ if (!activeClozeLesson.value) return null
+ const details = activeClozeLesson.value.fields
.map(field => fieldStates.value[field.key])
.filter(Boolean) as FieldState[]
if (!details.length) return null
@@ -3610,42 +3686,74 @@ function computeScore(): ScoreResult | null {
}
function evaluate() {
- if (!activeLesson.value || !current.value) return
+ if (!activeClozeLesson.value || !current.value || !activeLesson.value) return
evaluating.value = true
try {
const summary = computeScore()
if (!summary) return
result.value = summary
-
- const modId = current.value.id
- const lesId = activeLesson.value.id
- if (!progress.value[modId]) progress.value[modId] = {}
- const previous = progress.value[modId][lesId] || {best: 0, done: false}
- const best = Math.max(previous.best || 0, summary.score)
- const passed = summary.passed || summary.score >= 80
- const wasDone = previous.done
-
- progress.value[modId][lesId] = {best, done: passed}
-
- let gained = 0
- if (passed && !wasDone) gained += 40
- if (summary.score >= 95 && summary.score > (previous.best || 0)) gained += 15
- if (summary.score >= 80 && summary.score > (previous.best || 0)) gained += 10
-
- if (gained) {
- xp.value += gained
- toastNow(`+${gained} XP · ${activeLesson.value.title}`)
- }
+ applyLessonProgress(summary.score, summary.passed || summary.score >= 80, activeLesson.value.title)
} finally {
evaluating.value = false
}
}
+function applyLessonProgress(score: number, passed: boolean, lessonTitle: string) {
+ if (!current.value || !activeLesson.value) return
+ const modId = current.value.id
+ const lesId = activeLesson.value.id
+ if (!progress.value[modId]) progress.value[modId] = {}
+ const previous = progress.value[modId][lesId] || {best: 0, done: false}
+ const best = Math.max(previous.best || 0, score)
+ const completed = passed || previous.done
+ const wasDone = previous.done
+
+ progress.value[modId][lesId] = {best, done: completed}
+
+ let gained = 0
+ if (completed && !wasDone) gained += 40
+ if (score >= 95 && score > (previous.best || 0)) gained += 15
+ if (score >= 80 && score > (previous.best || 0)) gained += 10
+
+ if (gained) {
+ xp.value += gained
+ toastNow(`+${gained} XP · ${lessonTitle}`)
+ }
+}
+
+function handleDecisionComplete(payload: { score: number; safety: number; correctness: number; efficiency: number }) {
+ if (!activeLesson.value || !activeDecisionLesson.value) return
+ decisionResult.value = payload
+ result.value = null
+ applyLessonProgress(payload.score, payload.score >= 80, activeLesson.value.title)
+}
+
+function moduleTrackLocation(id: string): { track: TrackDef; index: number } | null {
+ for (const track of tracks.value) {
+ const index = track.modules.findIndex(module => module.id === id)
+ if (index >= 0) {
+ return { track, index }
+ }
+ }
+ return null
+}
+
function isModuleUnlocked(id: string) {
if (unlockedModules.value.includes(id)) return true
- if (id === 'normalize') return true
- const order = modules.value.findIndex(module => module.id === id)
- const previous = modules.value[order - 1]
+ const location = moduleTrackLocation(id)
+ if (!location) return true
+ const { track, index } = location
+
+ if (track.id === 'abnormal') {
+ if (!moduleCompleted('normalize') || !moduleCompleted('arc')) return false
+ }
+
+ if (track.id === 'atc-perspective') {
+ if (!moduleCompleted('decision-tree')) return false
+ }
+
+ if (index === 0) return true
+ const previous = track.modules[index - 1]
return previous ? pct(previous.id) >= 80 : true
}
@@ -3670,6 +3778,8 @@ function openModule(id: string, options: { autoStart?: boolean } = {}) {
panel.value = module ? 'module' : 'hub'
pendingAutoStart.value = options.autoStart ?? false
activeLesson.value = null
+ decisionScenario.value = null
+ decisionResult.value = null
if (!module) {
activeLesson.value = null
@@ -3727,7 +3837,7 @@ function goToPrimaryObjective() {
panel.value = 'hub'
}
-function selectLesson(lesson: Lesson) {
+function selectLesson(lesson: ModuleLesson) {
activeLesson.value = lesson
}
@@ -6439,6 +6549,26 @@ onMounted(() => {
gap: 18px;
}
+.decision-console {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.decision-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.decision-hints {
+ margin-bottom: 4px;
+}
+
+.decision-score-inline {
+ margin-top: 4px;
+}
+
.cloze {
display: flex;
flex-wrap: wrap;
diff --git a/shared/data/learnDecisionModules.ts b/shared/data/learnDecisionModules.ts
new file mode 100644
index 0000000..e29e141
--- /dev/null
+++ b/shared/data/learnDecisionModules.ts
@@ -0,0 +1,705 @@
+import type { DecisionLesson, DecisionScenario, FlightStrip } from '~~/shared/learn/decision-types'
+import type { ModuleDef } from '~~/shared/learn/types'
+
+function gradientArt(colors: string[]): string {
+ const stops = colors
+ .map((color, idx) => ``)
+ .join('')
+ const svg = ``
+ return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`
+}
+
+function randInt(min: number, max: number): number {
+ return Math.floor(Math.random() * (max - min + 1)) + min
+}
+
+function sample(values: readonly T[]): T {
+ return values[randInt(0, values.length - 1)]
+}
+
+function shuffle(values: T[]): T[] {
+ const copy = [...values]
+ for (let i = copy.length - 1; i > 0; i--) {
+ const j = randInt(0, i)
+ const tmp = copy[i]
+ copy[i] = copy[j]
+ copy[j] = tmp
+ }
+ return copy
+}
+
+const airlines = ['DLH', 'BAW', 'AFR', 'KLM', 'SWR', 'EZY', 'RYR', 'AUA', 'SAS', 'UAE']
+const aircraftTypes = ['A320', 'A321', 'B738', 'B77W', 'A359', 'C172', 'E190', 'A20N']
+
+function makeCallsign(): string {
+ return `${sample(airlines)}${randInt(101, 999)}`
+}
+
+function categoryForType(type: string): 'heavy' | 'medium' | 'light' {
+ if (['B77W', 'A359', 'B744', 'A388'].includes(type)) return 'heavy'
+ if (['C172', 'DA42', 'SR22'].includes(type)) return 'light'
+ return 'medium'
+}
+
+function makeStrip(partial: Partial = {}): FlightStrip {
+ const type = partial.type ?? sample(aircraftTypes)
+ const category = partial.category ?? categoryForType(type)
+ return {
+ callsign: partial.callsign ?? makeCallsign(),
+ type,
+ altitude: partial.altitude ?? `FL${randInt(70, 260)}`,
+ heading: partial.heading ?? `${randInt(0, 35) * 10}`.padStart(3, '0'),
+ position: partial.position ?? sample(['12nm final', 'downwind', 'base leg', 'holding', 'taxiway A', 'departure hold']),
+ intention: partial.intention ?? sample(['arrival', 'departure', 'crossing', 'pushback']),
+ category,
+ status: partial.status ?? 'normal'
+ }
+}
+
+function cloneScenario(scenario: DecisionScenario): DecisionScenario {
+ return JSON.parse(JSON.stringify(scenario)) as DecisionScenario
+}
+
+function scenario(briefing: string, strips: FlightStrip[], steps: DecisionScenario['steps']): DecisionScenario {
+ return cloneScenario({ briefing, strips, steps })
+}
+
+const controllerWorkflowLessons: DecisionLesson[] = [
+ {
+ id: 'controller-departure-sequencing',
+ title: 'Departure Sequencing',
+ desc: 'Sequence departures while respecting wake turbulence and SID conflicts.',
+ keywords: ['Departure', 'Wake', 'Sequencing'],
+ hints: [
+ 'Launch lighter traffic first when routes diverge to avoid wake delays.',
+ 'Keep same-SID departures separated unless you can guarantee divergence.'
+ ],
+ generate: () => {
+ const heavy = makeStrip({ type: 'B77W', category: 'heavy', intention: 'departure', position: 'holding point A1' })
+ const medium = makeStrip({ type: 'A320', category: 'medium', intention: 'departure', position: 'holding point A1' })
+ const light = makeStrip({ type: 'C172', category: 'light', intention: 'departure', position: 'holding point A1' })
+ const order = [light.callsign, medium.callsign, heavy.callsign]
+ return scenario(
+ 'You are Tower. Three departures are ready at the same holding point. Sequence them for safe, efficient flow.',
+ [heavy, medium, light],
+ [
+ {
+ prompt: 'Set the departure order (first to last).',
+ type: 'sequencing',
+ items: shuffle(order),
+ correct: order,
+ explanation: 'Light first, then medium, then heavy keeps wake impact low while maintaining flow.'
+ }
+ ]
+ )
+ }
+ },
+ {
+ id: 'controller-arrival-spacing',
+ title: 'Arrival Spacing',
+ desc: 'Build a stable arrival sequence and assign base headings.',
+ keywords: ['Arrival', 'Spacing', 'Approach'],
+ hints: [
+ 'Order by distance and closing speed, not just altitude.',
+ 'Use heading assignments to create stable spacing before final.'
+ ],
+ generate: () => {
+ const inboundA = makeStrip({ type: 'A320', intention: 'arrival', position: '18nm north', heading: '190' })
+ const inboundB = makeStrip({ type: 'B738', intention: 'arrival', position: '14nm east', heading: '260' })
+ const inboundC = makeStrip({ type: 'E190', intention: 'arrival', position: '22nm west', heading: '090' })
+ const inboundD = makeStrip({ type: 'A359', category: 'heavy', intention: 'arrival', position: '10nm south', heading: '360' })
+ const landingOrder = [inboundD.callsign, inboundB.callsign, inboundA.callsign, inboundC.callsign]
+ return scenario(
+ 'You are Approach with four arrivals converging toward runway 26. Build spacing and assign downwind vectors.',
+ [inboundA, inboundB, inboundC, inboundD],
+ [
+ {
+ prompt: 'Choose the safest landing sequence.',
+ type: 'sequencing',
+ items: shuffle(landingOrder),
+ correct: landingOrder,
+ explanation: 'Closest aircraft lands first, then medium traffic, while the most distant is delayed on vector.'
+ },
+ {
+ prompt: 'Assign initial vectors for each callsign (same order as list).',
+ type: 'assignment',
+ items: landingOrder,
+ options: ['180', '210', '240', '270'],
+ correct: ['180', '210', '240', '270'],
+ explanation: 'Staggered base headings preserve spacing into final approach.'
+ }
+ ]
+ )
+ }
+ },
+ {
+ id: 'controller-wake-separation',
+ title: 'Wake Turbulence Separation',
+ desc: 'Pick the correct wake turbulence timing constraints.',
+ keywords: ['Wake', 'Separation', 'Timing'],
+ hints: [
+ 'Heavy to light combinations need the largest time spacing.',
+ 'When in doubt, protect the lighter aircraft.'
+ ],
+ generate: () => scenario(
+ 'Heavy traffic is leading on the ILS. You must apply standard wake separation behind it.',
+ [
+ makeStrip({ callsign: makeCallsign(), type: 'B77W', category: 'heavy', intention: 'arrival', position: '5nm final', heading: '262' }),
+ makeStrip({ callsign: makeCallsign(), type: 'A320', category: 'medium', intention: 'arrival', position: '8nm final', heading: '262' }),
+ makeStrip({ callsign: makeCallsign(), type: 'C172', category: 'light', intention: 'arrival', position: '11nm final', heading: '262' })
+ ],
+ [
+ {
+ prompt: 'Which wake turbulence spacing set is correct?',
+ type: 'choice',
+ options: [
+ 'Heavy->Medium 2 min, Heavy->Light 3 min, Medium->Light 3 min',
+ 'Heavy->Medium 1 min, Heavy->Light 2 min, Medium->Light 2 min',
+ 'Heavy->Medium 3 min, Heavy->Light 4 min, Medium->Light 2 min'
+ ],
+ correct: 'Heavy->Medium 2 min, Heavy->Light 3 min, Medium->Light 3 min',
+ explanation: 'This option protects medium and light aircraft from heavy wake in line with standard practice.'
+ }
+ ]
+ )
+ },
+ {
+ id: 'controller-runway-conflict',
+ title: 'Runway Conflict',
+ desc: 'Resolve competing runway requests without compromising safety.',
+ keywords: ['Runway', 'Conflict', 'Tower'],
+ hints: [
+ 'Arrivals inside 4nm final strongly limit runway occupancy options.',
+ 'Crossings must wait when runway availability is uncertain.'
+ ],
+ generate: () => scenario(
+ 'You have one aircraft on 3nm final, one departure lined up, and one aircraft requesting runway crossing.',
+ [
+ makeStrip({ intention: 'arrival', position: '3nm final', heading: '265' }),
+ makeStrip({ intention: 'departure', position: 'line up and wait', heading: '265' }),
+ makeStrip({ intention: 'crossing', position: 'holding short runway', heading: '---', altitude: 'GND' })
+ ],
+ [
+ {
+ prompt: 'What is the safest immediate instruction?',
+ type: 'choice',
+ options: [
+ 'Hold the crosser, clear the arrival to land, keep departure holding',
+ 'Clear departure immediately and cross runway traffic behind it',
+ 'Send arrival around and clear crossing traffic now'
+ ],
+ correct: 'Hold the crosser, clear the arrival to land, keep departure holding',
+ explanation: 'Protecting the arriving aircraft and keeping crossings stopped removes runway conflict risk.'
+ }
+ ]
+ )
+ },
+ {
+ id: 'controller-frequency-priority',
+ title: 'Frequency Priority',
+ desc: 'Prioritize transmissions during heavy frequency load.',
+ keywords: ['Priority', 'Workload', 'Emergency'],
+ hints: [
+ 'Emergency traffic is always first.',
+ 'Aircraft in airborne critical phases outrank ground requests.'
+ ],
+ generate: () => {
+ const emergency = makeStrip({ status: 'emergency', intention: 'arrival', position: '8nm final', type: 'A320' })
+ const arrivals = [
+ makeStrip({ intention: 'arrival', position: '12nm final', type: 'B738' }),
+ makeStrip({ intention: 'arrival', position: 'downwind', type: 'E190' })
+ ]
+ const departures = [
+ makeStrip({ intention: 'departure', position: 'holding point', type: 'A321' }),
+ makeStrip({ intention: 'departure', position: 'taxiway A', type: 'B738' })
+ ]
+ const ground = [
+ makeStrip({ intention: 'pushback', position: 'stand C12', altitude: 'GND', heading: '---' }),
+ makeStrip({ intention: 'pushback', position: 'stand D8', altitude: 'GND', heading: '---' }),
+ makeStrip({ intention: 'crossing', position: 'taxiway K', altitude: 'GND', heading: '---' })
+ ]
+ const strips = [emergency, ...arrivals, ...departures, ...ground]
+ const order = [
+ emergency.callsign,
+ arrivals[0].callsign,
+ arrivals[1].callsign,
+ departures[0].callsign,
+ departures[1].callsign,
+ ground[0].callsign,
+ ground[1].callsign,
+ ground[2].callsign
+ ]
+ return scenario(
+ 'Eight aircraft are calling at once. Rank who should receive ATC attention first.',
+ strips,
+ [
+ {
+ prompt: 'Rank callsigns by transmission priority (highest to lowest).',
+ type: 'priority',
+ items: shuffle(order),
+ correct: order,
+ explanation: 'Emergency first, then airborne traffic in approach phase, then departures, then lower urgency ground traffic.'
+ }
+ ]
+ )
+ }
+ },
+ {
+ id: 'controller-weather-deviation',
+ title: 'Weather Deviation Decision',
+ desc: 'Balance weather avoidance requests against conflicting traffic.',
+ keywords: ['Weather', 'Deviation', 'Conflict'],
+ hints: [
+ 'Approve deviations only when separation remains protected.',
+ 'If conflict exists, offer an alternative heading with clear reason.'
+ ],
+ generate: () => scenario(
+ 'An arrival requests 20nm left deviation for weather, but opposite-direction traffic is on that route.',
+ [
+ makeStrip({ intention: 'arrival', position: '25nm final', heading: '270', type: 'A321' }),
+ makeStrip({ intention: 'arrival', position: 'opposite direction 18nm', heading: '090', type: 'B738' }),
+ makeStrip({ intention: 'arrival', position: 'downwind', heading: '180', type: 'E190' })
+ ],
+ [
+ {
+ prompt: 'Pick the best response.',
+ type: 'choice',
+ options: [
+ 'Deny full left deviation, issue a smaller right deviation with vectors',
+ 'Approve full 20nm left immediately without restrictions',
+ 'Ignore the request and maintain present heading'
+ ],
+ correct: 'Deny full left deviation, issue a smaller right deviation with vectors',
+ explanation: 'You acknowledge weather needs while preserving separation by offering a safe alternative.'
+ }
+ ]
+ )
+ }
+]
+
+const thinkLikeAtcLessons: DecisionLesson[] = [
+ {
+ id: 'atc-departure-puzzle',
+ title: 'Departure Puzzle',
+ desc: 'Decide the most efficient departure order from wake and route constraints.',
+ keywords: ['Departure', 'Puzzle', 'Wake'],
+ hints: [
+ 'Diverging routes can reduce wake-impact delays.',
+ 'Use the runway timeline, not just aircraft size.'
+ ],
+ generate: () => {
+ const heavyNorth = makeStrip({ type: 'B77W', category: 'heavy', intention: 'departure', position: 'holding A1', heading: '360' })
+ const lightSouth = makeStrip({ type: 'C172', category: 'light', intention: 'departure', position: 'holding A1', heading: '180' })
+ const order = [lightSouth.callsign, heavyNorth.callsign]
+ return scenario(
+ 'Two departures wait on the same runway. One heavy departs north, one light departs south.',
+ [heavyNorth, lightSouth],
+ [
+ {
+ prompt: 'Set departure order.',
+ type: 'sequencing',
+ items: shuffle(order),
+ correct: order,
+ explanation: 'Launching the light aircraft first can avoid a long wake delay because routes diverge quickly.'
+ },
+ {
+ prompt: 'Pick the best controller reasoning.',
+ type: 'choice',
+ options: [
+ 'Light first due diverging tracks, then heavy',
+ 'Heavy always first regardless of route',
+ 'Delay both until a full stop runway check'
+ ],
+ correct: 'Light first due diverging tracks, then heavy',
+ explanation: 'You still maintain safety while improving flow using route geometry.'
+ }
+ ]
+ )
+ }
+ },
+ {
+ id: 'atc-conflict-detection',
+ title: 'Conflict Detection',
+ desc: 'Resolve a converging conflict with an immediate vector decision.',
+ keywords: ['Conflict', 'Vectoring', 'Separation'],
+ hints: [
+ 'Intervene early before closure rates spike.',
+ 'Turn the aircraft with lower downstream impact when possible.'
+ ],
+ generate: () => {
+ const a = makeStrip({ type: 'A320', intention: 'arrival', position: '20nm west', heading: '090', altitude: 'FL110' })
+ const b = makeStrip({ type: 'B738', intention: 'arrival', position: '18nm south', heading: '360', altitude: 'FL110' })
+ return scenario(
+ 'Two arrivals are converging at the same altitude with high closure rate.',
+ [a, b],
+ [
+ {
+ prompt: 'Which aircraft gets the avoidance turn?',
+ type: 'choice',
+ options: [a.callsign, b.callsign],
+ correct: b.callsign,
+ explanation: 'Turning the southbound aircraft creates cleaner spacing with less impact on final sequencing.'
+ },
+ {
+ prompt: `Assign a safe heading to ${b.callsign}.`,
+ type: 'assignment',
+ items: [b.callsign],
+ options: ['030', '060', '120'],
+ correct: ['060'],
+ explanation: 'A moderate heading change resolves the crossing conflict without overcorrecting.'
+ }
+ ]
+ )
+ }
+ },
+ {
+ id: 'atc-go-around-call',
+ title: 'Go-Around Decision',
+ desc: 'Decide if a go-around is required and issue a stabilizing instruction.',
+ keywords: ['Go-Around', 'Tower', 'Final'],
+ hints: [
+ 'When runway occupancy is uncertain, choose the safer option early.',
+ 'A go-around instruction must include heading/altitude if needed.'
+ ],
+ generate: () => {
+ const finalAircraft = makeStrip({ type: 'A320', intention: 'arrival', position: '2nm final', heading: '265', altitude: '2500' })
+ const runwayTraffic = makeStrip({ type: 'E190', intention: 'arrival', position: 'vacating runway', heading: '---', altitude: 'GND' })
+ return scenario(
+ 'Final traffic is 2nm out and the runway is not confirmed clear yet.',
+ [finalAircraft, runwayTraffic],
+ [
+ {
+ prompt: 'Choose the immediate action.',
+ type: 'choice',
+ options: [
+ 'Issue go-around now',
+ 'Wait 10 more seconds and continue approach',
+ 'Clear to land and monitor'
+ ],
+ correct: 'Issue go-around now',
+ explanation: 'At 2nm final with uncertain occupancy, a proactive go-around is safest.'
+ },
+ {
+ prompt: `Assign follow-up vector for ${finalAircraft.callsign}.`,
+ type: 'assignment',
+ items: [finalAircraft.callsign],
+ options: ['Runway heading climb 3000', 'Left heading 180 climb 4000', 'Right heading 090 maintain 2000'],
+ correct: ['Runway heading climb 3000'],
+ explanation: 'Runway heading and climb is a stable, predictable miss approach response.'
+ }
+ ]
+ )
+ }
+ },
+ {
+ id: 'atc-emergency-reshuffle',
+ title: 'Emergency Reshuffling',
+ desc: 'Rebuild an approach sequence when one flight declares MAYDAY.',
+ keywords: ['Emergency', 'Priority', 'Approach'],
+ hints: [
+ 'Emergency traffic jumps to the top priority immediately.',
+ 'Re-sequence everyone else with minimal disruption.'
+ ],
+ generate: () => {
+ const normal1 = makeStrip({ intention: 'arrival', position: '9nm final', heading: '262' })
+ const normal2 = makeStrip({ intention: 'arrival', position: '13nm final', heading: '262' })
+ const emergency = makeStrip({ intention: 'arrival', position: '15nm final', heading: '262', status: 'emergency', type: 'A321' })
+ const normal3 = makeStrip({ intention: 'arrival', position: '18nm final', heading: '262' })
+ const newOrder = [emergency.callsign, normal1.callsign, normal2.callsign, normal3.callsign]
+ return scenario(
+ 'Four arrivals are sequenced. The third aircraft declares MAYDAY with smoke in cockpit.',
+ [normal1, normal2, emergency, normal3],
+ [
+ {
+ prompt: 'Re-rank arrival priority.',
+ type: 'priority',
+ items: shuffle(newOrder),
+ correct: newOrder,
+ explanation: 'Emergency traffic gets immediate priority; the rest keep relative order where possible.'
+ },
+ {
+ prompt: 'Pick the best instruction for non-emergency traffic.',
+ type: 'choice',
+ options: [
+ 'Vector others away and extend downwind for spacing',
+ 'Keep all aircraft on present heading',
+ 'Clear all to land in current order'
+ ],
+ correct: 'Vector others away and extend downwind for spacing',
+ explanation: 'Protect the emergency path while maintaining separation with managed delays.'
+ }
+ ]
+ )
+ }
+ },
+ {
+ id: 'atc-taxi-conflict',
+ title: 'Taxi Conflict Management',
+ desc: 'Resolve a ground intersection conflict with hold and reroute decisions.',
+ keywords: ['Ground', 'Taxi', 'Conflict'],
+ hints: [
+ 'Prevent nose-to-nose conflicts before they happen.',
+ 'Use one hold and one reroute instead of stopping everyone.'
+ ],
+ generate: () => {
+ const a = makeStrip({ intention: 'taxi', position: 'taxiway B approaching K', altitude: 'GND', heading: '---' })
+ const b = makeStrip({ intention: 'taxi', position: 'taxiway K approaching B', altitude: 'GND', heading: '---' })
+ const c = makeStrip({ intention: 'taxi', position: 'taxiway C inbound to K', altitude: 'GND', heading: '---' })
+ return scenario(
+ 'Three taxiing aircraft converge on one intersection with no room for simultaneous movement.',
+ [a, b, c],
+ [
+ {
+ prompt: 'Who should hold position first?',
+ type: 'choice',
+ options: [a.callsign, b.callsign, c.callsign],
+ correct: c.callsign,
+ explanation: 'Holding the latest-arriving aircraft avoids deadlock and keeps two flows moving.'
+ },
+ {
+ prompt: 'Rank next movement order once hold is issued.',
+ type: 'sequencing',
+ items: shuffle([a.callsign, b.callsign, c.callsign]),
+ correct: [a.callsign, b.callsign, c.callsign],
+ explanation: 'Clear one path first, then cross-conflict traffic, then release the held aircraft.'
+ }
+ ]
+ )
+ }
+ },
+ {
+ id: 'atc-handoff-timing',
+ title: 'Handoff Timing',
+ desc: 'Choose the best sector handoff timing under downstream congestion.',
+ keywords: ['Handoff', 'Coordination', 'Sector'],
+ hints: [
+ 'Standard handoff points are baseline; coordination can adjust timing.',
+ 'Do not transfer workload blindly into an overloaded sector.'
+ ],
+ generate: () => scenario(
+ 'A climbing departure nears the sector boundary while the next sector reports high workload.',
+ [
+ makeStrip({ intention: 'departure', position: '2nm from boundary', heading: '325', altitude: 'FL145' }),
+ makeStrip({ intention: 'arrival', position: 'holding south stack', heading: '090', altitude: 'FL170' })
+ ],
+ [
+ {
+ prompt: 'Best handoff strategy?',
+ type: 'choice',
+ options: [
+ 'Coordinate and handoff at boundary with update',
+ 'Handoff immediately to offload your frequency',
+ 'Keep aircraft indefinitely in your sector'
+ ],
+ correct: 'Coordinate and handoff at boundary with update',
+ explanation: 'Boundary handoff with coordination balances standards and downstream workload.'
+ }
+ ]
+ )
+ }
+]
+
+const reverseSimLessons: DecisionLesson[] = [
+ {
+ id: 'reverse-busy-ground',
+ title: 'Reverse Sim: Busy Ground',
+ desc: 'Run a full ground sequence with pushback, taxi and inbound conflicts.',
+ keywords: ['Reverse Sim', 'Ground', 'Multi-Step'],
+ hints: [
+ 'Keep the movement map in your head and resolve one bottleneck at a time.',
+ 'Issue simple instructions that keep options open for the next step.'
+ ],
+ generate: () => {
+ const pushA = makeStrip({ intention: 'pushback', position: 'stand A12', altitude: 'GND', heading: '---' })
+ const pushB = makeStrip({ intention: 'pushback', position: 'stand B18', altitude: 'GND', heading: '---' })
+ const taxiOut = makeStrip({ intention: 'taxi', position: 'taxiway N toward runway 26', altitude: 'GND', heading: '---' })
+ const landed = makeStrip({ intention: 'arrival', position: 'vacated runway via K', altitude: 'GND', heading: '---' })
+ return scenario(
+ 'You are Ground with two pushback requests, one taxi-out, and one arrival taxi-in request.',
+ [pushA, pushB, taxiOut, landed],
+ [
+ {
+ prompt: 'Choose pushback order.',
+ type: 'sequencing',
+ items: shuffle([pushA.callsign, pushB.callsign]),
+ correct: [pushA.callsign, pushB.callsign],
+ explanation: 'Releasing A12 first avoids blocking the taxi lane needed by the inbound arrival.'
+ },
+ {
+ prompt: 'Who gets taxi priority through the choke point?',
+ type: 'choice',
+ options: [landed.callsign, taxiOut.callsign],
+ correct: landed.callsign,
+ explanation: 'Inbound traffic already on movement area should clear faster to reduce gridlock.'
+ },
+ {
+ prompt: 'Set final movement order for all four flights.',
+ type: 'priority',
+ items: shuffle([landed.callsign, taxiOut.callsign, pushA.callsign, pushB.callsign]),
+ correct: [landed.callsign, taxiOut.callsign, pushA.callsign, pushB.callsign],
+ explanation: 'Clear the runway-adjacent movement first, then feed departures in controlled order.'
+ }
+ ]
+ )
+ }
+ },
+ {
+ id: 'reverse-tower-rush-hour',
+ title: 'Reverse Sim: Tower Rush Hour',
+ desc: 'Handle mixed arrival/departure pressure with potential go-around.',
+ keywords: ['Reverse Sim', 'Tower', 'Rush Hour'],
+ hints: [
+ 'Protect the runway timeline first; everything else follows.',
+ 'Keep missed-approach contingencies ready before issuing clearances.'
+ ],
+ generate: () => {
+ const dep1 = makeStrip({ intention: 'departure', position: 'line-up runway 26', altitude: 'GND', heading: '262' })
+ const dep2 = makeStrip({ intention: 'departure', position: 'holding A2', altitude: 'GND', heading: '262' })
+ const dep3 = makeStrip({ intention: 'departure', position: 'holding A3', altitude: 'GND', heading: '262' })
+ const arr1 = makeStrip({ intention: 'arrival', position: '4nm final', heading: '262', altitude: '1900' })
+ const arr2 = makeStrip({ intention: 'arrival', position: '8nm final', heading: '262', altitude: '2500' })
+ return scenario(
+ 'Tower is saturated: three departures waiting and two arrivals inbound.',
+ [dep1, dep2, dep3, arr1, arr2],
+ [
+ {
+ prompt: 'Who gets immediate runway use?',
+ type: 'choice',
+ options: [arr1.callsign, dep1.callsign],
+ correct: arr1.callsign,
+ explanation: 'An aircraft at 4nm final is in the critical phase and must be protected first.'
+ },
+ {
+ prompt: 'Sequence departures after first arrival lands.',
+ type: 'sequencing',
+ items: shuffle([dep1.callsign, dep2.callsign, dep3.callsign]),
+ correct: [dep1.callsign, dep2.callsign, dep3.callsign],
+ explanation: 'Use the runway-ready aircraft first, then release queued traffic progressively.'
+ },
+ {
+ prompt: `If runway not vacated for ${arr2.callsign}, choose action.`,
+ type: 'choice',
+ options: ['Issue go-around with runway heading climb', 'Continue approach and hope runway clears', 'Stop all departures only'],
+ correct: 'Issue go-around with runway heading climb',
+ explanation: 'Uncertain runway occupancy near short final requires immediate go-around.'
+ }
+ ]
+ )
+ }
+ },
+ {
+ id: 'reverse-approach-sequencing',
+ title: 'Reverse Sim: Approach Sequencing',
+ desc: 'Vector five arrivals from different quadrants into a stable stream.',
+ keywords: ['Reverse Sim', 'Approach', 'Sequencing'],
+ hints: [
+ 'Convert geometry into order: distance, speed, wake and turn potential.',
+ 'Use heading buckets to build predictable spacing.'
+ ],
+ generate: () => {
+ const a = makeStrip({ intention: 'arrival', position: 'north 26nm', heading: '180', altitude: 'FL120', type: 'A320' })
+ const b = makeStrip({ intention: 'arrival', position: 'east 21nm', heading: '270', altitude: 'FL130', type: 'B77W', category: 'heavy' })
+ const c = makeStrip({ intention: 'arrival', position: 'south 18nm', heading: '360', altitude: 'FL110', type: 'B738' })
+ const d = makeStrip({ intention: 'arrival', position: 'west 24nm', heading: '090', altitude: 'FL140', type: 'E190' })
+ const e = makeStrip({ intention: 'arrival', position: 'northwest 16nm', heading: '140', altitude: 'FL100', type: 'A20N' })
+ const landingOrder = [e.callsign, c.callsign, a.callsign, d.callsign, b.callsign]
+ return scenario(
+ 'Five arrivals are inbound from all directions. Build a safe, efficient stream to final.',
+ [a, b, c, d, e],
+ [
+ {
+ prompt: 'Set landing order.',
+ type: 'sequencing',
+ items: shuffle(landingOrder),
+ correct: landingOrder,
+ explanation: 'Prioritize nearest/stable aircraft first while delaying heavy traffic for spacing.'
+ },
+ {
+ prompt: 'Assign downwind headings in that order.',
+ type: 'assignment',
+ items: landingOrder,
+ options: ['180', '200', '220', '240', '260'],
+ correct: ['180', '200', '220', '240', '260'],
+ explanation: 'Progressive headings create a stable arrival fan before base turns.'
+ }
+ ]
+ )
+ }
+ },
+ {
+ id: 'reverse-emergency-inbound',
+ title: 'Reverse Sim: Emergency Inbound',
+ desc: 'Rebuild normal flow when an inbound MAYDAY aircraft appears.',
+ keywords: ['Reverse Sim', 'Emergency', 'Priority'],
+ hints: [
+ 'State intent clearly: who is number one, and what happens to everyone else.',
+ 'Avoid over-managing; simple vectors and holds are safest under pressure.'
+ ],
+ generate: () => {
+ const mayday = makeStrip({ status: 'emergency', intention: 'arrival', position: '14nm final', heading: '260', type: 'A320' })
+ const normal1 = makeStrip({ intention: 'arrival', position: '9nm final', heading: '260', type: 'B738' })
+ const normal2 = makeStrip({ intention: 'arrival', position: '17nm final', heading: '260', type: 'E190' })
+ const dep = makeStrip({ intention: 'departure', position: 'line-up runway', heading: '260', altitude: 'GND' })
+ return scenario(
+ 'Normal approach flow is interrupted by a MAYDAY inbound with smoke indication.',
+ [mayday, normal1, normal2, dep],
+ [
+ {
+ prompt: 'Rank immediate priority.',
+ type: 'priority',
+ items: shuffle([mayday.callsign, normal1.callsign, normal2.callsign, dep.callsign]),
+ correct: [mayday.callsign, normal1.callsign, normal2.callsign, dep.callsign],
+ explanation: 'Emergency inbound first; keep departures low priority until sequence stabilizes.'
+ },
+ {
+ prompt: 'Best command for non-emergency arrivals?',
+ type: 'choice',
+ options: [
+ 'Extend downwind and expect delay vectors',
+ 'Continue both arrivals unchanged',
+ 'Clear both to land after emergency'
+ ],
+ correct: 'Extend downwind and expect delay vectors',
+ explanation: 'Controlled delay vectors preserve spacing and keep the emergency path clean.'
+ },
+ {
+ prompt: 'Choose departure handling.',
+ type: 'choice',
+ options: ['Hold departure in position', 'Immediate takeoff before emergency', 'Taxi departure onto runway and wait'],
+ correct: 'Hold departure in position',
+ explanation: 'Stop runway complexity while emergency handling is underway.'
+ }
+ ]
+ )
+ }
+ }
+]
+
+export const atcPerspectiveModules: ModuleDef[] = [
+ {
+ id: 'controller-workflow',
+ title: 'Controller Workflow',
+ subtitle: 'Sequence, separate and prioritize like tower/approach',
+ art: gradientArt(['#004d40', '#00695c', '#00796b']),
+ lessons: controllerWorkflowLessons,
+ meta: { exerciseType: 'decision' }
+ },
+ {
+ id: 'think-like-atc',
+ title: 'Think Like ATC',
+ subtitle: 'Conflict solving and tactical controller decisions',
+ art: gradientArt(['#1b5e20', '#2e7d32', '#388e3c']),
+ lessons: thinkLikeAtcLessons,
+ meta: { exerciseType: 'decision' }
+ },
+ {
+ id: 'reverse-sim',
+ title: 'Reverse Sim',
+ subtitle: 'Multi-step ATC scenarios under pressure',
+ art: gradientArt(['#0d47a1', '#1565c0', '#1976d2']),
+ lessons: reverseSimLessons,
+ meta: { exerciseType: 'decision' }
+ }
+]
+
+export { controllerWorkflowLessons, thinkLikeAtcLessons, reverseSimLessons }
diff --git a/shared/data/learnModules.ts b/shared/data/learnModules.ts
index 90c9d3e..86530f6 100644
--- a/shared/data/learnModules.ts
+++ b/shared/data/learnModules.ts
@@ -1,4 +1,5 @@
-import { createBaseScenario, createScenarioSeries, digitsToWords, formatTemp, lettersToNato } from '~~/shared/learn/scenario'
+import { createBaseScenario, createScenarioSeries, digitsToWords, formatTemp, lettersToNato, minutesToWords } from '~~/shared/learn/scenario'
+import { atcPerspectiveModules } from '~~/shared/data/learnDecisionModules'
import type { ModuleDef, Scenario, TrackDef } from '~~/shared/learn/types'
function gradientArt(colors: string[]): string {
@@ -3257,513 +3258,6 @@ const fullFlightLessons = [
}
]
-/* ────────────────────────────────────────────────────────────
- * ABNORMAL COMMUNICATIONS
- * ──────────────────────────────────────────────────────────── */
-
-const abnormalCommsLessons = [
- {
- id: 'conditional-crossing',
- title: 'Conditional Runway Crossing',
- desc: 'Cross a runway behind specific traffic',
- keywords: ['Conditional', 'Runway', 'Crossing'],
- hints: [
- 'Conditional clearances always start AND end with the condition — "behind" appears twice.',
- 'Identify the traffic type before the runway and intersection.',
- 'Never cross until the condition is met — the departing traffic must be clear.'
- ],
- fields: [
- {
- key: 'traffic-type',
- label: 'Traffic',
- expected: () => 'departing 737',
- alternatives: () => ['departing 737', 'Departing 737', 'departing Boeing 737'],
- placeholder: 'e.g. departing 737',
- width: 'md' as const
- },
- {
- key: 'runway',
- label: 'Runway',
- expected: (scenario: Scenario) => scenario.runway,
- alternatives: (scenario: Scenario) => [scenario.runway, scenario.runway.replace(/^0/, '')],
- placeholder: 'e.g. 25C',
- width: 'sm' as const
- },
- {
- key: 'intersection',
- label: 'Intersection',
- expected: (scenario: Scenario) => scenario.taxiRoute.split(' ')[0],
- placeholder: 'e.g. A5',
- width: 'sm' as const
- }
- ],
- readback: [
- { type: 'text' as const, text: 'Behind ' },
- { type: 'field' as const, key: 'traffic-type', width: 'md' as const },
- { type: 'text' as const, text: ', cross runway ' },
- { type: 'field' as const, key: 'runway', width: 'sm' as const },
- { type: 'text' as const, text: ' at ' },
- { type: 'field' as const, key: 'intersection', width: 'sm' as const },
- { type: 'text' as const, text: (scenario: Scenario) => `, behind, ${scenario.radioCall}` }
- ],
- defaultFrequency: 'GND' as const,
- phrase: (scenario: Scenario) =>
- `${scenario.radioCall}, behind the departing 737, cross runway ${scenario.runway} at ${scenario.taxiRoute.split(' ')[0]}, behind.`,
- info: (scenario: Scenario) => [
- `Traffic: departing 737`,
- `Runway: ${scenario.runway}`,
- `Intersection: ${scenario.taxiRoute.split(' ')[0]}`,
- 'Conditional clearances must include the condition at the start AND end of the readback.'
- ],
- generate: createBaseScenario
- },
- {
- id: 'complex-holding',
- title: 'Complex Holding Pattern',
- desc: 'Copy a full holding clearance with all parameters',
- keywords: ['Holding', 'Pattern', 'EFC'],
- hints: [
- 'Holding clearances have six parts: direction, fix, inbound course, turn direction, leg time, and EFC.',
- 'Right turns are standard — only non-standard (left) turns are explicitly stated.',
- 'EFC is "expect further clearance" — copy the Zulu time carefully.'
- ],
- fields: [
- {
- key: 'direction',
- label: 'Direction',
- expected: () => 'east',
- alternatives: () => ['east', 'west', 'north', 'south', 'northeast', 'northwest', 'southeast', 'southwest'],
- threshold: 0.8,
- placeholder: 'e.g. east',
- width: 'sm' as const
- },
- {
- key: 'fix',
- label: 'Fix',
- expected: (scenario: Scenario) => scenario.holdingFix,
- placeholder: 'e.g. TOBAK',
- width: 'sm' as const
- },
- {
- key: 'inbound-course',
- label: 'Inbound course',
- expected: (scenario: Scenario) => scenario.holdingInbound,
- placeholder: 'e.g. 270',
- width: 'sm' as const,
- inputmode: 'numeric' as const
- },
- {
- key: 'turn-direction',
- label: 'Turn direction',
- expected: (scenario: Scenario) => scenario.holdingTurn,
- alternatives: (scenario: Scenario) => [scenario.holdingTurn, scenario.holdingTurn === 'right' ? 'right' : 'left'],
- placeholder: 'e.g. right',
- width: 'sm' as const
- },
- {
- key: 'leg-time',
- label: 'Leg time',
- expected: (scenario: Scenario) => scenario.holdingLegTime,
- placeholder: 'e.g. 1.5',
- width: 'sm' as const
- },
- {
- key: 'efc-time',
- label: 'EFC time',
- expected: (scenario: Scenario) => scenario.holdingEfc,
- placeholder: 'e.g. 1435',
- width: 'sm' as const,
- inputmode: 'numeric' as const
- }
- ],
- readback: [
- { type: 'text' as const, text: 'Hold ' },
- { type: 'field' as const, key: 'direction', width: 'sm' as const },
- { type: 'text' as const, text: ' of ' },
- { type: 'field' as const, key: 'fix', width: 'sm' as const },
- { type: 'text' as const, text: ', inbound course ' },
- { type: 'field' as const, key: 'inbound-course', width: 'sm' as const },
- { type: 'text' as const, text: ', ' },
- { type: 'field' as const, key: 'turn-direction', width: 'sm' as const },
- { type: 'text' as const, text: ' turns, ' },
- { type: 'field' as const, key: 'leg-time', width: 'sm' as const },
- { type: 'text' as const, text: ' minute legs, expect further clearance ' },
- { type: 'field' as const, key: 'efc-time', width: 'sm' as const },
- { type: 'text' as const, text: (scenario: Scenario) => `, ${scenario.radioCall}` }
- ],
- defaultFrequency: 'CTR' as const,
- phrase: (scenario: Scenario) =>
- `${scenario.radioCall}, hold east of ${scenario.holdingFix}, inbound course ${scenario.holdingInbound}, ${scenario.holdingTurn} turns, ${scenario.holdingLegTime} minute legs, expect further clearance ${scenario.holdingEfc}.`,
- info: (scenario: Scenario) => [
- `Fix: ${scenario.holdingFix}`,
- `Inbound course: ${scenario.holdingInbound}`,
- `Turn: ${scenario.holdingTurn}, Leg: ${scenario.holdingLegTime} min`,
- `EFC: ${scenario.holdingEfc}Z`
- ],
- generate: createBaseScenario
- },
- {
- id: 'amended-departure',
- title: 'Amended Departure Clearance',
- desc: 'Copy a full re-clearance with SID, altitude, frequency and squawk',
- keywords: ['Amended', 'Clearance', 'Departure'],
- hints: [
- 'An amended clearance replaces the original — copy every element.',
- 'Note "climb via SID except maintain" — the initial altitude caps the SID.',
- 'Departure frequency and squawk may differ from the original clearance.'
- ],
- fields: [
- {
- key: 'destination',
- label: 'Destination',
- expected: (scenario: Scenario) => scenario.destination.name,
- placeholder: 'e.g. Munich',
- width: 'md' as const
- },
- {
- key: 'sid',
- label: 'SID',
- expected: (scenario: Scenario) => scenario.sid,
- placeholder: 'e.g. TOBAK 5Q',
- width: 'md' as const
- },
- {
- key: 'altitude',
- label: 'Initial altitude',
- expected: (scenario: Scenario) => scenario.altitudes.initial.toString(),
- placeholder: 'e.g. 5000',
- width: 'sm' as const,
- inputmode: 'numeric' as const
- },
- {
- key: 'expect-alt',
- label: 'Expect altitude',
- expected: (scenario: Scenario) => scenario.altitudes.climb.toString(),
- placeholder: 'e.g. 7000',
- width: 'sm' as const,
- inputmode: 'numeric' as const
- },
- {
- key: 'dep-freq',
- label: 'Departure freq',
- expected: (scenario: Scenario) => scenario.departureFreq,
- placeholder: 'e.g. 125.350',
- width: 'md' as const
- },
- {
- key: 'squawk',
- label: 'Squawk',
- expected: (scenario: Scenario) => scenario.squawk,
- placeholder: 'e.g. 4521',
- width: 'sm' as const,
- inputmode: 'numeric' as const
- }
- ],
- readback: [
- { type: 'text' as const, text: 'Amended clearance, cleared to ' },
- { type: 'field' as const, key: 'destination', width: 'md' as const },
- { type: 'text' as const, text: ' via ' },
- { type: 'field' as const, key: 'sid', width: 'md' as const },
- { type: 'text' as const, text: ' departure, climb via SID except maintain ' },
- { type: 'field' as const, key: 'altitude', width: 'sm' as const },
- { type: 'text' as const, text: ', expect ' },
- { type: 'field' as const, key: 'expect-alt', width: 'sm' as const },
- { type: 'text' as const, text: ' ten minutes after departure, departure frequency ' },
- { type: 'field' as const, key: 'dep-freq', width: 'md' as const },
- { type: 'text' as const, text: ', squawk ' },
- { type: 'field' as const, key: 'squawk', width: 'sm' as const },
- { type: 'text' as const, text: (scenario: Scenario) => `, ${scenario.radioCall}` }
- ],
- defaultFrequency: 'DEL' as const,
- phrase: (scenario: Scenario) =>
- `${scenario.radioCall}, amended clearance: Cleared to ${scenario.destination.name} via ${scenario.sid} departure, climb via SID except maintain ${scenario.altitudes.initial}, expect ${scenario.altitudes.climb} ten minutes after departure, departure frequency ${scenario.departureFreq}, squawk ${scenario.squawk}.`,
- info: (scenario: Scenario) => [
- `Destination: ${scenario.destination.name}`,
- `SID: ${scenario.sid}`,
- `Initial: ${scenario.altitudes.initial}, Expect: ${scenario.altitudes.climb}`,
- `Dep freq: ${scenario.departureFreq}, Squawk: ${scenario.squawk}`
- ],
- generate: createBaseScenario
- },
- {
- id: 'speed-then-altitude',
- title: 'Speed Then Altitude',
- desc: 'Execute a sequential speed-then-descend instruction',
- keywords: ['Speed', 'Descent', 'Sequential'],
- hints: [
- '"Then" means sequential — reduce speed FIRST, begin descent SECOND.',
- 'Read back both values in the correct order to confirm you understood the sequence.',
- 'Do not descend until the target speed is reached.'
- ],
- fields: [
- {
- key: 'speed',
- label: 'Speed (kt)',
- expected: (scenario: Scenario) => scenario.speedRestriction.toString(),
- placeholder: 'e.g. 210',
- width: 'sm' as const,
- inputmode: 'numeric' as const
- },
- {
- key: 'level',
- label: 'Flight level',
- expected: (scenario: Scenario) => scenario.approachAltitude.toString(),
- placeholder: 'e.g. 5000',
- width: 'sm' as const,
- inputmode: 'numeric' as const
- }
- ],
- readback: [
- { type: 'text' as const, text: 'Reduce speed ' },
- { type: 'field' as const, key: 'speed', width: 'sm' as const },
- { type: 'text' as const, text: ' knots, then descend and maintain flight level ' },
- { type: 'field' as const, key: 'level', width: 'sm' as const },
- { type: 'text' as const, text: (scenario: Scenario) => `, ${scenario.radioCall}` }
- ],
- defaultFrequency: 'APP' as const,
- phrase: (scenario: Scenario) =>
- `${scenario.radioCall}, reduce speed ${scenario.speedRestriction} knots, then descend and maintain flight level ${scenario.approachAltitude}.`,
- info: (scenario: Scenario) => [
- `Speed: ${scenario.speedRestriction} knots`,
- `Descend to: FL${scenario.approachAltitude}`,
- '"Then" = sequential: speed first, descent second.'
- ],
- generate: createBaseScenario
- },
- {
- id: 'immediate-traffic',
- title: 'Immediate Turn for Traffic',
- desc: 'Execute an immediate heading change for traffic avoidance',
- keywords: ['Immediate', 'Traffic', 'Avoidance'],
- hints: [
- 'IMMEDIATELY means begin the turn while reading back — do not wait.',
- 'Traffic calls include clock position, distance, direction and altitude.',
- 'Comply first, then read back — safety overrides normal sequencing.'
- ],
- fields: [
- {
- key: 'turn-direction',
- label: 'Turn direction',
- expected: () => 'left',
- alternatives: () => ['left'],
- placeholder: 'e.g. left',
- width: 'sm' as const
- },
- {
- key: 'heading',
- label: 'Heading',
- expected: (scenario: Scenario) => scenario.vectorHeading,
- placeholder: 'e.g. 270',
- width: 'sm' as const,
- inputmode: 'numeric' as const
- }
- ],
- readback: [
- { type: 'text' as const, text: 'IMMEDIATELY turning left heading ' },
- { type: 'field' as const, key: 'heading', width: 'sm' as const },
- { type: 'text' as const, text: (scenario: Scenario) => `, ${scenario.radioCall}` }
- ],
- defaultFrequency: 'APP' as const,
- phrase: (scenario: Scenario) =>
- `${scenario.radioCall}, turn left IMMEDIATELY heading ${scenario.vectorHeading}, traffic 12 o'clock, 3 miles, opposite direction, same level.`,
- info: (scenario: Scenario) => [
- `Turn: left heading ${scenario.vectorHeading}`,
- 'Traffic: 12 o\'clock, 3 miles, opposite direction, same level',
- 'IMMEDIATELY = execute while reading back.'
- ],
- generate: createBaseScenario
- },
- {
- id: 'multiple-crossing-restrictions',
- title: 'Multiple Crossing Restrictions',
- desc: 'Copy a STAR descent with two crossing fixes and altitudes',
- keywords: ['STAR', 'Crossing', 'Restrictions'],
- hints: [
- 'Each crossing fix has its own altitude restriction — copy them separately.',
- 'Restrictions can be "at", "at or above", or "at or below" — the wording matters.',
- 'Read back each fix and restriction in the order given.'
- ],
- fields: [
- {
- key: 'fix1',
- label: 'Fix 1',
- expected: (scenario: Scenario) => scenario.crossingFix1,
- placeholder: 'e.g. ANEKI',
- width: 'sm' as const
- },
- {
- key: 'restriction1',
- label: 'Restriction',
- expected: (scenario: Scenario) => scenario.crossingRestriction1,
- alternatives: (scenario: Scenario) => [scenario.crossingRestriction1],
- threshold: 0.8,
- placeholder: 'e.g. at or above',
- width: 'md' as const
- },
- {
- key: 'alt1',
- label: 'Altitude 1',
- expected: (scenario: Scenario) => scenario.crossingAlt1,
- placeholder: 'e.g. 10000',
- width: 'sm' as const,
- inputmode: 'numeric' as const
- },
- {
- key: 'fix2',
- label: 'Fix 2',
- expected: (scenario: Scenario) => scenario.crossingFix2,
- placeholder: 'e.g. TOBAK',
- width: 'sm' as const
- },
- {
- key: 'alt2',
- label: 'Altitude 2',
- expected: (scenario: Scenario) => scenario.crossingAlt2,
- placeholder: 'e.g. 6000',
- width: 'sm' as const,
- inputmode: 'numeric' as const
- }
- ],
- readback: [
- { type: 'text' as const, text: 'Descend via the STAR, cross ' },
- { type: 'field' as const, key: 'fix1', width: 'sm' as const },
- { type: 'text' as const, text: ' ' },
- { type: 'field' as const, key: 'restriction1', width: 'md' as const },
- { type: 'text' as const, text: ' ' },
- { type: 'field' as const, key: 'alt1', width: 'sm' as const },
- { type: 'text' as const, text: ', cross ' },
- { type: 'field' as const, key: 'fix2', width: 'sm' as const },
- { type: 'text' as const, text: ' at ' },
- { type: 'field' as const, key: 'alt2', width: 'sm' as const },
- { type: 'text' as const, text: (scenario: Scenario) => `, ${scenario.radioCall}` }
- ],
- defaultFrequency: 'CTR' as const,
- phrase: (scenario: Scenario) =>
- `${scenario.radioCall}, descend via the STAR, cross ${scenario.crossingFix1} ${scenario.crossingRestriction1} ${scenario.crossingAlt1}, cross ${scenario.crossingFix2} at ${scenario.crossingAlt2}.`,
- info: (scenario: Scenario) => [
- `Fix 1: ${scenario.crossingFix1} — ${scenario.crossingRestriction1} ${scenario.crossingAlt1}`,
- `Fix 2: ${scenario.crossingFix2} — at ${scenario.crossingAlt2}`,
- 'Read back each crossing restriction separately.'
- ],
- generate: createBaseScenario
- },
- {
- id: 'tcas-ra-override',
- title: 'TCAS RA Override',
- desc: 'Reject an ATC instruction due to a TCAS Resolution Advisory',
- keywords: ['TCAS', 'RA', 'Override'],
- hints: [
- 'A TCAS RA always takes priority over ATC instructions — you MUST follow the RA.',
- 'Tell ATC "Unable, TCAS RA" to reject their instruction.',
- 'Once clear of conflict, report back: "Clear of conflict, returning to [assigned level]".'
- ],
- fields: [
- {
- key: 'rejection',
- label: 'Rejection',
- expected: () => 'Unable, TCAS RA',
- alternatives: () => ['Unable, TCAS RA', 'Unable TCAS RA', 'unable, TCAS RA', 'unable TCAS RA'],
- threshold: 0.75,
- placeholder: 'e.g. Unable, TCAS RA',
- width: 'lg' as const
- },
- {
- key: 'resolution',
- label: 'Resolution report',
- expected: (scenario: Scenario) => `Clear of conflict, returning to flight level ${scenario.altitudes.climb}`,
- alternatives: (scenario: Scenario) => [
- `Clear of conflict, returning to flight level ${scenario.altitudes.climb}`,
- `Clear of conflict, returning to FL${Math.round(scenario.altitudes.climb / 100)}`,
- `clear of conflict, returning to flight level ${scenario.altitudes.climb}`,
- ],
- threshold: 0.6,
- placeholder: 'e.g. Clear of conflict, returning to ...',
- width: 'xl' as const
- }
- ],
- readback: [
- { type: 'field' as const, key: 'rejection', width: 'lg' as const },
- { type: 'text' as const, text: ' · ' },
- { type: 'field' as const, key: 'resolution', width: 'xl' as const },
- { type: 'text' as const, text: (scenario: Scenario) => `, ${scenario.radioCall}` }
- ],
- defaultFrequency: 'CTR' as const,
- phrase: (scenario: Scenario) =>
- `${scenario.radioCall}, descend flight level ${scenario.altitudes.initial}. [TCAS commands: CLIMB, CLIMB NOW]`,
- info: (scenario: Scenario) => [
- 'ATC says descend — but your TCAS RA commands CLIMB.',
- 'TCAS RA always overrides ATC. Follow the RA, then report clear of conflict.',
- `Return to: flight level ${scenario.altitudes.climb}`
- ],
- generate: createBaseScenario
- },
- {
- id: 'late-runway-change',
- title: 'Late Runway Change',
- desc: 'Cancel approach and accept vectors to a new runway',
- keywords: ['Runway Change', 'Vectors', 'Approach'],
- hints: [
- 'Read back the cancelled runway, the new heading, the new runway, and the new altitude.',
- 'Confirm which runway is cancelled and which is the new assignment.',
- 'Late changes happen — stay calm and copy all four elements.'
- ],
- fields: [
- {
- key: 'old-runway',
- label: 'Old runway',
- expected: (scenario: Scenario) => scenario.arrivalRunway,
- alternatives: (scenario: Scenario) => [scenario.arrivalRunway, scenario.arrivalRunway.replace(/^0/, '')],
- placeholder: 'e.g. 25C',
- width: 'sm' as const
- },
- {
- key: 'heading',
- label: 'Heading',
- expected: (scenario: Scenario) => scenario.vectorHeading,
- placeholder: 'e.g. 180',
- width: 'sm' as const,
- inputmode: 'numeric' as const
- },
- {
- key: 'new-runway',
- label: 'New runway',
- expected: (scenario: Scenario) => scenario.runway,
- alternatives: (scenario: Scenario) => [scenario.runway, scenario.runway.replace(/^0/, '')],
- placeholder: 'e.g. 07R',
- width: 'sm' as const
- },
- {
- key: 'altitude',
- label: 'Altitude',
- expected: (scenario: Scenario) => scenario.approachAltitude.toString(),
- placeholder: 'e.g. 4000',
- width: 'sm' as const,
- inputmode: 'numeric' as const
- }
- ],
- readback: [
- { type: 'text' as const, text: 'Cancel approach runway ' },
- { type: 'field' as const, key: 'old-runway', width: 'sm' as const },
- { type: 'text' as const, text: ', turn right heading ' },
- { type: 'field' as const, key: 'heading', width: 'sm' as const },
- { type: 'text' as const, text: ', vectors runway ' },
- { type: 'field' as const, key: 'new-runway', width: 'sm' as const },
- { type: 'text' as const, text: ', descend ' },
- { type: 'field' as const, key: 'altitude', width: 'sm' as const },
- { type: 'text' as const, text: (scenario: Scenario) => `, ${scenario.radioCall}` }
- ],
- defaultFrequency: 'APP' as const,
- phrase: (scenario: Scenario) =>
- `${scenario.radioCall}, cancel approach runway ${scenario.arrivalRunway}, turn right heading ${scenario.vectorHeading}, vectors runway ${scenario.runway}, descend ${scenario.approachAltitude}.`,
- info: (scenario: Scenario) => [
- `Cancelled: runway ${scenario.arrivalRunway}`,
- `New: runway ${scenario.runway}, heading ${scenario.vectorHeading}`,
- `Descend to: ${scenario.approachAltitude}`
- ],
- generate: createBaseScenario
- }
-]
-
/* ────────────────────────────────────────────────────────────
* ABNORMAL COMMS
* ──────────────────────────────────────────────────────────── */
@@ -4691,6 +4185,1302 @@ const vatsimEssentialsLessons = [
}
]
+function emergencyCallsignAlternatives(scenario: Scenario): string[] {
+ return [
+ scenario.radioCall,
+ `${scenario.airlineCall} ${scenario.flightNumber}`,
+ scenario.callsign
+ ]
+}
+
+function emergencyStation(scenario: Scenario): string {
+ return `${scenario.airport.city} Center`
+}
+
+function emergencyAssistance(): string {
+ return 'priority vectors and immediate approach'
+}
+
+function emergencyCancelReason(scenario: Scenario): string {
+ return `${scenario.emergencyProblem} resolved`
+}
+
+function squawkPhraseology(scenario: Scenario): string {
+ if (scenario.squawk === '7600') return 'radio failure, squawking seven six zero zero'
+ if (scenario.squawk === '7500') return 'unlawful interference, squawk seven five zero zero set'
+ return 'general emergency, squawking seven seven zero zero'
+}
+
+function createSquawkCodeScenario(): Scenario {
+ const scenario = createBaseScenario()
+ const code = sample(['7700', '7600', '7500'])
+ scenario.squawk = code
+ scenario.squawkWords = digitsToWords(code)
+ if (code === '7700') {
+ scenario.emergencyProblem = 'engine fire'
+ scenario.emergencyIntent = `immediate landing at ${scenario.airport.city}`
+ } else if (code === '7600') {
+ scenario.emergencyProblem = 'radio failure'
+ scenario.emergencyIntent = 'continue with lost comms procedure'
+ } else {
+ scenario.emergencyProblem = 'unlawful interference'
+ scenario.emergencyIntent = 'request discreet handling'
+ }
+ return scenario
+}
+
+const emergencyBasicsLessons = [
+ {
+ id: 'mayday-declaration',
+ title: 'MAYDAY Declaration',
+ desc: 'Build a complete MAYDAY call with position, fuel and souls on board',
+ keywords: ['Emergency', 'MAYDAY', 'Distress'],
+ hints: [
+ 'Say MAYDAY three times first, then station and callsign.',
+ 'Include nature, intentions, position, heading, fuel in minutes and souls on board.'
+ ],
+ fields: [
+ {
+ key: 'mayday-station',
+ label: 'Station',
+ expected: scenario => emergencyStation(scenario),
+ alternatives: scenario => [`${scenario.airport.city} center`, `${scenario.airport.name} center`],
+ width: 'lg' as const
+ },
+ {
+ key: 'mayday-callsign',
+ label: 'Callsign',
+ expected: scenario => scenario.radioCall,
+ alternatives: emergencyCallsignAlternatives,
+ width: 'lg' as const
+ },
+ {
+ key: 'mayday-nature',
+ label: 'Nature',
+ expected: scenario => scenario.emergencyProblem,
+ width: 'xl' as const
+ },
+ {
+ key: 'mayday-intentions',
+ label: 'Intentions',
+ expected: scenario => scenario.emergencyIntent,
+ width: 'xl' as const
+ },
+ {
+ key: 'mayday-position',
+ label: 'Position',
+ expected: scenario => scenario.positionDescription,
+ alternatives: scenario => [scenario.positionDescription.toLowerCase()],
+ threshold: 0.7,
+ width: 'xl' as const
+ },
+ {
+ key: 'mayday-heading',
+ label: 'Heading',
+ expected: scenario => scenario.emergencyHeading,
+ alternatives: scenario => [scenario.emergencyHeadingWords],
+ width: 'sm' as const,
+ inputmode: 'numeric' as const
+ },
+ {
+ key: 'mayday-fuel',
+ label: 'Fuel (minutes)',
+ expected: scenario => scenario.fuelMinutes.toString(),
+ alternatives: scenario => [scenario.fuelMinutesWords, `${scenario.fuelMinutes} minutes`],
+ width: 'sm' as const,
+ inputmode: 'numeric' as const
+ },
+ {
+ key: 'mayday-souls',
+ label: 'Souls on board',
+ expected: scenario => scenario.soulsOnBoard.toString(),
+ alternatives: scenario => [scenario.soulsOnBoardWords],
+ width: 'sm' as const,
+ inputmode: 'numeric' as const
+ }
+ ],
+ readback: [
+ { type: 'text' as const, text: 'MAYDAY MAYDAY MAYDAY, ' },
+ { type: 'field' as const, key: 'mayday-station', width: 'lg' as const },
+ { type: 'text' as const, text: ', ' },
+ { type: 'field' as const, key: 'mayday-callsign', width: 'lg' as const },
+ { type: 'text' as const, text: ', ' },
+ { type: 'field' as const, key: 'mayday-nature', width: 'xl' as const },
+ { type: 'text' as const, text: ', intentions ' },
+ { type: 'field' as const, key: 'mayday-intentions', width: 'xl' as const },
+ { type: 'text' as const, text: ', position ' },
+ { type: 'field' as const, key: 'mayday-position', width: 'xl' as const },
+ { type: 'text' as const, text: ', heading ' },
+ { type: 'field' as const, key: 'mayday-heading', width: 'sm' as const },
+ { type: 'text' as const, text: ', fuel ' },
+ { type: 'field' as const, key: 'mayday-fuel', width: 'sm' as const },
+ { type: 'text' as const, text: ' minutes, ' },
+ { type: 'field' as const, key: 'mayday-souls', width: 'sm' as const },
+ { type: 'text' as const, text: ' souls on board' }
+ ],
+ defaultFrequency: 'CTR',
+ phrase: scenario =>
+ `You have ${scenario.emergencyProblem}. Declare full MAYDAY with heading ${scenario.emergencyHeading}, fuel ${scenario.fuelMinutes} minutes, and ${scenario.soulsOnBoard} souls on board.`,
+ info: scenario => [
+ 'MAYDAY is spoken three times for distress.',
+ `Position: ${scenario.positionDescription}`,
+ 'Fuel must be given as time remaining, not kilograms or pounds.'
+ ],
+ generate: createBaseScenario
+ },
+ {
+ id: 'pan-pan-declaration',
+ title: 'PAN PAN Declaration',
+ desc: 'Build a complete PAN PAN urgency message',
+ keywords: ['Emergency', 'PAN PAN', 'Urgency'],
+ hints: [
+ 'PAN PAN is spoken three times (six words total: PAN PAN PAN PAN PAN PAN).',
+ 'Include requested assistance and fuel time.'
+ ],
+ fields: [
+ {
+ key: 'pan-addressee',
+ label: 'Addressee',
+ expected: scenario => emergencyStation(scenario),
+ alternatives: () => ['all stations'],
+ width: 'lg' as const
+ },
+ {
+ key: 'pan-callsign',
+ label: 'Callsign',
+ expected: scenario => scenario.radioCall,
+ alternatives: emergencyCallsignAlternatives,
+ width: 'lg' as const
+ },
+ {
+ key: 'pan-position',
+ label: 'Position',
+ expected: scenario => scenario.positionDescription,
+ alternatives: scenario => [scenario.positionDescription.toLowerCase()],
+ threshold: 0.7,
+ width: 'xl' as const
+ },
+ {
+ key: 'pan-nature',
+ label: 'Nature',
+ expected: scenario => scenario.emergencyProblem,
+ width: 'xl' as const
+ },
+ {
+ key: 'pan-assistance',
+ label: 'Assistance',
+ expected: () => emergencyAssistance(),
+ alternatives: () => ['priority vectors', 'priority handling', 'immediate approach'],
+ threshold: 0.6,
+ width: 'xl' as const
+ },
+ {
+ key: 'pan-fuel',
+ label: 'Fuel (minutes)',
+ expected: scenario => scenario.fuelMinutes.toString(),
+ alternatives: scenario => [scenario.fuelMinutesWords, `${scenario.fuelMinutes} minutes`],
+ width: 'sm' as const,
+ inputmode: 'numeric' as const
+ },
+ {
+ key: 'pan-souls',
+ label: 'POB',
+ expected: scenario => scenario.soulsOnBoard.toString(),
+ alternatives: scenario => [scenario.soulsOnBoardWords],
+ width: 'sm' as const,
+ inputmode: 'numeric' as const
+ }
+ ],
+ readback: [
+ { type: 'text' as const, text: 'PAN PAN PAN PAN PAN PAN, ' },
+ { type: 'field' as const, key: 'pan-addressee', width: 'lg' as const },
+ { type: 'text' as const, text: ', ' },
+ { type: 'field' as const, key: 'pan-callsign', width: 'lg' as const },
+ { type: 'text' as const, text: ', position ' },
+ { type: 'field' as const, key: 'pan-position', width: 'xl' as const },
+ { type: 'text' as const, text: ', ' },
+ { type: 'field' as const, key: 'pan-nature', width: 'xl' as const },
+ { type: 'text' as const, text: ', request ' },
+ { type: 'field' as const, key: 'pan-assistance', width: 'xl' as const },
+ { type: 'text' as const, text: ', fuel ' },
+ { type: 'field' as const, key: 'pan-fuel', width: 'sm' as const },
+ { type: 'text' as const, text: ' minutes, ' },
+ { type: 'field' as const, key: 'pan-souls', width: 'sm' as const },
+ { type: 'text' as const, text: ' souls on board' }
+ ],
+ defaultFrequency: 'CTR',
+ phrase: scenario =>
+ `Passenger issue on board and no immediate danger. Build a full PAN PAN call with fuel ${scenario.fuelMinutes} minutes and ${scenario.soulsOnBoard} souls on board.`,
+ info: () => [
+ 'PAN PAN is urgency, not distress.',
+ 'Souls on board includes passengers and crew.'
+ ],
+ generate: createBaseScenario
+ },
+ {
+ id: 'mayday-vs-panpan-fuel',
+ title: 'MAYDAY FUEL vs PAN PAN FUEL',
+ desc: 'Choose the correct fuel urgency call and build the message',
+ keywords: ['Fuel', 'MAYDAY FUEL', 'PAN PAN FUEL'],
+ hints: [
+ 'Below final reserve: MAYDAY FUEL.',
+ 'Above reserve but critical trend: PAN PAN FUEL / MINIMUM FUEL.'
+ ],
+ fields: [
+ {
+ key: 'fuel-call-type',
+ label: 'Call type',
+ expected: scenario => (scenario.fuelMinutes <= 30 ? 'MAYDAY FUEL' : 'PAN PAN FUEL'),
+ alternatives: scenario => (scenario.fuelMinutes <= 30 ? ['MAYDAY', 'mayday fuel'] : ['PAN PAN', 'minimum fuel']),
+ width: 'md' as const
+ },
+ {
+ key: 'fuel-state',
+ label: 'Fuel state',
+ expected: scenario => `${scenario.fuelMinutes} minutes`,
+ alternatives: scenario => [scenario.fuelMinutes.toString(), scenario.fuelMinutesWords],
+ width: 'md' as const
+ },
+ {
+ key: 'fuel-callsign',
+ label: 'Callsign',
+ expected: scenario => scenario.radioCall,
+ alternatives: emergencyCallsignAlternatives,
+ width: 'lg' as const
+ },
+ {
+ key: 'fuel-request',
+ label: 'Request',
+ expected: () => emergencyAssistance(),
+ alternatives: () => ['priority handling', 'shortest approach', 'direct vectors'],
+ threshold: 0.6,
+ width: 'xl' as const
+ }
+ ],
+ readback: [
+ { type: 'field' as const, key: 'fuel-call-type', width: 'md' as const },
+ { type: 'text' as const, text: ', ' },
+ { type: 'field' as const, key: 'fuel-callsign', width: 'lg' as const },
+ { type: 'text' as const, text: ', fuel ' },
+ { type: 'field' as const, key: 'fuel-state', width: 'md' as const },
+ { type: 'text' as const, text: ', request ' },
+ { type: 'field' as const, key: 'fuel-request', width: 'xl' as const }
+ ],
+ defaultFrequency: 'CTR',
+ phrase: scenario =>
+ `Planned final reserve is 30 minutes. Current fuel remaining is ${scenario.fuelMinutes} minutes. Choose the correct fuel urgency call.`,
+ info: scenario => [
+ 'MINIMUM/PAN PAN fuel is advisory when still above reserve.',
+ `Current fuel: ${scenario.fuelMinutes} minutes (reserve threshold: 30).`
+ ],
+ generate: createBaseScenario
+ },
+ {
+ id: 'emergency-descent',
+ title: 'Emergency Descent',
+ desc: 'Announce emergency descent and squawk assignment',
+ keywords: ['Emergency Descent', '7700'],
+ hints: [
+ 'State leaving level and intention to descend immediately.',
+ 'Squawk 7700 for general emergency.'
+ ],
+ fields: [
+ {
+ key: 'descent-station',
+ label: 'Station',
+ expected: scenario => emergencyStation(scenario),
+ width: 'lg' as const
+ },
+ {
+ key: 'descent-callsign',
+ label: 'Callsign',
+ expected: scenario => scenario.radioCall,
+ alternatives: emergencyCallsignAlternatives,
+ width: 'lg' as const
+ },
+ {
+ key: 'descent-level',
+ label: 'Leaving level',
+ expected: scenario => scenario.altitudes.climb.toString(),
+ alternatives: scenario => [scenario.altitudes.climbWords, `FL${Math.round(scenario.altitudes.climb / 100)}`],
+ width: 'md' as const,
+ inputmode: 'numeric' as const
+ },
+ {
+ key: 'descent-squawk',
+ label: 'Squawk',
+ expected: () => '7700',
+ alternatives: () => ['seven seven zero zero', '7700'],
+ width: 'sm' as const,
+ inputmode: 'numeric' as const
+ }
+ ],
+ readback: [
+ { type: 'text' as const, text: 'MAYDAY MAYDAY MAYDAY, ' },
+ { type: 'field' as const, key: 'descent-station', width: 'lg' as const },
+ { type: 'text' as const, text: ', ' },
+ { type: 'field' as const, key: 'descent-callsign', width: 'lg' as const },
+ { type: 'text' as const, text: ', emergency descent, leaving ' },
+ { type: 'field' as const, key: 'descent-level', width: 'md' as const },
+ { type: 'text' as const, text: ', squawking ' },
+ { type: 'field' as const, key: 'descent-squawk', width: 'sm' as const }
+ ],
+ defaultFrequency: 'CTR',
+ phrase: scenario =>
+ `${scenario.radioCall}, cabin depressurization, emergency descent from ${scenario.altitudes.climb}.`,
+ info: () => [
+ 'General emergency code is 7700.',
+ 'Execute the descent while transmitting.'
+ ],
+ generate: createBaseScenario
+ },
+ {
+ id: 'emergency-query-response',
+ title: 'ATC Emergency Query Response',
+ desc: 'Respond with fuel in minutes and souls on board',
+ keywords: ['Fuel', 'Souls on Board', 'Emergency'],
+ hints: [
+ 'Fuel must be reported in minutes, not weight.',
+ 'Souls on board includes crew and passengers.'
+ ],
+ fields: [
+ {
+ key: 'query-callsign',
+ label: 'Callsign',
+ expected: scenario => scenario.radioCall,
+ alternatives: emergencyCallsignAlternatives,
+ width: 'lg' as const
+ },
+ {
+ key: 'query-fuel',
+ label: 'Fuel (minutes)',
+ expected: scenario => scenario.fuelMinutes.toString(),
+ alternatives: scenario => [scenario.fuelMinutesWords, `${scenario.fuelMinutes} minutes`],
+ width: 'sm' as const,
+ inputmode: 'numeric' as const
+ },
+ {
+ key: 'query-souls',
+ label: 'Souls',
+ expected: scenario => scenario.soulsOnBoard.toString(),
+ alternatives: scenario => [scenario.soulsOnBoardWords],
+ width: 'sm' as const,
+ inputmode: 'numeric' as const
+ }
+ ],
+ readback: [
+ { type: 'field' as const, key: 'query-callsign', width: 'lg' as const },
+ { type: 'text' as const, text: ', fuel ' },
+ { type: 'field' as const, key: 'query-fuel', width: 'sm' as const },
+ { type: 'text' as const, text: ' minutes, ' },
+ { type: 'field' as const, key: 'query-souls', width: 'sm' as const },
+ { type: 'text' as const, text: ' souls on board' }
+ ],
+ defaultFrequency: 'CTR',
+ phrase: () => 'State fuel remaining and souls on board.',
+ info: () => [
+ 'ATC expects fuel in minutes remaining.',
+ 'Do not report fuel in kilograms or pounds for this question.'
+ ],
+ generate: createBaseScenario
+ },
+ {
+ id: 'cancel-emergency',
+ title: 'Cancel Emergency',
+ desc: 'Cancel MAYDAY/PAN PAN when the situation is resolved',
+ keywords: ['Cancel', 'MAYDAY', 'PAN PAN'],
+ hints: [
+ 'State cancel MAYDAY or cancel PAN PAN explicitly.',
+ 'Briefly provide reason and intentions.'
+ ],
+ fields: [
+ {
+ key: 'cancel-station',
+ label: 'Station',
+ expected: scenario => emergencyStation(scenario),
+ width: 'lg' as const
+ },
+ {
+ key: 'cancel-callsign',
+ label: 'Callsign',
+ expected: scenario => scenario.radioCall,
+ alternatives: emergencyCallsignAlternatives,
+ width: 'lg' as const
+ },
+ {
+ key: 'cancel-calltype',
+ label: 'Cancel type',
+ expected: () => 'MAYDAY',
+ alternatives: () => ['PAN PAN', 'mayday', 'pan pan'],
+ width: 'md' as const
+ },
+ {
+ key: 'cancel-reason',
+ label: 'Reason',
+ expected: scenario => emergencyCancelReason(scenario),
+ alternatives: scenario => ['problem resolved', `${scenario.emergencyProblem} resolved`],
+ threshold: 0.6,
+ width: 'xl' as const
+ },
+ {
+ key: 'cancel-intentions',
+ label: 'Intentions',
+ expected: scenario => scenario.emergencyIntent,
+ width: 'xl' as const
+ }
+ ],
+ readback: [
+ { type: 'field' as const, key: 'cancel-station', width: 'lg' as const },
+ { type: 'text' as const, text: ', ' },
+ { type: 'field' as const, key: 'cancel-callsign', width: 'lg' as const },
+ { type: 'text' as const, text: ', cancel ' },
+ { type: 'field' as const, key: 'cancel-calltype', width: 'md' as const },
+ { type: 'text' as const, text: ', ' },
+ { type: 'field' as const, key: 'cancel-reason', width: 'xl' as const },
+ { type: 'text' as const, text: ', request ' },
+ { type: 'field' as const, key: 'cancel-intentions', width: 'xl' as const }
+ ],
+ defaultFrequency: 'CTR',
+ phrase: scenario =>
+ `Situation stabilised. Cancel the emergency and state next intent: ${scenario.emergencyIntent}.`,
+ info: () => [
+ 'Only cancel when emergency is actually resolved and controllable.',
+ 'Use short, unambiguous phraseology.'
+ ],
+ generate: createBaseScenario
+ },
+ {
+ id: 'emergency-squawk-codes',
+ title: 'Emergency Squawk Codes',
+ desc: 'Choose and report the correct emergency transponder code',
+ keywords: ['7700', '7600', '7500', 'Squawk'],
+ hints: [
+ '7700 = general emergency, 7600 = radio failure, 7500 = unlawful interference.',
+ '7500 is never cancelled by routine radio phraseology.'
+ ],
+ fields: [
+ {
+ key: 'squawk-code',
+ label: 'Squawk',
+ expected: scenario => scenario.squawk,
+ alternatives: scenario => [scenario.squawkWords, scenario.squawk.split('').join(' ')],
+ width: 'sm' as const,
+ inputmode: 'numeric' as const
+ },
+ {
+ key: 'squawk-phrase',
+ label: 'Phraseology',
+ expected: scenario => squawkPhraseology(scenario),
+ alternatives: () => [
+ 'general emergency, squawking seven seven zero zero',
+ 'radio failure, squawking seven six zero zero',
+ 'unlawful interference, squawk seven five zero zero set'
+ ],
+ threshold: 0.65,
+ width: 'xl' as const
+ }
+ ],
+ readback: [
+ { type: 'text' as const, text: 'Squawking ' },
+ { type: 'field' as const, key: 'squawk-code', width: 'sm' as const },
+ { type: 'text' as const, text: ', ' },
+ { type: 'field' as const, key: 'squawk-phrase', width: 'xl' as const }
+ ],
+ defaultFrequency: 'CTR',
+ phrase: scenario =>
+ `Scenario: ${scenario.emergencyProblem}. Choose the correct squawk and phraseology.`,
+ info: scenario => [
+ `Current scenario cue: ${scenario.emergencyProblem}`,
+ '7500 should not be casually discussed if unlawful interference is suspected.'
+ ],
+ generate: createSquawkCodeScenario
+ }
+]
+
+const medicalEmergencySeries = createScenarioSeries(() => {
+ const scenario = createBaseScenario()
+ scenario.emergencyProblem = 'medical emergency on board'
+ scenario.emergencyIntent = `priority landing at ${scenario.destination.city}`
+ return scenario
+})
+
+const engineFailureSeries = createScenarioSeries(() => {
+ const scenario = createBaseScenario()
+ scenario.emergencyProblem = 'engine failure'
+ scenario.emergencyIntent = `vectors for immediate return to ${scenario.airport.city}`
+ return scenario
+})
+
+const fuelEmergencySeries = createScenarioSeries(() => {
+ const scenario = createBaseScenario()
+ scenario.emergencyProblem = 'fuel critical state'
+ scenario.fuelMinutes = randInt(20, 35)
+ scenario.fuelMinutesWords = minutesToWords(scenario.fuelMinutes)
+ scenario.emergencyIntent = `priority direct approach to runway ${scenario.arrivalRunway}`
+ return scenario
+})
+
+const diversionSeries = createScenarioSeries(() => {
+ const scenario = createBaseScenario()
+ scenario.emergencyProblem = 'technical issue requiring diversion'
+ scenario.emergencyIntent = `divert to ${scenario.destination.city}`
+ return scenario
+})
+
+const makeMedicalEmergencyGenerator = (reset = false) => () => {
+ if (reset) medicalEmergencySeries.reset()
+ return medicalEmergencySeries()
+}
+
+const makeEngineFailureGenerator = (reset = false) => () => {
+ if (reset) engineFailureSeries.reset()
+ return engineFailureSeries()
+}
+
+const makeFuelEmergencyGenerator = (reset = false) => () => {
+ if (reset) fuelEmergencySeries.reset()
+ return fuelEmergencySeries()
+}
+
+const makeDiversionGenerator = (reset = false) => () => {
+ if (reset) diversionSeries.reset()
+ return diversionSeries()
+}
+
+const emergencyScenarioLessons = [
+ {
+ id: 'medical-cruise-checkin',
+ title: 'Medical Emergency · Cruise Check-in',
+ desc: 'Normal check-in before the urgency call',
+ keywords: ['Medical', 'Scenario', 'Check-in'],
+ hints: [
+ 'Start with callsign, level and position.',
+ 'This step sets context before the PAN PAN call.'
+ ],
+ fields: [
+ {
+ key: 'med-check-callsign',
+ label: 'Callsign',
+ expected: scenario => scenario.radioCall,
+ alternatives: emergencyCallsignAlternatives,
+ width: 'lg' as const
+ },
+ {
+ key: 'med-check-level',
+ label: 'Level',
+ expected: scenario => scenario.altitudes.climb.toString(),
+ alternatives: scenario => [scenario.altitudes.climbWords, `FL${Math.round(scenario.altitudes.climb / 100)}`],
+ width: 'md' as const
+ },
+ {
+ key: 'med-check-position',
+ label: 'Position',
+ expected: scenario => scenario.positionDescription,
+ alternatives: scenario => [scenario.positionDescription.toLowerCase()],
+ threshold: 0.7,
+ width: 'xl' as const
+ }
+ ],
+ readback: [
+ { type: 'field' as const, key: 'med-check-callsign', width: 'lg' as const },
+ { type: 'text' as const, text: ', level ' },
+ { type: 'field' as const, key: 'med-check-level', width: 'md' as const },
+ { type: 'text' as const, text: ', position ' },
+ { type: 'field' as const, key: 'med-check-position', width: 'xl' as const }
+ ],
+ defaultFrequency: 'CTR',
+ phrase: scenario => `${scenario.radioCall}, report level ${scenario.altitudes.climb} and position.`,
+ info: () => ['Build a clean baseline report before escalating urgency.'],
+ generate: makeMedicalEmergencyGenerator(true)
+ },
+ {
+ id: 'medical-panpan-declaration',
+ title: 'Medical Emergency · PAN PAN',
+ desc: 'Declare urgency and request priority handling',
+ keywords: ['Medical', 'PAN PAN', 'Scenario'],
+ hints: [
+ 'Use PAN PAN six-word opener.',
+ 'Include assistance request, fuel time and souls on board.'
+ ],
+ fields: [
+ {
+ key: 'med-pan-station',
+ label: 'Station',
+ expected: scenario => emergencyStation(scenario),
+ width: 'lg' as const
+ },
+ {
+ key: 'med-pan-callsign',
+ label: 'Callsign',
+ expected: scenario => scenario.radioCall,
+ alternatives: emergencyCallsignAlternatives,
+ width: 'lg' as const
+ },
+ {
+ key: 'med-pan-assist',
+ label: 'Assistance',
+ expected: () => emergencyAssistance(),
+ alternatives: () => ['priority handling', 'immediate vectors'],
+ width: 'xl' as const
+ },
+ {
+ key: 'med-pan-fuel',
+ label: 'Fuel',
+ expected: scenario => scenario.fuelMinutes.toString(),
+ alternatives: scenario => [scenario.fuelMinutesWords],
+ width: 'sm' as const
+ },
+ {
+ key: 'med-pan-souls',
+ label: 'Souls',
+ expected: scenario => scenario.soulsOnBoard.toString(),
+ alternatives: scenario => [scenario.soulsOnBoardWords],
+ width: 'sm' as const
+ }
+ ],
+ readback: [
+ { type: 'text' as const, text: 'PAN PAN PAN PAN PAN PAN, ' },
+ { type: 'field' as const, key: 'med-pan-station', width: 'lg' as const },
+ { type: 'text' as const, text: ', ' },
+ { type: 'field' as const, key: 'med-pan-callsign', width: 'lg' as const },
+ { type: 'text' as const, text: ', request ' },
+ { type: 'field' as const, key: 'med-pan-assist', width: 'xl' as const },
+ { type: 'text' as const, text: ', fuel ' },
+ { type: 'field' as const, key: 'med-pan-fuel', width: 'sm' as const },
+ { type: 'text' as const, text: ' minutes, ' },
+ { type: 'field' as const, key: 'med-pan-souls', width: 'sm' as const },
+ { type: 'text' as const, text: ' souls on board' }
+ ],
+ defaultFrequency: 'CTR',
+ phrase: () => 'Passenger requires urgent medical support. Declare PAN PAN and request priority.',
+ info: () => ['Urgency is communicated with PAN PAN unless immediate distress exists.'],
+ generate: makeMedicalEmergencyGenerator()
+ },
+ {
+ id: 'medical-priority-vectors',
+ title: 'Medical Emergency · Priority Vectors',
+ desc: 'Read back priority heading and altitude',
+ keywords: ['Medical', 'Vectors', 'Priority'],
+ hints: [
+ 'Read back heading and altitude exactly.',
+ 'Expect a direct transition to approach.'
+ ],
+ fields: [
+ {
+ key: 'med-vector-heading',
+ label: 'Heading',
+ expected: scenario => scenario.vectorHeading,
+ alternatives: scenario => [scenario.vectorHeadingWords],
+ width: 'sm' as const
+ },
+ {
+ key: 'med-vector-alt',
+ label: 'Altitude',
+ expected: scenario => scenario.approachAltitude.toString(),
+ alternatives: scenario => [scenario.approachAltitudeWords],
+ width: 'md' as const
+ },
+ {
+ key: 'med-vector-callsign',
+ label: 'Callsign',
+ expected: scenario => scenario.radioCall,
+ alternatives: emergencyCallsignAlternatives,
+ width: 'lg' as const
+ }
+ ],
+ readback: [
+ { type: 'text' as const, text: 'Heading ' },
+ { type: 'field' as const, key: 'med-vector-heading', width: 'sm' as const },
+ { type: 'text' as const, text: ', descend ' },
+ { type: 'field' as const, key: 'med-vector-alt', width: 'md' as const },
+ { type: 'text' as const, text: ', ' },
+ { type: 'field' as const, key: 'med-vector-callsign', width: 'lg' as const }
+ ],
+ defaultFrequency: 'APP',
+ phrase: scenario =>
+ `${scenario.radioCall}, priority approved, turn heading ${scenario.vectorHeadingWords}, descend ${scenario.approachAltitudeWords}.`,
+ info: () => ['Keep readback concise during emergency vectoring.'],
+ generate: makeMedicalEmergencyGenerator()
+ },
+ {
+ id: 'medical-landing-ambulance',
+ title: 'Medical Emergency · Landing + Ambulance',
+ desc: 'Read back landing clearance and request ambulance on stand',
+ keywords: ['Medical', 'Landing', 'Ambulance'],
+ hints: [
+ 'Read back runway and landing clearance first.',
+ 'Add the ambulance confirmation request at the end.'
+ ],
+ fields: [
+ {
+ key: 'med-landing-runway',
+ label: 'Runway',
+ expected: scenario => scenario.arrivalRunway,
+ alternatives: scenario => [scenario.arrivalRunwayWords],
+ width: 'sm' as const
+ },
+ {
+ key: 'med-landing-callsign',
+ label: 'Callsign',
+ expected: scenario => scenario.radioCall,
+ alternatives: emergencyCallsignAlternatives,
+ width: 'lg' as const
+ },
+ {
+ key: 'med-landing-request',
+ label: 'Additional request',
+ expected: () => 'confirm ambulance standing by',
+ alternatives: () => ['ambulance standing by', 'request ambulance on stand'],
+ threshold: 0.6,
+ width: 'xl' as const
+ }
+ ],
+ readback: [
+ { type: 'text' as const, text: 'Runway ' },
+ { type: 'field' as const, key: 'med-landing-runway', width: 'sm' as const },
+ { type: 'text' as const, text: ', cleared to land, ' },
+ { type: 'field' as const, key: 'med-landing-callsign', width: 'lg' as const },
+ { type: 'text' as const, text: ', ' },
+ { type: 'field' as const, key: 'med-landing-request', width: 'xl' as const }
+ ],
+ defaultFrequency: 'TWR',
+ phrase: scenario =>
+ `${scenario.radioCall}, runway ${scenario.arrivalRunwayWords} cleared to land. Ambulance will meet you after vacating.`,
+ info: scenario => [`Stand planned: ${scenario.arrivalStand}`],
+ generate: makeMedicalEmergencyGenerator()
+ },
+ {
+ id: 'engine-mayday-declaration',
+ title: 'Engine Failure · MAYDAY',
+ desc: 'Declare MAYDAY for engine failure and intentions',
+ keywords: ['Engine Failure', 'MAYDAY', 'Scenario'],
+ hints: [
+ 'Distress calls use MAYDAY three times.',
+ 'State nature and immediate intention.'
+ ],
+ fields: [
+ {
+ key: 'eng-mayday-station',
+ label: 'Station',
+ expected: scenario => emergencyStation(scenario),
+ width: 'lg' as const
+ },
+ {
+ key: 'eng-mayday-callsign',
+ label: 'Callsign',
+ expected: scenario => scenario.radioCall,
+ alternatives: emergencyCallsignAlternatives,
+ width: 'lg' as const
+ },
+ {
+ key: 'eng-mayday-nature',
+ label: 'Nature',
+ expected: scenario => scenario.emergencyProblem,
+ width: 'xl' as const
+ },
+ {
+ key: 'eng-mayday-intentions',
+ label: 'Intentions',
+ expected: scenario => scenario.emergencyIntent,
+ width: 'xl' as const
+ }
+ ],
+ readback: [
+ { type: 'text' as const, text: 'MAYDAY MAYDAY MAYDAY, ' },
+ { type: 'field' as const, key: 'eng-mayday-station', width: 'lg' as const },
+ { type: 'text' as const, text: ', ' },
+ { type: 'field' as const, key: 'eng-mayday-callsign', width: 'lg' as const },
+ { type: 'text' as const, text: ', ' },
+ { type: 'field' as const, key: 'eng-mayday-nature', width: 'xl' as const },
+ { type: 'text' as const, text: ', intentions ' },
+ { type: 'field' as const, key: 'eng-mayday-intentions', width: 'xl' as const }
+ ],
+ defaultFrequency: 'CTR',
+ phrase: () => 'Engine failure after climb. Make the MAYDAY declaration.',
+ info: () => ['Keep the initial MAYDAY concise and unambiguous.'],
+ generate: makeEngineFailureGenerator(true)
+ },
+ {
+ id: 'engine-emergency-descent',
+ title: 'Engine Failure · Emergency Descent',
+ desc: 'Coordinate emergency descent with level and heading',
+ keywords: ['Engine Failure', 'Descent'],
+ hints: [
+ 'Report leaving level and assigned heading.',
+ 'Include squawk 7700 if not already assigned.'
+ ],
+ fields: [
+ {
+ key: 'eng-descent-level',
+ label: 'Leaving level',
+ expected: scenario => scenario.altitudes.climb.toString(),
+ alternatives: scenario => [scenario.altitudes.climbWords],
+ width: 'md' as const
+ },
+ {
+ key: 'eng-descent-heading',
+ label: 'Heading',
+ expected: scenario => scenario.emergencyHeading,
+ alternatives: scenario => [scenario.emergencyHeadingWords],
+ width: 'sm' as const
+ },
+ {
+ key: 'eng-descent-squawk',
+ label: 'Squawk',
+ expected: () => '7700',
+ alternatives: () => ['seven seven zero zero'],
+ width: 'sm' as const
+ }
+ ],
+ readback: [
+ { type: 'text' as const, text: 'Emergency descent leaving ' },
+ { type: 'field' as const, key: 'eng-descent-level', width: 'md' as const },
+ { type: 'text' as const, text: ', heading ' },
+ { type: 'field' as const, key: 'eng-descent-heading', width: 'sm' as const },
+ { type: 'text' as const, text: ', squawking ' },
+ { type: 'field' as const, key: 'eng-descent-squawk', width: 'sm' as const }
+ ],
+ defaultFrequency: 'CTR',
+ phrase: scenario =>
+ `${scenario.radioCall}, descend immediately, fly heading ${scenario.emergencyHeadingWords}, squawk 7700.`,
+ info: () => ['Execute first, then complete readback.'],
+ generate: makeEngineFailureGenerator()
+ },
+ {
+ id: 'engine-atc-vectors',
+ title: 'Engine Failure · ATC Vectors',
+ desc: 'Read back vectors and altitude toward immediate approach',
+ keywords: ['Engine Failure', 'Vectors'],
+ hints: [
+ 'Heading and altitude are safety critical.',
+ 'Keep callsign at the end.'
+ ],
+ fields: [
+ {
+ key: 'eng-vector-heading',
+ label: 'Heading',
+ expected: scenario => scenario.vectorHeading,
+ alternatives: scenario => [scenario.vectorHeadingWords],
+ width: 'sm' as const
+ },
+ {
+ key: 'eng-vector-alt',
+ label: 'Altitude',
+ expected: scenario => scenario.approachAltitude.toString(),
+ alternatives: scenario => [scenario.approachAltitudeWords],
+ width: 'md' as const
+ },
+ {
+ key: 'eng-vector-callsign',
+ label: 'Callsign',
+ expected: scenario => scenario.radioCall,
+ alternatives: emergencyCallsignAlternatives,
+ width: 'lg' as const
+ }
+ ],
+ readback: [
+ { type: 'text' as const, text: 'Heading ' },
+ { type: 'field' as const, key: 'eng-vector-heading', width: 'sm' as const },
+ { type: 'text' as const, text: ', descend ' },
+ { type: 'field' as const, key: 'eng-vector-alt', width: 'md' as const },
+ { type: 'text' as const, text: ', ' },
+ { type: 'field' as const, key: 'eng-vector-callsign', width: 'lg' as const }
+ ],
+ defaultFrequency: 'APP',
+ phrase: scenario =>
+ `${scenario.radioCall}, turn left heading ${scenario.vectorHeadingWords}, descend ${scenario.approachAltitudeWords}, vectors ILS.`,
+ info: () => ['Readback should be short and exact.'],
+ generate: makeEngineFailureGenerator()
+ },
+ {
+ id: 'engine-ils-landing',
+ title: 'Engine Failure · ILS and Landing',
+ desc: 'Confirm ILS approach and landing clearance',
+ keywords: ['Engine Failure', 'ILS', 'Landing'],
+ hints: [
+ 'Confirm approach type and runway.',
+ 'Landing clearance readback should include runway and callsign.'
+ ],
+ fields: [
+ {
+ key: 'eng-ils-approach',
+ label: 'Approach',
+ expected: scenario => scenario.approach,
+ width: 'lg' as const
+ },
+ {
+ key: 'eng-ils-runway',
+ label: 'Runway',
+ expected: scenario => scenario.arrivalRunway,
+ alternatives: scenario => [scenario.arrivalRunwayWords],
+ width: 'sm' as const
+ },
+ {
+ key: 'eng-ils-callsign',
+ label: 'Callsign',
+ expected: scenario => scenario.radioCall,
+ alternatives: emergencyCallsignAlternatives,
+ width: 'lg' as const
+ }
+ ],
+ readback: [
+ { type: 'text' as const, text: 'Cleared ' },
+ { type: 'field' as const, key: 'eng-ils-approach', width: 'lg' as const },
+ { type: 'text' as const, text: ' runway ' },
+ { type: 'field' as const, key: 'eng-ils-runway', width: 'sm' as const },
+ { type: 'text' as const, text: ', ' },
+ { type: 'field' as const, key: 'eng-ils-callsign', width: 'lg' as const }
+ ],
+ defaultFrequency: 'APP',
+ phrase: scenario =>
+ `${scenario.radioCall}, cleared ${scenario.approach} approach runway ${scenario.arrivalRunwayWords}, then cleared to land.`,
+ info: () => ['Continue precise phraseology even under high workload.'],
+ generate: makeEngineFailureGenerator()
+ },
+ {
+ id: 'fuel-panpan-initial',
+ title: 'Fuel Emergency · PAN PAN FUEL',
+ desc: 'Declare initial fuel urgency above final reserve',
+ keywords: ['Fuel', 'PAN PAN FUEL', 'Scenario'],
+ hints: [
+ 'Initial advisory is PAN PAN FUEL when still above reserve.',
+ 'State current fuel in minutes.'
+ ],
+ fields: [
+ {
+ key: 'fuel-pan-call',
+ label: 'Call type',
+ expected: () => 'PAN PAN FUEL',
+ alternatives: () => ['PAN PAN', 'minimum fuel'],
+ width: 'md' as const
+ },
+ {
+ key: 'fuel-pan-callsign',
+ label: 'Callsign',
+ expected: scenario => scenario.radioCall,
+ alternatives: emergencyCallsignAlternatives,
+ width: 'lg' as const
+ },
+ {
+ key: 'fuel-pan-minutes',
+ label: 'Fuel minutes',
+ expected: scenario => scenario.fuelMinutes.toString(),
+ alternatives: scenario => [scenario.fuelMinutesWords],
+ width: 'sm' as const
+ }
+ ],
+ readback: [
+ { type: 'field' as const, key: 'fuel-pan-call', width: 'md' as const },
+ { type: 'text' as const, text: ', ' },
+ { type: 'field' as const, key: 'fuel-pan-callsign', width: 'lg' as const },
+ { type: 'text' as const, text: ', fuel ' },
+ { type: 'field' as const, key: 'fuel-pan-minutes', width: 'sm' as const },
+ { type: 'text' as const, text: ' minutes, request priority vectors' }
+ ],
+ defaultFrequency: 'CTR',
+ phrase: scenario =>
+ `Fuel trend is worsening with ${scenario.fuelMinutes} minutes remaining. Start with PAN PAN FUEL advisory.`,
+ info: () => ['Use MAYDAY FUEL only once reserve is below planned final reserve.'],
+ generate: makeFuelEmergencyGenerator(true)
+ },
+ {
+ id: 'fuel-priority-readback',
+ title: 'Fuel Emergency · Priority Readback',
+ desc: 'Read back priority vectors and approach planning',
+ keywords: ['Fuel', 'Priority', 'Vectors'],
+ hints: [
+ 'Acknowledge heading and descent clearly.',
+ 'Prepare for the upgrade call if fuel worsens.'
+ ],
+ fields: [
+ {
+ key: 'fuel-priority-heading',
+ label: 'Heading',
+ expected: scenario => scenario.vectorHeading,
+ alternatives: scenario => [scenario.vectorHeadingWords],
+ width: 'sm' as const
+ },
+ {
+ key: 'fuel-priority-alt',
+ label: 'Altitude',
+ expected: scenario => scenario.approachAltitude.toString(),
+ alternatives: scenario => [scenario.approachAltitudeWords],
+ width: 'md' as const
+ },
+ {
+ key: 'fuel-priority-callsign',
+ label: 'Callsign',
+ expected: scenario => scenario.radioCall,
+ alternatives: emergencyCallsignAlternatives,
+ width: 'lg' as const
+ }
+ ],
+ readback: [
+ { type: 'text' as const, text: 'Heading ' },
+ { type: 'field' as const, key: 'fuel-priority-heading', width: 'sm' as const },
+ { type: 'text' as const, text: ', descend ' },
+ { type: 'field' as const, key: 'fuel-priority-alt', width: 'md' as const },
+ { type: 'text' as const, text: ', ' },
+ { type: 'field' as const, key: 'fuel-priority-callsign', width: 'lg' as const }
+ ],
+ defaultFrequency: 'APP',
+ phrase: scenario =>
+ `${scenario.radioCall}, priority accepted, fly heading ${scenario.vectorHeadingWords}, descend ${scenario.approachAltitudeWords}.`,
+ info: () => ['Keep workload low with short readbacks.'],
+ generate: makeFuelEmergencyGenerator()
+ },
+ {
+ id: 'fuel-upgrade-mayday',
+ title: 'Fuel Emergency · Upgrade to MAYDAY FUEL',
+ desc: 'Escalate from PAN PAN FUEL to MAYDAY FUEL',
+ keywords: ['Fuel', 'MAYDAY FUEL', 'Escalation'],
+ hints: [
+ 'When fuel goes below reserve, upgrade immediately to MAYDAY FUEL.',
+ 'State minimum information fast and clearly.'
+ ],
+ fields: [
+ {
+ key: 'fuel-upgrade-call',
+ label: 'Call type',
+ expected: () => 'MAYDAY FUEL',
+ alternatives: () => ['MAYDAY', 'mayday fuel'],
+ width: 'md' as const
+ },
+ {
+ key: 'fuel-upgrade-callsign',
+ label: 'Callsign',
+ expected: scenario => scenario.radioCall,
+ alternatives: emergencyCallsignAlternatives,
+ width: 'lg' as const
+ },
+ {
+ key: 'fuel-upgrade-request',
+ label: 'Request',
+ expected: () => 'immediate approach and landing priority',
+ alternatives: () => ['immediate approach', 'landing priority', 'priority landing'],
+ threshold: 0.6,
+ width: 'xl' as const
+ }
+ ],
+ readback: [
+ { type: 'field' as const, key: 'fuel-upgrade-call', width: 'md' as const },
+ { type: 'text' as const, text: ', ' },
+ { type: 'field' as const, key: 'fuel-upgrade-callsign', width: 'lg' as const },
+ { type: 'text' as const, text: ', request ' },
+ { type: 'field' as const, key: 'fuel-upgrade-request', width: 'xl' as const }
+ ],
+ defaultFrequency: 'APP',
+ phrase: () => 'Fuel now below planned final reserve. Upgrade call to MAYDAY FUEL.',
+ info: () => ['Do not delay the escalation when reserve is compromised.'],
+ generate: makeFuelEmergencyGenerator()
+ },
+ {
+ id: 'fuel-direct-approach-landing',
+ title: 'Fuel Emergency · Direct and Landing',
+ desc: 'Read back direct vectors and immediate landing clearance',
+ keywords: ['Fuel', 'Direct', 'Landing'],
+ hints: [
+ 'Confirm direct fix/runway and final clearance.',
+ 'Keep readback crisp to reduce frequency load.'
+ ],
+ fields: [
+ {
+ key: 'fuel-direct-heading',
+ label: 'Heading',
+ expected: scenario => scenario.vectorHeading,
+ alternatives: scenario => [scenario.vectorHeadingWords],
+ width: 'sm' as const
+ },
+ {
+ key: 'fuel-direct-runway',
+ label: 'Runway',
+ expected: scenario => scenario.arrivalRunway,
+ alternatives: scenario => [scenario.arrivalRunwayWords],
+ width: 'sm' as const
+ },
+ {
+ key: 'fuel-direct-callsign',
+ label: 'Callsign',
+ expected: scenario => scenario.radioCall,
+ alternatives: emergencyCallsignAlternatives,
+ width: 'lg' as const
+ }
+ ],
+ readback: [
+ { type: 'text' as const, text: 'Heading ' },
+ { type: 'field' as const, key: 'fuel-direct-heading', width: 'sm' as const },
+ { type: 'text' as const, text: ', cleared straight-in runway ' },
+ { type: 'field' as const, key: 'fuel-direct-runway', width: 'sm' as const },
+ { type: 'text' as const, text: ', ' },
+ { type: 'field' as const, key: 'fuel-direct-callsign', width: 'lg' as const }
+ ],
+ defaultFrequency: 'TWR',
+ phrase: scenario =>
+ `${scenario.radioCall}, turn heading ${scenario.vectorHeadingWords}, direct final runway ${scenario.arrivalRunwayWords}, cleared to land.`,
+ info: () => ['Emergency fuel arrivals require fastest safe path to runway.'],
+ generate: makeFuelEmergencyGenerator()
+ },
+ {
+ id: 'diversion-problem-report',
+ title: 'Diversion · Problem Report',
+ desc: 'Report the issue that requires diversion',
+ keywords: ['Diversion', 'Report', 'Scenario'],
+ hints: [
+ 'State callsign and problem in one concise line.',
+ 'Prepare to request alternate routing next.'
+ ],
+ fields: [
+ {
+ key: 'div-report-callsign',
+ label: 'Callsign',
+ expected: scenario => scenario.radioCall,
+ alternatives: emergencyCallsignAlternatives,
+ width: 'lg' as const
+ },
+ {
+ key: 'div-report-problem',
+ label: 'Problem',
+ expected: scenario => scenario.emergencyProblem,
+ width: 'xl' as const
+ }
+ ],
+ readback: [
+ { type: 'field' as const, key: 'div-report-callsign', width: 'lg' as const },
+ { type: 'text' as const, text: ', ' },
+ { type: 'field' as const, key: 'div-report-problem', width: 'xl' as const }
+ ],
+ defaultFrequency: 'CTR',
+ phrase: scenario => `${scenario.radioCall}, report your issue for ATC coordination.`,
+ info: () => ['Keep the first report short to open diversion coordination quickly.'],
+ generate: makeDiversionGenerator(true)
+ },
+ {
+ id: 'diversion-request',
+ title: 'Diversion · Request Alternate',
+ desc: 'Request diversion to the alternate airport',
+ keywords: ['Diversion', 'Request', 'Alternate'],
+ hints: [
+ 'Use explicit request diversion phraseology.',
+ 'Name the alternate airport/city clearly.'
+ ],
+ fields: [
+ {
+ key: 'div-request-callsign',
+ label: 'Callsign',
+ expected: scenario => scenario.radioCall,
+ alternatives: emergencyCallsignAlternatives,
+ width: 'lg' as const
+ },
+ {
+ key: 'div-request-alt',
+ label: 'Alternate',
+ expected: scenario => scenario.destination.city,
+ alternatives: scenario => [scenario.destination.icao, scenario.destination.name],
+ width: 'md' as const
+ }
+ ],
+ readback: [
+ { type: 'field' as const, key: 'div-request-callsign', width: 'lg' as const },
+ { type: 'text' as const, text: ', request diversion to ' },
+ { type: 'field' as const, key: 'div-request-alt', width: 'md' as const }
+ ],
+ defaultFrequency: 'CTR',
+ phrase: scenario => `${scenario.radioCall}, request diversion to ${scenario.destination.city}.`,
+ info: scenario => [`Alternate planned: ${scenario.destination.city} (${scenario.destination.icao})`],
+ generate: makeDiversionGenerator()
+ },
+ {
+ id: 'diversion-new-clearance',
+ title: 'Diversion · New Clearance',
+ desc: 'Read back new routing and altitude for alternate',
+ keywords: ['Diversion', 'Clearance', 'Readback'],
+ hints: [
+ 'Treat diversion clearance like a full amendment.',
+ 'Read destination, route and altitude.'
+ ],
+ fields: [
+ {
+ key: 'div-clear-destination',
+ label: 'Destination',
+ expected: scenario => scenario.destination.city,
+ alternatives: scenario => [scenario.destination.icao],
+ width: 'md' as const
+ },
+ {
+ key: 'div-clear-transition',
+ label: 'Direct fix',
+ expected: scenario => scenario.transition,
+ width: 'md' as const
+ },
+ {
+ key: 'div-clear-altitude',
+ label: 'Altitude',
+ expected: scenario => scenario.altitudes.initial.toString(),
+ alternatives: scenario => [scenario.altitudes.initialWords],
+ width: 'md' as const
+ }
+ ],
+ readback: [
+ { type: 'text' as const, text: 'Cleared to ' },
+ { type: 'field' as const, key: 'div-clear-destination', width: 'md' as const },
+ { type: 'text' as const, text: ', direct ' },
+ { type: 'field' as const, key: 'div-clear-transition', width: 'md' as const },
+ { type: 'text' as const, text: ', maintain ' },
+ { type: 'field' as const, key: 'div-clear-altitude', width: 'md' as const }
+ ],
+ defaultFrequency: 'CTR',
+ phrase: scenario =>
+ `${scenario.radioCall}, amended clearance: proceed direct ${scenario.transition}, maintain ${scenario.altitudes.initial}, cleared ${scenario.destination.city}.`,
+ info: () => ['Diversion clearance replaces previous routing.'],
+ generate: makeDiversionGenerator()
+ },
+ {
+ id: 'diversion-approach-landing',
+ title: 'Diversion · Approach and Landing',
+ desc: 'Complete diversion with approach and landing readback',
+ keywords: ['Diversion', 'Approach', 'Landing'],
+ hints: [
+ 'Confirm approach type and runway at alternate.',
+ 'End with full callsign.'
+ ],
+ fields: [
+ {
+ key: 'div-approach-type',
+ label: 'Approach',
+ expected: scenario => scenario.approach,
+ width: 'lg' as const
+ },
+ {
+ key: 'div-approach-runway',
+ label: 'Runway',
+ expected: scenario => scenario.arrivalRunway,
+ alternatives: scenario => [scenario.arrivalRunwayWords],
+ width: 'sm' as const
+ },
+ {
+ key: 'div-approach-callsign',
+ label: 'Callsign',
+ expected: scenario => scenario.radioCall,
+ alternatives: emergencyCallsignAlternatives,
+ width: 'lg' as const
+ }
+ ],
+ readback: [
+ { type: 'text' as const, text: 'Cleared ' },
+ { type: 'field' as const, key: 'div-approach-type', width: 'lg' as const },
+ { type: 'text' as const, text: ' runway ' },
+ { type: 'field' as const, key: 'div-approach-runway', width: 'sm' as const },
+ { type: 'text' as const, text: ', ' },
+ { type: 'field' as const, key: 'div-approach-callsign', width: 'lg' as const }
+ ],
+ defaultFrequency: 'APP',
+ phrase: scenario =>
+ `${scenario.radioCall}, cleared ${scenario.approach} approach runway ${scenario.arrivalRunwayWords}, then contact tower for landing.`,
+ info: scenario => [`Alternate destination: ${scenario.destination.name}`],
+ generate: makeDiversionGenerator()
+ }
+]
+
export const learnModules: ModuleDef[] = [
{
id: 'normalize',
@@ -4752,8 +5542,28 @@ export const learnTracks: TrackDef[] = [
art: gradientArt(['#b71c1c', '#c62828', '#d32f2f']),
lessons: abnormalCommsLessons,
},
+ {
+ id: 'emergency-basics',
+ title: 'Emergency · Basics',
+ subtitle: 'MAYDAY, PAN PAN, squawk codes and fuel emergencies',
+ art: gradientArt(['#e65100', '#ef6c00', '#f57c00']),
+ lessons: emergencyBasicsLessons,
+ },
+ {
+ id: 'emergency-scenarios',
+ title: 'Emergency · Scenario Flights',
+ subtitle: 'Multi-step emergency scenarios from onset to landing',
+ art: gradientArt(['#ff6f00', '#ff8f00', '#ffa000']),
+ lessons: emergencyScenarioLessons,
+ },
],
},
+ {
+ id: 'atc-perspective',
+ title: 'ATC Perspective Missions',
+ subtitle: 'Understand, decide and control like ATC',
+ modules: atcPerspectiveModules,
+ }
]
export default learnModules
diff --git a/shared/learn/decision-types.ts b/shared/learn/decision-types.ts
new file mode 100644
index 0000000..afb5b01
--- /dev/null
+++ b/shared/learn/decision-types.ts
@@ -0,0 +1,36 @@
+export type DecisionType = 'sequencing' | 'choice' | 'assignment' | 'priority'
+
+export type FlightStrip = {
+ callsign: string
+ type: string
+ altitude: string
+ heading: string
+ position: string
+ intention: string
+ category: 'heavy' | 'medium' | 'light'
+ status: 'emergency' | 'normal'
+}
+
+export type DecisionStep = {
+ prompt: string
+ type: DecisionType
+ options?: string[]
+ items?: string[]
+ correct: string | string[]
+ explanation: string
+}
+
+export type DecisionScenario = {
+ briefing: string
+ strips: FlightStrip[]
+ steps: DecisionStep[]
+}
+
+export type DecisionLesson = {
+ id: string
+ title: string
+ desc: string
+ keywords: string[]
+ hints: string[]
+ generate: () => DecisionScenario
+}
diff --git a/shared/learn/types.ts b/shared/learn/types.ts
index f1e3064..b5302d6 100644
--- a/shared/learn/types.ts
+++ b/shared/learn/types.ts
@@ -1,3 +1,5 @@
+import type { DecisionLesson } from './decision-types'
+
export type BlankWidth = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
export type FrequencyType = 'ATIS' | 'DEL' | 'GND' | 'TWR' | 'DEP' | 'APP' | 'CTR'
@@ -192,14 +194,17 @@ export type Lesson = {
export type ModuleMeta = {
flightPlan?: boolean
briefingArt?: string
+ exerciseType?: 'cloze' | 'decision'
}
+export type ModuleLesson = Lesson | DecisionLesson
+
export type ModuleDef = {
id: string
title: string
subtitle: string
art: string
- lessons: Lesson[]
+ lessons: ModuleLesson[]
meta?: ModuleMeta
}