diff --git a/shared/data/learnModules.ts b/shared/data/learnModules.ts index f6fc6e1..0f40a69 100644 --- a/shared/data/learnModules.ts +++ b/shared/data/learnModules.ts @@ -1,4 +1,4 @@ -import { createBaseScenario, formatTemp } from '~~/shared/learn/scenario' +import { createBaseScenario, createScenarioSeries, formatTemp } from '~~/shared/learn/scenario' import type { ModuleDef } from '~~/shared/learn/types' function gradientArt(colors: string[]): string { @@ -9,795 +9,2949 @@ function gradientArt(colors: string[]): string { return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}` } +const fundamentalsLessons = [ + { + id: 'icao-alphabet', + title: 'ICAO Alphabet & Numbers', + desc: 'Spell letters and digits clearly', + keywords: ['Alphabet', 'Numbers', 'Basics'], + hints: [ + 'Spell each letter with its ICAO name (e.g. Delta, Lima, Hotel).', + 'Use ATC numbers: tree, fower, fife, niner.' + ], + fields: [ + { + key: 'letters', + label: 'Letters', + expected: scenario => scenario.callsignNato, + placeholder: 'Delta Lima Hotel', + width: 'xl', + threshold: 0.9 + }, + { + key: 'digits', + label: 'Numbers', + expected: scenario => scenario.flightNumberWords, + placeholder: 'one two three', + width: 'lg', + threshold: 0.88 + } + ], + readback: [ + { type: 'text', text: 'Call sign: ' }, + { type: 'field', key: 'letters', width: 'lg' }, + { type: 'text', text: ' · ' }, + { type: 'field', key: 'digits', width: 'md' } + ], + defaultFrequency: 'DEL', + phrase: scenario => `${scenario.callsignNato} ${scenario.flightNumberWords}`, + info: scenario => [ + `Callsign: ${scenario.callsign}`, + `Radio call: ${scenario.radioCall}`, + `ICAO spelling: ${scenario.callsignNato}`, + `Flight number spoken: ${scenario.flightNumberWords}` + ], + generate: createBaseScenario + }, + { + id: 'radio-call', + title: 'Radio Call Sign', + desc: 'Say the spoken and ICAO call sign', + keywords: ['Basics', 'Callsign'], + hints: [ + 'Use the airline telephony plus digits for the spoken version.', + 'ICAO format keeps the three-letter code with the numbers.' + ], + fields: [ + { + key: 'radio-call-spoken', + label: 'Spoken Call Sign', + expected: scenario => scenario.radioCall, + alternatives: scenario => [ + scenario.radioCall, + `${scenario.airlineCall} ${scenario.flightNumber}`, + `${scenario.airlineCall} ${scenario.flightNumberWords}` + ], + width: 'lg' + }, + { + key: 'radio-call-icao', + label: 'ICAO Identifier', + expected: scenario => scenario.callsign, + alternatives: scenario => [ + scenario.callsign, + `${scenario.airlineCode}${scenario.flightNumber}` + ], + width: 'md' + } + ], + readback: [ + { type: 'text', text: 'Call sign ' }, + { type: 'field', key: 'radio-call-spoken', width: 'lg' }, + { type: 'text', text: ' · ICAO ' }, + { type: 'field', key: 'radio-call-icao', width: 'md' } + ], + defaultFrequency: 'DEL', + phrase: scenario => `${scenario.radioCall}`, + info: scenario => [ + `Spoken call sign: ${scenario.radioCall}`, + `ICAO identifier: ${scenario.callsign}` + ], + 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 a single letter.', + 'Order: runway – wind – visibility – temperature – dew point – QNH.' + ], + fields: [ + { + key: 'atis-code', + label: 'ATIS', + expected: scenario => scenario.atisCode, + alternatives: scenario => [ + scenario.atisCode, + scenario.atisCode.toLowerCase(), + `Information ${scenario.atisCode}` + ], + placeholder: '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.atisSummary.visibility, + alternatives: scenario => [scenario.visibility, scenario.visibilityWords], + width: 'sm' + }, + { + key: 'atis-temp', + label: 'Temperature', + expected: scenario => scenario.atisSummary.temperature, + alternatives: scenario => [scenario.temperatureWords], + width: 'sm' + }, + { + key: 'atis-dew', + label: 'Dew point', + expected: scenario => scenario.atisSummary.dewpoint, + alternatives: scenario => [scenario.dewpointWords], + 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, runway, wind, visibility, temperature, dew point, and QNH.', + 'Visibility: four digits or 9999 for ≥10 km; QNH as a four-digit value.' + ], + 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: '9999', + 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: 'Report readability', + keywords: ['Ground', 'Comms'], + hints: [ + 'Reply with "Readability" followed by the number.', + 'Always finish the check with your call sign.' + ], + fields: [ + { + key: 'rc-callsign', + label: 'Callsign', + expected: scenario => scenario.radioCall, + alternatives: scenario => [ + scenario.radioCall, + `${scenario.airlineCall} ${scenario.flightNumber}`, + scenario.callsign + ], + placeholder: 'Lufthansa one two three', + width: 'lg' + }, + { + key: 'rc-readability', + label: 'Readability', + expected: scenario => scenario.readabilityWord, + alternatives: scenario => [scenario.readability.toString(), scenario.readabilityWord], + placeholder: 'five', + width: 'sm' + } + ], + readback: [ + { type: 'text', text: 'This is ' }, + { type: 'field', key: 'rc-callsign', width: 'lg' }, + { type: 'text', text: ', readability ' }, + { type: 'field', key: 'rc-readability', width: 'sm' } + ], + defaultFrequency: 'GND', + phrase: scenario => `${scenario.airport.name} Ground, ${scenario.radioCall}, radio check on ${scenario.groundFreq}.`, + info: scenario => [ + `Frequency: ${scenario.groundFreq} (${scenario.frequencyWords.GND})`, + `Expected response: Readability ${scenario.readability} (${scenario.readabilityWord})` + ], + generate: createBaseScenario + } +] + +const readbackLessons = [ + { + 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` + ], + 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: 'lineup', + title: 'Line-up Clearance', + desc: 'Acknowledge line up and wait', + keywords: ['Tower', 'Line Up'], + hints: [ + 'Repeat the runway, then say "line up and wait".', + 'Place the call sign at the end.' + ], + 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: runway – cleared for takeoff – call sign.', + 'Wind information can be omitted if it was not given.' + ], + fields: [ + { + 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: '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: [ + 'Lead with the runway, then "cleared to land".', + 'Keep the call sign at the end.' + ], + fields: [ + { + 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: '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 = [ + { + 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 => `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 => `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() + +const makeFullFlightGenerator = (reset = false) => () => { + if (reset) { + fullFlightScenario.reset() + } + return fullFlightScenario() +} + +const fullFlightLessons = [ + { + 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: [ + 'Repeat the runway and add "line up and wait".' + ], + 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: runway – cleared for takeoff – call sign.' + ], + fields: [ + { + 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: '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: [ + 'Lead with the runway, then "cleared to land".' + ], + fields: [ + { + 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: '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() + } +] + export const learnModules: ModuleDef[] = [ { id: 'normalize', - title: 'Normalize · Fundamentals', - subtitle: 'Alphabet, ATIS, METAR & Radio Check', + title: 'Fundamentals · Basics', + subtitle: 'Alphabet, Call Signs, ATIS & METAR', art: '/img/learn/modules/img10.jpeg', - lessons: [ - { - id: 'icao-alphabet', - title: 'ICAO Alphabet & Numbers', - desc: 'Spell letters and digits clearly', - keywords: ['Alphabet', 'Numbers', 'Normalize'], - hints: [ - 'Spell each letter with its ICAO name (e.g. Delta, Lima, Hotel).', - 'Use ATC numbers: tree, fower, fife, niner.' - ], - fields: [ - { - key: 'letters', - label: 'Letters', - expected: scenario => scenario.callsignNato, - placeholder: 'Delta Lima Hotel', - width: 'xl', - threshold: 0.9 - }, - { - key: 'digits', - label: 'Numbers', - expected: scenario => scenario.flightNumberWords, - placeholder: 'one two three', - width: 'lg', - threshold: 0.88 - } - ], - readback: [ - { type: 'text', text: 'Call sign: ' }, - { type: 'field', key: 'letters', width: 'lg' }, - { type: 'text', text: ' · ' }, - { type: 'field', key: 'digits', width: 'md' } - ], - defaultFrequency: 'DEL', - phrase: scenario => `${scenario.callsignNato} ${scenario.flightNumberWords}`, - info: scenario => [ - `Callsign: ${scenario.callsign}`, - `Radio call: ${scenario.radioCall}`, - `ICAO spelling: ${scenario.callsignNato}`, - `Flight number spoken: ${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 a single letter.', - 'Order: runway – wind – visibility – temperature – dew point – QNH.' - ], - fields: [ - { - key: 'atis-code', - label: 'ATIS', - expected: scenario => scenario.atisCode, - alternatives: scenario => [ - scenario.atisCode, - scenario.atisCode.toLowerCase(), - `Information ${scenario.atisCode}` - ], - placeholder: '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.atisSummary.visibility, - alternatives: scenario => [scenario.visibility, scenario.visibilityWords], - width: 'sm' - }, - { - key: 'atis-temp', - label: 'Temperature', - expected: scenario => scenario.atisSummary.temperature, - alternatives: scenario => [scenario.temperatureWords], - width: 'sm' - }, - { - key: 'atis-dew', - label: 'Dew point', - expected: scenario => scenario.atisSummary.dewpoint, - alternatives: scenario => [scenario.dewpointWords], - 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, runway, wind, visibility, temperature, dew point, and QNH.', - 'Visibility: four digits or 9999 for ≥10 km; QNH as a four-digit value.' - ], - 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: '9999', - 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: 'Report readability', - keywords: ['Ground', 'Comms'], - hints: [ - 'Reply with "Readability" followed by the number.', - 'Always finish the check with your call sign.' - ], - fields: [ - { - key: 'rc-callsign', - label: 'Callsign', - expected: scenario => scenario.radioCall, - alternatives: scenario => [ - scenario.radioCall, - `${scenario.airlineCall} ${scenario.flightNumber}`, - scenario.callsign - ], - placeholder: 'Lufthansa one two three', - width: 'lg' - }, - { - key: 'rc-readability', - label: 'Readability', - expected: scenario => scenario.readabilityWord, - alternatives: scenario => [scenario.readability.toString(), scenario.readabilityWord], - placeholder: 'five', - width: 'sm' - } - ], - readback: [ - { type: 'text', text: 'This is ' }, - { type: 'field', key: 'rc-callsign', width: 'lg' }, - { type: 'text', text: ', readability ' }, - { type: 'field', key: 'rc-readability', width: 'sm' } - ], - defaultFrequency: 'GND', - phrase: scenario => `${scenario.airport.name} Ground, ${scenario.radioCall}, radio check on ${scenario.groundFreq}.`, - info: scenario => [ - `Frequency: ${scenario.groundFreq} (${scenario.frequencyWords.GND})`, - `Expected response: Readability ${scenario.readability} (${scenario.readabilityWord})` - ], - generate: createBaseScenario - } - ] + lessons: fundamentalsLessons }, { id: 'arc', - title: 'ARC Decision Tree', - subtitle: 'From clearance call to departure', + title: 'Readbacks · Mandatory Acknowledgements', + subtitle: 'Clearances, taxi, approach & landing', art: '/img/learn/modules/img3.jpeg', - lessons: [ - { - 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: '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} (${scenario.squawkWords})` - ], - generate: createBaseScenario - }, - { - id: 'pushback', - title: 'Push & Start Readback', - desc: 'Acknowledge the pushback clearance', - keywords: ['Ground', 'Pushback'], - hints: [ - 'Repeat the runway direction and QNH, call sign at the end.', - 'QNH may be just the number or "QNH xxxx".' - ], - 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/Gate: ${scenario.stand}`, - `Ground: ${scenario.groundFreq} (${scenario.frequencyWords.GND})` - ], - 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: 'lineup', - title: 'Line-up Clearance', - desc: 'Acknowledge line up and wait', - keywords: ['Tower', 'Line Up'], - hints: [ - 'Repeat the runway, then say "line up and wait".', - 'Place the call sign at the end.' - ], - 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: runway – cleared for takeoff – call sign.', - 'Wind information can be omitted if it was not given.' - ], - fields: [ - { - 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: '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 => [ - `Tower: ${scenario.towerFreq} (${scenario.frequencyWords.TWR})`, - `Wind: ${scenario.wind} (${scenario.windWords})` - ], - 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: 'Frequenz', - 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: '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: '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: 'center-handoff', - title: 'Center Handoff', - desc: 'Acknowledge the handoff to center', - keywords: ['Center', 'Handoff'], - hints: [ - 'Repeat the frequency exactly (with or without the decimal point).', - 'Place the call sign at the end.' - ], - fields: [ - { - key: 'ctr-freq', - label: 'Frequenz', - expected: scenario => scenario.centerFreq, - alternatives: scenario => [ - scenario.centerFreq, - scenario.centerFreq.replace('.', ''), - scenario.frequencyWords.CTR - ], - width: 'md' - }, - { - key: 'ctr-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: 'ctr-freq', width: 'md' }, - { type: 'text', text: ', ' }, - { type: 'field', key: 'ctr-callsign', width: 'lg' } - ], - defaultFrequency: 'CTR', - phrase: scenario => `${scenario.radioCall}, contact center ${scenario.centerFreq}.`, - info: scenario => [ - `Center: ${scenario.centerFreq} (${scenario.frequencyWords.CTR})`, - `Next unit: Center` - ], - generate: createBaseScenario - } - ] + lessons: readbackLessons + }, + { + id: 'decision-tree', + title: 'ATC Decision Tree · Advanced Flow', + subtitle: 'Requests, contingencies & interrupts', + art: gradientArt(['#0f172a', '#1e293b', '#38bdf8']), + lessons: decisionTreeLessons + }, + { + id: 'full-flight', + title: 'Full Flight · Gate to Gate', + subtitle: 'One linked scenario from clearance to taxi-in', + art: '/img/learn/modules/img6.jpeg', + lessons: fullFlightLessons } ] diff --git a/shared/learn/scenario.ts b/shared/learn/scenario.ts index 877ab4e..ed8800c 100644 --- a/shared/learn/scenario.ts +++ b/shared/learn/scenario.ts @@ -56,6 +56,18 @@ const readabilityScale = [ { level: 5, word: 'five', description: 'Perfectly readable' } ] +const emergencyProblems = [ + 'engine failure on climb', + 'smoke in the cabin', + 'hydraulic failure', + 'medical emergency on board', + 'pressurization loss' +] + +const pushDelayOptions = [3, 5, 7, 10] +const defaultSpeedRestrictions = [180, 200, 210, 220] +const approachAltitudeOptions = [3000, 4000, 5000, 6000] + const airlines: AirlineData[] = [ { code: 'DLH', call: 'Lufthansa' }, { code: 'BAW', call: 'Speedbird' }, @@ -73,9 +85,18 @@ const airportsData: AirportData[] = [ runways: ['25C', '25R', '07C', '07R', '18'], stands: ['V155', 'A12', 'B24', 'G5', 'H43', 'L21'], sids: ['ANEKI 7S', 'TOBAK 5Q', 'OBOKA 6N'], + stars: ['ANEKI 2A', 'TOBAK 3G', 'UNOKO 1S'], transitions: ['ANEKI', 'TOBAK', 'OBOKA'], + arrivalTransitions: ['ANEKI', 'TOBAK', 'UNOKO'], approaches: ['ILS Z 25C', 'ILS Y 07C'], + missedApproaches: [ + 'Climb straight ahead to 3000 feet, then as published.', + 'As published.' + ], taxi: ['N3 U4', 'V A', 'N7 K', 'S V12'], + taxiIn: ['S N7', 'V12 S1', 'A K5', 'N3 U4'], + speedRestrictions: [200, 210], + emergencyHeadings: ['180', '220', '270'], freqs: { atis: '126.350', delivery: '121.900', @@ -94,9 +115,18 @@ const airportsData: AirportData[] = [ runways: ['26R', '26L', '08R', '08L'], stands: ['211', '214', '302', 'N16', 'H45'], sids: ['OBAXA 3S', 'MERSI 6S', 'TULSI 5M'], + stars: ['OBAXA 4A', 'MERSI 3S', 'TULSI 2M'], transitions: ['OBAXA', 'MERSI', 'TULSI'], + arrivalTransitions: ['OBAXA', 'MERSI', 'TULSI'], approaches: ['ILS Z 26R', 'ILS Y 08R'], + missedApproaches: [ + 'Climb to 4000 feet, then as published.', + 'As published.' + ], taxi: ['L4 N3', 'P3 W2', 'S1 D2'], + taxiIn: ['P3 W2', 'S1 D2', 'L4 N3'], + speedRestrictions: [190, 210], + emergencyHeadings: ['200', '240', '280'], freqs: { atis: '122.130', delivery: '121.775', @@ -115,9 +145,18 @@ const airportsData: AirportData[] = [ runways: ['24', '36L', '18C', '09'], stands: ['D14', 'E22', 'F8', 'H4'], sids: ['SUGOL 2S', 'ANDIK 2S', 'ARNEM 2V'], + stars: ['SUGOL 2A', 'ANDIK 2S', 'ARNEM 2V'], transitions: ['SUGOL', 'ANDIK', 'ARNEM'], + arrivalTransitions: ['SUGOL', 'ANDIK', 'ARNEM'], approaches: ['ILS Z 24', 'ILS Y 18C'], + missedApproaches: [ + 'Climb straight ahead to 2000 feet, then left turn ANDIK.', + 'As published.' + ], taxi: ['A5 B2', 'K1 V3', 'W4 S8'], + taxiIn: ['A5 B2', 'K1 V3', 'W4 S8'], + speedRestrictions: [180, 200], + emergencyHeadings: ['180', '210', '250'], freqs: { atis: '136.050', delivery: '121.800', @@ -235,6 +274,16 @@ function visibilityToWords(vis: string): string { return vis } +function minutesToWords(minutes: number): string { + const value = Math.max(1, Math.round(minutes)) + const unit = value === 1 ? 'minute' : 'minutes' + return `${digitsToWords(value.toString())} ${unit}` +} + +function speedToWords(speed: number): string { + return `${digitsToWords(Math.round(speed).toString())} knots` +} + function altitudeToWords(value: number): string { const thousands = Math.floor(value / 1000) const remainder = value % 1000 @@ -257,7 +306,12 @@ export function createBaseScenario(): Scenario { const taxiRoute = choice(airport.taxi) const sid = choice(airport.sids) const transition = choice(airport.transitions) - const approach = choice(airport.approaches) + const arrivalRunway = choice(destination.runways) + const arrivalStand = choice(destination.stands) + const arrivalTaxiRoute = choice(destination.taxiIn ?? destination.taxi) + const arrivalStar = choice(destination.stars ?? destination.sids) + const arrivalTransition = choice(destination.arrivalTransitions ?? destination.transitions) + const approach = choice(destination.approaches) const altitude = choice([4000, 5000, 6000, 7000]) const climbAltitude = altitude + 2000 const squawk = generateSquawk() @@ -272,6 +326,26 @@ export function createBaseScenario(): Scenario { const dewpoint = Math.max(temperature - randInt(2, 6), -10) const atisCode = choice(atisLetters) const remarks = choice(['NOSIG', 'BECMG 4000', 'TEMPO -SHRA']) + const arrivalQnh = randInt(980, 1035) + const arrivalWindDirection = randInt(0, 35) * 10 + const arrivalWindSpeed = randInt(3, 18) + const approachAltitude = choice(approachAltitudeOptions) + const speedRestriction = choice(destination.speedRestrictions ?? defaultSpeedRestrictions) + const pushDelay = choice(pushDelayOptions) + const vectorHeading = (randInt(0, 35) * 10).toString().padStart(3, '0') + const emergencyHeadingRaw = destination.emergencyHeadings + ? choice(destination.emergencyHeadings) + : (randInt(0, 35) * 10).toString() + const emergencyHeading = emergencyHeadingRaw.toString().padStart(3, '0') + const missedApproach = choice(destination.missedApproaches ?? ['as published']) + const emergencyIntentOptions = [ + `return to ${airport.city}`, + `divert to ${destination.city}`, + 'land at nearest suitable aerodrome', + 'hold to troubleshoot' + ] + const emergencyProblem = choice(emergencyProblems) + const emergencyIntent = choice(emergencyIntentOptions) const now = new Date() const minute = Math.floor(now.getUTCMinutes() / 5) * 5 const timestamp = `${now.getUTCDate().toString().padStart(2, '0')}${now @@ -283,6 +357,17 @@ export function createBaseScenario(): Scenario { const metar = `${airport.icao} ${timestamp} ${metarWindGroup} ${visibility} ${cloud} ${tempGroup} Q${qnh .toString() .padStart(4, '0')} ${remarks}` + const arrivalWind = `${arrivalWindDirection.toString().padStart(3, '0')}/${arrivalWindSpeed + .toString() + .padStart(2, '0')}` + const arrivalRunwayWords = runwayToWords(arrivalRunway) + const arrivalWindWords = windToWords(arrivalWindDirection, arrivalWindSpeed) + const arrivalQnhWords = qnhToWords(arrivalQnh) + const approachAltitudeWords = altitudeToWords(approachAltitude) + const speedRestrictionWords = speedToWords(speedRestriction) + const pushDelayWords = minutesToWords(pushDelay) + const vectorHeadingWords = digitsToWords(vectorHeading) + const emergencyHeadingWords = digitsToWords(emergencyHeading) const frequencies: Frequency[] = [ { type: 'ATIS', label: 'ATIS', value: airport.freqs.atis }, @@ -324,6 +409,16 @@ export function createBaseScenario(): Scenario { sid, transition, approach, + arrivalRunway, + arrivalRunwayWords, + arrivalTaxiRoute, + arrivalStand, + arrivalStar, + arrivalTransition, + arrivalQnh, + arrivalQnhWords, + arrivalWind, + arrivalWindWords, altitudes: { initial: altitude, climb: climbAltitude, @@ -352,6 +447,17 @@ export function createBaseScenario(): Scenario { temperatureWords: temperatureToWords(temperature), dewpoint, dewpointWords: temperatureToWords(dewpoint), + approachAltitude, + approachAltitudeWords, + pushDelayMinutes: pushDelay, + pushDelayWords, + speedRestriction, + speedRestrictionWords, + vectorHeading, + vectorHeadingWords, + emergencyHeading, + emergencyHeadingWords, + missedApproach, metar, metarSegments: { wind: metarWindGroup, @@ -372,6 +478,31 @@ export function createBaseScenario(): Scenario { approachFreq: airport.freqs.approach, centerFreq: airport.freqs.center, transLevel: airport.transLevel, - remarks + remarks, + emergencyProblem, + emergencyIntent } } + +function cloneScenario(scenario: Scenario): Scenario { + if (typeof structuredClone === 'function') { + return structuredClone(scenario) + } + return JSON.parse(JSON.stringify(scenario)) as Scenario +} + +export type ScenarioGenerator = (() => Scenario) & { reset: () => void } + +export function createScenarioSeries(): ScenarioGenerator { + let cached: Scenario | null = null + const generator = () => { + if (!cached) { + cached = createBaseScenario() + } + return cloneScenario(cached) + } + generator.reset = () => { + cached = null + } + return generator +} diff --git a/shared/learn/types.ts b/shared/learn/types.ts index 20cdd8d..e957b84 100644 --- a/shared/learn/types.ts +++ b/shared/learn/types.ts @@ -15,9 +15,15 @@ export type AirportData = { runways: string[] stands: string[] sids: string[] + stars?: string[] transitions: string[] + arrivalTransitions?: string[] approaches: string[] + missedApproaches?: string[] taxi: string[] + taxiIn?: string[] + speedRestrictions?: number[] + emergencyHeadings?: string[] freqs: { atis: string delivery: string @@ -52,6 +58,16 @@ export type Scenario = { sid: string transition: string approach: string + arrivalRunway: string + arrivalRunwayWords: string + arrivalTaxiRoute: string + arrivalStand: string + arrivalStar: string + arrivalTransition: string + arrivalQnh: number + arrivalQnhWords: string + arrivalWind: string + arrivalWindWords: string altitudes: { initial: number climb: number @@ -80,6 +96,17 @@ export type Scenario = { temperatureWords: string dewpoint: number dewpointWords: string + approachAltitude: number + approachAltitudeWords: string + pushDelayMinutes: number + pushDelayWords: string + speedRestriction: number + speedRestrictionWords: string + vectorHeading: string + vectorHeadingWords: string + emergencyHeading: string + emergencyHeadingWords: string + missedApproach: string metar: string metarSegments: { wind: string @@ -101,6 +128,8 @@ export type Scenario = { centerFreq: string transLevel: string remarks: string + emergencyProblem: string + emergencyIntent: string } export type LessonField = {