mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 00:46:00 +08:00
merge: classroom overhaul
This commit is contained in:
@@ -384,6 +384,18 @@ const openManager = () => {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.cookie-manage-button {
|
||||
position: static;
|
||||
display: block;
|
||||
width: fit-content;
|
||||
margin: 0.75rem;
|
||||
margin-bottom: calc(0.75rem + env(safe-area-inset-bottom, 0));
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.cookie-fade-enter-active,
|
||||
.cookie-fade-leave-active {
|
||||
transition: opacity 0.25s ease;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -32,11 +32,24 @@ function sanitizeProgress(input: LearnProgress | undefined): LearnProgress | und
|
||||
|
||||
const bestRaw = (value as any).best
|
||||
const doneRaw = (value as any).done
|
||||
const assessmentVersionRaw = (value as any).assessmentVersion
|
||||
const successfulVariantsRaw = (value as any).successfulVariants
|
||||
|
||||
const best = typeof bestRaw === 'number' && Number.isFinite(bestRaw) ? Math.max(0, Math.min(100, Math.round(bestRaw))) : 0
|
||||
const done = Boolean(doneRaw)
|
||||
const assessmentVersion = typeof assessmentVersionRaw === 'number' && Number.isFinite(assessmentVersionRaw)
|
||||
? Math.max(0, Math.round(assessmentVersionRaw))
|
||||
: undefined
|
||||
const successfulVariants = typeof successfulVariantsRaw === 'number' && Number.isFinite(successfulVariantsRaw)
|
||||
? Math.max(0, Math.min(100, Math.round(successfulVariantsRaw)))
|
||||
: undefined
|
||||
|
||||
lessonProgress[lessonId] = { best, done }
|
||||
lessonProgress[lessonId] = {
|
||||
best,
|
||||
done,
|
||||
...(assessmentVersion !== undefined ? { assessmentVersion } : {}),
|
||||
...(successfulVariants !== undefined ? { successfulVariants } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
sanitized[moduleId] = lessonProgress
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createBaseScenario, createScenarioSeries, digitsToWords, formatTemp, lettersToNato } from '~~/shared/learn/scenario'
|
||||
import type { Lesson, ModuleDef, Scenario } from '~~/shared/learn/types'
|
||||
import type { Lesson, LessonField, ModuleDef, Scenario } from '~~/shared/learn/types'
|
||||
|
||||
function gradientArt(colors: string[]): string {
|
||||
const stops = colors
|
||||
@@ -568,7 +568,7 @@ const readbackLessons: Lesson[] = [
|
||||
keywords: ['Delivery', 'Readback'],
|
||||
hints: [
|
||||
'Remember the order: destination – SID – runway – altitude – squawk.',
|
||||
'Speak altitude and squawk digits clearly.'
|
||||
'Use the prescribed ICAO pronunciations for altitude and squawk digits.'
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
@@ -3308,34 +3308,726 @@ const fullFlightLessons: Lesson[] = [
|
||||
}
|
||||
]
|
||||
|
||||
const copyLessonIds = new Set(['icao-alphabet', 'icao-marathon', 'atis', 'metar'])
|
||||
const situationLessonIds = new Set([
|
||||
'radio-check',
|
||||
'clearance-contact',
|
||||
'pushback-ready',
|
||||
'taxi-request',
|
||||
'lineup-request',
|
||||
'departure-checkin',
|
||||
'unable-direct',
|
||||
'alt-accept',
|
||||
'alt-reject',
|
||||
'approach-established',
|
||||
'taxi-in-request',
|
||||
'mayday',
|
||||
'pan-pan',
|
||||
'go-around',
|
||||
'tcas-ra',
|
||||
'tcas-resume',
|
||||
'full-clearance-contact',
|
||||
'full-departure-checkin',
|
||||
'full-pushback-request',
|
||||
'full-taxi-request',
|
||||
'full-approach-checkin',
|
||||
'full-established-report',
|
||||
])
|
||||
|
||||
const operationalFieldPattern =
|
||||
/call\s?sign|runway|heading|direction|altitude|level|speed|qnh|squawk|frequency|channel|route|taxi|hold|sid|star|approach/i
|
||||
|
||||
function curateField(field: LessonField, promptKind: 'atc' | 'situation' | 'copy'): LessonField {
|
||||
const safetyCritical = promptKind !== 'copy' && operationalFieldPattern.test(`${field.key} ${field.label}`)
|
||||
return {
|
||||
...field,
|
||||
required: field.required ?? true,
|
||||
safetyCritical: field.safetyCritical ?? safetyCritical,
|
||||
matching: field.matching ?? (safetyCritical ? 'controlled' : 'exact'),
|
||||
}
|
||||
}
|
||||
|
||||
function approachWithoutRunway(scenario: Scenario): string {
|
||||
return scenario.approach
|
||||
.replace(new RegExp(`\\s+${scenario.arrivalRunway}$`, 'i'), '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function callsignAlternatives(scenario: Scenario): string[] {
|
||||
return [
|
||||
scenario.radioCall,
|
||||
`${scenario.airlineCall} ${scenario.flightNumber}`,
|
||||
`${scenario.airlineCall} ${scenario.flightNumberWords}`,
|
||||
scenario.callsign,
|
||||
]
|
||||
}
|
||||
|
||||
const situationPrompts: Partial<Record<string, (scenario: Scenario) => string>> = {
|
||||
'clearance-contact': scenario =>
|
||||
`You are at stand ${scenario.stand} with information ${scenario.atisCodeWord}. Request IFR clearance to ${scenario.destination.city} from ${scenario.airport.city} Delivery.`,
|
||||
'pushback-ready': scenario =>
|
||||
`You are ready at stand ${scenario.stand}. Request pushback and start from ${scenario.airport.city} Ground.`,
|
||||
'taxi-request': scenario =>
|
||||
`Pushback is complete at stand ${scenario.stand}. Request taxi from ${scenario.airport.city} Ground.`,
|
||||
'lineup-request': scenario =>
|
||||
`You are holding short of runway ${scenario.runway} and ready for departure. Advise ${scenario.airport.city} Tower.`,
|
||||
'unable-direct': scenario =>
|
||||
`ATC offers direct ${scenario.transition}, but you cannot accept it. State UNABLE and continue the assigned ${scenario.sid}.`,
|
||||
'approach-established': scenario =>
|
||||
`You are established on the localizer for runway ${scenario.arrivalRunway}. Make the requested report.`,
|
||||
'taxi-in-request': scenario =>
|
||||
`You have vacated runway ${scenario.arrivalRunway}. Request taxi to stand ${scenario.arrivalStand} from ${scenario.destination.city} Ground.`,
|
||||
'standby': () =>
|
||||
'ATC asks a non-urgent question, but you need a short moment before answering. Ask ATC to stand by.',
|
||||
'full-clearance-contact': scenario =>
|
||||
`You are at stand ${scenario.stand} with information ${scenario.atisCodeWord}. Request IFR clearance to ${scenario.destination.city} from ${scenario.airport.city} Delivery.`,
|
||||
}
|
||||
|
||||
function clearanceWithoutWind(lesson: Lesson, landing: boolean): Lesson {
|
||||
const prefix = lesson.id.startsWith('full-') ? 'full-' : ''
|
||||
const runwayKey = landing ? `${prefix}landing-runway` : `${prefix}tko-runway`
|
||||
const callsignKey = landing ? `${prefix}landing-callsign` : `${prefix}tko-callsign`
|
||||
const fields = lesson.fields.filter(field => !/wind/i.test(`${field.key} ${field.label}`))
|
||||
return {
|
||||
...lesson,
|
||||
hints: [
|
||||
`Read back the runway, ${landing ? 'landing' : 'take-off'} clearance, and callsign.`,
|
||||
'Wind is information and is not a mandatory readback item.',
|
||||
],
|
||||
fields,
|
||||
readback: [
|
||||
{ type: 'text', text: 'Runway ' },
|
||||
{ type: 'field', key: runwayKey, width: 'sm' },
|
||||
{ type: 'text', text: landing ? ', cleared to land, ' : ', cleared for take-off, ' },
|
||||
{ type: 'field', key: callsignKey, width: 'lg' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function curateLesson(source: Lesson): Lesson {
|
||||
let lesson = { ...source, fields: [...source.fields], readback: [...source.readback] }
|
||||
|
||||
if (lesson.id === 'radio-check') {
|
||||
lesson = {
|
||||
...lesson,
|
||||
title: 'Request a Radio Check',
|
||||
desc: 'Make a complete radio-check transmission and understand readability 1–5',
|
||||
hints: [
|
||||
'Call the station, identify yourself, say “RADIO CHECK”, then state the channel.',
|
||||
'The reply uses one exact readability value: 1 unreadable, 2 readable now and then, 3 readable with difficulty, 4 readable, 5 perfectly readable.',
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
key: 'rc-station',
|
||||
label: 'Station',
|
||||
expected: scenario => `${scenario.airport.city} Ground`,
|
||||
alternatives: scenario => [`${scenario.airport.icao} Ground`, `${scenario.airport.name} Ground`],
|
||||
width: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'rc-callsign',
|
||||
label: 'Callsign',
|
||||
expected: scenario => scenario.radioCall,
|
||||
alternatives: callsignAlternatives,
|
||||
width: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'rc-frequency',
|
||||
label: 'Channel',
|
||||
expected: scenario => scenario.groundFreq,
|
||||
alternatives: scenario => [scenario.groundFreq.replace('.', ''), scenario.frequencyWords.GND],
|
||||
width: 'md',
|
||||
},
|
||||
],
|
||||
readback: [
|
||||
{ type: 'field', key: 'rc-station', width: 'lg' },
|
||||
{ type: 'text', text: ', ' },
|
||||
{ type: 'field', key: 'rc-callsign', width: 'lg' },
|
||||
{ type: 'text', text: ', radio check ' },
|
||||
{ type: 'field', key: 'rc-frequency', width: 'md' },
|
||||
],
|
||||
phrase: scenario => `You have selected ${scenario.groundFreq}. Request a radio check from ${scenario.airport.city} Ground.`,
|
||||
info: scenario => [
|
||||
`Channel: ${scenario.groundFreq} (${scenario.frequencyWords.GND})`,
|
||||
'Readability scale: 1 unreadable · 2 readable now and then · 3 readable with difficulty · 4 readable · 5 perfectly readable',
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (['clearance-contact', 'full-clearance-contact'].includes(lesson.id)) {
|
||||
const prefix = lesson.id.startsWith('full-') ? 'full-' : ''
|
||||
const callsignKey = `${prefix}clearance-contact-callsign`
|
||||
lesson = {
|
||||
...lesson,
|
||||
hints: [
|
||||
'Address the unit, use your full callsign, then give ATIS, destination, stand, and request.',
|
||||
'Transmit the ATIS identifier as its ICAO spelling word, for example “information Papa”.',
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
key: callsignKey,
|
||||
label: 'Callsign',
|
||||
expected: scenario => scenario.radioCall,
|
||||
alternatives: callsignAlternatives,
|
||||
width: 'lg',
|
||||
},
|
||||
...lesson.fields,
|
||||
],
|
||||
readback: [
|
||||
{ type: 'text', text: scenario => `${scenario.airport.city} Delivery, ` },
|
||||
{ type: 'field', key: callsignKey, width: 'lg' },
|
||||
{ type: 'text', text: ', information ' },
|
||||
...lesson.readback.slice(1),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (['takeoff', 'full-takeoff'].includes(lesson.id)) lesson = clearanceWithoutWind(lesson, false)
|
||||
if (['landing-clearance', 'full-landing'].includes(lesson.id)) lesson = clearanceWithoutWind(lesson, true)
|
||||
|
||||
if (['lineup', 'full-lineup'].includes(lesson.id)) {
|
||||
lesson.hints = [
|
||||
'Read back the runway, “line up and wait”, and your callsign.',
|
||||
'Keep the clearance elements in the order in which they were issued.',
|
||||
]
|
||||
}
|
||||
|
||||
if (['tower-contact', 'full-tower-contact'].includes(lesson.id)) {
|
||||
lesson = {
|
||||
...lesson,
|
||||
desc: 'Confirm the transfer to tower',
|
||||
hints: ['Read back the tower channel and your callsign.'],
|
||||
readback: lesson.readback.filter(segment =>
|
||||
segment.type !== 'text' || !segment.text.toString().includes('when number one')),
|
||||
phrase: scenario => `${scenario.radioCall}, contact Tower ${scenario.towerFreq}.`,
|
||||
}
|
||||
}
|
||||
|
||||
if (lesson.id === 'clearance-amendment') {
|
||||
lesson = {
|
||||
...lesson,
|
||||
title: 'Reclearance',
|
||||
hints: ['Start with “RECLEARED”, then repeat every changed clearance element and your callsign.'],
|
||||
phrase: scenario => `${scenario.radioCall}, recleared via ${scenario.sid} ${scenario.transition}, runway ${scenario.runway}.`,
|
||||
readback: lesson.readback.map(segment =>
|
||||
segment.type === 'text' && typeof segment.text === 'string' && /amended/i.test(segment.text)
|
||||
? { ...segment, text: segment.text.replace(/amended clearance:?/i, 'RECLEARED') }
|
||||
: segment),
|
||||
}
|
||||
}
|
||||
|
||||
if (['pushback', 'full-pushback'].includes(lesson.id)) {
|
||||
const callsignField = lesson.fields.find(field => /callsign/i.test(field.key))
|
||||
if (callsignField) {
|
||||
lesson = {
|
||||
...lesson,
|
||||
title: 'Pushback Clearance',
|
||||
desc: 'Acknowledge a standard pushback approval',
|
||||
hints: ['Read back “pushback approved” and your callsign. Start-up is a separate clearance when required.'],
|
||||
fields: [callsignField],
|
||||
readback: [
|
||||
{ type: 'text', text: 'Pushback approved, ' },
|
||||
{ type: 'field', key: callsignField.key, width: 'lg' },
|
||||
],
|
||||
phrase: scenario => `${scenario.radioCall}, pushback approved.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lesson.id === 'pushback-delay') {
|
||||
lesson = {
|
||||
...lesson,
|
||||
title: 'Pushback Delay',
|
||||
desc: 'Acknowledge an expected pushback delay',
|
||||
hints: ['A delay is not a pushback approval. Do not start moving until approval is issued.'],
|
||||
phrase: scenario => `${scenario.radioCall}, expect ${scenario.pushDelayWords} delay due traffic.`,
|
||||
}
|
||||
}
|
||||
|
||||
if (['approach-vector'].includes(lesson.id)) {
|
||||
lesson = {
|
||||
...lesson,
|
||||
fields: [
|
||||
{
|
||||
key: 'vector-direction',
|
||||
label: 'Turn direction',
|
||||
expected: () => 'left',
|
||||
alternatives: () => ['left turn'],
|
||||
width: 'sm',
|
||||
},
|
||||
...lesson.fields,
|
||||
],
|
||||
readback: [
|
||||
{ type: 'text', text: 'Turn ' },
|
||||
{ type: 'field', key: 'vector-direction', width: 'sm' },
|
||||
{ type: 'text', text: ', heading ' },
|
||||
...lesson.readback.slice(1),
|
||||
],
|
||||
hints: ['Turn direction, heading, level, speed, then callsign. Every assigned value is mandatory.'],
|
||||
}
|
||||
}
|
||||
|
||||
if (['descent-clearance', 'full-descent'].includes(lesson.id)) {
|
||||
const prefix = lesson.id.startsWith('full-') ? 'full-' : ''
|
||||
const levelKey = `${prefix}descent-level`
|
||||
lesson = {
|
||||
...lesson,
|
||||
desc: 'Read back the assigned descent level, arrival clearance, QNH, and callsign',
|
||||
fields: [
|
||||
{
|
||||
key: levelKey,
|
||||
label: 'Assigned level',
|
||||
expected: scenario => scenario.altitudes.initialWords,
|
||||
alternatives: scenario => [scenario.altitudes.initial.toString(), `${scenario.altitudes.initial} feet`],
|
||||
width: 'md',
|
||||
},
|
||||
...lesson.fields,
|
||||
],
|
||||
readback: [
|
||||
{ type: 'text', text: 'Descend to ' },
|
||||
{ type: 'field', key: levelKey, width: 'md' },
|
||||
{ type: 'text', text: ', via ' },
|
||||
...lesson.readback.slice(1),
|
||||
],
|
||||
phrase: scenario =>
|
||||
`${scenario.radioCall}, descend to ${scenario.altitudes.initial} feet via ${scenario.arrivalStar} ${scenario.arrivalTransition}, QNH ${scenario.arrivalQnh}.`,
|
||||
}
|
||||
}
|
||||
|
||||
if (['approach-clearance', 'full-approach'].includes(lesson.id)) {
|
||||
lesson = {
|
||||
...lesson,
|
||||
fields: lesson.fields.map(field =>
|
||||
/app-type/.test(field.key) ? { ...field, expected: approachWithoutRunway } : field),
|
||||
phrase: scenario =>
|
||||
`${scenario.radioCall}, cleared ${approachWithoutRunway(scenario)} approach runway ${scenario.arrivalRunway}, report established.`,
|
||||
}
|
||||
}
|
||||
|
||||
if (lesson.id === 'alt-accept') {
|
||||
lesson = {
|
||||
...lesson,
|
||||
title: 'Say Again',
|
||||
desc: 'Request a repeat when a transmission is not understood',
|
||||
hints: ['Use “SAY AGAIN” instead of guessing a safety-critical instruction.'],
|
||||
fields: [{
|
||||
key: 'say-again-callsign',
|
||||
label: 'Callsign',
|
||||
expected: scenario => scenario.radioCall,
|
||||
alternatives: callsignAlternatives,
|
||||
width: 'lg',
|
||||
}],
|
||||
readback: [
|
||||
{ type: 'field', key: 'say-again-callsign', width: 'lg' },
|
||||
{ type: 'text', text: ', say again.' },
|
||||
],
|
||||
phrase: () => 'A controller transmission was blocked by interference. Ask for it again.',
|
||||
info: () => ['Never invent or assume a clearance you did not understand.'],
|
||||
}
|
||||
}
|
||||
|
||||
if (lesson.id === 'alt-reject') {
|
||||
lesson = {
|
||||
...lesson,
|
||||
title: 'Unable: Reason and Request',
|
||||
desc: 'Decline an instruction and offer a safe alternative',
|
||||
hints: ['State “UNABLE”, give a concise reason, then request an alternative.'],
|
||||
fields: [
|
||||
{
|
||||
key: 'unable-reason',
|
||||
label: 'Reason',
|
||||
expected: scenario => scenario.emergencyProblem,
|
||||
width: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'unable-request',
|
||||
label: 'Alternative request',
|
||||
expected: scenario => scenario.emergencyIntent,
|
||||
width: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'unable-callsign',
|
||||
label: 'Callsign',
|
||||
expected: scenario => scenario.radioCall,
|
||||
alternatives: callsignAlternatives,
|
||||
width: 'lg',
|
||||
},
|
||||
],
|
||||
readback: [
|
||||
{ type: 'text', text: 'Unable due ' },
|
||||
{ type: 'field', key: 'unable-reason', width: 'lg' },
|
||||
{ type: 'text', text: ', request ' },
|
||||
{ type: 'field', key: 'unable-request', width: 'lg' },
|
||||
{ type: 'text', text: ', ' },
|
||||
{ type: 'field', key: 'unable-callsign', width: 'lg' },
|
||||
],
|
||||
phrase: scenario =>
|
||||
`You cannot comply because of ${scenario.emergencyProblem}. Request to ${scenario.emergencyIntent}.`,
|
||||
info: () => ['UNABLE is not a rejection without context: give the operational reason and a workable request.'],
|
||||
}
|
||||
}
|
||||
|
||||
if (['departure-checkin', 'full-departure-checkin'].includes(lesson.id)) {
|
||||
const prefix = lesson.id.startsWith('full-') ? 'full-' : ''
|
||||
const callsign = lesson.fields.find(field => /callsign/i.test(field.key))
|
||||
const passingKey = `${prefix}depcheck-passing`
|
||||
const clearedKey = `${prefix}depcheck-cleared`
|
||||
if (callsign) {
|
||||
lesson = {
|
||||
...lesson,
|
||||
desc: 'Make the required initial call after changing to departure',
|
||||
hints: [
|
||||
'State the unit, full callsign, passing level, and cleared level.',
|
||||
'Add an assigned speed only when ATC has issued one.',
|
||||
],
|
||||
fields: [
|
||||
callsign,
|
||||
{
|
||||
key: passingKey,
|
||||
label: 'Passing level',
|
||||
expected: scenario => scenario.altitudes.initialWords,
|
||||
alternatives: scenario => [scenario.altitudes.initial.toString(), `${scenario.altitudes.initial} feet`],
|
||||
width: 'md',
|
||||
},
|
||||
{
|
||||
key: clearedKey,
|
||||
label: 'Cleared level',
|
||||
expected: scenario => scenario.altitudes.climbWords,
|
||||
alternatives: scenario => [scenario.altitudes.climb.toString(), `${scenario.altitudes.climb} feet`],
|
||||
width: 'md',
|
||||
},
|
||||
],
|
||||
readback: [
|
||||
{ type: 'text', text: scenario => `${scenario.airport.city} Departure, ` },
|
||||
{ type: 'field', key: callsign.key, width: 'lg' },
|
||||
{ type: 'text', text: ', passing ' },
|
||||
{ type: 'field', key: passingKey, width: 'md' },
|
||||
{ type: 'text', text: ', cleared ' },
|
||||
{ type: 'field', key: clearedKey, width: 'md' },
|
||||
],
|
||||
phrase: scenario =>
|
||||
`You have changed to ${scenario.airport.city} Departure. You are passing ${scenario.altitudes.initial} feet, cleared to ${scenario.altitudes.climb} feet.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (['mayday', 'pan-pan'].includes(lesson.id)) {
|
||||
const distress = lesson.id === 'mayday'
|
||||
const prefix = distress ? 'mayday' : 'pan'
|
||||
lesson = {
|
||||
...lesson,
|
||||
title: distress ? 'MAYDAY Declaration' : 'PAN PAN Declaration',
|
||||
desc: distress
|
||||
? 'Build a complete distress transmission'
|
||||
: 'Build a complete urgency transmission',
|
||||
hints: [
|
||||
`${distress ? 'MAYDAY' : 'PAN PAN'} is preferably transmitted three times.`,
|
||||
'Include station, callsign, nature, intention, position, level, and heading.',
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
key: `${prefix}-callsign`,
|
||||
label: 'Callsign',
|
||||
expected: scenario => scenario.radioCall,
|
||||
alternatives: callsignAlternatives,
|
||||
width: 'lg',
|
||||
},
|
||||
{
|
||||
key: `${prefix}-nature`,
|
||||
label: 'Nature',
|
||||
expected: scenario => scenario.emergencyProblem,
|
||||
width: 'lg',
|
||||
},
|
||||
{
|
||||
key: `${prefix}-intention`,
|
||||
label: 'Intention',
|
||||
expected: scenario => scenario.emergencyIntent,
|
||||
width: 'lg',
|
||||
},
|
||||
{
|
||||
key: `${prefix}-position`,
|
||||
label: 'Position',
|
||||
expected: scenario => scenario.airport.city,
|
||||
alternatives: scenario => [scenario.airport.icao, scenario.airport.name],
|
||||
width: 'md',
|
||||
},
|
||||
{
|
||||
key: `${prefix}-level`,
|
||||
label: 'Level',
|
||||
expected: scenario => scenario.altitudes.initialWords,
|
||||
alternatives: scenario => [scenario.altitudes.initial.toString(), `${scenario.altitudes.initial} feet`],
|
||||
width: 'md',
|
||||
},
|
||||
{
|
||||
key: `${prefix}-heading`,
|
||||
label: 'Heading',
|
||||
expected: scenario => scenario.emergencyHeading,
|
||||
alternatives: scenario => [scenario.emergencyHeadingWords],
|
||||
width: 'md',
|
||||
},
|
||||
],
|
||||
readback: [
|
||||
{
|
||||
type: 'text',
|
||||
text: scenario =>
|
||||
`${scenario.airport.city} Control, ${distress ? 'MAYDAY MAYDAY MAYDAY' : 'PAN PAN PAN PAN PAN PAN'}, `,
|
||||
},
|
||||
{ type: 'field', key: `${prefix}-callsign`, width: 'lg' },
|
||||
{ type: 'text', text: ', ' },
|
||||
{ type: 'field', key: `${prefix}-nature`, width: 'lg' },
|
||||
{ type: 'text', text: ', intend to ' },
|
||||
{ type: 'field', key: `${prefix}-intention`, width: 'lg' },
|
||||
{ type: 'text', text: ', position ' },
|
||||
{ type: 'field', key: `${prefix}-position`, width: 'md' },
|
||||
{ type: 'text', text: ', level ' },
|
||||
{ type: 'field', key: `${prefix}-level`, width: 'md' },
|
||||
{ type: 'text', text: ', heading ' },
|
||||
{ type: 'field', key: `${prefix}-heading`, width: 'md' },
|
||||
],
|
||||
phrase: scenario =>
|
||||
`${distress ? 'A grave and imminent danger exists' : 'An urgent situation exists'}: ${scenario.emergencyProblem}. You intend to ${scenario.emergencyIntent}. Build the complete transmission.`,
|
||||
info: () => [
|
||||
distress
|
||||
? 'Use MAYDAY for grave and imminent danger requiring immediate assistance.'
|
||||
: 'Use PAN PAN for an urgent situation that does not require immediate assistance.',
|
||||
],
|
||||
standard: 'SERA.14095',
|
||||
}
|
||||
}
|
||||
|
||||
if (lesson.id === 'continue-approach') {
|
||||
lesson = {
|
||||
...lesson,
|
||||
whyItMatters: '“Continue approach” allows the approach to continue but is not a clearance to land.',
|
||||
hints: ['Acknowledge the instruction. Do not treat it as a landing clearance.'],
|
||||
}
|
||||
}
|
||||
|
||||
if (lesson.id === 'standby') {
|
||||
lesson = {
|
||||
...lesson,
|
||||
whyItMatters: '“STANDBY” only asks for time. It is not an approval, denial, clearance, or substitute for a mandatory safety readback.',
|
||||
hints: ['Use it only for a brief delay in a non-urgent exchange.'],
|
||||
}
|
||||
}
|
||||
|
||||
const situationPrompt = situationPrompts[lesson.id]
|
||||
if (situationPrompt) {
|
||||
lesson = { ...lesson, phrase: situationPrompt }
|
||||
}
|
||||
|
||||
if (lesson.id === 'go-around') {
|
||||
const callsignField = lesson.fields.find(field => /callsign/i.test(field.key))
|
||||
if (callsignField) {
|
||||
lesson = {
|
||||
...lesson,
|
||||
fields: [callsignField],
|
||||
readback: [
|
||||
{ type: 'field', key: callsignField.key, width: 'lg' },
|
||||
{ type: 'text', text: ', going around.' },
|
||||
],
|
||||
phrase: () => 'You discontinue the approach. Make the immediate pilot report; ATC instructions follow separately.',
|
||||
hints: ['Transmit your callsign and “GOING AROUND”. Do not combine an assumed missed-approach clearance.'],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lesson.id === 'tcas-ra') {
|
||||
const callsignKey = 'tcas-ra-callsign'
|
||||
lesson = {
|
||||
...lesson,
|
||||
fields: [{
|
||||
key: callsignKey,
|
||||
label: 'Callsign',
|
||||
expected: scenario => scenario.radioCall,
|
||||
alternatives: callsignAlternatives,
|
||||
width: 'lg',
|
||||
}],
|
||||
readback: [
|
||||
{ type: 'field', key: callsignKey, width: 'lg' },
|
||||
{ type: 'text', text: ', TCAS RA.' },
|
||||
],
|
||||
phrase: () => 'A resolution advisory is active. Report it immediately.',
|
||||
hints: ['Use the exact concise report “TCAS RA”. Follow the RA even if it conflicts with an ATC instruction.'],
|
||||
}
|
||||
}
|
||||
|
||||
if (lesson.id === 'tcas-resume') {
|
||||
const callsignKey = 'tcas-resume-callsign'
|
||||
lesson = {
|
||||
...lesson,
|
||||
fields: [{
|
||||
key: callsignKey,
|
||||
label: 'Callsign',
|
||||
expected: scenario => scenario.radioCall,
|
||||
alternatives: callsignAlternatives,
|
||||
width: 'lg',
|
||||
}],
|
||||
readback: [
|
||||
{ type: 'field', key: callsignKey, width: 'lg' },
|
||||
{ type: 'text', text: ', clear of conflict, returning to assigned clearance.' },
|
||||
],
|
||||
phrase: () => 'The resolution advisory has ended and you are returning to the assigned clearance.',
|
||||
hints: ['Report “CLEAR OF CONFLICT, RETURNING TO ASSIGNED CLEARANCE”.'],
|
||||
}
|
||||
}
|
||||
|
||||
const promptKind = copyLessonIds.has(lesson.id)
|
||||
? 'copy'
|
||||
: situationLessonIds.has(lesson.id)
|
||||
? 'situation'
|
||||
: 'atc'
|
||||
const responseLabel = promptKind === 'copy'
|
||||
? (lesson.id === 'atis' || lesson.id === 'metar' ? 'Decoded values' : 'Your copy')
|
||||
: promptKind === 'situation'
|
||||
? 'Pilot call'
|
||||
: 'Pilot readback'
|
||||
|
||||
return {
|
||||
...lesson,
|
||||
prompt: { kind: promptKind, text: lesson.phrase },
|
||||
responseLabel,
|
||||
standard: lesson.standard ?? (
|
||||
promptKind === 'copy'
|
||||
? 'ICAO Annex 10 / SERA.14070'
|
||||
: 'SERA.8015 and Appendix 1 to AMC1 SERA.14001'
|
||||
),
|
||||
whyItMatters: lesson.whyItMatters ?? (
|
||||
promptKind === 'copy'
|
||||
? 'Accurate copying prevents identifiers and operational values from being confused.'
|
||||
: 'The highlighted elements carry the clearance or operational intent and must not be guessed.'
|
||||
),
|
||||
fields: lesson.fields.map(field => curateField(field, promptKind)),
|
||||
}
|
||||
}
|
||||
|
||||
function fullFlightCallLesson(
|
||||
id: string,
|
||||
title: string,
|
||||
desc: string,
|
||||
build: (scenario: Scenario) => { situation: string; text: string },
|
||||
): Lesson {
|
||||
const callsignKey = `${id}-callsign`
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
desc,
|
||||
keywords: ['Guided flight', 'Pilot call'],
|
||||
hints: ['Address the unit first, use your full callsign, then state the operational request or report.'],
|
||||
fields: [{
|
||||
key: callsignKey,
|
||||
label: 'Callsign',
|
||||
expected: scenario => scenario.radioCall,
|
||||
alternatives: callsignAlternatives,
|
||||
width: 'lg',
|
||||
required: true,
|
||||
safetyCritical: true,
|
||||
matching: 'controlled',
|
||||
}],
|
||||
readback: [
|
||||
{ type: 'text', text: scenario => build(scenario).text.split(scenario.radioCall)[0] ?? '' },
|
||||
{ type: 'field', key: callsignKey, width: 'lg' },
|
||||
{ type: 'text', text: scenario => build(scenario).text.split(scenario.radioCall).slice(1).join(scenario.radioCall) },
|
||||
],
|
||||
phrase: scenario => build(scenario).situation,
|
||||
prompt: { kind: 'situation', text: scenario => build(scenario).situation },
|
||||
responseLabel: 'Pilot call',
|
||||
standard: 'Appendix 1 to AMC1 SERA.14001',
|
||||
whyItMatters: 'A concise initial call gives ATC the aircraft identity and the information needed for the next instruction.',
|
||||
info: () => ['Synthetic training sequence — not for operational use.'],
|
||||
generate: makeFullFlightGenerator(),
|
||||
}
|
||||
}
|
||||
|
||||
const fullFlightAdditions: Record<string, Lesson> = {
|
||||
'full-pushback-request': fullFlightCallLesson(
|
||||
'full-pushback-request',
|
||||
'Pushback Request',
|
||||
'Request pushback and start from the assigned stand',
|
||||
scenario => ({
|
||||
situation: `You are ready at stand ${scenario.stand}. Request pushback and start from ${scenario.airport.city} Ground.`,
|
||||
text: `${scenario.airport.city} Ground, ${scenario.radioCall}, stand ${scenario.stand}, request pushback and start.`,
|
||||
}),
|
||||
),
|
||||
'full-taxi-request': fullFlightCallLesson(
|
||||
'full-taxi-request',
|
||||
'Taxi Request',
|
||||
'Call ground when ready to taxi',
|
||||
scenario => ({
|
||||
situation: `Pushback is complete at stand ${scenario.stand}. Request taxi from ${scenario.airport.city} Ground.`,
|
||||
text: `${scenario.airport.city} Ground, ${scenario.radioCall}, stand ${scenario.stand}, request taxi.`,
|
||||
}),
|
||||
),
|
||||
'full-approach-checkin': fullFlightCallLesson(
|
||||
'full-approach-checkin',
|
||||
'Approach Initial Call',
|
||||
'Check in after the approach channel change',
|
||||
scenario => ({
|
||||
situation: `You have changed to ${scenario.destination.city} Approach. You are passing ${scenario.altitudes.climb} feet, cleared to ${scenario.altitudes.initial} feet.`,
|
||||
text: `${scenario.destination.city} Approach, ${scenario.radioCall}, passing ${scenario.altitudes.climbWords}, cleared ${scenario.altitudes.initialWords}.`,
|
||||
}),
|
||||
),
|
||||
'full-established-report': fullFlightCallLesson(
|
||||
'full-established-report',
|
||||
'Established Report',
|
||||
'Report established when instructed',
|
||||
scenario => ({
|
||||
situation: `You are established on the ${approachWithoutRunway(scenario)} approach runway ${scenario.arrivalRunway}. Report established.`,
|
||||
text: `${scenario.destination.city} Approach, ${scenario.radioCall}, established runway ${scenario.arrivalRunway}.`,
|
||||
}),
|
||||
),
|
||||
'full-vacate-transfer': fullFlightCallLesson(
|
||||
'full-vacate-transfer',
|
||||
'Runway Vacated and Ground Transfer',
|
||||
'Report clear of the runway after changing to ground',
|
||||
scenario => ({
|
||||
situation: `You have vacated runway ${scenario.arrivalRunway} via ${scenario.arrivalTaxiRoute} and changed to Ground.`,
|
||||
text: `${scenario.destination.city} Ground, ${scenario.radioCall}, runway vacated via ${scenario.arrivalTaxiRoute}.`,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
const curatedFullFlightLessons = fullFlightLessons
|
||||
.filter(lesson => !['full-tower-contact', 'full-departure-handoff'].includes(lesson.id))
|
||||
.flatMap(lesson => {
|
||||
const additions: Lesson[] = []
|
||||
if (lesson.id === 'full-pushback') additions.push(fullFlightAdditions['full-pushback-request']!)
|
||||
if (lesson.id === 'full-taxi') additions.push(fullFlightAdditions['full-taxi-request']!)
|
||||
if (lesson.id === 'full-descent') additions.push(fullFlightAdditions['full-approach-checkin']!)
|
||||
if (lesson.id === 'full-landing') additions.push(fullFlightAdditions['full-established-report']!)
|
||||
if (lesson.id === 'full-taxi-in') additions.push(fullFlightAdditions['full-vacate-transfer']!)
|
||||
return [...additions, lesson]
|
||||
})
|
||||
.map(curateLesson)
|
||||
|
||||
export const learnModules: ModuleDef[] = [
|
||||
{
|
||||
id: 'normalize',
|
||||
title: 'Fundamentals · Basics',
|
||||
subtitle: 'Alphabet, Call Signs, ATIS & METAR',
|
||||
title: 'Foundations',
|
||||
subtitle: 'Numbers, callsigns, ATIS/METAR, radio checks, and channels',
|
||||
art: '/img/learn/modules/img14.jpeg',
|
||||
lessons: fundamentalsLessons
|
||||
lessons: fundamentalsLessons.map(curateLesson)
|
||||
},
|
||||
{
|
||||
id: 'arc',
|
||||
title: 'Readbacks · Essential Calls',
|
||||
subtitle: 'Clearances, taxi, approach & landing',
|
||||
title: 'Mandatory Readbacks',
|
||||
subtitle: 'Route, runway, QNH, squawk, level, heading, speed, and channels',
|
||||
art: '/img/learn/modules/img11.jpeg',
|
||||
lessons: readbackLessons
|
||||
lessons: readbackLessons.map(curateLesson)
|
||||
},
|
||||
{
|
||||
id: 'decision-tree',
|
||||
title: 'ATC · Advanced Calls',
|
||||
subtitle: 'Requests, contingencies & interrupts',
|
||||
title: 'Pilot Calls & Abnormal Situations',
|
||||
subtitle: 'Initial calls, unable, corrections, go-around, emergencies, and TCAS',
|
||||
art: '/img/learn/modules/img10.jpeg',
|
||||
lessons: decisionTreeLessons
|
||||
lessons: decisionTreeLessons.map(curateLesson)
|
||||
},
|
||||
{
|
||||
id: 'full-flight',
|
||||
title: 'Full Flight · Gate to Gate',
|
||||
subtitle: 'One linked scenario from clearance to taxi-in',
|
||||
title: 'Guided Flight Sequence',
|
||||
subtitle: 'One consistent 17-step training flight from clearance to taxi-in',
|
||||
art: '/img/learn/modules/img6.jpeg',
|
||||
lessons: fullFlightLessons,
|
||||
lessons: curatedFullFlightLessons,
|
||||
meta: {
|
||||
flightPlan: true,
|
||||
briefingArt: '/img/learn/missions/full-flight/briefing-hero.png'
|
||||
|
||||
73
shared/learn/assessment.ts
Normal file
73
shared/learn/assessment.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
CLASSROOM_ASSESSMENT_VERSION,
|
||||
CLASSROOM_VARIANTS_FOR_MASTERY,
|
||||
type LessonProgress,
|
||||
} from './config'
|
||||
|
||||
export type AssessableField = {
|
||||
key: string
|
||||
required: boolean
|
||||
pass: boolean
|
||||
similarity: number
|
||||
}
|
||||
|
||||
export type AssessmentSummary = {
|
||||
score: number
|
||||
hits: number
|
||||
required: number
|
||||
similarity: number
|
||||
passed: boolean
|
||||
}
|
||||
|
||||
export function assessRequiredFields(fields: AssessableField[]): AssessmentSummary | null {
|
||||
const requiredFields = fields.filter(field => field.required)
|
||||
if (!requiredFields.length) return null
|
||||
|
||||
const hits = requiredFields.filter(field => field.pass).length
|
||||
const similarity = requiredFields.reduce((sum, field) => sum + field.similarity, 0) / requiredFields.length
|
||||
|
||||
return {
|
||||
score: Math.round(similarity * 100),
|
||||
hits,
|
||||
required: requiredFields.length,
|
||||
similarity,
|
||||
passed: hits === requiredFields.length,
|
||||
}
|
||||
}
|
||||
|
||||
export type ProgressUpdate = {
|
||||
progress: LessonProgress
|
||||
counted: boolean
|
||||
}
|
||||
|
||||
export function updateMasteryProgress(
|
||||
previous: LessonProgress | undefined,
|
||||
assessment: AssessmentSummary,
|
||||
options: { modelAnswerRevealed: boolean; variantAlreadyCounted: boolean },
|
||||
): ProgressUpdate {
|
||||
const currentVersion = previous?.assessmentVersion === CLASSROOM_ASSESSMENT_VERSION
|
||||
const previousVariants = currentVersion ? (previous?.successfulVariants || 0) : 0
|
||||
const counted = assessment.passed && !options.modelAnswerRevealed && !options.variantAlreadyCounted
|
||||
const successfulVariants = Math.min(
|
||||
CLASSROOM_VARIANTS_FOR_MASTERY,
|
||||
previousVariants + (counted ? 1 : 0),
|
||||
)
|
||||
|
||||
return {
|
||||
counted,
|
||||
progress: {
|
||||
best: Math.max(previous?.best || 0, assessment.score),
|
||||
done: successfulVariants >= CLASSROOM_VARIANTS_FOR_MASTERY,
|
||||
assessmentVersion: CLASSROOM_ASSESSMENT_VERSION,
|
||||
successfulVariants,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function isCurrentMastery(progress: LessonProgress | undefined): boolean {
|
||||
return Boolean(
|
||||
progress?.assessmentVersion === CLASSROOM_ASSESSMENT_VERSION
|
||||
&& progress.done
|
||||
&& (progress.successfulVariants || 0) >= CLASSROOM_VARIANTS_FOR_MASTERY,
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
export interface LessonProgress {
|
||||
best: number
|
||||
done: boolean
|
||||
assessmentVersion?: number
|
||||
successfulVariants?: number
|
||||
}
|
||||
|
||||
export type LearnProgress = Record<string, Record<string, LessonProgress>>
|
||||
@@ -14,12 +16,17 @@ export interface LearnConfig {
|
||||
}
|
||||
|
||||
export interface LearnState {
|
||||
/** Legacy compatibility only. Classroom no longer awards or displays XP. */
|
||||
xp: number
|
||||
progress: LearnProgress
|
||||
config: LearnConfig
|
||||
/** Legacy compatibility only. All Classroom modules are now available. */
|
||||
unlockedModules: string[]
|
||||
}
|
||||
|
||||
export const CLASSROOM_ASSESSMENT_VERSION = 2
|
||||
export const CLASSROOM_VARIANTS_FOR_MASTERY = 2
|
||||
|
||||
export const LEARN_CONFIG_DEFAULTS: LearnConfig = {
|
||||
tts: false,
|
||||
radioLevel: 5,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { AirlineData, AirportData, Frequency, FrequencyType, Scenario } fro
|
||||
import { generateSquawk } from '../utils/transponder'
|
||||
|
||||
const natoMap: Record<string, string> = {
|
||||
A: 'Alpha',
|
||||
A: 'Alfa',
|
||||
B: 'Bravo',
|
||||
C: 'Charlie',
|
||||
D: 'Delta',
|
||||
@@ -32,14 +32,14 @@ const natoMap: Record<string, string> = {
|
||||
|
||||
const atcNumberWords: Record<string, string> = {
|
||||
'0': 'zero',
|
||||
'1': 'one',
|
||||
'2': 'two',
|
||||
'1': 'wun',
|
||||
'2': 'too',
|
||||
'3': 'tree',
|
||||
'4': 'four',
|
||||
'4': 'fower',
|
||||
'5': 'fife',
|
||||
'6': 'six',
|
||||
'7': 'seven',
|
||||
'8': 'eight',
|
||||
'8': 'ait',
|
||||
'9': 'niner'
|
||||
}
|
||||
|
||||
@@ -243,12 +243,17 @@ export function runwayToWords(runway: string): string {
|
||||
return suffixWord ? `${base} ${suffixWord}` : base
|
||||
}
|
||||
|
||||
function frequencyToSpeech(freq: string): string {
|
||||
export function frequencyToSpeech(freq: string): string {
|
||||
const [intPart, decimalPart] = freq.split('.')
|
||||
const intWords = digitsToWords(intPart)
|
||||
if (!decimalPart) return intWords
|
||||
const trimmed = decimalPart.replace(/0+$/, '')
|
||||
const decWords = trimmed ? digitsToWords(trimmed) : 'zero'
|
||||
const channelDigits = decimalPart.padEnd(3, '0').slice(0, 3)
|
||||
const spokenDigits = channelDigits.endsWith('00')
|
||||
? channelDigits.slice(0, 1)
|
||||
: channelDigits.endsWith('0')
|
||||
? channelDigits.slice(0, 2)
|
||||
: channelDigits
|
||||
const decWords = digitsToWords(spokenDigits)
|
||||
return `${intWords} decimal ${decWords}`
|
||||
}
|
||||
|
||||
@@ -258,14 +263,19 @@ export function formatTemp(temp: number): string {
|
||||
}
|
||||
|
||||
function temperatureToWords(temp: number): string {
|
||||
const prefix = temp < 0 ? 'minus' : 'plus'
|
||||
return `${prefix} ${digitsToWords(Math.abs(temp).toString())}`
|
||||
const prefix = temp < 0 ? 'minus ' : ''
|
||||
return `${prefix}${digitsToWords(Math.abs(temp).toString())}`
|
||||
}
|
||||
|
||||
function qnhToWords(qnh: number): string {
|
||||
export function qnhToWords(qnh: number): string {
|
||||
if (qnh === 1000) return 'wun thousand'
|
||||
return digitsToWords(qnh.toString())
|
||||
}
|
||||
|
||||
export function squawkToWords(squawk: string): string {
|
||||
return squawk === '1000' ? 'wun thousand' : digitsToWords(squawk)
|
||||
}
|
||||
|
||||
function windToWords(direction: number, speed: number): string {
|
||||
const dir = direction.toString().padStart(3, '0')
|
||||
const spd = speed.toString().padStart(2, '0')
|
||||
@@ -306,7 +316,14 @@ export function altitudeToWords(value: number): string {
|
||||
const remainder = value % 1000
|
||||
let words = thousands ? `${digitsToWords(thousands.toString())} thousand` : ''
|
||||
if (remainder) {
|
||||
words = `${words} ${digitsToWords(remainder.toString())}`.trim()
|
||||
const hundreds = Math.floor(remainder / 100)
|
||||
const tens = remainder % 100
|
||||
if (hundreds) {
|
||||
words = `${words} ${digitsToWords(hundreds.toString())} hundred`.trim()
|
||||
}
|
||||
if (tens) {
|
||||
words = `${words} ${digitsToWords(tens.toString().padStart(2, '0'))}`.trim()
|
||||
}
|
||||
}
|
||||
return words.trim()
|
||||
}
|
||||
@@ -340,7 +357,7 @@ export function createBaseScenario(): Scenario {
|
||||
const windDirectionStr = windDirection.toString().padStart(3, '0')
|
||||
const windSpeedStr = windSpeed.toString().padStart(2, '0')
|
||||
const visibility = choice(['9999', '9000', '8000', '6000'])
|
||||
const cloud = choice(['SKC', 'FEW020', 'SCT025', 'BKN030'])
|
||||
const cloud = choice(['NSC', 'FEW020', 'SCT025', 'BKN030', 'OVC040'])
|
||||
const temperature = randInt(-3, 28)
|
||||
const dewpoint = Math.max(temperature - randInt(2, 6), -10)
|
||||
const atisCode = choice(atisLetters)
|
||||
@@ -454,7 +471,7 @@ export function createBaseScenario(): Scenario {
|
||||
climbWords: altitudeToWords(climbAltitude)
|
||||
},
|
||||
squawk,
|
||||
squawkWords: digitsToWords(squawk),
|
||||
squawkWords: squawkToWords(squawk),
|
||||
qnh,
|
||||
qnhWords: qnhToWords(qnh),
|
||||
atisCode,
|
||||
|
||||
@@ -148,6 +148,9 @@ export type LessonField = {
|
||||
expected: (scenario: Scenario) => string
|
||||
alternatives?: (scenario: Scenario) => string[]
|
||||
threshold?: number
|
||||
required?: boolean
|
||||
safetyCritical?: boolean
|
||||
matching?: 'exact' | 'controlled' | 'fuzzy'
|
||||
placeholder?: string
|
||||
width?: BlankWidth
|
||||
inputmode?: 'text' | 'numeric'
|
||||
@@ -162,6 +165,13 @@ export type ReferenceEntry = {
|
||||
value: string
|
||||
}
|
||||
|
||||
export type LessonPromptKind = 'atc' | 'situation' | 'copy'
|
||||
|
||||
export type LessonPrompt = {
|
||||
kind: LessonPromptKind
|
||||
text: (scenario: Scenario) => string
|
||||
}
|
||||
|
||||
export type Lesson = {
|
||||
id: string
|
||||
title: string
|
||||
@@ -171,6 +181,11 @@ export type Lesson = {
|
||||
fields: LessonField[]
|
||||
readback: ReadbackSegment[]
|
||||
defaultFrequency?: string
|
||||
prompt?: LessonPrompt
|
||||
responseLabel?: 'Pilot readback' | 'Pilot call' | 'Your copy' | 'Decoded values'
|
||||
standard?: string
|
||||
whyItMatters?: string
|
||||
/** @deprecated Use prompt.text. Kept while legacy curriculum entries are curated. */
|
||||
phrase: (scenario: Scenario) => string
|
||||
info: (scenario: Scenario) => string[]
|
||||
reference?: (scenario: Scenario) => ReferenceEntry[]
|
||||
|
||||
@@ -16,7 +16,7 @@ export const PUBLIC_SEO_ROUTES: SeoRoute[] = [
|
||||
{
|
||||
path: '/',
|
||||
title: 'Train ATC before you fly VATSIM | OpenSquawk',
|
||||
description: 'Practice realistic ATC phraseology with guided scenarios, instant feedback and structured radio training for VATSIM and flight simulation.',
|
||||
description: 'Learn ICAO/SERA radio patterns in audio-first Classroom drills with typed pilot responses, then practise them in the Live ATC alpha.',
|
||||
image: '/img/learn/missions/full-flight/briefing-hero.png',
|
||||
changefreq: 'weekly',
|
||||
priority: 1,
|
||||
@@ -25,7 +25,7 @@ export const PUBLIC_SEO_ROUTES: SeoRoute[] = [
|
||||
{
|
||||
path: '/de',
|
||||
title: 'ATC-Funk für VATSIM trainieren | OpenSquawk',
|
||||
description: 'Trainiere realistische ATC-Phraseologie mit geführten Szenarien, direktem Feedback und strukturierten Funkübungen für VATSIM und Flugsimulation.',
|
||||
description: 'Lerne ICAO/SERA-Funkmuster in audio-geführten Classroom-Übungen mit getippten Pilotenmeldungen und übe sie anschließend in Live ATC.',
|
||||
image: '/img/learn/missions/full-flight/briefing-hero.png',
|
||||
changefreq: 'weekly',
|
||||
priority: 0.9,
|
||||
@@ -142,11 +142,11 @@ export const FAQ_SCHEMA = {
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: [
|
||||
['Is Classroom free right now?', 'Yes. Classroom is currently free during the invitation alpha.'],
|
||||
['What is the difference between Classroom and Live ATC?', 'Classroom is structured listening, readback and feedback training. Live ATC is the real-time simulator radio loop and is still in closed alpha.'],
|
||||
['What is the difference between Classroom and Live ATC?', 'Classroom uses audio-first prompts, typed pilot responses and feedback on required elements, phraseology and structure. Live ATC is the real-time simulator radio loop with PTT or text input. Both are in invitation alpha.'],
|
||||
['Do I need to be on VATSIM to use it?', 'No. OpenSquawk is built for VATSIM preparation, so you can train before flying live on network frequencies.'],
|
||||
['Can I self-host and inspect the stack?', 'Yes. The core is open source and self-hosting remains free.'],
|
||||
['How do I get access?', 'Join the waitlist for Classroom and Live ATC. Access is released in rolling batches.'],
|
||||
['Is this for real-world aviation?', 'No. OpenSquawk is simulator training software and is not intended for real-world ATC communication.'],
|
||||
['Is this for real-world aviation?', 'No. OpenSquawk uses synthetic training data and is not a source for real-world navigation, frequencies or ATC communication.'],
|
||||
].map(([name, text]) => ({
|
||||
'@type': 'Question',
|
||||
name,
|
||||
@@ -159,11 +159,11 @@ export const DE_FAQ_SCHEMA = {
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: [
|
||||
['Ist Classroom aktuell kostenlos?', 'Ja. Classroom ist während der Einladungs-Alpha aktuell kostenlos.'],
|
||||
['Was ist der Unterschied zwischen Classroom und Live ATC?', 'Classroom bietet strukturierte Hör-, Readback- und Feedbackübungen. Live ATC ist der Echtzeit-Funk für den Flugsimulator und befindet sich in der geschlossenen Alpha.'],
|
||||
['Was ist der Unterschied zwischen Classroom und Live ATC?', 'Classroom bietet audio-geführte Übungen mit getippten Pilotenmeldungen und Feedback zu Pflichtangaben, Phraseologie und Struktur. Live ATC ist der Echtzeit-Funk mit PTT oder Texteingabe. Beide befinden sich in der Einladungs-Alpha.'],
|
||||
['Muss ich bei VATSIM angemeldet sein?', 'Nein. OpenSquawk bereitet dich auf VATSIM vor, damit du vor dem ersten Onlineflug sicher üben kannst.'],
|
||||
['Kann ich OpenSquawk selbst hosten?', 'Ja. Der Kern ist Open Source und kann kostenlos selbst gehostet werden.'],
|
||||
['Wie erhalte ich Zugang?', 'Trage dich in die Warteliste für Classroom und Live ATC ein. Einladungen werden schrittweise freigeschaltet.'],
|
||||
['Ist OpenSquawk für die reale Luftfahrt gedacht?', 'Nein. OpenSquawk ist Trainingssoftware für Flugsimulatoren und nicht für echten Flugfunk vorgesehen.'],
|
||||
['Ist OpenSquawk für die reale Luftfahrt gedacht?', 'Nein. OpenSquawk verwendet synthetische Trainingsdaten und ist keine Quelle für reale Navigation, Frequenzen oder echten Flugfunk.'],
|
||||
].map(([name, text]) => ({
|
||||
'@type': 'Question',
|
||||
name,
|
||||
@@ -179,7 +179,7 @@ export const SOFTWARE_APPLICATION_SCHEMA = {
|
||||
applicationCategory: 'EducationalApplication',
|
||||
applicationSubCategory: 'Flight simulator ATC training',
|
||||
operatingSystem: 'Web',
|
||||
description: 'Structured ATC radio and phraseology training for flight simulator pilots preparing for VATSIM.',
|
||||
description: 'Audio-first Classroom drills with typed pilot responses and an invitation-alpha Live ATC simulator radio loop.',
|
||||
image: absoluteSiteUrl(DEFAULT_OG_IMAGE),
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
@@ -188,9 +188,10 @@ export const SOFTWARE_APPLICATION_SCHEMA = {
|
||||
availability: 'https://schema.org/LimitedAvailability',
|
||||
},
|
||||
featureList: [
|
||||
'Guided ATC communication scenarios',
|
||||
'Readback practice and instant feedback',
|
||||
'Flight simulator integration',
|
||||
'Audio-first ICAO/SERA training prompts',
|
||||
'Typed pilot calls and readbacks with field-level feedback',
|
||||
'Rule-based Live ATC alpha sessions with PTT or text input',
|
||||
'Optional alpha simulator Bridge',
|
||||
'Self-hosted open-source option',
|
||||
],
|
||||
}
|
||||
|
||||
170
tests/shared/classroomCurriculum.test.ts
Normal file
170
tests/shared/classroomCurriculum.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { learnModules } from '~~/shared/data/learnModules'
|
||||
import {
|
||||
assessRequiredFields,
|
||||
isCurrentMastery,
|
||||
updateMasteryProgress,
|
||||
} from '~~/shared/learn/assessment'
|
||||
import { CLASSROOM_ASSESSMENT_VERSION } from '~~/shared/learn/config'
|
||||
|
||||
describe('Classroom curriculum contract', () => {
|
||||
it('exposes the four goal-led modules and a 17-step guided flight', () => {
|
||||
assert.deepEqual(
|
||||
learnModules.map(module => module.title),
|
||||
[
|
||||
'Foundations',
|
||||
'Mandatory Readbacks',
|
||||
'Pilot Calls & Abnormal Situations',
|
||||
'Guided Flight Sequence',
|
||||
],
|
||||
)
|
||||
assert.equal(learnModules.find(module => module.id === 'full-flight')?.lessons.length, 17)
|
||||
})
|
||||
|
||||
it('gives every lesson an explicit prompt, response label and valid required fields', () => {
|
||||
const ids = new Set<string>()
|
||||
|
||||
for (const module of learnModules) {
|
||||
for (const lesson of module.lessons) {
|
||||
assert.equal(ids.has(lesson.id), false, `duplicate lesson id: ${lesson.id}`)
|
||||
ids.add(lesson.id)
|
||||
assert.ok(lesson.prompt, `${lesson.id} has no prompt`)
|
||||
assert.ok(lesson.responseLabel, `${lesson.id} has no response label`)
|
||||
assert.ok(lesson.standard, `${lesson.id} has no standard reference`)
|
||||
assert.ok(lesson.whyItMatters, `${lesson.id} has no learning rationale`)
|
||||
|
||||
const scenario = lesson.generate()
|
||||
const prompt = lesson.prompt?.text(scenario).trim() ?? ''
|
||||
assert.ok(prompt, `${lesson.id} has an empty prompt`)
|
||||
if (lesson.prompt?.kind === 'situation') {
|
||||
assert.equal(
|
||||
prompt.includes(scenario.radioCall),
|
||||
false,
|
||||
`${lesson.id} exposes the finished pilot response as its situation prompt`,
|
||||
)
|
||||
assert.equal(
|
||||
lesson.fields.some(field => /callsign/i.test(`${field.key} ${field.label}`)),
|
||||
true,
|
||||
`${lesson.id} does not assess the pilot callsign`,
|
||||
)
|
||||
}
|
||||
|
||||
const fieldKeys = new Set(lesson.fields.map(field => field.key))
|
||||
for (const field of lesson.fields) {
|
||||
assert.equal(field.required, true, `${lesson.id}/${field.key} must declare required`)
|
||||
assert.ok(field.matching, `${lesson.id}/${field.key} has no matching policy`)
|
||||
assert.ok(field.expected(scenario).trim(), `${lesson.id}/${field.key} has no expected value`)
|
||||
}
|
||||
for (const segment of lesson.readback) {
|
||||
if (segment.type === 'field') {
|
||||
assert.equal(fieldKeys.has(segment.key), true, `${lesson.id} references missing field ${segment.key}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('uses correct prompt roles for pilot calls, ATC instructions and copy exercises', () => {
|
||||
const byId = new Map(learnModules.flatMap(module => module.lessons).map(lesson => [lesson.id, lesson]))
|
||||
|
||||
for (const id of ['clearance-contact', 'departure-checkin', 'mayday', 'pan-pan', 'go-around', 'radio-check']) {
|
||||
assert.equal(byId.get(id)?.prompt?.kind, 'situation', `${id} must not be played as ATC`)
|
||||
assert.equal(byId.get(id)?.responseLabel, 'Pilot call')
|
||||
}
|
||||
for (const id of ['atis', 'metar', 'icao-alphabet']) {
|
||||
assert.equal(byId.get(id)?.prompt?.kind, 'copy')
|
||||
}
|
||||
assert.equal(byId.get('takeoff')?.prompt?.kind, 'atc')
|
||||
})
|
||||
|
||||
it('models critical readbacks and emergency calls without misleading fields', () => {
|
||||
const byId = new Map(learnModules.flatMap(module => module.lessons).map(lesson => [lesson.id, lesson]))
|
||||
|
||||
for (const id of ['takeoff', 'landing-clearance', 'full-takeoff', 'full-landing']) {
|
||||
const lesson = byId.get(id)
|
||||
assert.ok(lesson)
|
||||
assert.equal(lesson?.fields.some(field => /wind/i.test(`${field.key} ${field.label}`)), false)
|
||||
}
|
||||
|
||||
assert.equal(byId.get('approach-vector')?.fields.some(field => field.key === 'vector-direction'), true)
|
||||
assert.equal(byId.get('descent-clearance')?.fields.some(field => field.key === 'descent-level'), true)
|
||||
assert.equal(byId.get('radio-check')?.fields.some(field => /readability/i.test(field.key)), false)
|
||||
|
||||
for (const id of ['mayday', 'pan-pan']) {
|
||||
const labels = byId.get(id)?.fields.map(field => field.label) ?? []
|
||||
for (const expected of ['Callsign', 'Nature', 'Intention', 'Position', 'Level', 'Heading']) {
|
||||
assert.equal(labels.includes(expected), true, `${id} misses ${expected}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps one scenario identity throughout the guided flight', () => {
|
||||
const lessons = learnModules.find(module => module.id === 'full-flight')?.lessons ?? []
|
||||
const scenarios = lessons.map(lesson => lesson.generate())
|
||||
assert.ok(scenarios.length)
|
||||
for (const scenario of scenarios) {
|
||||
assert.equal(scenario.callsign, scenarios[0]?.callsign)
|
||||
assert.equal(scenario.airport.icao, scenarios[0]?.airport.icao)
|
||||
assert.equal(scenario.destination.icao, scenarios[0]?.destination.icao)
|
||||
assert.equal(scenario.runway, scenarios[0]?.runway)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Classroom assessment', () => {
|
||||
it('never passes when one required safety element is missing at 80 percent', () => {
|
||||
const summary = assessRequiredFields([
|
||||
{ key: 'runway', required: true, pass: true, similarity: 1 },
|
||||
{ key: 'heading', required: true, pass: true, similarity: 1 },
|
||||
{ key: 'level', required: true, pass: true, similarity: 1 },
|
||||
{ key: 'speed', required: true, pass: true, similarity: 1 },
|
||||
{ key: 'callsign', required: true, pass: false, similarity: 0 },
|
||||
])
|
||||
assert.equal(summary?.score, 80)
|
||||
assert.equal(summary?.passed, false)
|
||||
})
|
||||
|
||||
it('requires two unrevealed variations for current mastery', () => {
|
||||
const passed = { score: 100, hits: 2, required: 2, similarity: 1, passed: true }
|
||||
const first = updateMasteryProgress(undefined, passed, {
|
||||
modelAnswerRevealed: false,
|
||||
variantAlreadyCounted: false,
|
||||
})
|
||||
assert.equal(first.progress.done, false)
|
||||
assert.equal(first.progress.successfulVariants, 1)
|
||||
|
||||
const duplicate = updateMasteryProgress(first.progress, passed, {
|
||||
modelAnswerRevealed: false,
|
||||
variantAlreadyCounted: true,
|
||||
})
|
||||
assert.equal(duplicate.progress.successfulVariants, 1)
|
||||
|
||||
const revealed = updateMasteryProgress(first.progress, passed, {
|
||||
modelAnswerRevealed: true,
|
||||
variantAlreadyCounted: false,
|
||||
})
|
||||
assert.equal(revealed.progress.successfulVariants, 1)
|
||||
|
||||
const second = updateMasteryProgress(first.progress, passed, {
|
||||
modelAnswerRevealed: false,
|
||||
variantAlreadyCounted: false,
|
||||
})
|
||||
assert.equal(second.progress.done, true)
|
||||
assert.equal(isCurrentMastery(second.progress), true)
|
||||
})
|
||||
|
||||
it('treats legacy completion as review needed rather than current mastery', () => {
|
||||
assert.equal(isCurrentMastery({ best: 100, done: true }), false)
|
||||
assert.equal(
|
||||
isCurrentMastery({
|
||||
best: 100,
|
||||
done: true,
|
||||
assessmentVersion: CLASSROOM_ASSESSMENT_VERSION,
|
||||
successfulVariants: 2,
|
||||
}),
|
||||
true,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -5,16 +5,23 @@ import {
|
||||
altitudeToWords,
|
||||
createBaseScenario,
|
||||
createScenarioSeries,
|
||||
frequencyToSpeech,
|
||||
minutesToWords,
|
||||
qnhToWords,
|
||||
runwayToWords,
|
||||
squawkToWords,
|
||||
} from '~~/shared/learn/scenario'
|
||||
|
||||
describe('scenario helpers', () => {
|
||||
it('formats runway, minutes and altitude values', () => {
|
||||
assert.equal(runwayToWords('25R'), 'two fife right')
|
||||
assert.equal(minutesToWords(0), 'one minute')
|
||||
assert.equal(minutesToWords(4.4), 'four minutes')
|
||||
assert.equal(altitudeToWords(4500), 'four thousand fife zero zero')
|
||||
assert.equal(runwayToWords('25R'), 'too fife right')
|
||||
assert.equal(minutesToWords(0), 'wun minute')
|
||||
assert.equal(minutesToWords(4.4), 'fower minutes')
|
||||
assert.equal(altitudeToWords(4500), 'fower thousand fife hundred')
|
||||
assert.equal(qnhToWords(1000), 'wun thousand')
|
||||
assert.equal(squawkToWords('1000'), 'wun thousand')
|
||||
assert.equal(frequencyToSpeech('118.700'), 'wun wun ait decimal seven')
|
||||
assert.equal(frequencyToSpeech('121.805'), 'wun too wun decimal ait zero fife')
|
||||
})
|
||||
|
||||
it('creates a base scenario with critical data fields', () => {
|
||||
@@ -25,6 +32,7 @@ describe('scenario helpers', () => {
|
||||
assert.equal(scenario.frequencies.length > 0, true)
|
||||
assert.equal(typeof scenario.handoff.frequencyWords, 'string')
|
||||
assert.equal(scenario.handoff.frequencyWords.length > 0, true)
|
||||
assert.equal(/\bSKC\b/.test(scenario.metar), false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user