Files
OpenSquawk/shared/data/learnModules.ts

4039 lines
134 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createBaseScenario, createScenarioSeries, digitsToWords, formatTemp, lettersToNato } from '~~/shared/learn/scenario'
import type { Lesson, LessonField, ModuleDef, Scenario } from '~~/shared/learn/types'
function gradientArt(colors: string[]): string {
const stops = colors
.map((color, idx) => `<stop offset="${Math.round((idx / Math.max(colors.length - 1, 1)) * 100)}%" stop-color="${color}"/>`)
.join('')
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 240"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">${stops}</linearGradient></defs><rect fill="url(#g)" width="400" height="240"/></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<T>(values: readonly T[]): T {
return values[randInt(0, values.length - 1)]!
}
const identifierCharacters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
function randomIdentifier(length: number): string {
let value = ''
for (let i = 0; i < length; i++) {
value += identifierCharacters[randInt(0, identifierCharacters.length - 1)]
}
return value
}
function toPhoneticBlocks(value: string): string {
return value
.toUpperCase()
.split('')
.map(char => {
if (/[A-Z]/.test(char)) {
return lettersToNato(char)
}
if (/[0-9]/.test(char)) {
return digitsToWords(char)
}
return char
})
.join(' ')
.replace(/\s+/g, ' ')
.trim()
}
function createExtendedIdentifierScenario(length = 10): Scenario {
const scenario = createBaseScenario()
const identifier = randomIdentifier(length)
scenario.phoneticCode = identifier
scenario.phoneticCodeWords = toPhoneticBlocks(identifier)
return scenario
}
const taxiPrefixes = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'R', 'S', 'T', 'U', 'V', 'W', 'Y', 'Z']
const taxiNumbers = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '14', '15', '16']
function randomTaxiWaypoint(): string {
const prefix = sample(taxiPrefixes)
const number = sample(taxiNumbers)
return `${prefix}${number}`
}
function createComplexTaxiScenario(): Scenario {
const scenario = createBaseScenario()
const segments: string[] = []
const total = randInt(5, 8)
while (segments.length < total) {
const next = randomTaxiWaypoint()
if (segments[segments.length - 1] === next) continue
segments.push(next)
}
scenario.taxiRoute = segments.join(' ')
return scenario
}
const fundamentalsLessons: Lesson[] = [
{
id: 'icao-alphabet',
title: 'Decode ICAO Letters & Numbers',
desc: 'Turn phonetic blocks into identifiers',
keywords: ['Alphabet', 'Numbers', 'Basics'],
hints: [
'Convert the NATO letters and ATC numbers back into characters.',
'Write the identifier exactly as you would enter it into the FMS.'
],
fields: [
{
key: 'icao-code',
label: 'Identifier',
expected: scenario => scenario.phoneticCode,
alternatives: scenario => {
const spelled = scenario.phoneticCodeWords
const upper = scenario.phoneticCode.toUpperCase()
const lower = upper.toLowerCase()
const spacedUpper = upper.split('').join(' ')
const spacedLower = lower.split('').join(' ')
return Array.from(
new Set([
upper,
lower,
spelled,
spelled.toLowerCase(),
spacedUpper,
spacedLower
])
)
},
placeholder: 'Enter identifier',
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Identifier ' },
{ type: 'field', key: 'icao-code', width: 'lg' }
],
defaultFrequency: 'DEL',
phrase: scenario => scenario.phoneticCodeWords,
info: scenario => [
`Heard: ${scenario.phoneticCodeWords}`,
`Identifier: ${scenario.phoneticCode}`
],
generate: createBaseScenario
},
{
id: 'icao-marathon',
title: 'ICAO Marathon Drill',
desc: 'Copy extended identifiers with up to ten characters',
keywords: ['Alphabet', 'Numbers', 'Advanced'],
hints: [
'Scan for natural breaks every two to three characters to keep long identifiers manageable.',
'Say the full sequence back without pauses, including letters and digits exactly as transmitted.'
],
fields: [
{
key: 'icao-long-code',
label: 'Identifier',
expected: scenario => scenario.phoneticCode,
alternatives: scenario => {
const value = scenario.phoneticCode
const upper = value.toUpperCase()
const lower = upper.toLowerCase()
const spacedUpper = upper.split('').join(' ')
const spacedLower = lower.split('').join(' ')
const spelled = scenario.phoneticCodeWords
return Array.from(
new Set([upper, lower, spacedUpper, spacedLower, spelled, spelled.toLowerCase()])
)
},
placeholder: 'Enter long identifier',
width: 'xl'
}
],
readback: [
{ type: 'text', text: 'Identifier ' },
{ type: 'field', key: 'icao-long-code', width: 'xl' }
],
defaultFrequency: 'DEL',
phrase: scenario => scenario.phoneticCodeWords,
info: scenario => [
`Heard: ${scenario.phoneticCodeWords}`,
`Identifier: ${scenario.phoneticCode}`
],
generate: () => createExtendedIdentifierScenario(10)
},
{
id: 'radio-call',
title: 'Radio Call Sign',
desc: 'Say the spoken or ICAO call sign confidently',
keywords: ['Basics', 'Callsign'],
hints: [
'Use the airline telephony plus digits when talking to ATC.',
'Readbacks can use either the spoken or ICAO format.'
],
fields: [
{
key: 'radio-call',
label: 'Call sign',
expected: scenario => scenario.radioCall,
alternatives: scenario => {
const variants = new Set<string>()
const add = (value?: string) => {
if (!value) return
variants.add(value)
variants.add(value.toLowerCase())
}
add(scenario.radioCall)
add(`${scenario.airlineCall} ${scenario.flightNumber}`)
add(`${scenario.airlineCall} ${scenario.flightNumberWords}`)
add(scenario.callsign)
add(`${scenario.airlineCode} ${scenario.flightNumber}`)
add(`${scenario.callsignNato} ${scenario.flightNumberWords}`)
return Array.from(variants)
},
width: 'xl'
}
],
readback: [
{ type: 'text', text: 'Call sign ' },
{ type: 'field', key: 'radio-call', width: 'xl' }
],
defaultFrequency: 'DEL',
phrase: scenario => `${scenario.radioCall}`,
info: scenario => [
`Spoken: ${scenario.radioCall}`,
`ICAO: ${scenario.callsign}`,
`Phonetic: ${scenario.callsignNato} ${scenario.flightNumberWords}`
],
generate: createBaseScenario
},
{
id: 'atis',
title: 'Understand the ATIS',
desc: 'Extract the identifier and key data from the ATIS',
keywords: ['ATIS', 'Weather'],
hints: [
'Remember the ATIS identifier as its NATO word (e.g. Information Yankee).',
'Order: runway wind visibility temperature dew point QNH; copy the numbers as digits.'
],
fields: [
{
key: 'atis-code',
label: 'ATIS',
expected: scenario => scenario.atisCode,
alternatives: scenario => [
scenario.atisCode,
scenario.atisCode.toLowerCase(),
scenario.atisCodeWord,
scenario.atisCodeWord.toLowerCase(),
`Information ${scenario.atisCode}`,
`information ${scenario.atisCode.toLowerCase()}`,
`Information ${scenario.atisCodeWord}`,
`information ${scenario.atisCodeWord.toLowerCase()}`
],
placeholder: 'Enter ATIS letter',
width: 'xs',
threshold: 0.9
},
{
key: 'atis-runway',
label: 'Runway',
expected: scenario => scenario.runway,
alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords],
width: 'sm'
},
{
key: 'atis-wind',
label: 'Wind',
expected: scenario => scenario.wind,
alternatives: scenario => [
scenario.wind,
`${scenario.wind}KT`,
scenario.windWords
],
width: 'md'
},
{
key: 'atis-visibility',
label: 'Visibility',
expected: scenario => scenario.visibility,
alternatives: scenario => {
const values = new Set<string>()
const add = (value?: string) => {
if (!value) return
values.add(value)
values.add(value.toLowerCase())
}
add(scenario.visibility)
add(scenario.atisSummary.visibility)
add(scenario.visibilityWords)
const numeric = Number(scenario.visibility)
if (!Number.isNaN(numeric)) {
if (numeric >= 1000) {
const precise = (numeric / 1000).toFixed(1).replace(/\.0$/, '')
add(precise)
add(`${precise}km`)
add(`${precise} km`)
}
if (numeric >= 1000) {
const rounded = Math.round(numeric / 1000)
add(rounded.toString())
add(`${rounded}km`)
add(`${rounded} km`)
}
}
if (scenario.visibility === '9999') {
add('10')
add('10km')
add('10 km')
}
return Array.from(values)
},
width: 'sm'
},
{
key: 'atis-temp',
label: 'Temperature',
expected: scenario => scenario.temperature.toString(),
alternatives: scenario => {
const value = scenario.temperature
const base = value.toString()
const plus = value > 0 ? `+${base}` : base
const c = `${base}°C`
const cSpaced = `${base} °C`
const shortC = value > 0 ? `${plus}C` : `${base}C`
const options = new Set<string>([
base,
plus,
c,
cSpaced,
shortC,
scenario.temperatureWords,
formatTemp(value)
])
options.add(`${plus}°C`)
options.add(`${plus} °C`)
options.add(scenario.temperatureWords.toLowerCase())
return Array.from(options)
},
width: 'sm'
},
{
key: 'atis-dew',
label: 'Dew point',
expected: scenario => scenario.dewpoint.toString(),
alternatives: scenario => {
const value = scenario.dewpoint
const base = value.toString()
const plus = value > 0 ? `+${base}` : base
const c = `${base}°C`
const cSpaced = `${base} °C`
const shortC = value > 0 ? `${plus}C` : `${base}C`
const options = new Set<string>([
base,
plus,
c,
cSpaced,
shortC,
scenario.dewpointWords,
formatTemp(value)
])
options.add(`${plus}°C`)
options.add(`${plus} °C`)
options.add(scenario.dewpointWords.toLowerCase())
return Array.from(options)
},
width: 'sm'
},
{
key: 'atis-qnh',
label: 'QNH',
expected: scenario => scenario.qnh.toString(),
alternatives: scenario => [`QNH ${scenario.qnh}`, scenario.qnhWords],
width: 'sm'
}
],
readback: [
{ type: 'text', text: 'Information ' },
{ type: 'field', key: 'atis-code', width: 'xs' },
{ type: 'text', text: ', runway ' },
{ type: 'field', key: 'atis-runway', width: 'sm' },
{ type: 'text', text: ', wind ' },
{ type: 'field', key: 'atis-wind', width: 'md' },
{ type: 'text', text: ', visibility ' },
{ type: 'field', key: 'atis-visibility', width: 'sm' },
{ type: 'text', text: ', temperature ' },
{ type: 'field', key: 'atis-temp', width: 'sm' },
{ type: 'text', text: ', dew point ' },
{ type: 'field', key: 'atis-dew', width: 'sm' },
{ type: 'text', text: ', QNH ' },
{ type: 'field', key: 'atis-qnh', width: 'sm' }
],
defaultFrequency: 'ATIS',
phrase: scenario => scenario.atisText,
info: () => [
'Note the identifier (NATO word), runway, wind, visibility, temperature, dew point, and QNH.',
'Temperatures and dew point can be recorded as plain numbers; visibility may be metres or kilometres.'
],
generate: createBaseScenario
},
{
id: 'metar',
title: 'Decode the METAR',
desc: 'Extract the raw METAR values',
keywords: ['METAR', 'Weather'],
hints: [
'Read the METAR in blocks: wind visibility clouds temperature QNH.',
'The temperature block looks like 18/10; negative values start with M.'
],
fields: [
{
key: 'metar-wind',
label: 'Wind',
expected: scenario => scenario.metarSegments.wind,
alternatives: scenario => [
scenario.metarSegments.wind,
`${scenario.wind.replace('/', '')}KT`,
scenario.wind
],
width: 'md'
},
{
key: 'metar-vis',
label: 'Visibility',
expected: scenario => scenario.metarSegments.visibility,
alternatives: scenario => [scenario.visibility],
placeholder: 'Enter visibility value',
width: 'sm',
inputmode: 'numeric'
},
{
key: 'metar-temp',
label: 'Temp/Dew',
expected: scenario => scenario.metarSegments.temp,
alternatives: scenario => [
`${formatTemp(scenario.temperature)}/${formatTemp(scenario.dewpoint)}`
],
width: 'md'
},
{
key: 'metar-qnh',
label: 'QNH',
expected: scenario => scenario.metarSegments.qnh,
alternatives: scenario => [`Q${scenario.qnh}`, scenario.qnh.toString()],
width: 'sm'
}
],
readback: [
{ type: 'text', text: 'Wind ' },
{ type: 'field', key: 'metar-wind', width: 'md' },
{ type: 'text', text: ', visibility ' },
{ type: 'field', key: 'metar-vis', width: 'sm' },
{ type: 'text', text: ', temperature ' },
{ type: 'field', key: 'metar-temp', width: 'md' },
{ type: 'text', text: ', QNH ' },
{ type: 'field', key: 'metar-qnh', width: 'sm' }
],
defaultFrequency: 'ATIS',
phrase: scenario => scenario.metar,
info: scenario => [
`METAR: ${scenario.metar}`,
`Interpretation: Wind ${scenario.metarSegments.wind}, visibility ${scenario.visibilityWords}, temperature ${scenario.temperature}°C, QNH ${scenario.qnh}`
],
generate: createBaseScenario
},
{
id: 'radio-check',
title: 'Radio Check',
desc: 'Acknowledge a requested radio check',
keywords: ['Ground', 'Comms'],
hints: [
'Reply with "Readability" plus the number you hear.',
'Finish with your call sign; no need to repeat the frequency.'
],
fields: [
{
key: 'rc-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => {
const variants = new Set<string>()
const add = (value?: string) => {
if (!value) return
variants.add(value)
variants.add(value.toLowerCase())
}
add(scenario.radioCall)
add(`${scenario.airlineCall} ${scenario.flightNumber}`)
add(`${scenario.airlineCall} ${scenario.flightNumberWords}`)
add(scenario.callsign)
add(`${scenario.airlineCode} ${scenario.flightNumber}`)
add(`${scenario.callsignNato} ${scenario.flightNumberWords}`)
return Array.from(variants)
},
placeholder: 'Enter call sign',
width: 'lg'
},
{
key: 'rc-readability',
label: 'Readability',
expected: scenario => scenario.readabilityWord,
alternatives: () => ['1', '2', '3', '4', '5', 'one', 'two', 'three', 'four', 'five', 'wun', 'too', 'tree', 'fife'],
placeholder: '15',
width: 'sm'
}
],
readback: [
{ type: 'text', text: 'Readability ' },
{ type: 'field', key: 'rc-readability', width: 'sm' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'rc-callsign', width: 'lg' }
],
defaultFrequency: 'GND',
phrase: scenario => `${scenario.radioCall}, ${scenario.airport.name} Ground, say radio check on ${scenario.groundFreq}.`,
info: scenario => [
`Request: ${scenario.airport.name} Ground on ${scenario.groundFreq} (${scenario.frequencyWords.GND})`,
`Respond: Readability ${scenario.readability} (${scenario.readabilityWord}) with your call sign`
],
generate: createBaseScenario
},
{
id: 'frequency-change',
title: 'Frequency Change Readback',
desc: 'Confirm a handoff instruction',
keywords: ['Comms', 'Frequency'],
hints: [
'Repeat the station and frequency before your call sign.',
'State the frequency as digits or with "decimal".'
],
fields: [
{
key: 'freq-contact-frequency',
label: 'Frequency',
expected: scenario => scenario.handoff.frequency,
threshold: 0.99,
alternatives: scenario => {
const freq = scenario.handoff.frequency
const trimmed = freq.replace(/\.?0+$/, '')
const comma = freq.replace('.', ',')
const trimmedComma = trimmed.replace('.', ',')
const spaced = freq.replace('.', ' ')
const trimmedSpaced = trimmed.replace('.', ' ')
const words = scenario.handoff.frequencyWords
const pointWords = words.replace('decimal', 'point')
const compact = freq.replace('.', '')
const trimmedCompact = trimmed.replace('.', '')
const values = new Set<string>([
freq,
trimmed,
comma,
trimmedComma,
spaced,
trimmedSpaced,
words,
words.toLowerCase(),
pointWords,
pointWords.toLowerCase(),
compact,
trimmedCompact
])
return Array.from(values)
},
placeholder: 'Enter contact frequency',
width: 'md'
}
],
readback: [
{ type: 'text', text: scenario => `Contact ${scenario.handoff.facility} on ` },
{ type: 'field', key: 'freq-contact-frequency', width: 'md' },
{ type: 'text', text: scenario => `, ${scenario.radioCall}` }
],
defaultFrequency: 'TWR',
phrase: scenario => `${scenario.radioCall}, contact ${scenario.handoff.facility} on ${scenario.handoff.frequencyWords}.`,
info: scenario => [
`Station: ${scenario.handoff.facility}`,
`Frequency: ${scenario.handoff.frequency} (${scenario.handoff.frequencyWords})`
],
generate: createBaseScenario
}
]
const readbackLessons: Lesson[] = [
{
id: 'clearance-readback',
title: 'Clearance Readback',
desc: 'Read back the clearance in full',
keywords: ['Delivery', 'Readback'],
hints: [
'Remember the order: destination SID runway altitude squawk.',
'Speak altitude and squawk digits clearly.'
],
fields: [
{
key: 'clr-dest',
label: 'Destination',
expected: scenario => scenario.destination.city,
alternatives: scenario => [scenario.destination.icao, scenario.destination.name],
width: 'md'
},
{
key: 'clr-sid',
label: 'SID',
expected: scenario => scenario.sid,
width: 'lg'
},
{
key: 'clr-runway',
label: 'Runway',
expected: scenario => scenario.runway,
alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords],
width: 'sm'
},
{
key: 'clr-alt',
label: 'Initial Altitude',
expected: scenario => scenario.altitudes.initialWords,
alternatives: scenario => [scenario.altitudes.initial.toString(), `${scenario.altitudes.initial} feet`],
width: 'md'
},
{
key: 'clr-squawk',
label: 'Squawk',
expected: scenario => scenario.squawkWords,
alternatives: scenario => [scenario.squawk, scenario.squawk.split('').join(' ')],
width: 'md'
}
],
readback: [
{ type: 'text', text: scenario => `${scenario.radioCall} cleared ` },
{ type: 'field', key: 'clr-dest', width: 'md' },
{ type: 'text', text: ' via ' },
{ type: 'field', key: 'clr-sid', width: 'lg' },
{ type: 'text', text: ', runway ' },
{ type: 'field', key: 'clr-runway', width: 'sm' },
{ type: 'text', text: ', climb ' },
{ type: 'field', key: 'clr-alt', width: 'md' },
{ type: 'text', text: ', squawk ' },
{ type: 'field', key: 'clr-squawk', width: 'md' }
],
defaultFrequency: 'DEL',
phrase: scenario => `${scenario.radioCall}, cleared to ${scenario.destination.city} via ${scenario.sid}, runway ${scenario.runway}, climb ${scenario.altitudes.initial} feet, squawk ${scenario.squawk}.`,
info: scenario => [
`SID: ${scenario.sid} (${scenario.transition})`,
`Initial Altitude: ${scenario.altitudes.initial} ft (${scenario.altitudes.initialWords})`,
`Squawk: ${scenario.squawk}`
],
generate: createBaseScenario
},
{
id: 'clearance-amendment',
title: 'Amended Clearance',
desc: 'Confirm the updated SID, transition and runway',
keywords: ['Delivery', 'Amendment'],
hints: [
'Start with "Amended clearance" followed by SID and transition.',
'Include the runway and end with your call sign.'
],
fields: [
{
key: 'clr-amend-sid',
label: 'SID',
expected: scenario => scenario.sid,
width: 'lg'
},
{
key: 'clr-amend-transition',
label: 'Transition',
expected: scenario => scenario.transition,
width: 'md'
},
{
key: 'clr-amend-runway',
label: 'Runway',
expected: scenario => scenario.runway,
alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords],
width: 'sm'
},
{
key: 'clr-amend-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Amended clearance: ' },
{ type: 'field', key: 'clr-amend-sid', width: 'lg' },
{ type: 'text', text: ' ' },
{ type: 'field', key: 'clr-amend-transition', width: 'md' },
{ type: 'text', text: ' departure, runway ' },
{ type: 'field', key: 'clr-amend-runway', width: 'sm' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'clr-amend-callsign', width: 'lg' }
],
defaultFrequency: 'DEL',
phrase: scenario => `${scenario.radioCall}, amended clearance: ${scenario.sid} ${scenario.transition} departure, runway ${scenario.runway}.`,
info: scenario => [
`SID: ${scenario.sid}`,
`Transition: ${scenario.transition}`,
`Runway: ${scenario.runway}`
],
generate: createBaseScenario
},
{
id: 'pushback',
title: 'Pushback Clearance',
desc: 'Acknowledge the pushback approval',
keywords: ['Ground', 'Pushback'],
hints: [
'Repeat the direction you must face and the QNH.',
'Keep the call sign at the end.'
],
fields: [
{
key: 'push-runway',
label: 'Runway',
expected: scenario => scenario.runway,
alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords],
width: 'sm'
},
{
key: 'push-qnh',
label: 'QNH',
expected: scenario => scenario.qnh.toString(),
alternatives: scenario => [`QNH ${scenario.qnh}`, scenario.qnhWords],
width: 'sm'
},
{
key: 'push-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Push and start approved, facing runway ' },
{ type: 'field', key: 'push-runway', width: 'sm' },
{ type: 'text', text: ', QNH ' },
{ type: 'field', key: 'push-qnh', width: 'sm' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'push-callsign', width: 'lg' }
],
defaultFrequency: 'GND',
phrase: scenario => `${scenario.radioCall}, push and start approved, facing runway ${scenario.runway}. QNH ${scenario.qnh}.`,
info: scenario => [
`Stand: ${scenario.stand}`,
`Ground frequency: ${scenario.groundFreq} (${scenario.frequencyWords.GND})`
],
generate: createBaseScenario
},
{
id: 'pushback-delay',
title: 'Pushback Delay Acknowledgement',
desc: 'Confirm delayed push and the expected taxi route',
keywords: ['Ground', 'Pushback', 'Exception'],
hints: [
'Read back the delay before the taxi route.',
'Close with the call sign.'
],
fields: [
{
key: 'push-delay',
label: 'Delay',
expected: scenario => scenario.pushDelayWords,
alternatives: scenario => [
scenario.pushDelayWords,
`${scenario.pushDelayMinutes} minutes`,
scenario.pushDelayMinutes.toString()
],
width: 'md'
},
{
key: 'push-route',
label: 'Taxi Route',
expected: scenario => scenario.taxiRoute,
alternatives: scenario => [scenario.taxiRoute, `via ${scenario.taxiRoute}`],
width: 'lg'
},
{
key: 'push-delay-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Push and start approved in ' },
{ type: 'field', key: 'push-delay', width: 'md' },
{ type: 'text', text: ' minutes, expect taxi via ' },
{ type: 'field', key: 'push-route', width: 'lg' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'push-delay-callsign', width: 'lg' }
],
defaultFrequency: 'GND',
phrase: scenario => `${scenario.radioCall}, push and start approved in ${scenario.pushDelayMinutes} minutes, expect taxi via ${scenario.taxiRoute}.`,
info: scenario => [
`Delay: ${scenario.pushDelayMinutes} minutes (${scenario.pushDelayWords})`,
`Taxi route: ${scenario.taxiRoute}`
],
generate: createBaseScenario
},
{
id: 'taxi',
title: 'Taxi Readback',
desc: 'Read back the taxi clearance with hold short',
keywords: ['Ground', 'Taxi'],
hints: [
'Repeat the route exactly as received, including the hold short.',
'Finish with the call sign.'
],
fields: [
{
key: 'taxi-runway',
label: 'Runway',
expected: scenario => scenario.runway,
alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords],
width: 'sm'
},
{
key: 'taxi-route',
label: 'Route',
expected: scenario => scenario.taxiRoute,
alternatives: scenario => [scenario.taxiRoute, `via ${scenario.taxiRoute}`],
width: 'lg'
},
{
key: 'taxi-hold',
label: 'Hold Short',
expected: scenario => scenario.runway,
alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords],
width: 'sm'
},
{
key: 'taxi-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Taxi to runway ' },
{ type: 'field', key: 'taxi-runway', width: 'sm' },
{ type: 'text', text: ' via ' },
{ type: 'field', key: 'taxi-route', width: 'lg' },
{ type: 'text', text: ', holding short runway ' },
{ type: 'field', key: 'taxi-hold', width: 'sm' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'taxi-callsign', width: 'lg' }
],
defaultFrequency: 'GND',
phrase: scenario => `${scenario.radioCall}, taxi to runway ${scenario.runway} via ${scenario.taxiRoute}, hold short runway ${scenario.runway}.`,
info: scenario => [
`Taxi route: ${scenario.taxiRoute}`,
`Hold short: ${scenario.runway}`
],
generate: createBaseScenario
},
{
id: 'taxi-complex',
title: 'Complex Taxi Route',
desc: 'Handle long taxi instructions with multiple waypoints',
keywords: ['Ground', 'Taxi', 'Advanced'],
hints: [
'Copy the entire route, including every waypoint in the order delivered.',
'Group waypoints into small chunks so you can read the route back smoothly.'
],
fields: [
{
key: 'taxi-complex-runway',
label: 'Runway',
expected: scenario => scenario.runway,
alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords],
width: 'sm'
},
{
key: 'taxi-complex-route',
label: 'Route',
expected: scenario => scenario.taxiRoute,
alternatives: scenario => [scenario.taxiRoute, `via ${scenario.taxiRoute}`],
width: 'xl'
},
{
key: 'taxi-complex-hold',
label: 'Hold Short',
expected: scenario => scenario.runway,
alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords],
width: 'sm'
},
{
key: 'taxi-complex-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Taxi to runway ' },
{ type: 'field', key: 'taxi-complex-runway', width: 'sm' },
{ type: 'text', text: ' via ' },
{ type: 'field', key: 'taxi-complex-route', width: 'xl' },
{ type: 'text', text: ', holding short runway ' },
{ type: 'field', key: 'taxi-complex-hold', width: 'sm' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'taxi-complex-callsign', width: 'lg' }
],
defaultFrequency: 'GND',
phrase: scenario => `${scenario.radioCall}, taxi to runway ${scenario.runway} via ${scenario.taxiRoute}, hold short runway ${scenario.runway}.`,
info: scenario => [
`Taxi route: ${scenario.taxiRoute}`,
`Hold short: ${scenario.runway}`
],
generate: createComplexTaxiScenario
},
{
id: 'lineup',
title: 'Line-up Clearance',
desc: 'Acknowledge line up and wait',
keywords: ['Tower', 'Line Up'],
hints: [
'In the readback, state the runway first — even though the controller said it last.',
'ICAO standard: runway leads the readback, then "line up and wait", then your call sign.'
],
fields: [
{
key: 'lineup-runway',
label: 'Runway',
expected: scenario => scenario.runway,
alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords],
width: 'sm'
},
{
key: 'lineup-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Runway ' },
{ type: 'field', key: 'lineup-runway', width: 'sm' },
{ type: 'text', text: ', line up and wait, ' },
{ type: 'field', key: 'lineup-callsign', width: 'lg' }
],
defaultFrequency: 'TWR',
phrase: scenario => `${scenario.radioCall}, line up and wait runway ${scenario.runway}.`,
info: scenario => [
`Tower: ${scenario.towerFreq} (${scenario.frequencyWords.TWR})`,
`Line-up runway: ${scenario.runway}`
],
generate: createBaseScenario
},
{
id: 'takeoff',
title: 'Takeoff Clearance',
desc: 'Acknowledge the takeoff clearance',
keywords: ['Tower', 'Departure'],
hints: [
'Order: wind runway cleared for takeoff call sign.',
'Write the wind as direction/speed, e.g. 030/12.'
],
fields: [
{
key: 'tko-wind',
label: 'Wind',
expected: scenario => scenario.wind,
alternatives: scenario => [
scenario.wind,
`${scenario.wind}KT`,
scenario.windWords
],
width: 'md'
},
{
key: 'tko-runway',
label: 'Runway',
expected: scenario => scenario.runway,
alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords],
width: 'sm'
},
{
key: 'tko-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Wind ' },
{ type: 'field', key: 'tko-wind', width: 'md' },
{ type: 'text', text: ', runway ' },
{ type: 'field', key: 'tko-runway', width: 'sm' },
{ type: 'text', text: ', cleared for takeoff, ' },
{ type: 'field', key: 'tko-callsign', width: 'lg' }
],
defaultFrequency: 'TWR',
phrase: scenario => `${scenario.radioCall}, wind ${scenario.windWords}, runway ${scenario.runway}, cleared for takeoff.`,
info: scenario => [
`Wind: ${scenario.wind} (${scenario.windWords})`,
`Runway: ${scenario.runway}`
],
generate: createBaseScenario
},
{
id: 'climb-instruction',
title: 'Climb & Direct',
desc: 'Read back the climb instruction in full',
keywords: ['Departure', 'Climb'],
hints: [
'Start with "Climb", then the altitude and any direct clearance.',
'Repeat the call sign at the end.'
],
fields: [
{
key: 'climb-alt',
label: 'Altitude',
expected: scenario => scenario.altitudes.climbWords,
alternatives: scenario => [
scenario.altitudes.climbWords,
scenario.altitudes.climb.toString(),
`${scenario.altitudes.climb} feet`
],
width: 'md'
},
{
key: 'climb-direct',
label: 'Direct',
expected: scenario => scenario.transition,
width: 'md'
},
{
key: 'climb-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Climb ' },
{ type: 'field', key: 'climb-alt', width: 'md' },
{ type: 'text', text: ', direct ' },
{ type: 'field', key: 'climb-direct', width: 'md' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'climb-callsign', width: 'lg' }
],
defaultFrequency: 'DEP',
phrase: scenario => `${scenario.radioCall}, climb ${scenario.altitudes.climb} feet, proceed direct ${scenario.transition}.`,
info: scenario => [
`Climb: ${scenario.altitudes.climb} ft (${scenario.altitudes.climbWords})`,
`Direct to: ${scenario.transition}`
],
generate: createBaseScenario
},
{
id: 'climb-alt-route',
title: 'Climb & Reroute',
desc: 'Confirm a new altitude and routing',
keywords: ['Center', 'Climb'],
hints: [
'Mention the altitude first, then the direct routing.',
'End the readback with your call sign.'
],
fields: [
{
key: 'climb-alt-route-alt',
label: 'Altitude',
expected: scenario => scenario.altitudes.climbWords,
alternatives: scenario => [
scenario.altitudes.climbWords,
scenario.altitudes.climb.toString(),
`${scenario.altitudes.climb} feet`
],
width: 'md'
},
{
key: 'climb-alt-route-direct',
label: 'Direct',
expected: scenario => scenario.transition,
width: 'md'
},
{
key: 'climb-alt-route-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Climb ' },
{ type: 'field', key: 'climb-alt-route-alt', width: 'md' },
{ type: 'text', text: ', proceed direct ' },
{ type: 'field', key: 'climb-alt-route-direct', width: 'md' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'climb-alt-route-callsign', width: 'lg' }
],
defaultFrequency: 'CTR',
phrase: scenario => `${scenario.radioCall}, climb to ${scenario.altitudes.climb} feet, proceed direct ${scenario.transition}.`,
info: scenario => [
`Requested altitude: ${scenario.altitudes.climb} ft`,
`New routing: direct ${scenario.transition}`
],
generate: createBaseScenario
},
{
id: 'departure-handoff',
title: 'Departure Handoff',
desc: 'Switch to departure',
keywords: ['Departure', 'Handoff'],
hints: [
'Repeat the frequency exactly, either as digits or spoken form.',
'Append the call sign at the end.'
],
fields: [
{
key: 'dep-freq',
label: 'Frequency',
expected: scenario => scenario.departureFreq,
alternatives: scenario => [
scenario.departureFreq,
scenario.departureFreq.replace('.', ''),
scenario.frequencyWords.DEP
],
width: 'md'
},
{
key: 'dep-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Contact departure ' },
{ type: 'field', key: 'dep-freq', width: 'md' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'dep-callsign', width: 'lg' }
],
defaultFrequency: 'DEP',
phrase: scenario => `${scenario.radioCall}, contact departure ${scenario.departureFreq}.`,
info: scenario => [
`Departure: ${scenario.departureFreq} (${scenario.frequencyWords.DEP})`,
`Tower handoff after departure.`
],
generate: createBaseScenario
},
{
id: 'center-handoff',
title: 'Center Handoff',
desc: 'Confirm the handoff to center',
keywords: ['Enroute', 'Handoff'],
hints: [
'Repeat the frequency and include your call sign.',
'You can say the digits or the spoken version.'
],
fields: [
{
key: 'center-freq',
label: 'Frequency',
expected: scenario => scenario.centerFreq,
alternatives: scenario => [
scenario.centerFreq,
scenario.centerFreq.replace('.', ''),
scenario.frequencyWords.CTR
],
width: 'md'
},
{
key: 'center-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Contact center ' },
{ type: 'field', key: 'center-freq', width: 'md' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'center-callsign', width: 'lg' }
],
defaultFrequency: 'CTR',
phrase: scenario => `${scenario.radioCall}, contact Center ${scenario.centerFreq}.`,
info: scenario => [
`Center: ${scenario.centerFreq} (${scenario.frequencyWords.CTR})`,
`Expect to report level or route as required.`
],
generate: createBaseScenario
},
{
id: 'descent-clearance',
title: 'Descent Clearance',
desc: 'Read back the STAR, transition and QNH',
keywords: ['Approach', 'Descent', 'Readback'],
hints: [
'State the STAR and transition in order.',
'Include the QNH and end with your call sign.'
],
fields: [
{
key: 'descent-star',
label: 'STAR',
expected: scenario => scenario.arrivalStar,
width: 'lg'
},
{
key: 'descent-transition',
label: 'Transition',
expected: scenario => scenario.arrivalTransition,
width: 'md'
},
{
key: 'descent-qnh',
label: 'QNH',
expected: scenario => scenario.arrivalQnh.toString(),
alternatives: scenario => [
scenario.arrivalQnh.toString(),
`QNH ${scenario.arrivalQnh}`,
scenario.arrivalQnhWords
],
width: 'sm'
},
{
key: 'descent-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Descend via ' },
{ type: 'field', key: 'descent-star', width: 'lg' },
{ type: 'text', text: ' ' },
{ type: 'field', key: 'descent-transition', width: 'md' },
{ type: 'text', text: ', QNH ' },
{ type: 'field', key: 'descent-qnh', width: 'sm' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'descent-callsign', width: 'lg' }
],
defaultFrequency: 'APP',
phrase: scenario => `${scenario.radioCall}, descend via ${scenario.arrivalStar} ${scenario.arrivalTransition}, QNH ${scenario.arrivalQnh}.`,
info: scenario => [
`STAR: ${scenario.arrivalStar}`,
`Transition: ${scenario.arrivalTransition}`,
`Arrival QNH: ${scenario.arrivalQnh} (${scenario.arrivalQnhWords})`
],
generate: createBaseScenario
},
{
id: 'approach-vector',
title: 'Approach Vector',
desc: 'Read back heading, altitude and speed',
keywords: ['Approach', 'Vector'],
hints: [
'Heading, then altitude, then speed restriction.',
'Use ATC number words for the speed and altitude.'
],
fields: [
{
key: 'vector-heading',
label: 'Heading',
expected: scenario => scenario.vectorHeading,
alternatives: scenario => [
scenario.vectorHeading,
scenario.vectorHeadingWords
],
width: 'md'
},
{
key: 'vector-alt',
label: 'Altitude',
expected: scenario => scenario.altitudes.initialWords,
alternatives: scenario => [
scenario.altitudes.initialWords,
scenario.altitudes.initial.toString(),
`${scenario.altitudes.initial} feet`
],
width: 'md'
},
{
key: 'vector-speed',
label: 'Speed',
expected: scenario => scenario.speedRestrictionWords,
alternatives: scenario => [
scenario.speedRestrictionWords,
`${scenario.speedRestriction} knots`,
scenario.speedRestriction.toString()
],
width: 'md'
},
{
key: 'vector-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Heading ' },
{ type: 'field', key: 'vector-heading', width: 'md' },
{ type: 'text', text: ', descend to ' },
{ type: 'field', key: 'vector-alt', width: 'md' },
{ type: 'text', text: ', reduce speed ' },
{ type: 'field', key: 'vector-speed', width: 'md' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'vector-callsign', width: 'lg' }
],
defaultFrequency: 'APP',
phrase: scenario => `${scenario.radioCall}, turn left heading ${scenario.vectorHeading}, descend to ${scenario.altitudes.initial} feet, reduce speed ${scenario.speedRestriction}.`,
info: scenario => [
`Heading: ${scenario.vectorHeading} (${scenario.vectorHeadingWords})`,
`Altitude: ${scenario.altitudes.initial} ft`,
`Speed restriction: ${scenario.speedRestriction} (${scenario.speedRestrictionWords})`
],
generate: createBaseScenario
},
{
id: 'approach-clearance',
title: 'Approach Clearance',
desc: 'Confirm the approach type and runway',
keywords: ['Approach', 'Readback'],
hints: [
'Say "Cleared" followed by the approach.',
'Repeat the runway and finish with your call sign.'
],
fields: [
{
key: 'app-type',
label: 'Approach',
expected: scenario => scenario.approach,
width: 'lg'
},
{
key: 'app-runway',
label: 'Runway',
expected: scenario => scenario.arrivalRunway,
alternatives: scenario => [
scenario.arrivalRunway.replace(/^0/, ''),
scenario.arrivalRunwayWords
],
width: 'sm'
},
{
key: 'app-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Cleared ' },
{ type: 'field', key: 'app-type', width: 'lg' },
{ type: 'text', text: ' approach runway ' },
{ type: 'field', key: 'app-runway', width: 'sm' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'app-callsign', width: 'lg' }
],
defaultFrequency: 'APP',
phrase: scenario => `${scenario.radioCall}, cleared ${scenario.approach} approach runway ${scenario.arrivalRunway}, report established.`,
info: scenario => [
`Approach: ${scenario.approach}`,
`Runway: ${scenario.arrivalRunway} (${scenario.arrivalRunwayWords})`
],
generate: createBaseScenario
},
{
id: 'approach-contact',
title: 'Approach Contact',
desc: 'Switch from center to approach',
keywords: ['Approach', 'Handoff'],
hints: [
'Repeat the frequency and add your call sign.',
'You can pronounce the frequency as numbers or using "decimal".'
],
fields: [
{
key: 'app-contact-freq',
label: 'Frequency',
expected: scenario => scenario.approachFreq,
alternatives: scenario => [
scenario.approachFreq,
scenario.approachFreq.replace('.', ''),
scenario.frequencyWords.APP
],
width: 'md'
},
{
key: 'app-contact-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Contact approach ' },
{ type: 'field', key: 'app-contact-freq', width: 'md' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'app-contact-callsign', width: 'lg' }
],
defaultFrequency: 'APP',
phrase: scenario => `${scenario.radioCall}, contact Approach ${scenario.approachFreq}.`,
info: scenario => [
`Approach: ${scenario.approachFreq} (${scenario.frequencyWords.APP})`
],
generate: createBaseScenario
},
{
id: 'tower-contact',
title: 'Tower Handoff',
desc: 'Confirm the switch to tower when number one',
keywords: ['Tower', 'Handoff'],
hints: [
'Repeat the frequency and mention "when number one".',
'End with the call sign.'
],
fields: [
{
key: 'tower-contact-freq',
label: 'Frequency',
expected: scenario => scenario.towerFreq,
alternatives: scenario => [
scenario.towerFreq,
scenario.towerFreq.replace('.', ''),
scenario.frequencyWords.TWR
],
width: 'md'
},
{
key: 'tower-contact-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Contact tower ' },
{ type: 'field', key: 'tower-contact-freq', width: 'md' },
{ type: 'text', text: ' when number one, ' },
{ type: 'field', key: 'tower-contact-callsign', width: 'lg' }
],
defaultFrequency: 'GND',
phrase: scenario => `${scenario.radioCall}, contact Tower ${scenario.towerFreq} when number one.`,
info: scenario => [
`Tower frequency: ${scenario.towerFreq} (${scenario.frequencyWords.TWR})`
],
generate: createBaseScenario
},
{
id: 'landing-clearance',
title: 'Landing Clearance',
desc: 'Read back the landing clearance',
keywords: ['Tower', 'Landing', 'Readback'],
hints: [
'Order: wind runway cleared to land call sign.',
'Write the wind as direction/speed, e.g. 260/08.'
],
fields: [
{
key: 'landing-wind',
label: 'Wind',
expected: scenario => scenario.arrivalWind,
alternatives: scenario => [
scenario.arrivalWind,
`${scenario.arrivalWind}KT`,
scenario.arrivalWindWords
],
width: 'md'
},
{
key: 'landing-runway',
label: 'Runway',
expected: scenario => scenario.arrivalRunway,
alternatives: scenario => [
scenario.arrivalRunway.replace(/^0/, ''),
scenario.arrivalRunwayWords
],
width: 'sm'
},
{
key: 'landing-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Wind ' },
{ type: 'field', key: 'landing-wind', width: 'md' },
{ type: 'text', text: ', runway ' },
{ type: 'field', key: 'landing-runway', width: 'sm' },
{ type: 'text', text: ', cleared to land, ' },
{ type: 'field', key: 'landing-callsign', width: 'lg' }
],
defaultFrequency: 'TWR',
phrase: scenario => `${scenario.radioCall}, wind ${scenario.arrivalWindWords}, runway ${scenario.arrivalRunway} cleared to land.`,
info: scenario => [
`Wind: ${scenario.arrivalWind} (${scenario.arrivalWindWords})`,
`Runway: ${scenario.arrivalRunway}`
],
generate: createBaseScenario
},
{
id: 'vacate',
title: 'Vacate Instruction',
desc: 'Confirm runway exit and ground frequency',
keywords: ['Tower', 'Arrival'],
hints: [
'Repeat the taxi route for vacating the runway.',
'Include the ground frequency before your call sign.'
],
fields: [
{
key: 'vacate-route',
label: 'Taxi Route',
expected: scenario => scenario.arrivalTaxiRoute,
alternatives: scenario => [
scenario.arrivalTaxiRoute,
`via ${scenario.arrivalTaxiRoute}`
],
width: 'lg'
},
{
key: 'vacate-freq',
label: 'Ground Frequency',
expected: scenario => scenario.groundFreq,
alternatives: scenario => [
scenario.groundFreq,
scenario.groundFreq.replace('.', ''),
scenario.frequencyWords.GND
],
width: 'md'
},
{
key: 'vacate-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Vacate via ' },
{ type: 'field', key: 'vacate-route', width: 'lg' },
{ type: 'text', text: ', contact ground ' },
{ type: 'field', key: 'vacate-freq', width: 'md' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'vacate-callsign', width: 'lg' }
],
defaultFrequency: 'TWR',
phrase: scenario => `${scenario.radioCall}, vacate via ${scenario.arrivalTaxiRoute}, contact Ground ${scenario.groundFreq}.`,
info: scenario => [
`Taxi route: ${scenario.arrivalTaxiRoute}`,
`Ground: ${scenario.groundFreq} (${scenario.frequencyWords.GND})`
],
generate: createBaseScenario
},
{
id: 'taxi-in-readback',
title: 'Taxi-In Readback',
desc: 'Acknowledge taxi instructions to the stand',
keywords: ['Ground', 'Taxi', 'Arrival'],
hints: [
'Repeat the stand and the full route.',
'End with your call sign.'
],
fields: [
{
key: 'taxi-in-stand',
label: 'Stand',
expected: scenario => scenario.arrivalStand,
width: 'sm'
},
{
key: 'taxi-in-route',
label: 'Route',
expected: scenario => scenario.arrivalTaxiRoute,
alternatives: scenario => [
scenario.arrivalTaxiRoute,
`via ${scenario.arrivalTaxiRoute}`
],
width: 'lg'
},
{
key: 'taxi-in-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Taxi to stand ' },
{ type: 'field', key: 'taxi-in-stand', width: 'sm' },
{ type: 'text', text: ' via ' },
{ type: 'field', key: 'taxi-in-route', width: 'lg' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'taxi-in-callsign', width: 'lg' }
],
defaultFrequency: 'GND',
phrase: scenario => `${scenario.radioCall}, taxi to stand ${scenario.arrivalStand} via ${scenario.arrivalTaxiRoute}.`,
info: scenario => [
`Stand: ${scenario.arrivalStand}`,
`Taxi route: ${scenario.arrivalTaxiRoute}`
],
generate: createBaseScenario
},
{
id: 'mayday-vector',
title: 'MAYDAY Vector Readback',
desc: 'Confirm emergency heading, altitude and QNH',
keywords: ['Emergency', 'Mayday'],
hints: [
'Acknowledge the heading, altitude, direct routing and QNH.',
'Keep the call sign at the end even in emergencies.'
],
fields: [
{
key: 'mayday-heading',
label: 'Heading',
expected: scenario => scenario.emergencyHeading,
alternatives: scenario => [
scenario.emergencyHeading,
scenario.emergencyHeadingWords
],
width: 'md'
},
{
key: 'mayday-alt',
label: 'Altitude',
expected: scenario => scenario.altitudes.initialWords,
alternatives: scenario => [
scenario.altitudes.initialWords,
scenario.altitudes.initial.toString(),
`${scenario.altitudes.initial} feet`
],
width: 'md'
},
{
key: 'mayday-dest',
label: 'Direct',
expected: scenario => scenario.destination.city,
alternatives: scenario => [
scenario.destination.city,
scenario.destination.icao,
scenario.destination.name
],
width: 'md'
},
{
key: 'mayday-qnh',
label: 'QNH',
expected: scenario => scenario.qnh.toString(),
alternatives: scenario => [`QNH ${scenario.qnh}`, scenario.qnhWords],
width: 'sm'
},
{
key: 'mayday-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Heading ' },
{ type: 'field', key: 'mayday-heading', width: 'md' },
{ type: 'text', text: ', climb ' },
{ type: 'field', key: 'mayday-alt', width: 'md' },
{ type: 'text', text: ', direct ' },
{ type: 'field', key: 'mayday-dest', width: 'md' },
{ type: 'text', text: ' when able, QNH ' },
{ type: 'field', key: 'mayday-qnh', width: 'sm' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'mayday-callsign', width: 'lg' }
],
defaultFrequency: 'CTR',
phrase: scenario => `${scenario.radioCall}, roger MAYDAY, fly heading ${scenario.emergencyHeading}, climb ${scenario.altitudes.initial} feet, direct ${scenario.destination.city} when able, QNH ${scenario.qnh}.`,
info: scenario => [
`Emergency heading: ${scenario.emergencyHeading} (${scenario.emergencyHeadingWords})`,
`Initial altitude: ${scenario.altitudes.initial} ft`,
`QNH: ${scenario.qnh}`
],
generate: createBaseScenario
},
{
id: 'go-around-instruction',
title: 'Go-Around Instruction',
desc: 'Read back the published missed approach instructions',
keywords: ['Missed Approach', 'Readback'],
hints: [
'Acknowledge the missed approach, altitude and frequency.',
'State the call sign last even when workload is high.'
],
fields: [
{
key: 'goaround-missed',
label: 'Missed Approach',
expected: scenario => scenario.missedApproach,
alternatives: scenario => [
scenario.missedApproach,
'published missed approach'
],
width: 'lg'
},
{
key: 'goaround-alt',
label: 'Altitude',
expected: scenario => scenario.altitudes.initialWords,
alternatives: scenario => [
scenario.altitudes.initialWords,
scenario.altitudes.initial.toString(),
`${scenario.altitudes.initial} feet`
],
width: 'md'
},
{
key: 'goaround-freq',
label: 'Approach Frequency',
expected: scenario => scenario.approachFreq,
alternatives: scenario => [
scenario.approachFreq,
scenario.approachFreq.replace('.', ''),
scenario.frequencyWords.APP
],
width: 'md'
},
{
key: 'goaround-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Going around, ' },
{ type: 'field', key: 'goaround-missed', width: 'lg' },
{ type: 'text', text: ', climb ' },
{ type: 'field', key: 'goaround-alt', width: 'md' },
{ type: 'text', text: ', contact approach ' },
{ type: 'field', key: 'goaround-freq', width: 'md' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'goaround-callsign', width: 'lg' }
],
defaultFrequency: 'APP',
phrase: scenario => `${scenario.radioCall}, roger go-around, fly published missed approach, climb ${scenario.altitudes.initial} feet, contact Approach ${scenario.approachFreq}.`,
info: scenario => [
`Missed approach: ${scenario.missedApproach}`,
`Altitude: ${scenario.altitudes.initial} ft`,
`Approach frequency: ${scenario.approachFreq} (${scenario.frequencyWords.APP})`
],
generate: createBaseScenario
}
]
const decisionTreeLessons: Lesson[] = [
{
id: 'clearance-contact',
title: 'Delivery: Initial contact',
desc: 'Contact clearance delivery',
keywords: ['Delivery', 'Clearance'],
hints: [
'Order: unit, call sign, ATIS, destination, stand, request.',
'Say the ATIS as a single letter.'
],
fields: [
{
key: 'cd-atis',
label: 'ATIS',
expected: scenario => scenario.atisCode,
alternatives: scenario => [
scenario.atisCode,
scenario.atisCode.toLowerCase(),
`Information ${scenario.atisCode}`
],
width: 'xs',
threshold: 0.9
},
{
key: 'cd-dest',
label: 'Destination',
expected: scenario => scenario.destination.city,
alternatives: scenario => [scenario.destination.icao, scenario.destination.name],
width: 'md'
},
{
key: 'cd-stand',
label: 'Stand/Gate',
expected: scenario => scenario.stand,
width: 'sm'
}
],
readback: [
{
type: 'text',
text: scenario => `${scenario.airport.city} Delivery, ${scenario.radioCall}, information `
},
{ type: 'field', key: 'cd-atis', width: 'xs' },
{ type: 'text', text: ', IFR to ' },
{ type: 'field', key: 'cd-dest', width: 'md' },
{ type: 'text', text: ', stand ' },
{ type: 'field', key: 'cd-stand', width: 'sm' },
{ type: 'text', text: ', request clearance.' }
],
defaultFrequency: 'DEL',
phrase: scenario => `${scenario.airport.city} Delivery, ${scenario.radioCall}, information ${scenario.atisCode}, IFR to ${scenario.destination.city}, stand ${scenario.stand}, request clearance.`,
info: scenario => [
`ATIS: ${scenario.atisCode}`,
`Destination: ${scenario.destination.city} (${scenario.destination.icao})`,
`Stand/Gate: ${scenario.stand}`
],
generate: createBaseScenario
},
{
id: 'pushback-ready',
title: 'Ready for Pushback',
desc: 'Call ground when ready for push and start',
keywords: ['Ground', 'Pushback'],
hints: [
'Address the ground unit, include your stand, then say "ready for push and start".',
'Finish with the call sign.'
],
fields: [
{
key: 'pushready-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
},
{
key: 'pushready-stand',
label: 'Stand',
expected: scenario => scenario.stand,
width: 'sm'
}
],
readback: [
{ type: 'text', text: scenario => `${scenario.airport.city} Ground, ` },
{ type: 'field', key: 'pushready-callsign', width: 'lg' },
{ type: 'text', text: ', stand ' },
{ type: 'field', key: 'pushready-stand', width: 'sm' },
{ type: 'text', text: ', ready for push and start.' }
],
defaultFrequency: 'GND',
phrase: scenario => `${scenario.airport.city} Ground, ${scenario.radioCall}, stand ${scenario.stand}, ready for push and start.`,
info: scenario => [
`Stand: ${scenario.stand}`,
`Next expected clearance: pushback approval`
],
generate: createBaseScenario
},
{
id: 'taxi-request',
title: 'Taxi Request',
desc: 'Request taxi from ground when ready',
keywords: ['Ground', 'Taxi'],
hints: [
'Address the unit before your call sign and stand.',
'Finish with "request taxi".'
],
fields: [
{
key: 'taxi-req-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
},
{
key: 'taxi-req-stand',
label: 'Stand',
expected: scenario => scenario.stand,
width: 'sm'
}
],
readback: [
{ type: 'text', text: scenario => `${scenario.airport.city} Ground, ` },
{ type: 'field', key: 'taxi-req-callsign', width: 'lg' },
{ type: 'text', text: ', stand ' },
{ type: 'field', key: 'taxi-req-stand', width: 'sm' },
{ type: 'text', text: ', request taxi.' }
],
defaultFrequency: 'GND',
phrase: scenario => `${scenario.airport.city} Ground, ${scenario.radioCall}, stand ${scenario.stand}, request taxi.`,
info: scenario => [
`Stand: ${scenario.stand}`,
`Likely taxi route: ${scenario.taxiRoute}`
],
generate: createBaseScenario
},
{
id: 'lineup-request',
title: 'Ready for Departure',
desc: 'Advise tower you are ready for departure',
keywords: ['Tower', 'Departure'],
hints: [
'Include the runway and keep the message short.',
'Finish with your call sign.'
],
fields: [
{
key: 'lineup-req-runway',
label: 'Runway',
expected: scenario => scenario.runway,
alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords],
width: 'sm'
},
{
key: 'lineup-req-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: scenario => `${scenario.airport.city} Tower, ` },
{ type: 'field', key: 'lineup-req-callsign', width: 'lg' },
{ type: 'text', text: ', ready for departure runway ' },
{ type: 'field', key: 'lineup-req-runway', width: 'sm' }
],
defaultFrequency: 'TWR',
phrase: scenario => `${scenario.airport.city} Tower, ${scenario.radioCall}, ready for departure runway ${scenario.runway}.`,
info: scenario => [
`Runway in use: ${scenario.runway}`,
`Expect line-up or takeoff clearance`
],
generate: createBaseScenario
},
{
id: 'departure-checkin',
title: 'Departure Check-in',
desc: 'Initial call to departure',
keywords: ['Departure', 'Check-in'],
hints: [
'Address the unit first, then state the call sign.',
'Repeat the altitude and SID exactly as received.'
],
fields: [
{
key: 'depcheck-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
},
{
key: 'depcheck-alt',
label: 'Altitude',
expected: scenario => scenario.altitudes.initialWords,
alternatives: scenario => [
scenario.altitudes.initialWords,
scenario.altitudes.initial.toString(),
`${scenario.altitudes.initial} feet`
],
width: 'md'
},
{
key: 'depcheck-sid',
label: 'SID',
expected: scenario => scenario.sid,
width: 'lg'
}
],
readback: [
{ type: 'text', text: scenario => `${scenario.airport.city} Departure, ` },
{ type: 'field', key: 'depcheck-callsign', width: 'lg' },
{ type: 'text', text: ', passing ' },
{ type: 'field', key: 'depcheck-alt', width: 'md' },
{ type: 'text', text: ', on SID ' },
{ type: 'field', key: 'depcheck-sid', width: 'lg' }
],
defaultFrequency: 'DEP',
phrase: scenario => `${scenario.airport.city} Departure, ${scenario.radioCall}, passing ${scenario.altitudes.initialWords}, on SID ${scenario.sid}.`,
info: scenario => [
`Initial altitude: ${scenario.altitudes.initial} ft (${scenario.altitudes.initialWords})`,
`SID: ${scenario.sid}`
],
generate: createBaseScenario
},
{
id: 'unable-direct',
title: 'Unable Direct',
desc: 'Decline a direct routing and keep the SID',
keywords: ['Departure', 'Exception'],
hints: [
'Say "Unable direct" followed by the waypoint.',
'Finish with the call sign.'
],
fields: [
{
key: 'unable-transition',
label: 'Waypoint',
expected: scenario => scenario.transition,
width: 'md'
},
{
key: 'unable-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'field', key: 'unable-callsign', width: 'lg' },
{ type: 'text', text: ' unable direct ' },
{ type: 'field', key: 'unable-transition', width: 'md' }
],
defaultFrequency: 'DEP',
phrase: scenario => `${scenario.radioCall}, unable direct ${scenario.transition}.`,
info: scenario => [
`Requested direct: ${scenario.transition}`,
`Climb continues on SID`
],
generate: createBaseScenario
},
{
id: 'alt-accept',
title: 'Accept Alternate Instruction',
desc: 'Acknowledge ATCs alternative plan',
keywords: ['Interrupt', 'Compliance'],
hints: [
'Simply respond with your call sign followed by "wilco".',
'Keep it concise to show you comply.'
],
fields: [
{
key: 'alt-accept-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'field', key: 'alt-accept-callsign', width: 'lg' },
{ type: 'text', text: ' wilco.' }
],
defaultFrequency: 'CTR',
phrase: scenario => `${scenario.radioCall}, alternative instruction acknowledged with "wilco".`,
info: () => [
'Use "wilco" to indicate you will comply with the new instruction.'
],
generate: createBaseScenario
},
{
id: 'alt-reject',
title: 'Reject Alternate Instruction',
desc: 'Request a different solution when unable',
keywords: ['Interrupt', 'Request'],
hints: [
'State "negative" then what you request instead.',
'Keep the intent short, e.g. "request vectors" or destination.'
],
fields: [
{
key: 'alt-reject-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
},
{
key: 'alt-reject-intent',
label: 'Requested Intent',
expected: scenario => scenario.emergencyIntent,
width: 'lg'
}
],
readback: [
{ type: 'field', key: 'alt-reject-callsign', width: 'lg' },
{ type: 'text', text: ' negative, request ' },
{ type: 'field', key: 'alt-reject-intent', width: 'lg' }
],
defaultFrequency: 'CTR',
phrase: scenario => `${scenario.radioCall}, negative, request ${scenario.emergencyIntent}.`,
info: scenario => [
`Requested intent: ${scenario.emergencyIntent}`
],
generate: createBaseScenario
},
{
id: 'continue-approach',
title: 'Continue Approach',
desc: 'Acknowledge a late landing clearance instruction',
keywords: ['Tower', 'Exception'],
hints: [
'Keep the response short: continuing approach plus call sign.'
],
fields: [
{
key: 'continue-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Continuing approach, ' },
{ type: 'field', key: 'continue-callsign', width: 'lg' }
],
defaultFrequency: 'TWR',
phrase: scenario => `${scenario.radioCall}, continue approach, expect late landing clearance.`,
info: scenario => [`Runway in use: ${scenario.arrivalRunway}`],
generate: createBaseScenario
},
{
id: 'approach-established',
title: 'Approach Established',
desc: 'Report established on the localizer',
keywords: ['Approach', 'Report'],
hints: [
'State the runway after saying you are established.',
'Finish with the call sign.'
],
fields: [
{
key: 'app-est-runway',
label: 'Runway',
expected: scenario => scenario.arrivalRunway,
alternatives: scenario => [
scenario.arrivalRunway.replace(/^0/, ''),
scenario.arrivalRunwayWords
],
width: 'sm'
},
{
key: 'app-est-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'field', key: 'app-est-callsign', width: 'lg' },
{ type: 'text', text: ' established localizer ' },
{ type: 'field', key: 'app-est-runway', width: 'sm' }
],
defaultFrequency: 'APP',
phrase: scenario => `${scenario.radioCall}, established localizer runway ${scenario.arrivalRunway}.`,
info: scenario => [
`Runway: ${scenario.arrivalRunway} (${scenario.arrivalRunwayWords})`
],
generate: createBaseScenario
},
{
id: 'taxi-in-request',
title: 'Taxi-In Request',
desc: 'Ask ground for taxi to stand after vacating',
keywords: ['Ground', 'Taxi-In'],
hints: [
'Announce runway vacated, then request taxi to your stand.',
'Include the stand number if known.'
],
fields: [
{
key: 'taxiin-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
},
{
key: 'taxiin-stand',
label: 'Stand',
expected: scenario => scenario.arrivalStand,
width: 'sm'
}
],
readback: [
{ type: 'field', key: 'taxiin-callsign', width: 'lg' },
{ type: 'text', text: ' runway vacated, request taxi to stand ' },
{ type: 'field', key: 'taxiin-stand', width: 'sm' }
],
defaultFrequency: 'GND',
phrase: scenario => `${scenario.radioCall}, runway vacated, request taxi to stand ${scenario.arrivalStand}.`,
info: scenario => [
`Arrival stand: ${scenario.arrivalStand}`,
`Expect taxi route: ${scenario.arrivalTaxiRoute}`
],
generate: createBaseScenario
},
{
id: 'mayday',
title: 'MAYDAY Call',
desc: 'Declare an emergency and state intentions',
keywords: ['Emergency', 'MAYDAY'],
hints: [
'Say "MAYDAY" three times, then the call sign.',
'State the problem and your intentions clearly.'
],
fields: [
{
key: 'mayday-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
},
{
key: 'mayday-problem',
label: 'Problem',
expected: scenario => scenario.emergencyProblem,
width: 'lg'
},
{
key: 'mayday-intent',
label: 'Intentions',
expected: scenario => scenario.emergencyIntent,
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'MAYDAY MAYDAY MAYDAY, ' },
{ type: 'field', key: 'mayday-callsign', width: 'lg' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'mayday-problem', width: 'lg' },
{ type: 'text', text: ', intentions ' },
{ type: 'field', key: 'mayday-intent', width: 'lg' }
],
defaultFrequency: 'CTR',
phrase: scenario => `${scenario.radioCall}, Emergency scenario: ${scenario.emergencyProblem}. Declare MAYDAY with intentions ${scenario.emergencyIntent}.`,
info: scenario => [
`Problem: ${scenario.emergencyProblem}`,
`Intentions: ${scenario.emergencyIntent}`
],
generate: createBaseScenario
},
{
id: 'pan-pan',
title: 'PAN PAN Call',
desc: 'Request priority for an urgent situation',
keywords: ['Emergency', 'PAN'],
hints: [
'Say "PAN PAN PAN" three times, then the call sign.',
'Describe the problem and end with "request priority".'
],
fields: [
{
key: 'pan-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
},
{
key: 'pan-problem',
label: 'Problem',
expected: scenario => scenario.emergencyProblem,
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'PAN PAN PAN, ' },
{ type: 'field', key: 'pan-callsign', width: 'lg' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'pan-problem', width: 'lg' },
{ type: 'text', text: ', request priority.' }
],
defaultFrequency: 'CTR',
phrase: scenario => `${scenario.radioCall}, Urgent situation: ${scenario.emergencyProblem}. Declare PAN to obtain priority.`,
info: scenario => [
`Problem: ${scenario.emergencyProblem}`,
'Ensure ATC knows you require priority handling.'
],
generate: createBaseScenario
},
{
id: 'go-around',
title: 'Go-Around Call',
desc: 'Declare a go-around with the missed approach',
keywords: ['Tower', 'Missed Approach', 'Exception'],
hints: [
'State your call sign, then "going around".',
'Include the missed approach instructions.'
],
fields: [
{
key: 'goaround-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
},
{
key: 'goaround-missed',
label: 'Missed Approach',
expected: scenario => scenario.missedApproach,
width: 'lg'
}
],
readback: [
{ type: 'field', key: 'goaround-callsign', width: 'lg' },
{ type: 'text', text: ' going around, ' },
{ type: 'field', key: 'goaround-missed', width: 'lg' }
],
defaultFrequency: 'TWR',
phrase: scenario => `${scenario.radioCall}, going around, ${scenario.missedApproach}.`,
info: scenario => [
`Missed approach: ${scenario.missedApproach}`
],
generate: createBaseScenario
},
{
id: 'tcas-ra',
title: 'TCAS RA Call',
desc: 'Report a TCAS resolution advisory',
keywords: ['Emergency', 'TCAS'],
hints: [
'Say your call sign followed by "TCAS RA, deviating".'
],
fields: [
{
key: 'tcas-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'field', key: 'tcas-callsign', width: 'lg' },
{ type: 'text', text: ' TCAS RA, deviating.' }
],
defaultFrequency: 'CTR',
phrase: scenario => `${scenario.radioCall} experiences a TCAS RA. Report it immediately.`,
info: () => [
'Inform ATC that you are following the RA.',
'Expect to report when clear of conflict.'
],
generate: createBaseScenario
},
{
id: 'tcas-resume',
title: 'TCAS Clear of Conflict',
desc: 'Report returning to your clearance after an RA',
keywords: ['Emergency', 'TCAS'],
hints: [
'State your call sign followed by "clear of conflict, returning to clearance".'
],
fields: [
{
key: 'tcas-resume-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'field', key: 'tcas-resume-callsign', width: 'lg' },
{ type: 'text', text: ' clear of conflict, returning to clearance.' }
],
defaultFrequency: 'CTR',
phrase: scenario => `${scenario.radioCall}, report clear of conflict and returning to clearance.`,
info: () => [
'Advise ATC once the RA is resolved so normal control can resume.'
],
generate: createBaseScenario
},
{
id: 'standby',
title: 'Standby Call',
desc: 'Pause the exchange when busy',
keywords: ['Interrupt'],
hints: [
'Say your call sign followed by "standby" to pause the conversation briefly.'
],
fields: [
{
key: 'standby-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'field', key: 'standby-callsign', width: 'lg' },
{ type: 'text', text: ' standby.' }
],
defaultFrequency: 'CTR',
phrase: scenario => `${scenario.radioCall}, standby.`,
info: () => [
'Use sparingly when workload requires a short pause before responding.'
],
generate: createBaseScenario
}
]
const fullFlightScenario = createScenarioSeries()
export function seedFullFlightScenario(scenario: Scenario | null) {
if (scenario) {
fullFlightScenario.setScenario(scenario)
} else {
fullFlightScenario.setScenario(null)
}
}
export function peekFullFlightScenario(): Scenario | null {
return fullFlightScenario.peek()
}
const makeFullFlightGenerator = (reset = false) => () => {
if (reset) {
fullFlightScenario.reset()
}
return fullFlightScenario()
}
const fullFlightLessons: Lesson[] = [
{
id: 'full-clearance-contact',
title: 'Delivery Contact',
desc: 'Start the flight with clearance delivery',
keywords: ['Delivery', 'Flow'],
hints: [
'Use the full opening: unit, call sign, ATIS, destination, stand, request.'
],
fields: [
{
key: 'full-cd-atis',
label: 'ATIS',
expected: scenario => scenario.atisCode,
alternatives: scenario => [
scenario.atisCode,
scenario.atisCode.toLowerCase(),
`Information ${scenario.atisCode}`
],
width: 'xs',
threshold: 0.9
},
{
key: 'full-cd-dest',
label: 'Destination',
expected: scenario => scenario.destination.city,
alternatives: scenario => [scenario.destination.icao, scenario.destination.name],
width: 'md'
},
{
key: 'full-cd-stand',
label: 'Stand',
expected: scenario => scenario.stand,
width: 'sm'
}
],
readback: [
{
type: 'text',
text: scenario => `${scenario.airport.city} Delivery, ${scenario.radioCall}, information `
},
{ type: 'field', key: 'full-cd-atis', width: 'xs' },
{ type: 'text', text: ', IFR to ' },
{ type: 'field', key: 'full-cd-dest', width: 'md' },
{ type: 'text', text: ', stand ' },
{ type: 'field', key: 'full-cd-stand', width: 'sm' },
{ type: 'text', text: ', request clearance.' }
],
defaultFrequency: 'DEL',
phrase: scenario => `${scenario.airport.city} Delivery, ${scenario.radioCall}, information ${scenario.atisCode}, IFR to ${scenario.destination.city}, stand ${scenario.stand}, request clearance.`,
info: scenario => [
`ATIS: ${scenario.atisCode}`,
`Destination: ${scenario.destination.city} (${scenario.destination.icao})`,
`Stand: ${scenario.stand}`
],
generate: makeFullFlightGenerator(true)
},
{
id: 'full-clearance-readback',
title: 'Clearance Readback',
desc: 'Confirm the IFR clearance in full',
keywords: ['Delivery', 'Readback', 'Flow'],
hints: [
'Order: destination SID runway altitude squawk.'
],
fields: [
{
key: 'full-clr-dest',
label: 'Destination',
expected: scenario => scenario.destination.city,
alternatives: scenario => [scenario.destination.icao, scenario.destination.name],
width: 'md'
},
{
key: 'full-clr-sid',
label: 'SID',
expected: scenario => scenario.sid,
width: 'lg'
},
{
key: 'full-clr-runway',
label: 'Runway',
expected: scenario => scenario.runway,
alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords],
width: 'sm'
},
{
key: 'full-clr-alt',
label: 'Initial Altitude',
expected: scenario => scenario.altitudes.initialWords,
alternatives: scenario => [
scenario.altitudes.initial.toString(),
`${scenario.altitudes.initial} feet`
],
width: 'md'
},
{
key: 'full-clr-squawk',
label: 'Squawk',
expected: scenario => scenario.squawkWords,
alternatives: scenario => [scenario.squawk, scenario.squawk.split('').join(' ')],
width: 'md'
}
],
readback: [
{ type: 'text', text: scenario => `${scenario.radioCall} cleared ` },
{ type: 'field', key: 'full-clr-dest', width: 'md' },
{ type: 'text', text: ' via ' },
{ type: 'field', key: 'full-clr-sid', width: 'lg' },
{ type: 'text', text: ', runway ' },
{ type: 'field', key: 'full-clr-runway', width: 'sm' },
{ type: 'text', text: ', climb ' },
{ type: 'field', key: 'full-clr-alt', width: 'md' },
{ type: 'text', text: ', squawk ' },
{ type: 'field', key: 'full-clr-squawk', width: 'md' }
],
defaultFrequency: 'DEL',
phrase: scenario => `${scenario.radioCall}, cleared to ${scenario.destination.city} via ${scenario.sid}, runway ${scenario.runway}, climb ${scenario.altitudes.initial} feet, squawk ${scenario.squawk}.`,
info: scenario => [
`SID: ${scenario.sid} (${scenario.transition})`,
`Initial altitude: ${scenario.altitudes.initial} ft`,
`Squawk: ${scenario.squawk} (${scenario.squawkWords})`
],
generate: makeFullFlightGenerator()
},
{
id: 'full-pushback',
title: 'Push & Start',
desc: 'Acknowledge the pushback clearance',
keywords: ['Ground', 'Pushback', 'Flow'],
hints: [
'Repeat the runway direction and QNH, call sign at the end.'
],
fields: [
{
key: 'full-push-runway',
label: 'Runway',
expected: scenario => scenario.runway,
alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords],
width: 'sm'
},
{
key: 'full-push-qnh',
label: 'QNH',
expected: scenario => scenario.qnh.toString(),
alternatives: scenario => [`QNH ${scenario.qnh}`, scenario.qnhWords],
width: 'sm'
},
{
key: 'full-push-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Push and start approved, facing runway ' },
{ type: 'field', key: 'full-push-runway', width: 'sm' },
{ type: 'text', text: ', QNH ' },
{ type: 'field', key: 'full-push-qnh', width: 'sm' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'full-push-callsign', width: 'lg' }
],
defaultFrequency: 'GND',
phrase: scenario => `${scenario.radioCall}, push and start approved, facing runway ${scenario.runway}. QNH ${scenario.qnh}.`,
info: scenario => [
`Stand: ${scenario.stand}`,
`Ground frequency: ${scenario.groundFreq} (${scenario.frequencyWords.GND})`
],
generate: makeFullFlightGenerator()
},
{
id: 'full-taxi',
title: 'Taxi Clearance',
desc: 'Read back the taxi clearance on the same scenario',
keywords: ['Ground', 'Taxi', 'Flow'],
hints: [
'Repeat the runway and full taxi route, including the hold short.'
],
fields: [
{
key: 'full-taxi-runway',
label: 'Runway',
expected: scenario => scenario.runway,
alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords],
width: 'sm'
},
{
key: 'full-taxi-route',
label: 'Route',
expected: scenario => scenario.taxiRoute,
alternatives: scenario => [scenario.taxiRoute, `via ${scenario.taxiRoute}`],
width: 'lg'
},
{
key: 'full-taxi-hold',
label: 'Hold Short',
expected: scenario => scenario.runway,
alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords],
width: 'sm'
},
{
key: 'full-taxi-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Taxi to runway ' },
{ type: 'field', key: 'full-taxi-runway', width: 'sm' },
{ type: 'text', text: ' via ' },
{ type: 'field', key: 'full-taxi-route', width: 'lg' },
{ type: 'text', text: ', holding short runway ' },
{ type: 'field', key: 'full-taxi-hold', width: 'sm' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'full-taxi-callsign', width: 'lg' }
],
defaultFrequency: 'GND',
phrase: scenario => `${scenario.radioCall}, taxi to runway ${scenario.runway} via ${scenario.taxiRoute}, hold short runway ${scenario.runway}.`,
info: scenario => [
`Taxi route: ${scenario.taxiRoute}`,
`Hold short: runway ${scenario.runway}`
],
generate: makeFullFlightGenerator()
},
{
id: 'full-tower-contact',
title: 'Tower Handoff',
desc: 'Confirm the switch to tower when number one',
keywords: ['Tower', 'Handoff', 'Flow'],
hints: [
'Read back the frequency, mention "when number one" and end with the call sign.'
],
fields: [
{
key: 'full-tower-freq',
label: 'Frequency',
expected: scenario => scenario.towerFreq,
alternatives: scenario => [
scenario.towerFreq,
scenario.towerFreq.replace('.', ''),
scenario.frequencyWords.TWR
],
width: 'md'
},
{
key: 'full-tower-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Contact tower ' },
{ type: 'field', key: 'full-tower-freq', width: 'md' },
{ type: 'text', text: ' when number one, ' },
{ type: 'field', key: 'full-tower-callsign', width: 'lg' }
],
defaultFrequency: 'GND',
phrase: scenario => `${scenario.radioCall}, contact Tower ${scenario.towerFreq} when number one.`,
info: scenario => [
`Tower frequency: ${scenario.towerFreq} (${scenario.frequencyWords.TWR})`
],
generate: makeFullFlightGenerator()
},
{
id: 'full-lineup',
title: 'Line Up and Wait',
desc: 'Acknowledge the line-up clearance',
keywords: ['Tower', 'Line Up', 'Flow'],
hints: [
'In the readback, state the runway first — even though the controller said it last.',
'ICAO standard: runway leads the readback, then "line up and wait", then your call sign.'
],
fields: [
{
key: 'full-lineup-runway',
label: 'Runway',
expected: scenario => scenario.runway,
alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords],
width: 'sm'
},
{
key: 'full-lineup-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Runway ' },
{ type: 'field', key: 'full-lineup-runway', width: 'sm' },
{ type: 'text', text: ', line up and wait, ' },
{ type: 'field', key: 'full-lineup-callsign', width: 'lg' }
],
defaultFrequency: 'TWR',
phrase: scenario => `${scenario.radioCall}, line up and wait runway ${scenario.runway}.`,
info: scenario => [
`Tower: ${scenario.towerFreq} (${scenario.frequencyWords.TWR})`,
`Line-up: runway ${scenario.runway}`
],
generate: makeFullFlightGenerator()
},
{
id: 'full-takeoff',
title: 'Takeoff Clearance',
desc: 'Acknowledge the takeoff clearance',
keywords: ['Tower', 'Departure', 'Flow'],
hints: [
'Order: wind runway cleared for takeoff call sign.',
'Write the wind as direction/speed, e.g. 030/12.'
],
fields: [
{
key: 'full-tko-wind',
label: 'Wind',
expected: scenario => scenario.wind,
alternatives: scenario => [
scenario.wind,
`${scenario.wind}KT`,
scenario.windWords
],
width: 'md'
},
{
key: 'full-tko-runway',
label: 'Runway',
expected: scenario => scenario.runway,
alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords],
width: 'sm'
},
{
key: 'full-tko-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Wind ' },
{ type: 'field', key: 'full-tko-wind', width: 'md' },
{ type: 'text', text: ', runway ' },
{ type: 'field', key: 'full-tko-runway', width: 'sm' },
{ type: 'text', text: ', cleared for takeoff, ' },
{ type: 'field', key: 'full-tko-callsign', width: 'lg' }
],
defaultFrequency: 'TWR',
phrase: scenario => `${scenario.radioCall}, wind ${scenario.windWords}, runway ${scenario.runway}, cleared for takeoff.`,
info: scenario => [
`Wind: ${scenario.wind} (${scenario.windWords})`
],
generate: makeFullFlightGenerator()
},
{
id: 'full-departure-handoff',
title: 'Handoff to Departure',
desc: 'Switch to departure after takeoff',
keywords: ['Departure', 'Handoff', 'Flow'],
hints: [
'Read back the frequency exactly and include your call sign.'
],
fields: [
{
key: 'full-dep-freq',
label: 'Frequency',
expected: scenario => scenario.departureFreq,
alternatives: scenario => [
scenario.departureFreq,
scenario.departureFreq.replace('.', ''),
scenario.frequencyWords.DEP
],
width: 'md'
},
{
key: 'full-dep-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Contact departure ' },
{ type: 'field', key: 'full-dep-freq', width: 'md' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'full-dep-callsign', width: 'lg' }
],
defaultFrequency: 'DEP',
phrase: scenario => `${scenario.radioCall}, contact departure ${scenario.departureFreq}.`,
info: scenario => [
`Departure frequency: ${scenario.departureFreq} (${scenario.frequencyWords.DEP})`
],
generate: makeFullFlightGenerator()
},
{
id: 'full-departure-checkin',
title: 'Departure Check-in',
desc: 'Call departure with altitude and SID',
keywords: ['Departure', 'Flow'],
hints: [
'Address departure first, then your call sign, altitude and SID.'
],
fields: [
{
key: 'full-depcheck-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
},
{
key: 'full-depcheck-alt',
label: 'Altitude',
expected: scenario => scenario.altitudes.initialWords,
alternatives: scenario => [
scenario.altitudes.initialWords,
scenario.altitudes.initial.toString(),
`${scenario.altitudes.initial} feet`
],
width: 'md'
},
{
key: 'full-depcheck-sid',
label: 'SID',
expected: scenario => scenario.sid,
width: 'lg'
}
],
readback: [
{ type: 'text', text: scenario => `${scenario.airport.city} Departure, ` },
{ type: 'field', key: 'full-depcheck-callsign', width: 'lg' },
{ type: 'text', text: ', passing ' },
{ type: 'field', key: 'full-depcheck-alt', width: 'md' },
{ type: 'text', text: ', on SID ' },
{ type: 'field', key: 'full-depcheck-sid', width: 'lg' }
],
defaultFrequency: 'DEP',
phrase: scenario => `${scenario.airport.city} Departure, ${scenario.radioCall}, passing ${scenario.altitudes.initialWords}, on SID ${scenario.sid}.`,
info: scenario => [
`Initial altitude: ${scenario.altitudes.initial} ft`,
`SID: ${scenario.sid}`
],
generate: makeFullFlightGenerator()
},
{
id: 'full-climb',
title: 'Climb & Direct',
desc: 'Confirm the climb and direct instruction',
keywords: ['Departure', 'Climb', 'Flow'],
hints: [
'Start with "Climb", then the altitude and any direct clearance.'
],
fields: [
{
key: 'full-climb-alt',
label: 'Altitude',
expected: scenario => scenario.altitudes.climbWords,
alternatives: scenario => [
scenario.altitudes.climbWords,
scenario.altitudes.climb.toString(),
`${scenario.altitudes.climb} feet`
],
width: 'md'
},
{
key: 'full-climb-direct',
label: 'Direct',
expected: scenario => scenario.transition,
width: 'md'
},
{
key: 'full-climb-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Climb ' },
{ type: 'field', key: 'full-climb-alt', width: 'md' },
{ type: 'text', text: ', direct ' },
{ type: 'field', key: 'full-climb-direct', width: 'md' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'full-climb-callsign', width: 'lg' }
],
defaultFrequency: 'DEP',
phrase: scenario => `${scenario.radioCall}, climb ${scenario.altitudes.climb} feet, proceed direct ${scenario.transition}.`,
info: scenario => [
`Climb altitude: ${scenario.altitudes.climb} ft (${scenario.altitudes.climbWords})`,
`Waypoint: ${scenario.transition}`
],
generate: makeFullFlightGenerator()
},
{
id: 'full-descent',
title: 'Descent Clearance',
desc: 'Read back STAR, transition and QNH',
keywords: ['Approach', 'Descent', 'Flow'],
hints: [
'Mention the STAR, transition and QNH before the call sign.'
],
fields: [
{
key: 'full-descent-star',
label: 'STAR',
expected: scenario => scenario.arrivalStar,
width: 'lg'
},
{
key: 'full-descent-transition',
label: 'Transition',
expected: scenario => scenario.arrivalTransition,
width: 'md'
},
{
key: 'full-descent-qnh',
label: 'QNH',
expected: scenario => scenario.arrivalQnh.toString(),
alternatives: scenario => [
scenario.arrivalQnh.toString(),
`QNH ${scenario.arrivalQnh}`,
scenario.arrivalQnhWords
],
width: 'sm'
},
{
key: 'full-descent-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Descend via ' },
{ type: 'field', key: 'full-descent-star', width: 'lg' },
{ type: 'text', text: ' ' },
{ type: 'field', key: 'full-descent-transition', width: 'md' },
{ type: 'text', text: ', QNH ' },
{ type: 'field', key: 'full-descent-qnh', width: 'sm' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'full-descent-callsign', width: 'lg' }
],
defaultFrequency: 'APP',
phrase: scenario => `${scenario.radioCall}, descend via ${scenario.arrivalStar} ${scenario.arrivalTransition}, QNH ${scenario.arrivalQnh}.`,
info: scenario => [
`STAR: ${scenario.arrivalStar}`,
`Transition: ${scenario.arrivalTransition}`,
`Arrival QNH: ${scenario.arrivalQnh}`
],
generate: makeFullFlightGenerator()
},
{
id: 'full-approach',
title: 'Approach Clearance',
desc: 'Confirm approach type and runway',
keywords: ['Approach', 'Readback', 'Flow'],
hints: [
'Say "Cleared" followed by the approach and runway.'
],
fields: [
{
key: 'full-app-type',
label: 'Approach',
expected: scenario => scenario.approach,
width: 'lg'
},
{
key: 'full-app-runway',
label: 'Runway',
expected: scenario => scenario.arrivalRunway,
alternatives: scenario => [scenario.arrivalRunway.replace(/^0/, ''), scenario.arrivalRunwayWords],
width: 'sm'
},
{
key: 'full-app-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Cleared ' },
{ type: 'field', key: 'full-app-type', width: 'lg' },
{ type: 'text', text: ' approach runway ' },
{ type: 'field', key: 'full-app-runway', width: 'sm' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'full-app-callsign', width: 'lg' }
],
defaultFrequency: 'APP',
phrase: scenario => `${scenario.radioCall}, cleared ${scenario.approach} approach runway ${scenario.arrivalRunway}, report established.`,
info: scenario => [
`Approach: ${scenario.approach}`,
`Runway: ${scenario.arrivalRunway} (${scenario.arrivalRunwayWords})`
],
generate: makeFullFlightGenerator()
},
{
id: 'full-landing',
title: 'Landing Clearance',
desc: 'Confirm the landing clearance',
keywords: ['Tower', 'Landing', 'Flow'],
hints: [
'Order: wind runway cleared to land call sign.',
'Write the wind as direction/speed, e.g. 260/08.'
],
fields: [
{
key: 'full-landing-wind',
label: 'Wind',
expected: scenario => scenario.arrivalWind,
alternatives: scenario => [
scenario.arrivalWind,
`${scenario.arrivalWind}KT`,
scenario.arrivalWindWords
],
width: 'md'
},
{
key: 'full-landing-runway',
label: 'Runway',
expected: scenario => scenario.arrivalRunway,
alternatives: scenario => [scenario.arrivalRunway.replace(/^0/, ''), scenario.arrivalRunwayWords],
width: 'sm'
},
{
key: 'full-landing-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Wind ' },
{ type: 'field', key: 'full-landing-wind', width: 'md' },
{ type: 'text', text: ', runway ' },
{ type: 'field', key: 'full-landing-runway', width: 'sm' },
{ type: 'text', text: ', cleared to land, ' },
{ type: 'field', key: 'full-landing-callsign', width: 'lg' }
],
defaultFrequency: 'TWR',
phrase: scenario => `${scenario.radioCall}, wind ${scenario.arrivalWindWords}, runway ${scenario.arrivalRunway} cleared to land.`,
info: scenario => [
`Wind: ${scenario.arrivalWind} (${scenario.arrivalWindWords})`
],
generate: makeFullFlightGenerator()
},
{
id: 'full-taxi-in',
title: 'Taxi-In Clearance',
desc: 'Read back taxi instructions to the stand',
keywords: ['Ground', 'Taxi', 'Arrival'],
hints: [
'Repeat the stand and full route, end with the call sign.'
],
fields: [
{
key: 'full-taxiin-stand',
label: 'Stand',
expected: scenario => scenario.arrivalStand,
width: 'sm'
},
{
key: 'full-taxiin-route',
label: 'Route',
expected: scenario => scenario.arrivalTaxiRoute,
alternatives: scenario => [scenario.arrivalTaxiRoute, `via ${scenario.arrivalTaxiRoute}`],
width: 'lg'
},
{
key: 'full-taxiin-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Taxi to stand ' },
{ type: 'field', key: 'full-taxiin-stand', width: 'sm' },
{ type: 'text', text: ' via ' },
{ type: 'field', key: 'full-taxiin-route', width: 'lg' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'full-taxiin-callsign', width: 'lg' }
],
defaultFrequency: 'GND',
phrase: scenario => `${scenario.radioCall}, taxi to stand ${scenario.arrivalStand} via ${scenario.arrivalTaxiRoute}.`,
info: scenario => [
`Stand: ${scenario.arrivalStand}`,
`Taxi route: ${scenario.arrivalTaxiRoute}`
],
generate: makeFullFlightGenerator()
}
]
const copyLessonIds = new Set(['icao-alphabet', 'icao-marathon', 'atis', 'metar'])
const situationLessonIds = new Set([
'radio-check',
'clearance-contact',
'pushback-ready',
'taxi-request',
'lineup-request',
'departure-checkin',
'unable-direct',
'alt-accept',
'alt-reject',
'approach-established',
'taxi-in-request',
'mayday',
'pan-pan',
'go-around',
'tcas-ra',
'tcas-resume',
'full-clearance-contact',
'full-departure-checkin',
'full-pushback-request',
'full-taxi-request',
'full-approach-checkin',
'full-established-report',
])
const operationalFieldPattern =
/call\s?sign|runway|heading|direction|altitude|level|speed|qnh|squawk|frequency|channel|route|taxi|hold|sid|star|approach/i
function curateField(field: LessonField, promptKind: 'atc' | 'situation' | 'copy'): LessonField {
const safetyCritical = promptKind !== 'copy' && operationalFieldPattern.test(`${field.key} ${field.label}`)
return {
...field,
required: field.required ?? true,
safetyCritical: field.safetyCritical ?? safetyCritical,
matching: field.matching ?? (safetyCritical ? 'controlled' : 'exact'),
}
}
function approachWithoutRunway(scenario: Scenario): string {
return scenario.approach
.replace(new RegExp(`\\s+${scenario.arrivalRunway}$`, 'i'), '')
.trim()
}
function callsignAlternatives(scenario: Scenario): string[] {
return [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
`${scenario.airlineCall} ${scenario.flightNumberWords}`,
scenario.callsign,
]
}
const situationPrompts: Partial<Record<string, (scenario: Scenario) => string>> = {
'clearance-contact': scenario =>
`You are at stand ${scenario.stand} with information ${scenario.atisCodeWord}. Request IFR clearance to ${scenario.destination.city} from ${scenario.airport.city} Delivery.`,
'pushback-ready': scenario =>
`You are ready at stand ${scenario.stand}. Request pushback and start from ${scenario.airport.city} Ground.`,
'taxi-request': scenario =>
`Pushback is complete at stand ${scenario.stand}. Request taxi from ${scenario.airport.city} Ground.`,
'lineup-request': scenario =>
`You are holding short of runway ${scenario.runway} and ready for departure. Advise ${scenario.airport.city} Tower.`,
'unable-direct': scenario =>
`ATC offers direct ${scenario.transition}, but you cannot accept it. State UNABLE and continue the assigned ${scenario.sid}.`,
'approach-established': scenario =>
`You are established on the localizer for runway ${scenario.arrivalRunway}. Make the requested report.`,
'taxi-in-request': scenario =>
`You have vacated runway ${scenario.arrivalRunway}. Request taxi to stand ${scenario.arrivalStand} from ${scenario.destination.city} Ground.`,
'standby': () =>
'ATC asks a non-urgent question, but you need a short moment before answering. Ask ATC to stand by.',
'full-clearance-contact': scenario =>
`You are at stand ${scenario.stand} with information ${scenario.atisCodeWord}. Request IFR clearance to ${scenario.destination.city} from ${scenario.airport.city} Delivery.`,
}
function clearanceWithoutWind(lesson: Lesson, landing: boolean): Lesson {
const prefix = lesson.id.startsWith('full-') ? 'full-' : ''
const runwayKey = landing ? `${prefix}landing-runway` : `${prefix}tko-runway`
const callsignKey = landing ? `${prefix}landing-callsign` : `${prefix}tko-callsign`
const fields = lesson.fields.filter(field => !/wind/i.test(`${field.key} ${field.label}`))
return {
...lesson,
hints: [
`Read back the runway, ${landing ? 'landing' : 'take-off'} clearance, and callsign.`,
'Wind is information and is not a mandatory readback item.',
],
fields,
readback: [
{ type: 'text', text: 'Runway ' },
{ type: 'field', key: runwayKey, width: 'sm' },
{ type: 'text', text: landing ? ', cleared to land, ' : ', cleared for take-off, ' },
{ type: 'field', key: callsignKey, width: 'lg' },
],
}
}
function curateLesson(source: Lesson): Lesson {
let lesson = { ...source, fields: [...source.fields], readback: [...source.readback] }
if (lesson.id === 'radio-check') {
lesson = {
...lesson,
title: 'Request a Radio Check',
desc: 'Make a complete radio-check transmission and understand readability 15',
hints: [
'Call the station, identify yourself, say “RADIO CHECK”, then state the channel.',
'The reply uses one exact readability value: 1 unreadable, 2 readable now and then, 3 readable with difficulty, 4 readable, 5 perfectly readable.',
],
fields: [
{
key: 'rc-station',
label: 'Station',
expected: scenario => `${scenario.airport.city} Ground`,
alternatives: scenario => [`${scenario.airport.icao} Ground`, `${scenario.airport.name} Ground`],
width: 'lg',
},
{
key: 'rc-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: callsignAlternatives,
width: 'lg',
},
{
key: 'rc-frequency',
label: 'Channel',
expected: scenario => scenario.groundFreq,
alternatives: scenario => [scenario.groundFreq.replace('.', ''), scenario.frequencyWords.GND],
width: 'md',
},
],
readback: [
{ type: 'field', key: 'rc-station', width: 'lg' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'rc-callsign', width: 'lg' },
{ type: 'text', text: ', radio check ' },
{ type: 'field', key: 'rc-frequency', width: 'md' },
],
phrase: scenario => `You have selected ${scenario.groundFreq}. Request a radio check from ${scenario.airport.city} Ground.`,
info: scenario => [
`Channel: ${scenario.groundFreq} (${scenario.frequencyWords.GND})`,
'Readability scale: 1 unreadable · 2 readable now and then · 3 readable with difficulty · 4 readable · 5 perfectly readable',
],
}
}
if (['clearance-contact', 'full-clearance-contact'].includes(lesson.id)) {
const prefix = lesson.id.startsWith('full-') ? 'full-' : ''
const callsignKey = `${prefix}clearance-contact-callsign`
lesson = {
...lesson,
hints: [
'Address the unit, use your full callsign, then give ATIS, destination, stand, and request.',
'Transmit the ATIS identifier as its ICAO spelling word, for example “information Papa”.',
],
fields: [
{
key: callsignKey,
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: callsignAlternatives,
width: 'lg',
},
...lesson.fields,
],
readback: [
{ type: 'text', text: scenario => `${scenario.airport.city} Delivery, ` },
{ type: 'field', key: callsignKey, width: 'lg' },
{ type: 'text', text: ', information ' },
...lesson.readback.slice(1),
],
}
}
if (['takeoff', 'full-takeoff'].includes(lesson.id)) lesson = clearanceWithoutWind(lesson, false)
if (['landing-clearance', 'full-landing'].includes(lesson.id)) lesson = clearanceWithoutWind(lesson, true)
if (['lineup', 'full-lineup'].includes(lesson.id)) {
lesson.hints = [
'Read back the runway, “line up and wait”, and your callsign.',
'Keep the clearance elements in the order in which they were issued.',
]
}
if (['tower-contact', 'full-tower-contact'].includes(lesson.id)) {
lesson = {
...lesson,
desc: 'Confirm the transfer to tower',
hints: ['Read back the tower channel and your callsign.'],
readback: lesson.readback.filter(segment =>
segment.type !== 'text' || !segment.text.toString().includes('when number one')),
phrase: scenario => `${scenario.radioCall}, contact Tower ${scenario.towerFreq}.`,
}
}
if (lesson.id === 'clearance-amendment') {
lesson = {
...lesson,
title: 'Reclearance',
hints: ['Start with “RECLEARED”, then repeat every changed clearance element and your callsign.'],
phrase: scenario => `${scenario.radioCall}, recleared via ${scenario.sid} ${scenario.transition}, runway ${scenario.runway}.`,
readback: lesson.readback.map(segment =>
segment.type === 'text' && typeof segment.text === 'string' && /amended/i.test(segment.text)
? { ...segment, text: segment.text.replace(/amended clearance:?/i, 'RECLEARED') }
: segment),
}
}
if (['pushback', 'full-pushback'].includes(lesson.id)) {
const callsignField = lesson.fields.find(field => /callsign/i.test(field.key))
if (callsignField) {
lesson = {
...lesson,
title: 'Pushback Clearance',
desc: 'Acknowledge a standard pushback approval',
hints: ['Read back “pushback approved” and your callsign. Start-up is a separate clearance when required.'],
fields: [callsignField],
readback: [
{ type: 'text', text: 'Pushback approved, ' },
{ type: 'field', key: callsignField.key, width: 'lg' },
],
phrase: scenario => `${scenario.radioCall}, pushback approved.`,
}
}
}
if (lesson.id === 'pushback-delay') {
lesson = {
...lesson,
title: 'Pushback Delay',
desc: 'Acknowledge an expected pushback delay',
hints: ['A delay is not a pushback approval. Do not start moving until approval is issued.'],
phrase: scenario => `${scenario.radioCall}, expect ${scenario.pushDelayWords} delay due traffic.`,
}
}
if (['approach-vector'].includes(lesson.id)) {
lesson = {
...lesson,
fields: [
{
key: 'vector-direction',
label: 'Turn direction',
expected: () => 'left',
alternatives: () => ['left turn'],
width: 'sm',
},
...lesson.fields,
],
readback: [
{ type: 'text', text: 'Turn ' },
{ type: 'field', key: 'vector-direction', width: 'sm' },
{ type: 'text', text: ', heading ' },
...lesson.readback.slice(1),
],
hints: ['Turn direction, heading, level, speed, then callsign. Every assigned value is mandatory.'],
}
}
if (['descent-clearance', 'full-descent'].includes(lesson.id)) {
const prefix = lesson.id.startsWith('full-') ? 'full-' : ''
const levelKey = `${prefix}descent-level`
lesson = {
...lesson,
desc: 'Read back the assigned descent level, arrival clearance, QNH, and callsign',
fields: [
{
key: levelKey,
label: 'Assigned level',
expected: scenario => scenario.altitudes.initialWords,
alternatives: scenario => [scenario.altitudes.initial.toString(), `${scenario.altitudes.initial} feet`],
width: 'md',
},
...lesson.fields,
],
readback: [
{ type: 'text', text: 'Descend to ' },
{ type: 'field', key: levelKey, width: 'md' },
{ type: 'text', text: ', via ' },
...lesson.readback.slice(1),
],
phrase: scenario =>
`${scenario.radioCall}, descend to ${scenario.altitudes.initial} feet via ${scenario.arrivalStar} ${scenario.arrivalTransition}, QNH ${scenario.arrivalQnh}.`,
}
}
if (['approach-clearance', 'full-approach'].includes(lesson.id)) {
lesson = {
...lesson,
fields: lesson.fields.map(field =>
/app-type/.test(field.key) ? { ...field, expected: approachWithoutRunway } : field),
phrase: scenario =>
`${scenario.radioCall}, cleared ${approachWithoutRunway(scenario)} approach runway ${scenario.arrivalRunway}, report established.`,
}
}
if (lesson.id === 'alt-accept') {
lesson = {
...lesson,
title: 'Say Again',
desc: 'Request a repeat when a transmission is not understood',
hints: ['Use “SAY AGAIN” instead of guessing a safety-critical instruction.'],
fields: [{
key: 'say-again-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: callsignAlternatives,
width: 'lg',
}],
readback: [
{ type: 'field', key: 'say-again-callsign', width: 'lg' },
{ type: 'text', text: ', say again.' },
],
phrase: () => 'A controller transmission was blocked by interference. Ask for it again.',
info: () => ['Never invent or assume a clearance you did not understand.'],
}
}
if (lesson.id === 'alt-reject') {
lesson = {
...lesson,
title: 'Unable: Reason and Request',
desc: 'Decline an instruction and offer a safe alternative',
hints: ['State “UNABLE”, give a concise reason, then request an alternative.'],
fields: [
{
key: 'unable-reason',
label: 'Reason',
expected: scenario => scenario.emergencyProblem,
width: 'lg',
},
{
key: 'unable-request',
label: 'Alternative request',
expected: scenario => scenario.emergencyIntent,
width: 'lg',
},
{
key: 'unable-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: callsignAlternatives,
width: 'lg',
},
],
readback: [
{ type: 'text', text: 'Unable due ' },
{ type: 'field', key: 'unable-reason', width: 'lg' },
{ type: 'text', text: ', request ' },
{ type: 'field', key: 'unable-request', width: 'lg' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'unable-callsign', width: 'lg' },
],
phrase: scenario =>
`You cannot comply because of ${scenario.emergencyProblem}. Request to ${scenario.emergencyIntent}.`,
info: () => ['UNABLE is not a rejection without context: give the operational reason and a workable request.'],
}
}
if (['departure-checkin', 'full-departure-checkin'].includes(lesson.id)) {
const prefix = lesson.id.startsWith('full-') ? 'full-' : ''
const callsign = lesson.fields.find(field => /callsign/i.test(field.key))
const passingKey = `${prefix}depcheck-passing`
const clearedKey = `${prefix}depcheck-cleared`
if (callsign) {
lesson = {
...lesson,
desc: 'Make the required initial call after changing to departure',
hints: [
'State the unit, full callsign, passing level, and cleared level.',
'Add an assigned speed only when ATC has issued one.',
],
fields: [
callsign,
{
key: passingKey,
label: 'Passing level',
expected: scenario => scenario.altitudes.initialWords,
alternatives: scenario => [scenario.altitudes.initial.toString(), `${scenario.altitudes.initial} feet`],
width: 'md',
},
{
key: clearedKey,
label: 'Cleared level',
expected: scenario => scenario.altitudes.climbWords,
alternatives: scenario => [scenario.altitudes.climb.toString(), `${scenario.altitudes.climb} feet`],
width: 'md',
},
],
readback: [
{ type: 'text', text: scenario => `${scenario.airport.city} Departure, ` },
{ type: 'field', key: callsign.key, width: 'lg' },
{ type: 'text', text: ', passing ' },
{ type: 'field', key: passingKey, width: 'md' },
{ type: 'text', text: ', cleared ' },
{ type: 'field', key: clearedKey, width: 'md' },
],
phrase: scenario =>
`You have changed to ${scenario.airport.city} Departure. You are passing ${scenario.altitudes.initial} feet, cleared to ${scenario.altitudes.climb} feet.`,
}
}
}
if (['mayday', 'pan-pan'].includes(lesson.id)) {
const distress = lesson.id === 'mayday'
const prefix = distress ? 'mayday' : 'pan'
lesson = {
...lesson,
title: distress ? 'MAYDAY Declaration' : 'PAN PAN Declaration',
desc: distress
? 'Build a complete distress transmission'
: 'Build a complete urgency transmission',
hints: [
`${distress ? 'MAYDAY' : 'PAN PAN'} is preferably transmitted three times.`,
'Include station, callsign, nature, intention, position, level, and heading.',
],
fields: [
{
key: `${prefix}-callsign`,
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: callsignAlternatives,
width: 'lg',
},
{
key: `${prefix}-nature`,
label: 'Nature',
expected: scenario => scenario.emergencyProblem,
width: 'lg',
},
{
key: `${prefix}-intention`,
label: 'Intention',
expected: scenario => scenario.emergencyIntent,
width: 'lg',
},
{
key: `${prefix}-position`,
label: 'Position',
expected: scenario => scenario.airport.city,
alternatives: scenario => [scenario.airport.icao, scenario.airport.name],
width: 'md',
},
{
key: `${prefix}-level`,
label: 'Level',
expected: scenario => scenario.altitudes.initialWords,
alternatives: scenario => [scenario.altitudes.initial.toString(), `${scenario.altitudes.initial} feet`],
width: 'md',
},
{
key: `${prefix}-heading`,
label: 'Heading',
expected: scenario => scenario.emergencyHeading,
alternatives: scenario => [scenario.emergencyHeadingWords],
width: 'md',
},
],
readback: [
{
type: 'text',
text: scenario =>
`${scenario.airport.city} Control, ${distress ? 'MAYDAY MAYDAY MAYDAY' : 'PAN PAN PAN PAN PAN PAN'}, `,
},
{ type: 'field', key: `${prefix}-callsign`, width: 'lg' },
{ type: 'text', text: ', ' },
{ type: 'field', key: `${prefix}-nature`, width: 'lg' },
{ type: 'text', text: ', intend to ' },
{ type: 'field', key: `${prefix}-intention`, width: 'lg' },
{ type: 'text', text: ', position ' },
{ type: 'field', key: `${prefix}-position`, width: 'md' },
{ type: 'text', text: ', level ' },
{ type: 'field', key: `${prefix}-level`, width: 'md' },
{ type: 'text', text: ', heading ' },
{ type: 'field', key: `${prefix}-heading`, width: 'md' },
],
phrase: scenario =>
`${distress ? 'A grave and imminent danger exists' : 'An urgent situation exists'}: ${scenario.emergencyProblem}. You intend to ${scenario.emergencyIntent}. Build the complete transmission.`,
info: () => [
distress
? 'Use MAYDAY for grave and imminent danger requiring immediate assistance.'
: 'Use PAN PAN for an urgent situation that does not require immediate assistance.',
],
standard: 'SERA.14095',
}
}
if (lesson.id === 'continue-approach') {
lesson = {
...lesson,
whyItMatters: '“Continue approach” allows the approach to continue but is not a clearance to land.',
hints: ['Acknowledge the instruction. Do not treat it as a landing clearance.'],
}
}
if (lesson.id === 'standby') {
lesson = {
...lesson,
whyItMatters: '“STANDBY” only asks for time. It is not an approval, denial, clearance, or substitute for a mandatory safety readback.',
hints: ['Use it only for a brief delay in a non-urgent exchange.'],
}
}
const situationPrompt = situationPrompts[lesson.id]
if (situationPrompt) {
lesson = { ...lesson, phrase: situationPrompt }
}
if (lesson.id === 'go-around') {
const callsignField = lesson.fields.find(field => /callsign/i.test(field.key))
if (callsignField) {
lesson = {
...lesson,
fields: [callsignField],
readback: [
{ type: 'field', key: callsignField.key, width: 'lg' },
{ type: 'text', text: ', going around.' },
],
phrase: () => 'You discontinue the approach. Make the immediate pilot report; ATC instructions follow separately.',
hints: ['Transmit your callsign and “GOING AROUND”. Do not combine an assumed missed-approach clearance.'],
}
}
}
if (lesson.id === 'tcas-ra') {
const callsignKey = 'tcas-ra-callsign'
lesson = {
...lesson,
fields: [{
key: callsignKey,
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: callsignAlternatives,
width: 'lg',
}],
readback: [
{ type: 'field', key: callsignKey, width: 'lg' },
{ type: 'text', text: ', TCAS RA.' },
],
phrase: () => 'A resolution advisory is active. Report it immediately.',
hints: ['Use the exact concise report “TCAS RA”. Follow the RA even if it conflicts with an ATC instruction.'],
}
}
if (lesson.id === 'tcas-resume') {
const callsignKey = 'tcas-resume-callsign'
lesson = {
...lesson,
fields: [{
key: callsignKey,
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: callsignAlternatives,
width: 'lg',
}],
readback: [
{ type: 'field', key: callsignKey, width: 'lg' },
{ type: 'text', text: ', clear of conflict, returning to assigned clearance.' },
],
phrase: () => 'The resolution advisory has ended and you are returning to the assigned clearance.',
hints: ['Report “CLEAR OF CONFLICT, RETURNING TO ASSIGNED CLEARANCE”.'],
}
}
const promptKind = copyLessonIds.has(lesson.id)
? 'copy'
: situationLessonIds.has(lesson.id)
? 'situation'
: 'atc'
const responseLabel = promptKind === 'copy'
? (lesson.id === 'atis' || lesson.id === 'metar' ? 'Decoded values' : 'Your copy')
: promptKind === 'situation'
? 'Pilot call'
: 'Pilot readback'
return {
...lesson,
prompt: { kind: promptKind, text: lesson.phrase },
responseLabel,
standard: lesson.standard ?? (
promptKind === 'copy'
? 'ICAO Annex 10 / SERA.14070'
: 'SERA.8015 and Appendix 1 to AMC1 SERA.14001'
),
whyItMatters: lesson.whyItMatters ?? (
promptKind === 'copy'
? 'Accurate copying prevents identifiers and operational values from being confused.'
: 'The highlighted elements carry the clearance or operational intent and must not be guessed.'
),
fields: lesson.fields.map(field => curateField(field, promptKind)),
}
}
function fullFlightCallLesson(
id: string,
title: string,
desc: string,
build: (scenario: Scenario) => { situation: string; text: string },
): Lesson {
const callsignKey = `${id}-callsign`
return {
id,
title,
desc,
keywords: ['Guided flight', 'Pilot call'],
hints: ['Address the unit first, use your full callsign, then state the operational request or report.'],
fields: [{
key: callsignKey,
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: callsignAlternatives,
width: 'lg',
required: true,
safetyCritical: true,
matching: 'controlled',
}],
readback: [
{ type: 'text', text: scenario => build(scenario).text.split(scenario.radioCall)[0] ?? '' },
{ type: 'field', key: callsignKey, width: 'lg' },
{ type: 'text', text: scenario => build(scenario).text.split(scenario.radioCall).slice(1).join(scenario.radioCall) },
],
phrase: scenario => build(scenario).situation,
prompt: { kind: 'situation', text: scenario => build(scenario).situation },
responseLabel: 'Pilot call',
standard: 'Appendix 1 to AMC1 SERA.14001',
whyItMatters: 'A concise initial call gives ATC the aircraft identity and the information needed for the next instruction.',
info: () => ['Synthetic training sequence — not for operational use.'],
generate: makeFullFlightGenerator(),
}
}
const fullFlightAdditions: Record<string, Lesson> = {
'full-pushback-request': fullFlightCallLesson(
'full-pushback-request',
'Pushback Request',
'Request pushback and start from the assigned stand',
scenario => ({
situation: `You are ready at stand ${scenario.stand}. Request pushback and start from ${scenario.airport.city} Ground.`,
text: `${scenario.airport.city} Ground, ${scenario.radioCall}, stand ${scenario.stand}, request pushback and start.`,
}),
),
'full-taxi-request': fullFlightCallLesson(
'full-taxi-request',
'Taxi Request',
'Call ground when ready to taxi',
scenario => ({
situation: `Pushback is complete at stand ${scenario.stand}. Request taxi from ${scenario.airport.city} Ground.`,
text: `${scenario.airport.city} Ground, ${scenario.radioCall}, stand ${scenario.stand}, request taxi.`,
}),
),
'full-approach-checkin': fullFlightCallLesson(
'full-approach-checkin',
'Approach Initial Call',
'Check in after the approach channel change',
scenario => ({
situation: `You have changed to ${scenario.destination.city} Approach. You are passing ${scenario.altitudes.climb} feet, cleared to ${scenario.altitudes.initial} feet.`,
text: `${scenario.destination.city} Approach, ${scenario.radioCall}, passing ${scenario.altitudes.climbWords}, cleared ${scenario.altitudes.initialWords}.`,
}),
),
'full-established-report': fullFlightCallLesson(
'full-established-report',
'Established Report',
'Report established when instructed',
scenario => ({
situation: `You are established on the ${approachWithoutRunway(scenario)} approach runway ${scenario.arrivalRunway}. Report established.`,
text: `${scenario.destination.city} Approach, ${scenario.radioCall}, established runway ${scenario.arrivalRunway}.`,
}),
),
'full-vacate-transfer': fullFlightCallLesson(
'full-vacate-transfer',
'Runway Vacated and Ground Transfer',
'Report clear of the runway after changing to ground',
scenario => ({
situation: `You have vacated runway ${scenario.arrivalRunway} via ${scenario.arrivalTaxiRoute} and changed to Ground.`,
text: `${scenario.destination.city} Ground, ${scenario.radioCall}, runway vacated via ${scenario.arrivalTaxiRoute}.`,
}),
),
}
const curatedFullFlightLessons = fullFlightLessons
.filter(lesson => !['full-tower-contact', 'full-departure-handoff'].includes(lesson.id))
.flatMap(lesson => {
const additions: Lesson[] = []
if (lesson.id === 'full-pushback') additions.push(fullFlightAdditions['full-pushback-request']!)
if (lesson.id === 'full-taxi') additions.push(fullFlightAdditions['full-taxi-request']!)
if (lesson.id === 'full-descent') additions.push(fullFlightAdditions['full-approach-checkin']!)
if (lesson.id === 'full-landing') additions.push(fullFlightAdditions['full-established-report']!)
if (lesson.id === 'full-taxi-in') additions.push(fullFlightAdditions['full-vacate-transfer']!)
return [...additions, lesson]
})
.map(curateLesson)
export const learnModules: ModuleDef[] = [
{
id: 'normalize',
title: 'Foundations',
subtitle: 'Numbers, callsigns, ATIS/METAR, radio checks, and channels',
art: '/img/learn/modules/img14.jpeg',
lessons: fundamentalsLessons.map(curateLesson)
},
{
id: 'arc',
title: 'Mandatory Readbacks',
subtitle: 'Route, runway, QNH, squawk, level, heading, speed, and channels',
art: '/img/learn/modules/img11.jpeg',
lessons: readbackLessons.map(curateLesson)
},
{
id: 'decision-tree',
title: 'Pilot Calls & Abnormal Situations',
subtitle: 'Initial calls, unable, corrections, go-around, emergencies, and TCAS',
art: '/img/learn/modules/img10.jpeg',
lessons: decisionTreeLessons.map(curateLesson)
},
{
id: 'full-flight',
title: 'Guided Flight Sequence',
subtitle: 'One consistent 17-step training flight from clearance to taxi-in',
art: '/img/learn/modules/img6.jpeg',
lessons: curatedFullFlightLessons,
meta: {
flightPlan: true,
briefingArt: '/img/learn/missions/full-flight/briefing-hero.png'
}
}
]
export default learnModules