diff --git a/app/pages/learn.vue b/app/pages/learn.vue index 557414c..df1fe52 100644 --- a/app/pages/learn.vue +++ b/app/pages/learn.vue @@ -167,48 +167,419 @@ +
- - / {{ current.title }} + / {{ current.title }} + / Mission setup + / Mission briefing
-
- mdi-progress-check {{ - doneCount(current.id) - }}/{{ current.lessons.length }} - mdi-star Ø {{ avgScore(current.id) }}% +
+
+
+ {{ currentPlan ? 'mdi-airplane-check' : 'mdi-airplane-cog' }} +
+
+ + {{ currentPlan ? currentPlan.scenario.radioCall : 'Flight plan pending' }} + + {{ currentPlanRoute }} + Select or import a flight to launch. +
+ +
+
+ mdi-progress-check {{ doneCount(current.id) }}/{{ current.lessons.length }} + mdi-star Ø {{ avgScore(current.id) }}% +
-
-
-
- +
+ +
+
+ Mission hero +
+ Auto flight +
{{ draftPlanScenario?.radioCall }}
+
{{ draftPlanRoute }}
+
{{ draftPlanTagline }}
+
+
+
+ + +
+
+ +
+
+
+ mdi-airplane + Flight identity +
+
+ + + +
+
+ +
+
+ mdi-airplane-takeoff + Departure +
+
+ + + + + + + + +
+
+ +
+
+ mdi-airplane-landing + Arrival +
+
+ + + + + + + + + +
+
+ +
+
+ mdi-altimeter + Altitudes & codes +
+
+ + + + + +
+
+ +
+
+ mdi-alert + {{ error }} +
+
+ +
+ + +
+
+ +
+
+ mdi-clipboard-text-clock +
+
SimBrief dispatch
+

+ Import the latest OFP from + SimBrief. +

+
+
+
+ + +
+
+ + Contacting SimBrief… +
+
+ mdi-alert + {{ flightPlanError }} +
+
+ mdi-airplane + {{ simbriefPlanMeta.callsign }} · {{ simbriefPlanMeta.route }} + +
+
+
+ +
+
+ Mission hero +
+ Mission briefing +
{{ briefingSnapshot.radioCall }}
+
{{ briefingSnapshot.route }}
+
{{ briefingSnapshot.tagline }}
+
+ + mdi-airplane-takeoff + {{ briefingSnapshot.departure.icao }} · RWY {{ briefingSnapshot.departure.runway }} + + + mdi-airplane-landing + {{ briefingSnapshot.arrival.icao }} · RWY {{ briefingSnapshot.arrival.runway }} + + + mdi-form-textbox-password + Squawk {{ briefingSnapshot.codes.squawk }} + +
+
+
+ +
+
+ Route preview +
+ mdi-map-marker-path + Flight deck +
+
    +
  • Push: {{ briefingSnapshot.codes.push }}
  • +
  • ATIS: Information {{ briefingSnapshot.departure.atis }}
  • +
  • Delivery: {{ briefingSnapshot.departure.freq }} · {{ briefingSnapshot.departure.freqWords }}
  • +
+
+
+ Departure +
+ mdi-airplane-takeoff + Departure flow +
+
    +
  • Stand: {{ briefingSnapshot.departure.stand || 'As assigned' }}
  • +
  • Taxi: {{ briefingSnapshot.departure.taxiRoute || 'As assigned' }}
  • +
  • SID: {{ briefingSnapshot.departure.sid }} · {{ briefingSnapshot.departure.transition }}
  • +
  • Initial altitude: {{ briefingSnapshot.altitudes.initial }}
  • +
+
+
+ Arrival +
+ mdi-airplane-landing + Arrival setup +
+
    +
  • STAR: {{ briefingSnapshot.arrival.star }} · {{ briefingSnapshot.arrival.transition }}
  • +
  • Approach: {{ briefingSnapshot.arrival.approach }}
  • +
  • Taxi-in: {{ briefingSnapshot.arrival.taxiRoute || 'As assigned' }}
  • +
  • Arrival stand: {{ briefingSnapshot.arrival.stand || 'As assigned' }}
  • +
+
+
+ Weather +
+ mdi-weather-cloudy + Weather +
+
    +
  • Departure wind: {{ briefingSnapshot.weather.depWind }}
  • +
  • Departure QNH: {{ briefingSnapshot.weather.depQnh }}
  • +
  • Arrival wind: {{ briefingSnapshot.weather.arrWind }}
  • +
  • Arrival QNH: {{ briefingSnapshot.weather.arrQnh }}
  • +
+
+
+ +
+ + +
+
+ +
+
+
+
+ +
@@ -353,6 +724,10 @@ mdi-auto-fix Auto-fill +
{{ result.score }}%
@@ -407,6 +782,7 @@
+

Your progress

