feat(classroom): add VATSIM Essentials module (6 lessons) in Abnormal track

Lessons: Unicom Blind Call, Radio Check & Readability, Say Again
(Targeted), Monitor vs Contact, Two-Step Frequency Change, ATIS
Update During Taxi. All with multiple meaningful fields and
ICAO-standard phraseology.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-02-15 10:51:36 +01:00
parent c6e60fc88d
commit 8b20dc9131

View File

@@ -3257,6 +3257,406 @@ const fullFlightLessons = [
}
]
/* ────────────────────────────────────────────────────────────
* VATSIM ESSENTIALS
* ──────────────────────────────────────────────────────────── */
const vatsimEssentialsLessons = [
{
id: 'unicom-blind-call',
title: 'Unicom Blind Call',
desc: 'Announce your intentions when no ATC is online',
keywords: ['VATSIM', 'Unicom', '122.800'],
hints: [
'When no controller is online, announce on the advisory frequency (122.800 or local CTAF).',
'Format: "[Callsign], [Airport] Traffic, [position], [intention]".',
'Keep it brief — other pilots just need to know where you are and what you plan to do.'
],
fields: [
{
key: 'icao',
label: 'Airport ICAO',
expected: (scenario: Scenario) => scenario.airport.icao,
placeholder: 'ICAO code',
width: 'sm' as const
},
{
key: 'position',
label: 'Position',
expected: (scenario: Scenario) => scenario.positionDescription,
alternatives: (scenario: Scenario) => [
scenario.positionDescription,
scenario.positionDescription.toLowerCase(),
],
threshold: 0.7,
placeholder: 'e.g. 10 miles south',
width: 'lg' as const
},
{
key: 'intention',
label: 'Intention',
expected: () => 'inbound for landing',
alternatives: () => [
'inbound for landing',
'inbound',
'inbound runway',
'inbound for ILS approach',
'inbound for approach',
],
threshold: 0.6,
placeholder: 'e.g. inbound for landing',
width: 'lg' as const
}
],
readback: [
{ type: 'text' as const, text: (scenario: Scenario) => `${scenario.radioCall}, ` },
{ type: 'field' as const, key: 'icao', width: 'sm' as const },
{ type: 'text' as const, text: ' Traffic, ' },
{ type: 'field' as const, key: 'position', width: 'lg' as const },
{ type: 'text' as const, text: ', ' },
{ type: 'field' as const, key: 'intention', width: 'lg' as const },
],
defaultFrequency: undefined,
phrase: (scenario: Scenario) =>
`No ATC online at ${scenario.airport.name}. You are ${scenario.positionDescription}, planning to land. Make a blind call on the advisory frequency.`,
info: (scenario: Scenario) => [
`Airport: ${scenario.airport.icao}${scenario.airport.name}`,
`Position: ${scenario.positionDescription}`,
'Advisory frequency: 122.800 (or local CTAF)'
],
generate: createBaseScenario
},
{
id: 'radio-check-readability',
title: 'Radio Check & Readability',
desc: 'Initiate a radio check and confirm readability',
keywords: ['Radio Check', 'Readability', 'Basics'],
hints: [
'Format: "[Station], [Callsign], radio check [frequency]".',
'Readability scale: 1 = unreadable, 2 = barely readable, 3 = readable with difficulty, 4 = readable, 5 = perfectly readable.',
'Confirm with: "Readability [number], [Callsign]".'
],
fields: [
{
key: 'station',
label: 'Station',
expected: (scenario: Scenario) => `${scenario.airport.city} Delivery`,
alternatives: (scenario: Scenario) => [
`${scenario.airport.city} Delivery`,
`${scenario.airport.name} Delivery`,
`${scenario.airport.city} delivery`,
],
threshold: 0.75,
placeholder: 'e.g. Frankfurt Delivery',
width: 'lg' as const
},
{
key: 'frequency',
label: 'Frequency',
expected: (scenario: Scenario) => scenario.deliveryFreq,
alternatives: (scenario: Scenario) => [
scenario.deliveryFreq,
scenario.deliveryFreq.replace('.', ','),
],
placeholder: 'e.g. 121.900',
width: 'md' as const
},
{
key: 'readability',
label: 'Readability',
expected: (scenario: Scenario) => scenario.readability.toString(),
alternatives: (scenario: Scenario) => [
scenario.readability.toString(),
scenario.readabilityWord,
],
placeholder: '1-5',
width: 'xs' as const,
inputmode: 'numeric' as const
}
],
readback: [
{ type: 'field' as const, key: 'station', width: 'lg' as const },
{ type: 'text' as const, text: (scenario: Scenario) => `, ${scenario.radioCall}, radio check ` },
{ type: 'field' as const, key: 'frequency', width: 'md' as const },
{ type: 'text' as const, text: ' · Readability ' },
{ type: 'field' as const, key: 'readability', width: 'xs' as const },
],
defaultFrequency: 'DEL',
phrase: (scenario: Scenario) =>
`${scenario.radioCall}, ${scenario.readabilityPhrase}.`,
info: (scenario: Scenario) => [
`Station: ${scenario.airport.city} Delivery`,
`Frequency: ${scenario.deliveryFreq}`,
`Readability: ${scenario.readability}${scenario.readabilityWord}`,
],
generate: createBaseScenario
},
{
id: 'say-again-targeted',
title: 'Say Again (Targeted)',
desc: 'Request the specific missing part of a garbled clearance',
keywords: ['Say Again', 'Comms', 'Recovery'],
hints: [
'Never just say "say again" — always specify WHAT you missed.',
'Use: "Say again [element]" or "Say again all before [word]" or "Say again all after [word]".',
'Common targets: clearance limit, routing, squawk code, altitude.'
],
fields: [
{
key: 'say-again-target',
label: 'Say again target',
expected: () => 'clearance limit and routing',
alternatives: () => [
'clearance limit and routing',
'clearance limit',
'routing',
'all before squawk',
'destination and routing',
'all before departure frequency',
],
threshold: 0.55,
placeholder: 'e.g. clearance limit and routing',
width: 'xl' as const
}
],
readback: [
{ type: 'text' as const, text: (scenario: Scenario) => `${scenario.radioCall}, say again ` },
{ type: 'field' as const, key: 'say-again-target', width: 'xl' as const },
],
defaultFrequency: 'DEL',
phrase: (scenario: Scenario) =>
`${scenario.radioCall}, cleared to... [garbled] ...${scenario.sid} departure, squawk ${scenario.squawkWords}.`,
info: (scenario: Scenario) => [
'The clearance limit (destination) and routing were garbled.',
`You heard: SID "${scenario.sid}" and squawk "${scenario.squawk}"`,
'Request specifically what you missed — not "say again all".'
],
generate: createBaseScenario
},
{
id: 'monitor-vs-contact',
title: 'Monitor vs Contact',
desc: 'Know when to call in and when to just listen',
keywords: ['Frequency', 'Monitor', 'Contact', 'VATSIM'],
hints: [
'"Contact [station]" = switch frequency AND make an initial call.',
'"Monitor [station]" = switch frequency and LISTEN ONLY — do NOT transmit.',
'This is the most common VATSIM beginner mistake.'
],
fields: [
{
key: 'action',
label: 'Action',
expected: () => 'contact',
alternatives: () => ['contact', 'Contact'],
placeholder: 'contact or monitor',
width: 'md' as const
},
{
key: 'facility',
label: 'Facility',
expected: (scenario: Scenario) => `${scenario.airport.city} Tower`,
alternatives: (scenario: Scenario) => [
`${scenario.airport.city} Tower`,
`${scenario.airport.city} tower`,
`${scenario.airport.name} Tower`,
],
threshold: 0.75,
placeholder: 'e.g. Frankfurt Tower',
width: 'lg' as const
},
{
key: 'freq',
label: 'Frequency',
expected: (scenario: Scenario) => scenario.towerFreq,
placeholder: 'e.g. 118.700',
width: 'md' as const
},
{
key: 'initial-call',
label: 'Initial call',
expected: (scenario: Scenario) =>
`${scenario.airport.city} Tower, ${scenario.radioCall}, ${scenario.altitudes.initialWords} feet, information ${scenario.atisCodeWord}`,
alternatives: (scenario: Scenario) => [
`${scenario.airport.city} Tower, ${scenario.radioCall}, ${scenario.altitudes.initial} feet, information ${scenario.atisCode}`,
`${scenario.airport.city} Tower, ${scenario.radioCall}, ${scenario.altitudes.initial}, information ${scenario.atisCode}`,
],
threshold: 0.55,
placeholder: 'Full initial call on new frequency',
width: 'xl' as const
}
],
readback: [
{ type: 'field' as const, key: 'action', width: 'md' as const },
{ type: 'text' as const, text: ' ' },
{ type: 'field' as const, key: 'facility', width: 'lg' as const },
{ type: 'text' as const, text: ' ' },
{ type: 'field' as const, key: 'freq', width: 'md' as const },
{ type: 'text' as const, text: (scenario: Scenario) => `, ${scenario.radioCall} · ` },
{ type: 'field' as const, key: 'initial-call', width: 'xl' as const },
],
defaultFrequency: 'GND',
phrase: (scenario: Scenario) =>
`${scenario.radioCall}, contact ${scenario.airport.city} Tower ${scenario.towerFreq}.`,
info: (scenario: Scenario) => [
'"Contact" means: switch AND call in with your position + ATIS.',
`Station: ${scenario.airport.city} Tower on ${scenario.towerFreq}`,
`Your altitude: ${scenario.altitudes.initial} ft, ATIS: ${scenario.atisCode}`,
],
generate: createBaseScenario
},
{
id: 'two-step-freq-change',
title: 'Two-Step Frequency Change',
desc: 'Read back a combined instruction then make the initial call',
keywords: ['Frequency', 'Handoff', 'Climb'],
hints: [
'When ATC gives an instruction + frequency change, read back BOTH before switching.',
'After switching: make your initial call with callsign, altitude, and ATIS.',
'Format: "[Station], [Callsign], climbing/at [altitude], information [ATIS]".'
],
fields: [
{
key: 'climb-alt',
label: 'Climb altitude',
expected: (scenario: Scenario) => `flight level ${scenario.altitudes.climbWords.replace(/ thousand/g, '').replace(/\s+/g, ' ')}`,
alternatives: (scenario: Scenario) => {
const raw = scenario.altitudes.climb.toString()
const fl = `FL${Math.round(scenario.altitudes.climb / 100)}`
return [
`flight level ${scenario.altitudes.climbWords}`,
`FL ${Math.round(scenario.altitudes.climb / 100)}`,
fl,
raw,
scenario.altitudes.climbWords,
]
},
threshold: 0.6,
placeholder: 'e.g. flight level 350',
width: 'lg' as const
},
{
key: 'handoff-facility',
label: 'Facility',
expected: (scenario: Scenario) => scenario.handoff.facility,
alternatives: (scenario: Scenario) => [
scenario.handoff.facility,
scenario.handoff.facility.toLowerCase(),
],
threshold: 0.7,
placeholder: 'e.g. London Control',
width: 'lg' as const
},
{
key: 'handoff-freq',
label: 'Frequency',
expected: (scenario: Scenario) => scenario.handoff.frequency,
placeholder: 'e.g. 128.050',
width: 'md' as const
},
{
key: 'atis-code',
label: 'ATIS code',
expected: (scenario: Scenario) => scenario.atisCode,
alternatives: (scenario: Scenario) => [
scenario.atisCode,
scenario.atisCodeWord,
scenario.atisCode.toLowerCase(),
scenario.atisCodeWord.toLowerCase(),
],
placeholder: 'e.g. D or Delta',
width: 'sm' as const
}
],
readback: [
{ type: 'text' as const, text: 'Climb ' },
{ type: 'field' as const, key: 'climb-alt', width: 'lg' as const },
{ type: 'text' as const, text: ', contact ' },
{ type: 'field' as const, key: 'handoff-facility', width: 'lg' as const },
{ type: 'text' as const, text: ' ' },
{ type: 'field' as const, key: 'handoff-freq', width: 'md' as const },
{ type: 'text' as const, text: (scenario: Scenario) => `, ${scenario.radioCall} · Initial: ... information ` },
{ type: 'field' as const, key: 'atis-code', width: 'sm' as const },
],
defaultFrequency: 'DEP',
phrase: (scenario: Scenario) =>
`${scenario.radioCall}, climb ${scenario.altitudes.climbWords}, contact ${scenario.handoff.facility} ${scenario.handoff.frequency}.`,
info: (scenario: Scenario) => [
`Instruction: Climb ${scenario.altitudes.climb} ft`,
`Handoff: ${scenario.handoff.facility} on ${scenario.handoff.frequency}`,
`ATIS: Information ${scenario.atisCode}`,
],
generate: createBaseScenario
},
{
id: 'atis-update-taxi',
title: 'ATIS Update During Taxi',
desc: 'Acknowledge a new ATIS with changed QNH and runway',
keywords: ['ATIS', 'QNH', 'Taxi', 'Runway Change'],
hints: [
'When ATC announces a new ATIS during taxi, read back the safety-critical items: ATIS letter, QNH, and new runway.',
'Always set the new QNH immediately — it affects your altimeter.',
'The runway change may require re-briefing your departure procedure.'
],
fields: [
{
key: 'atis-letter',
label: 'ATIS letter',
expected: (scenario: Scenario) => scenario.atisCode,
alternatives: (scenario: Scenario) => [
scenario.atisCode,
scenario.atisCodeWord,
scenario.atisCode.toLowerCase(),
scenario.atisCodeWord.toLowerCase(),
],
placeholder: 'e.g. D or Delta',
width: 'sm' as const
},
{
key: 'qnh',
label: 'QNH',
expected: (scenario: Scenario) => scenario.qnh.toString(),
alternatives: (scenario: Scenario) => [
scenario.qnh.toString(),
scenario.qnhWords,
],
placeholder: 'e.g. 1013',
width: 'sm' as const,
inputmode: 'numeric' as const
},
{
key: 'new-runway',
label: 'New runway',
expected: (scenario: Scenario) => scenario.runway,
alternatives: (scenario: Scenario) => [
scenario.runway,
scenario.runwayWords,
],
placeholder: 'e.g. 25C',
width: 'sm' as const
}
],
readback: [
{ type: 'text' as const, text: 'Information ' },
{ type: 'field' as const, key: 'atis-letter', width: 'sm' as const },
{ type: 'text' as const, text: ', QNH ' },
{ type: 'field' as const, key: 'qnh', width: 'sm' as const },
{ type: 'text' as const, text: ', runway ' },
{ type: 'field' as const, key: 'new-runway', width: 'sm' as const },
{ type: 'text' as const, text: (scenario: Scenario) => `, ${scenario.radioCall}` },
],
defaultFrequency: 'GND',
phrase: (scenario: Scenario) =>
`${scenario.radioCall}, information is now ${scenario.atisCodeWord}, QNH ${scenario.qnhWords}, wind changed ${scenario.windWords}, runway changed to ${scenario.runwayWords}.`,
info: (scenario: Scenario) => [
`New ATIS: ${scenario.atisCode} (${scenario.atisCodeWord})`,
`QNH: ${scenario.qnh}`,
`New runway: ${scenario.runway}`,
],
generate: createBaseScenario
}
]
export const learnModules: ModuleDef[] = [
{
id: 'normalize',
@@ -3299,6 +3699,20 @@ export const learnTracks: TrackDef[] = [
subtitle: 'From basics to full flight',
modules: learnModules,
},
{
id: 'abnormal',
title: 'Abnormal & Emergency Missions',
subtitle: 'Non-standard situations, emergencies & VATSIM procedures',
modules: [
{
id: 'vatsim-essentials',
title: 'VATSIM · Essentials',
subtitle: 'Unicom, radio checks, frequency management',
art: gradientArt(['#1a237e', '#283593', '#3949ab']),
lessons: vatsimEssentialsLessons,
},
],
},
]
export default learnModules