mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-23 16:15:31 +08:00
4039 lines
134 KiB
TypeScript
4039 lines
134 KiB
TypeScript
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: '1–5',
|
||
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 ATC’s 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 1–5',
|
||
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
|