From 9b2beb2d788fc17d6da6a1973e31cedbfc23f4a0 Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Mon, 27 Jul 2026 10:26:45 +0200 Subject: [PATCH] feat(classroom): rebuild curriculum and mastery assessment --- server/api/learn/state.put.ts | 15 +- shared/data/learnModules.ts | 718 ++++++++++++++++++++++- shared/learn/assessment.ts | 73 +++ shared/learn/config.ts | 7 + shared/learn/scenario.ts | 45 +- shared/learn/types.ts | 15 + tests/shared/classroomCurriculum.test.ts | 170 ++++++ tests/shared/scenario.test.ts | 16 +- 8 files changed, 1027 insertions(+), 32 deletions(-) create mode 100644 shared/learn/assessment.ts create mode 100644 tests/shared/classroomCurriculum.test.ts diff --git a/server/api/learn/state.put.ts b/server/api/learn/state.put.ts index 143fb64..b925654 100644 --- a/server/api/learn/state.put.ts +++ b/server/api/learn/state.put.ts @@ -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 diff --git a/shared/data/learnModules.ts b/shared/data/learnModules.ts index 6a4d186..01ee3f3 100644 --- a/shared/data/learnModules.ts +++ b/shared/data/learnModules.ts @@ -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 @@ -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 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 = { + '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' diff --git a/shared/learn/assessment.ts b/shared/learn/assessment.ts new file mode 100644 index 0000000..cf0f97c --- /dev/null +++ b/shared/learn/assessment.ts @@ -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, + ) +} diff --git a/shared/learn/config.ts b/shared/learn/config.ts index 29004e8..2390fa2 100644 --- a/shared/learn/config.ts +++ b/shared/learn/config.ts @@ -1,6 +1,8 @@ export interface LessonProgress { best: number done: boolean + assessmentVersion?: number + successfulVariants?: number } export type LearnProgress = Record> @@ -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, diff --git a/shared/learn/scenario.ts b/shared/learn/scenario.ts index 18edb3b..024bd2d 100644 --- a/shared/learn/scenario.ts +++ b/shared/learn/scenario.ts @@ -2,7 +2,7 @@ import type { AirlineData, AirportData, Frequency, FrequencyType, Scenario } fro import { generateSquawk } from '../utils/transponder' const natoMap: Record = { - A: 'Alpha', + A: 'Alfa', B: 'Bravo', C: 'Charlie', D: 'Delta', @@ -32,14 +32,14 @@ const natoMap: Record = { const atcNumberWords: Record = { '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, diff --git a/shared/learn/types.ts b/shared/learn/types.ts index 3748178..e9e4453 100644 --- a/shared/learn/types.ts +++ b/shared/learn/types.ts @@ -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[] diff --git a/tests/shared/classroomCurriculum.test.ts b/tests/shared/classroomCurriculum.test.ts new file mode 100644 index 0000000..989534f --- /dev/null +++ b/tests/shared/classroomCurriculum.test.ts @@ -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() + + 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, + ) + }) +}) diff --git a/tests/shared/scenario.test.ts b/tests/shared/scenario.test.ts index 9d958d9..e7d343d 100644 --- a/tests/shared/scenario.test.ts +++ b/tests/shared/scenario.test.ts @@ -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) }) })