@@ -557,7 +933,8 @@ import {computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, shallowRe import {useApi} from '~/composables/useApi' import {createDefaultLearnConfig} from '~~/shared/learn/config' import type {LearnConfig, LearnProgress, LearnState} from '~~/shared/learn/config' -import {learnModules} from '~~/shared/data/learnModules' +import {learnModules, seedFullFlightScenario} from '~~/shared/data/learnModules' +import {createBaseScenario, digitsToWords, lettersToNato, runwayToWords, altitudeToWords, minutesToWords} from '~~/shared/learn/scenario' import type {BlankWidth, Frequency, Lesson, LessonField, ModuleDef, Scenario} from '~~/shared/learn/types' import {loadPizzicatoLite} from '~~/shared/utils/pizzicatoLite' import type {PizzicatoLite} from '~~/shared/utils/pizzicatoLite' @@ -635,10 +1012,689 @@ function similarity(a: string, b: string): number { const modules = shallowRef(learnModules) +type FlightPlanMode = 'random' | 'manual' | 'simbrief' +type MissionPlanState = { scenario: Scenario; mode: FlightPlanMode; updatedAt: number } +type MissionPlanInput = { + callsign?: string + airlineCode?: string + airlineCall?: string + flightNumber?: string + radioCall?: string + departure?: { + icao?: string + name?: string + city?: string + runway?: string + stand?: string + taxi?: string + sid?: string + transition?: string + } + destination?: { + icao?: string + name?: string + city?: string + runway?: string + stand?: string + taxi?: string + star?: string + transition?: string + approach?: string + } + altitudes?: { + initial?: number + climb?: number + } + squawk?: string + pushDelayMinutes?: number + remarks?: string +} + +type ManualForm = { + airlineCode: string + airlineCall: string + flightNumber: string + departureIcao: string + departureName: string + departureCity: string + departureRunway: string + stand: string + taxiRoute: string + sid: string + transition: string + destinationIcao: string + destinationName: string + destinationCity: string + arrivalRunway: string + arrivalStand: string + arrivalTaxiRoute: string + arrivalStar: string + arrivalTransition: string + approach: string + initialAltitude: string + climbAltitude: string + squawk: string + pushDelay: string + remarks: string +} + const panel = ref<'hub' | 'module'>('hub') const current = ref(null) const activeLesson = ref(null) const scenario = ref(null) +const moduleStage = ref<'lessons' | 'setup' | 'briefing'>('lessons') +const flightPlanMode = ref('random') +const missionPlans = reactive>({}) +const draftPlanScenario = ref(null) +const manualBaseScenario = ref(null) +const briefingReturnStage = ref<'setup' | 'lessons'>('setup') +const pendingAutoStart = ref(false) +const manualForm = reactive({ + airlineCode: '', + airlineCall: '', + flightNumber: '', + departureIcao: '', + departureName: '', + departureCity: '', + departureRunway: '', + stand: '', + taxiRoute: '', + sid: '', + transition: '', + destinationIcao: '', + destinationName: '', + destinationCity: '', + arrivalRunway: '', + arrivalStand: '', + arrivalTaxiRoute: '', + arrivalStar: '', + arrivalTransition: '', + approach: '', + initialAltitude: '', + climbAltitude: '', + squawk: '', + pushDelay: '', + remarks: '' +}) +const manualErrors = ref([]) +const flightPlanError = ref(null) +const simbriefForm = reactive({ userId: '', loading: false }) +const simbriefPlanMeta = ref<{ callsign: string; route: string } | null>(null) +const lessonTrack = ref(null) + +const flightPlanModes: Array<{ id: FlightPlanMode; title: string; icon: string; desc: string }> = [ + { + id: 'random', + title: 'Auto-generate', + icon: 'mdi-dice-multiple', + desc: 'Spin up a surprise full flight with matching ATC flow.' + }, + { + id: 'manual', + title: 'Manual setup', + icon: 'mdi-airplane-edit', + desc: 'Enter your own callsign, airports and procedures.' + }, + { + id: 'simbrief', + title: 'SimBrief import', + icon: 'mdi-clipboard-text-clock', + desc: 'Pull your latest SimBrief dispatch and brief it in-game.' + } +] + +function cloneScenarioData(source: Scenario | null): Scenario | null { + if (!source) return null + if (typeof structuredClone === 'function') { + try { + return structuredClone(source) + } catch (error) { + console.warn('structuredClone failed for scenario clone', error) + } + } + return JSON.parse(JSON.stringify(source)) as Scenario +} + +const isNonEmpty = (value?: string | null): value is string => Boolean(value && value.trim().length) + +function sanitizeUpper(value?: string): string { + return (value || '').trim().toUpperCase() +} + +function ensureList(list: string[] | undefined, value?: string): string[] { + if (!list && !value) return [] + const next = Array.isArray(list) ? [...list] : [] + if (value) { + const trimmed = value.trim() + if (trimmed && !next.includes(trimmed)) next.push(trimmed) + } + return next +} + +function scenarioRoute(s: Scenario | null): string { + if (!s) return '' + return `${s.airport.icao} → ${s.destination.icao}` +} + +function scenarioTagline(s: Scenario | null): string { + if (!s) return '' + return `${s.airport.city} → ${s.destination.city}` +} + +type BriefingSnapshot = { + callsign: string + radioCall: string + route: string + tagline: string + departure: { + city: string + icao: string + runway: string + stand: string + sid: string + transition: string + atis: string + freq: string + freqWords: string + taxiRoute: string + } + arrival: { + city: string + icao: string + runway: string + stand: string + star: string + transition: string + approach: string + taxiRoute: string + } + altitudes: { + initial: string + climb: string + } + codes: { + squawk: string + push: string + } + weather: { + depWind: string + depQnh: string + arrWind: string + arrQnh: string + } +} + +function buildBriefingSnapshot(s: Scenario | null): BriefingSnapshot | null { + if (!s) return null + return { + callsign: s.callsign, + radioCall: s.radioCall, + route: scenarioRoute(s), + tagline: scenarioTagline(s), + departure: { + city: s.airport.city, + icao: s.airport.icao, + runway: s.runway, + stand: s.stand, + sid: s.sid, + transition: s.transition, + atis: s.atisCode, + freq: s.deliveryFreq, + freqWords: s.frequencyWords?.DEL || s.deliveryFreq, + taxiRoute: s.taxiRoute + }, + arrival: { + city: s.destination.city, + icao: s.destination.icao, + runway: s.arrivalRunway, + stand: s.arrivalStand, + star: s.arrivalStar, + transition: s.arrivalTransition, + approach: s.approach, + taxiRoute: s.arrivalTaxiRoute + }, + altitudes: { + initial: s.altitudes.initialWords || `${s.altitudes.initial} ft`, + climb: s.altitudes.climbWords || `${s.altitudes.climb} ft` + }, + codes: { + squawk: s.squawk, + push: s.pushDelayWords + }, + weather: { + depWind: s.windWords || s.wind, + depQnh: s.qnhWords, + arrWind: s.arrivalWindWords, + arrQnh: s.arrivalQnhWords + } + } +} + +const parseOptionalNumber = (input: string): number | undefined => { + if (!input) return undefined + const normalized = input.toString().replace(/[^0-9.-]/g, '') + const value = Number.parseFloat(normalized) + return Number.isFinite(value) ? value : undefined +} + +function applyPlanToScenario(base: Scenario, plan: MissionPlanInput): Scenario { + const scenario = cloneScenarioData(base) + if (!scenario) return base + + const airlineCode = sanitizeUpper(plan.airlineCode) || scenario.airlineCode + const airlineCall = plan.airlineCall?.trim() || scenario.airlineCall + const rawFlightNumber = plan.flightNumber?.toString().trim() || scenario.flightNumber + const normalizedFlightNumber = rawFlightNumber.replace(/\s+/g, '') + const digitsOnly = normalizedFlightNumber.replace(/[^0-9]/g, '') || normalizedFlightNumber + const callsign = plan.callsign?.trim().toUpperCase() || `${airlineCode}${normalizedFlightNumber}`.toUpperCase() + + scenario.airlineCode = airlineCode + scenario.airlineCall = airlineCall + scenario.flightNumber = digitsOnly + scenario.flightNumberWords = digitsToWords(digitsOnly) + scenario.callsign = callsign + scenario.callsignNato = lettersToNato(airlineCode) + scenario.radioCall = plan.radioCall?.trim() || `${airlineCall} ${digitsToWords(digitsOnly)}` + + const dep = plan.departure || {} + if (isNonEmpty(dep.icao)) scenario.airport.icao = sanitizeUpper(dep.icao) + if (isNonEmpty(dep.name)) scenario.airport.name = dep.name.trim() + if (isNonEmpty(dep.city)) scenario.airport.city = dep.city.trim() + if (isNonEmpty(dep.runway)) { + const runway = sanitizeUpper(dep.runway) + scenario.runway = runway + scenario.runwayWords = runwayToWords(runway) + scenario.airport.runways = ensureList(scenario.airport.runways, runway) + } + if (isNonEmpty(dep.stand)) { + scenario.stand = sanitizeUpper(dep.stand) + scenario.airport.stands = ensureList(scenario.airport.stands, scenario.stand) + } + if (isNonEmpty(dep.taxi)) scenario.taxiRoute = dep.taxi.trim() + if (isNonEmpty(dep.sid)) { + scenario.sid = sanitizeUpper(dep.sid) + scenario.airport.sids = ensureList(scenario.airport.sids, scenario.sid) + } + if (isNonEmpty(dep.transition)) { + scenario.transition = sanitizeUpper(dep.transition) + scenario.airport.transitions = ensureList(scenario.airport.transitions, scenario.transition) + } + scenario.airport.taxi = ensureList(scenario.airport.taxi, dep.taxi) + + const dest = plan.destination || {} + if (isNonEmpty(dest.icao)) scenario.destination.icao = sanitizeUpper(dest.icao) + if (isNonEmpty(dest.name)) scenario.destination.name = dest.name.trim() + if (isNonEmpty(dest.city)) scenario.destination.city = dest.city.trim() + if (isNonEmpty(dest.runway)) { + const runway = sanitizeUpper(dest.runway) + scenario.arrivalRunway = runway + scenario.arrivalRunwayWords = runwayToWords(runway) + scenario.destination.runways = ensureList(scenario.destination.runways, runway) + } + if (isNonEmpty(dest.stand)) { + scenario.arrivalStand = sanitizeUpper(dest.stand) + scenario.destination.stands = ensureList(scenario.destination.stands, scenario.arrivalStand) + } + if (isNonEmpty(dest.taxi)) scenario.arrivalTaxiRoute = dest.taxi.trim() + if (isNonEmpty(dest.star)) { + scenario.arrivalStar = sanitizeUpper(dest.star) + scenario.destination.stars = ensureList(scenario.destination.stars, scenario.arrivalStar) + } + if (isNonEmpty(dest.transition)) { + scenario.arrivalTransition = sanitizeUpper(dest.transition) + scenario.destination.arrivalTransitions = ensureList(scenario.destination.arrivalTransitions, scenario.arrivalTransition) + } + if (isNonEmpty(dest.approach)) { + scenario.approach = dest.approach.trim() + scenario.destination.approaches = ensureList(scenario.destination.approaches, scenario.approach) + } + scenario.destination.taxi = ensureList(scenario.destination.taxi, dest.taxi) + + const initialAlt = plan.altitudes?.initial + if (typeof initialAlt === 'number' && Number.isFinite(initialAlt) && initialAlt > 0) { + scenario.altitudes.initial = Math.round(initialAlt) + scenario.altitudes.initialWords = altitudeToWords(scenario.altitudes.initial) + } + const climbAlt = plan.altitudes?.climb + if (typeof climbAlt === 'number' && Number.isFinite(climbAlt) && climbAlt > 0) { + const rounded = Math.max(Math.round(climbAlt), scenario.altitudes.initial + 100) + scenario.altitudes.climb = rounded + scenario.altitudes.climbWords = altitudeToWords(rounded) + } + + if (isNonEmpty(plan.squawk)) { + const squawk = plan.squawk.replace(/[^0-7]/g, '') + if (squawk) { + scenario.squawk = squawk + scenario.squawkWords = digitsToWords(squawk) + } + } + + if (typeof plan.pushDelayMinutes === 'number' && Number.isFinite(plan.pushDelayMinutes) && plan.pushDelayMinutes > 0) { + const minutes = Math.max(1, Math.round(plan.pushDelayMinutes)) + scenario.pushDelayMinutes = minutes + scenario.pushDelayWords = minutesToWords(minutes) + } + + if (isNonEmpty(plan.remarks)) { + scenario.remarks = plan.remarks.trim() + } + + return scenario +} + +function hydrateManualForm(base: Scenario) { + manualForm.airlineCode = base.airlineCode || '' + manualForm.airlineCall = base.airlineCall || '' + manualForm.flightNumber = base.flightNumber || '' + manualForm.departureIcao = base.airport.icao || '' + manualForm.departureName = base.airport.name || '' + manualForm.departureCity = base.airport.city || '' + manualForm.departureRunway = base.runway || '' + manualForm.stand = base.stand || '' + manualForm.taxiRoute = base.taxiRoute || '' + manualForm.sid = base.sid || '' + manualForm.transition = base.transition || '' + manualForm.destinationIcao = base.destination.icao || '' + manualForm.destinationName = base.destination.name || '' + manualForm.destinationCity = base.destination.city || '' + manualForm.arrivalRunway = base.arrivalRunway || '' + manualForm.arrivalStand = base.arrivalStand || '' + manualForm.arrivalTaxiRoute = base.arrivalTaxiRoute || '' + manualForm.arrivalStar = base.arrivalStar || '' + manualForm.arrivalTransition = base.arrivalTransition || '' + manualForm.approach = base.approach || '' + manualForm.initialAltitude = base.altitudes.initial.toString() + manualForm.climbAltitude = base.altitudes.climb.toString() + manualForm.squawk = base.squawk || '' + manualForm.pushDelay = base.pushDelayMinutes?.toString() || '' + manualForm.remarks = base.remarks || '' +} + +function resetManualForm() { + if (manualBaseScenario.value) { + hydrateManualForm(manualBaseScenario.value) + } else { + const base = createBaseScenario() + manualBaseScenario.value = cloneScenarioData(base) + hydrateManualForm(base) + } + manualErrors.value = [] +} + +function initSetupState(seed: Scenario | null) { + const base = cloneScenarioData(seed) || createBaseScenario() + if (!base) return + draftPlanScenario.value = cloneScenarioData(base) + manualBaseScenario.value = cloneScenarioData(base) + hydrateManualForm(base) + manualErrors.value = [] + flightPlanError.value = null + simbriefPlanMeta.value = null +} + +function rerollRandomPlan() { + const rolled = createBaseScenario() + draftPlanScenario.value = cloneScenarioData(rolled) + manualBaseScenario.value = cloneScenarioData(rolled) + hydrateManualForm(rolled) + flightPlanMode.value = 'random' +} + +function enterBriefingFromSetup() { + if (!draftPlanScenario.value) return + briefingReturnStage.value = 'setup' + moduleStage.value = 'briefing' +} + +function buildManualScenario(): Scenario | null { + const errors: string[] = [] + if (!isNonEmpty(manualForm.airlineCode)) errors.push('Airline ICAO required') + if (!isNonEmpty(manualForm.airlineCall)) errors.push('Spoken call sign required') + if (!isNonEmpty(manualForm.flightNumber)) errors.push('Flight number required') + if (!isNonEmpty(manualForm.departureIcao)) errors.push('Departure ICAO required') + if (!isNonEmpty(manualForm.destinationIcao)) errors.push('Destination ICAO required') + + manualErrors.value = errors + if (errors.length) return null + + const base = cloneScenarioData(manualBaseScenario.value) || createBaseScenario() + if (!base) return null + + const plan: MissionPlanInput = { + airlineCode: manualForm.airlineCode, + airlineCall: manualForm.airlineCall, + flightNumber: manualForm.flightNumber, + departure: { + icao: manualForm.departureIcao, + name: manualForm.departureName, + city: manualForm.departureCity, + runway: manualForm.departureRunway, + stand: manualForm.stand, + taxi: manualForm.taxiRoute, + sid: manualForm.sid, + transition: manualForm.transition + }, + destination: { + icao: manualForm.destinationIcao, + name: manualForm.destinationName, + city: manualForm.destinationCity, + runway: manualForm.arrivalRunway, + stand: manualForm.arrivalStand, + taxi: manualForm.arrivalTaxiRoute, + star: manualForm.arrivalStar, + transition: manualForm.arrivalTransition, + approach: manualForm.approach + }, + altitudes: { + initial: parseOptionalNumber(manualForm.initialAltitude), + climb: parseOptionalNumber(manualForm.climbAltitude) + }, + squawk: manualForm.squawk, + pushDelayMinutes: parseOptionalNumber(manualForm.pushDelay), + remarks: manualForm.remarks + } + + return applyPlanToScenario(base, plan) +} + +async function loadSimbriefPlan() { + if (!current.value) return + if (simbriefForm.loading) return + const userId = simbriefForm.userId.trim() + if (!userId) { + flightPlanError.value = 'Please enter your SimBrief ID.' + return + } + flightPlanError.value = null + manualErrors.value = [] + simbriefForm.loading = true + try { + const response = await api.get<{ data: any }>('/api/learn/simbrief', { query: { userId }, auth: true }) + const payload = response?.data + if (!payload) { + flightPlanError.value = 'No SimBrief dispatch found.' + return + } + const base = createBaseScenario() + const plan = normalizeSimbriefPlan(payload) + const scenario = applyPlanToScenario(base, plan) + draftPlanScenario.value = cloneScenarioData(scenario) + manualBaseScenario.value = cloneScenarioData(scenario) + hydrateManualForm(scenario) + simbriefPlanMeta.value = { + callsign: scenario.radioCall, + route: scenarioRoute(scenario) + } + flightPlanMode.value = 'simbrief' + enterBriefingFromSetup() + } catch (error: any) { + flightPlanError.value = error?.data?.message || error?.message || 'Failed to load SimBrief plan.' + } finally { + simbriefForm.loading = false + } +} + +function submitSimbriefFromInput() { + if (simbriefForm.loading) return + if (!simbriefForm.userId.trim()) return + void loadSimbriefPlan() +} + +function handleManualSubmit() { + const scenario = buildManualScenario() + if (!scenario) return + draftPlanScenario.value = cloneScenarioData(scenario) + simbriefPlanMeta.value = { + callsign: scenario.radioCall, + route: scenarioRoute(scenario) + } + enterBriefingFromSetup() +} + +function confirmPlanAndEnterMission() { + if (!current.value || !draftPlanScenario.value) return + const snapshot = cloneScenarioData(draftPlanScenario.value) + if (!snapshot) return + missionPlans[current.value.id] = { + scenario: snapshot, + mode: flightPlanMode.value, + updatedAt: Date.now() + } + seedFullFlightScenario(cloneScenarioData(snapshot)) + briefingReturnStage.value = 'lessons' + moduleStage.value = 'lessons' + pendingAutoStart.value = false + startLessonsForCurrent() +} + +function handleBriefingConfirm() { + if (briefingReturnStage.value === 'setup') { + confirmPlanAndEnterMission() + } else { + moduleStage.value = 'lessons' + } +} + +function backToSetupFromBriefing() { + if (briefingReturnStage.value !== 'setup') { + moduleStage.value = 'lessons' + return + } + moduleStage.value = 'setup' + if (draftPlanScenario.value) { + initSetupState(draftPlanScenario.value) + } +} + +function openMissionBriefing() { + if (!current.value) return + const stored = missionPlans[current.value.id] + if (!stored?.scenario) return + draftPlanScenario.value = cloneScenarioData(stored.scenario) + flightPlanMode.value = stored.mode + briefingReturnStage.value = 'lessons' + moduleStage.value = 'briefing' +} + +function startLessonsForCurrent() { + if (!current.value) return + const mod = current.value + const prog = progress.value[mod.id] || {} + const next = mod.lessons.find(lesson => !(prog[lesson.id]?.done)) || mod.lessons[0] + activeLesson.value = next || null +} + +function goToHub() { + panel.value = 'hub' + moduleStage.value = 'lessons' + current.value = null + activeLesson.value = null + scenario.value = null + seedFullFlightScenario(null) +} + +function readProp(source: any, key: string): any { + if (!source || typeof source !== 'object') return undefined + if (key in source) return source[key] + const lower = key.toLowerCase() + const foundKey = Object.keys(source).find(prop => prop.toLowerCase() === lower) + return foundKey ? source[foundKey] : undefined +} + +function extractPath(source: any, path: string): any { + return path.split('.').reduce((acc, segment) => readProp(acc, segment), source) +} + +function pickString(source: any, ...paths: string[]): string | undefined { + for (const path of paths) { + const value = extractPath(source, path) + if (typeof value === 'string' && value.trim()) { + return value.trim() + } + } + return undefined +} + +function pickNumber(source: any, ...paths: string[]): number | undefined { + for (const path of paths) { + const raw = extractPath(source, path) + if (typeof raw === 'number' && Number.isFinite(raw)) return raw + if (typeof raw === 'string') { + const numeric = Number.parseFloat(raw.replace(/[^0-9.-]/g, '')) + if (Number.isFinite(numeric)) return numeric + } + } + return undefined +} + +function normalizeSimbriefPlan(raw: any): MissionPlanInput { + const plan: MissionPlanInput = {} + + plan.airlineCode = pickString(raw, 'general.icao_airline', 'general.airline_icao', 'general.icao') || undefined + plan.airlineCall = pickString(raw, 'general.callsign_name', 'general.airline_name', 'general.airline', 'general.callsign') || undefined + plan.flightNumber = pickString(raw, 'general.flight_number', 'general.flt_num', 'general.flightnum') || undefined + plan.callsign = pickString(raw, 'general.callsign') || undefined + plan.radioCall = pickString(raw, 'general.callsign', 'general.callsign_name') || undefined + + plan.departure = { + icao: pickString(raw, 'origin.icao_code', 'general.origin', 'plan.origin'), + name: pickString(raw, 'origin.name', 'origin.airport_name', 'origin.station_name'), + city: pickString(raw, 'origin.pos_city', 'origin.city'), + runway: pickString(raw, 'origin.plan_rwy', 'plan.plan_rwy_dept', 'plan.rwy_dep'), + stand: pickString(raw, 'origin.plan_gate', 'plan.gate'), + taxi: pickString(raw, 'origin.plan_taxi_route', 'origin.plan_taxiroute'), + sid: pickString(raw, 'origin.plan_sid', 'plan.plan_sid'), + transition: pickString(raw, 'origin.plan_sid_trans', 'origin.plan_sid_transition', 'plan.sid_transition') + } + + plan.destination = { + icao: pickString(raw, 'destination.icao_code', 'general.destination', 'plan.destination'), + name: pickString(raw, 'destination.name', 'destination.airport_name', 'destination.station_name'), + city: pickString(raw, 'destination.pos_city', 'destination.city'), + runway: pickString(raw, 'destination.plan_rwy', 'plan.plan_rwy_arr', 'plan.rwy_arr'), + stand: pickString(raw, 'destination.plan_gate', 'plan.arrival_gate'), + taxi: pickString(raw, 'destination.plan_taxi_route', 'destination.plan_taxiroute'), + star: pickString(raw, 'destination.plan_star', 'plan.plan_star'), + transition: pickString(raw, 'destination.plan_star_trans', 'destination.plan_star_transition', 'plan.star_transition'), + approach: pickString(raw, 'destination.plan_approach', 'plan.plan_approach') + } + + const initialAlt = pickNumber(raw, 'atc.initial_altitude', 'plan.initial_altitude', 'general.initial_altitude') + const climbAlt = pickNumber(raw, 'plan.cruise_altitude', 'general.cruise_altitude') + if (initialAlt || climbAlt) { + plan.altitudes = { + initial: initialAlt, + climb: climbAlt + } + } + + plan.squawk = pickString(raw, 'atc.squawk', 'general.squawk', 'plan.squawk') || undefined + plan.pushDelayMinutes = pickNumber(raw, 'times.taxi_out', 'general.taxi_out') + plan.remarks = pickString(raw, 'general.remarks', 'plan.remarks') || undefined + + return plan +} const hasSpokenTarget = ref(false) const pendingAutoSay = ref(false) const activeFrequency = ref(null) @@ -731,6 +1787,14 @@ const globalAccuracy = computed(() => { const audioContentHidden = computed(() => cfg.value.audioChallenge && !audioReveal.value) const audioSpeedDisplay = computed(() => (cfg.value.audioSpeed ?? 1).toFixed(2)) +const requiresFlightPlan = computed(() => Boolean(current.value?.meta?.flightPlan)) +const currentPlan = computed(() => (current.value ? missionPlans[current.value.id] ?? null : null)) +const currentPlanRoute = computed(() => scenarioRoute(currentPlan.value?.scenario || null)) +const currentPlanTagline = computed(() => scenarioTagline(currentPlan.value?.scenario || null)) +const draftPlanRoute = computed(() => scenarioRoute(draftPlanScenario.value)) +const draftPlanTagline = computed(() => scenarioTagline(draftPlanScenario.value)) +const briefingSnapshot = computed(() => buildBriefingSnapshot(draftPlanScenario.value)) +const currentBriefingArt = computed(() => current.value?.meta?.briefingArt || '/img/learn/missions/full-flight/briefing-hero.png') type LearnStateResponse = LearnState @@ -996,7 +2060,15 @@ const nextActionLabel = computed(() => { watch(activeLesson, lesson => { if (lesson) { - rollScenario(true) + if (moduleStage.value === 'lessons') { + rollScenario(true) + } + if (lessonTrack.value) { + void nextTick(() => { + const el = lessonTrack.value?.querySelector(`[data-lesson='${lesson.id}']`) + el?.scrollIntoView({behavior: 'smooth', block: 'nearest', inline: 'center'}) + }) + } } else { scenario.value = null } @@ -1165,19 +2237,48 @@ function isModuleUnlocked(id: string) { return previous ? pct(previous.id) >= 80 : true } -function openModule(id: string) { - current.value = modules.value.find(module => module.id === id) || null +function openModule(id: string, options: { autoStart?: boolean } = {}) { + const module = modules.value.find(item => item.id === id) || null + current.value = module + manualErrors.value = [] + flightPlanError.value = null + panel.value = module ? 'module' : 'hub' + pendingAutoStart.value = options.autoStart ?? false activeLesson.value = null - panel.value = 'module' + + if (!module) { + activeLesson.value = null + return + } + + if (!module.meta?.flightPlan) { + moduleStage.value = 'lessons' + seedFullFlightScenario(null) + startLessonsForCurrent() + pendingAutoStart.value = false + return + } + + const stored = missionPlans[module.id] + if (stored?.scenario) { + seedFullFlightScenario(cloneScenarioData(stored.scenario)) + moduleStage.value = 'lessons' + if (pendingAutoStart.value || !activeLesson.value) { + startLessonsForCurrent() + } + pendingAutoStart.value = false + return + } + + moduleStage.value = 'setup' + activeLesson.value = null + scenario.value = null + flightPlanMode.value = stored?.mode ?? 'random' + initSetupState(null) } function quickContinue(id: string) { - openModule(id) - if (!current.value) return - const mod = current.value - const prog = progress.value[mod.id] || {} - const next = mod.lessons.find(lesson => !(prog[lesson.id]?.done)) || mod.lessons[0] - activeLesson.value = next + openModule(id, {autoStart: true}) } function resumeMissionObjective() { @@ -2593,6 +3694,15 @@ onMounted(() => { margin-bottom: 10px } +.play-tools { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; + justify-content: flex-end; + min-height: 44px; +} + .crumbs { display: flex; align-items: center; @@ -2665,10 +3775,24 @@ onMounted(() => { margin-top: 0; } +.lesson-track { + overflow-x: auto; + padding-bottom: 12px; + margin: 0 -6px; + padding-left: 6px; + padding-right: 6px; + scroll-snap-type: x proximity; + mask-image: linear-gradient(to right, transparent, rgba(0, 0, 0, .9) 20%, rgba(0, 0, 0, .9) 80%, transparent); +} + .lesson-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); - gap: 10px + display: flex; + gap: 12px; + min-width: max-content; +} + +.lesson { + scroll-snap-align: center; } .lesson { @@ -3301,4 +4425,437 @@ onMounted(() => { } +.plan-status { + display: inline-flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + border: 1px solid color-mix(in srgb, var(--text) 12%, transparent); + border-radius: 14px; + background: color-mix(in srgb, var(--text) 5%, transparent); + box-shadow: 0 12px 24px rgba(2, 6, 23, .32); +} + +.plan-status-icon { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: 50%; + background: color-mix(in srgb, var(--accent) 20%, transparent); + color: var(--accent); +} + +.plan-status-body { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 160px; +} + +.plan-status-title { + font-weight: 600; + letter-spacing: .08em; + text-transform: uppercase; + font-size: 12px; +} + +.plan-status-sub { + font-size: 12px; + color: var(--t3); +} + +.plan-status.is-ready { + border-color: color-mix(in srgb, var(--accent) 30%, transparent); + background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 12%, transparent), color-mix(in srgb, var(--bg2) 80%, transparent)); +} + +.module-stage-panel { + margin-top: 24px; + border-radius: 24px; + border: 1px solid color-mix(in srgb, var(--text) 12%, transparent); + background: color-mix(in srgb, var(--text) 4%, transparent); + padding: 28px; + box-shadow: 0 28px 54px rgba(2, 6, 23, .5); + backdrop-filter: blur(18px); +} + +.mission-setup { + display: flex; + flex-direction: column; + gap: 24px; +} + +.setup-header { + display: flex; + flex-direction: column; + gap: 6px; +} + +.plan-modes { + display: flex; + flex-wrap: wrap; + gap: 14px; +} + +.plan-mode-card { + flex: 1 1 200px; + min-width: 220px; + border-radius: 18px; + border: 1px solid color-mix(in srgb, var(--text) 12%, transparent); + background: color-mix(in srgb, var(--text) 4%, transparent); + padding: 16px; + display: flex; + gap: 12px; + align-items: center; + transition: transform .2s ease, border-color .2s ease, box-shadow .2s ease; + box-shadow: 0 12px 32px rgba(2, 6, 23, .4); +} + +.plan-mode-card:hover, +.plan-mode-card:focus-visible { + transform: translateY(-2px); + border-color: color-mix(in srgb, var(--accent) 40%, transparent); + box-shadow: 0 16px 38px rgba(34, 211, 238, .25); + outline: none; +} + +.plan-mode-card.is-active { + border-color: color-mix(in srgb, var(--accent) 55%, transparent); + background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 16%, transparent), color-mix(in srgb, var(--bg2) 70%, transparent)); +} + +.plan-mode-icon { + width: 44px; + height: 44px; + border-radius: 14px; + display: grid; + place-items: center; + background: color-mix(in srgb, var(--accent) 20%, transparent); + color: var(--accent); +} + +.plan-mode-body { + display: flex; + flex-direction: column; + gap: 4px; +} + +.plan-mode-title { + font-weight: 600; + letter-spacing: .06em; + text-transform: uppercase; + font-size: 13px; +} + +.plan-panel { + border-radius: 20px; + border: 1px solid color-mix(in srgb, var(--text) 12%, transparent); + background: color-mix(in srgb, var(--text) 3%, transparent); + padding: 20px; + display: flex; + flex-direction: column; + gap: 18px; + box-shadow: 0 18px 42px rgba(2, 6, 23, .45); +} + +.plan-summary { + display: flex; + gap: 16px; + align-items: center; +} + +.plan-hero { + width: 128px; + height: 96px; + object-fit: cover; + border-radius: 16px; + border: 1px solid color-mix(in srgb, var(--text) 14%, transparent); + background: color-mix(in srgb, var(--text) 8%, transparent); +} + +.plan-summary-body { + display: flex; + flex-direction: column; + gap: 6px; +} + +.plan-callout { + font-size: 20px; + font-weight: 600; +} + +.plan-route { + font-size: 14px; + letter-spacing: .08em; + text-transform: uppercase; + color: var(--t3); +} + +.plan-tag { + font-size: 11px; + letter-spacing: .18em; + text-transform: uppercase; + color: var(--t3); +} + +.plan-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.plan-actions .btn { + flex: 1 1 200px; + justify-content: center; +} + +.manual-section { + display: flex; + flex-direction: column; + gap: 12px; +} + +.section-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + letter-spacing: .08em; + text-transform: uppercase; + font-size: 13px; +} + +.manual-panel .field-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.field-grid.compact { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); +} + +.manual-panel .field { + display: flex; + flex-direction: column; + gap: 6px; + background: color-mix(in srgb, var(--text) 4%, transparent); + border: 1px solid color-mix(in srgb, var(--text) 12%, transparent); + padding: 10px 12px; + border-radius: 12px; +} + +.manual-panel .field input { + background: transparent; + border: 0; + color: var(--text); + font-size: 14px; + font-weight: 500; +} + +.manual-panel .field span { + font-size: 12px; + letter-spacing: .08em; + text-transform: uppercase; + color: var(--t3); +} + +.form-errors { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px; + border-radius: 12px; + background: color-mix(in srgb, #f87171 16%, transparent); + border: 1px solid color-mix(in srgb, #f87171 40%, transparent); +} + +.form-error { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; +} + +.simbrief-panel { + display: flex; + flex-direction: column; + gap: 16px; +} + +.simbrief-callout { + display: flex; + gap: 12px; + align-items: center; +} + +.simbrief-meta { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + color: var(--t3); +} + +.loading-row { + display: inline-flex; + align-items: center; + gap: 10px; + color: var(--t3); + font-size: 13px; +} + +.error-banner { + display: flex; + gap: 8px; + align-items: center; + padding: 8px 10px; + border-radius: 12px; + background: color-mix(in srgb, #f87171 16%, transparent); + border: 1px solid color-mix(in srgb, #f87171 35%, transparent); + font-size: 13px; +} + +.mission-briefing { + display: flex; + flex-direction: column; + gap: 20px; +} + +.briefing-header { + display: flex; + gap: 16px; + align-items: center; + flex-wrap: wrap; +} + +.briefing-hero { + width: 180px; + height: 120px; + object-fit: cover; + border-radius: 18px; + border: 1px solid color-mix(in srgb, var(--text) 14%, transparent); + background: color-mix(in srgb, var(--text) 8%, transparent); +} + +.briefing-overview { + display: flex; + flex-direction: column; + gap: 8px; +} + +.briefing-title { + font-size: 22px; + font-weight: 600; +} + +.briefing-route { + font-size: 14px; + letter-spacing: .08em; + text-transform: uppercase; + color: var(--t3); +} + +.briefing-pills { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 999px; + background: color-mix(in srgb, var(--text) 6%, transparent); + border: 1px solid color-mix(in srgb, var(--text) 16%, transparent); + font-size: 12px; + letter-spacing: .06em; + text-transform: uppercase; +} + +.briefing-grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.briefing-card { + border-radius: 18px; + border: 1px solid color-mix(in srgb, var(--text) 12%, transparent); + background: color-mix(in srgb, var(--text) 4%, transparent); + padding: 16px; + display: flex; + flex-direction: column; + gap: 10px; + box-shadow: 0 18px 36px rgba(2, 6, 23, .45); +} + +.briefing-card-art { + width: 100%; + height: 96px; + object-fit: cover; + border-radius: 12px; + border: 1px solid color-mix(in srgb, var(--text) 14%, transparent); +} + +.card-title { + display: flex; + align-items: center; + gap: 6px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .08em; + font-size: 12px; +} + +.briefing-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; + color: var(--t3); +} + +.briefing-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: flex-end; +} + +.briefing-actions .btn { + flex: 1 1 200px; + justify-content: center; +} + +@media (max-width: 960px) { + .plan-mode-card { + min-width: 180px; + } + .plan-summary { + flex-direction: column; + align-items: flex-start; + } + .plan-hero { + width: 100%; + height: 140px; + } + .plan-actions .btn, + .briefing-actions .btn { + flex: 1 1 100%; + } + .briefing-header { + flex-direction: column; + align-items: flex-start; + } +} + diff --git a/public/img/learn/missions/full-flight/briefing-arrival.png b/public/img/learn/missions/full-flight/briefing-arrival.png new file mode 100644 index 0000000..45799b7 --- /dev/null +++ b/public/img/learn/missions/full-flight/briefing-arrival.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fa09610c1ef12eed0ca70878896f5873308ba1ab34b264d0d04d4eadafcfb89a +size 2146902 diff --git a/public/img/learn/missions/full-flight/briefing-departure.png b/public/img/learn/missions/full-flight/briefing-departure.png new file mode 100644 index 0000000..a378df8 --- /dev/null +++ b/public/img/learn/missions/full-flight/briefing-departure.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:40b406d3b31ded0eff41024e33b94d018e1449a0411ade3237e85b98002747ef +size 2249127 diff --git a/public/img/learn/missions/full-flight/briefing-hero.png b/public/img/learn/missions/full-flight/briefing-hero.png new file mode 100644 index 0000000..277ee85 --- /dev/null +++ b/public/img/learn/missions/full-flight/briefing-hero.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b0284350316cb391bec945b26d754c59fa378cd79b7d5d977c2498b8537121f2 +size 2005788 diff --git a/public/img/learn/missions/full-flight/briefing-route.png b/public/img/learn/missions/full-flight/briefing-route.png new file mode 100644 index 0000000..5070adf --- /dev/null +++ b/public/img/learn/missions/full-flight/briefing-route.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ac94774ef63657715069069446592220163ae318eef81163e8a1fb34f2c8302a +size 2186393 diff --git a/public/img/learn/missions/full-flight/briefing-weather.png b/public/img/learn/missions/full-flight/briefing-weather.png new file mode 100644 index 0000000..41e1233 --- /dev/null +++ b/public/img/learn/missions/full-flight/briefing-weather.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:188c73efbd184f9a0f1cf4726e0ec62ba61cc482fd2867f70346dd2e86c5bae9 +size 2260863 diff --git a/shared/data/learnModules.ts b/shared/data/learnModules.ts index dae8b0e..50f2773 100644 --- a/shared/data/learnModules.ts +++ b/shared/data/learnModules.ts @@ -1,5 +1,5 @@ import { createBaseScenario, createScenarioSeries, formatTemp } from '~~/shared/learn/scenario' -import type { ModuleDef } from '~~/shared/learn/types' +import type { ModuleDef, Scenario } from '~~/shared/learn/types' function gradientArt(colors: string[]): string { const stops = colors @@ -2194,6 +2194,18 @@ const decisionTreeLessons = [ const fullFlightScenario = createScenarioSeries() +export function seedFullFlightScenario(scenario: Scenario | null) { + if (scenario) { + fullFlightScenario.setScenario(scenario) + } else { + fullFlightScenario.setScenario(null) + } +} + +export function peekFullFlightScenario(): Scenario | null { + return fullFlightScenario.peek() +} + const makeFullFlightGenerator = (reset = false) => () => { if (reset) { fullFlightScenario.reset() @@ -2950,6 +2962,12 @@ export const learnModules: ModuleDef[] = [ id: 'full-flight', title: 'Full Flight · Gate to Gate', subtitle: 'One linked scenario from clearance to taxi-in', + art: '/img/learn/modules/img6.jpeg', + lessons: fullFlightLessons, + meta: { + flightPlan: true, + briefingArt: '/img/learn/missions/full-flight/briefing-hero.png' + } art: '/img/learn/modules/img12.jpeg', lessons: fullFlightLessons } diff --git a/shared/learn/scenario.ts b/shared/learn/scenario.ts index ed8800c..8adefac 100644 --- a/shared/learn/scenario.ts +++ b/shared/learn/scenario.ts @@ -198,7 +198,7 @@ function generateSquawk(): string { return code } -function digitsToWords(value: string): string { +export function digitsToWords(value: string): string { return value .split('') .map(char => atcNumberWords[char] ?? char) @@ -207,7 +207,7 @@ function digitsToWords(value: string): string { .trim() } -function lettersToNato(value: string): string { +export function lettersToNato(value: string): string { return value .toUpperCase() .split('') @@ -215,7 +215,7 @@ function lettersToNato(value: string): string { .join(' ') } -function runwayToWords(runway: string): string { +export function runwayToWords(runway: string): string { const digits = runway.replace(/[^0-9]/g, '').padStart(2, '0') const suffix = runway.replace(/[0-9]/g, '') const base = digits @@ -274,17 +274,17 @@ function visibilityToWords(vis: string): string { return vis } -function minutesToWords(minutes: number): string { +export function minutesToWords(minutes: number): string { const value = Math.max(1, Math.round(minutes)) const unit = value === 1 ? 'minute' : 'minutes' return `${digitsToWords(value.toString())} ${unit}` } -function speedToWords(speed: number): string { +export function speedToWords(speed: number): string { return `${digitsToWords(Math.round(speed).toString())} knots` } -function altitudeToWords(value: number): string { +export function altitudeToWords(value: number): string { const thousands = Math.floor(value / 1000) const remainder = value % 1000 let words = thousands ? `${digitsToWords(thousands.toString())} thousand` : '' @@ -491,18 +491,38 @@ function cloneScenario(scenario: Scenario): Scenario { return JSON.parse(JSON.stringify(scenario)) as Scenario } -export type ScenarioGenerator = (() => Scenario) & { reset: () => void } +export type ScenarioGenerator = (() => Scenario) & { + reset: () => void + setScenario: (scenario: Scenario | null) => void + peek: () => Scenario | null +} -export function createScenarioSeries(): ScenarioGenerator { +export function createScenarioSeries(source?: () => Scenario): ScenarioGenerator { let cached: Scenario | null = null - const generator = () => { + let override: Scenario | null = null + const resolveSource = () => { + if (override) { + return cloneScenario(override) + } + return source ? source() : createBaseScenario() + } + const generator = (() => { if (!cached) { - cached = createBaseScenario() + cached = resolveSource() } return cloneScenario(cached) - } + }) as ScenarioGenerator generator.reset = () => { - cached = null + cached = override ? cloneScenario(override) : null + } + generator.setScenario = (scenario: Scenario | null) => { + override = scenario ? cloneScenario(scenario) : null + cached = override ? cloneScenario(override) : null + } + generator.peek = () => { + if (cached) return cloneScenario(cached) + if (override) return cloneScenario(override) + return null } return generator } diff --git a/shared/learn/types.ts b/shared/learn/types.ts index e957b84..e5a3aad 100644 --- a/shared/learn/types.ts +++ b/shared/learn/types.ts @@ -161,10 +161,16 @@ export type Lesson = { generate: () => Scenario } +export type ModuleMeta = { + flightPlan?: boolean + briefingArt?: string +} + export type ModuleDef = { id: string title: string subtitle: string art: string lessons: Lesson[] + meta?: ModuleMeta }