diff --git a/public/img/simulator.jpg b/public/img/simulator.jpg deleted file mode 100644 index 3739d36..0000000 Binary files a/public/img/simulator.jpg and /dev/null differ diff --git a/server/api/atc/audio/[...path].get.ts b/server/api/atc/audio/[...path].get.ts deleted file mode 100644 index d846a76..0000000 --- a/server/api/atc/audio/[...path].get.ts +++ /dev/null @@ -1,110 +0,0 @@ -// server/api/atc/audio/[...path].get.ts -import { createError } from "h3"; -import { readFile, stat } from "node:fs/promises"; -import { join } from "node:path"; -import { existsSync } from "node:fs"; - -function outDir() { - const base = process.env.ATC_OUT_DIR?.trim() || join(process.cwd(), "storage", "atc"); - return base; -} - -export default defineEventHandler(async (event) => { - const path = getRouterParam(event, 'path'); - - if (!path || typeof path !== 'string') { - throw createError({ - statusCode: 400, - statusMessage: "Invalid path" - }); - } - - // Sicherheitscheck: verhindere Directory Traversal - if (path.includes('..') || path.includes('/./') || path.startsWith('/')) { - throw createError({ - statusCode: 400, - statusMessage: "Invalid path" - }); - } - - const filePath = join(outDir(), path); - - if (!existsSync(filePath)) { - throw createError({ - statusCode: 404, - statusMessage: "Audio file not found" - }); - } - - try { - const stats = await stat(filePath); - - if (!stats.isFile()) { - throw createError({ - statusCode: 400, - statusMessage: "Path is not a file" - }); - } - - // Nur Audio-Dateien servieren - const allowedExtensions = ['.wav', '.mp3', '.ogg']; - const hasValidExtension = allowedExtensions.some(ext => filePath.toLowerCase().endsWith(ext)); - - if (!hasValidExtension) { - throw createError({ - statusCode: 400, - statusMessage: "Not an audio file" - }); - } - - const fileBuffer = await readFile(filePath); - - // MIME Type basierend auf Dateiendung - let mimeType = 'audio/ogg'; - if (filePath.endsWith('.wav')) { - mimeType = 'audio/wav'; - } else if (filePath.endsWith('.mp3')) { - mimeType = 'audio/mpeg'; - } else if (filePath.endsWith('.ogg')) { - mimeType = 'audio/ogg; codecs=opus'; - } - - // HTTP Headers für Audio-Streaming - setHeader(event, 'Content-Type', mimeType); - setHeader(event, 'Content-Length', stats.size.toString()); - setHeader(event, 'Accept-Ranges', 'bytes'); - setHeader(event, 'Cache-Control', 'public, max-age=3600'); // 1 Stunde Cache - setHeader(event, 'Access-Control-Allow-Origin', '*'); - - // Range-Request Support für Audio-Seeking - const range = getHeader(event, 'range'); - if (range) { - const parts = range.replace(/bytes=/, "").split("-"); - const start = parseInt(parts[0], 10); - const end = parts[1] ? parseInt(parts[1], 10) : stats.size - 1; - - if (start >= stats.size || end >= stats.size) { - setResponseStatus(event, 416); // Range Not Satisfiable - setHeader(event, 'Content-Range', `bytes */${stats.size}`); - return ''; - } - - const chunkSize = (end - start) + 1; - const chunk = fileBuffer.slice(start, end + 1); - - setResponseStatus(event, 206); // Partial Content - setHeader(event, 'Content-Range', `bytes ${start}-${end}/${stats.size}`); - setHeader(event, 'Content-Length', chunkSize.toString()); - - return chunk; - } - - return fileBuffer; - - } catch (error) { - throw createError({ - statusCode: 500, - statusMessage: `Failed to serve audio file: ${error}` - }); - } -}); diff --git a/server/api/atc/generate.post.ts b/server/api/atc/generate.post.ts deleted file mode 100644 index d460e45..0000000 --- a/server/api/atc/generate.post.ts +++ /dev/null @@ -1,96 +0,0 @@ -// server/api/atc/generate.post.ts -import { createError, readBody } from "h3"; -import { generateATCPhrase, getRandomPhraseForLesson, getPhrasesForLesson } from "../../utils/atcPhrases"; -import { normalizeATC } from "../../utils/openaiOld"; - -export default defineEventHandler(async (event) => { - const body = await readBody<{ - moduleId: string; - lessonId: string; - phraseId?: string; - customVariables?: Record; - type?: 'instruction' | 'clearance' | 'information' | 'request'; - count?: number; - }>(event); - - const { moduleId, lessonId, phraseId, customVariables, type, count = 1 } = body; - - if (!moduleId || !lessonId) { - throw createError({ - statusCode: 400, - statusMessage: "moduleId and lessonId are required" - }); - } - - try { - let phrases: string[] = []; - - if (phraseId) { - // Spezifische Phrase generieren - const phrase = generateATCPhrase(phraseId, customVariables); - phrases.push(phrase); - } else { - // Zufällige Phrasen für das Modul/Lektion generieren - const availablePhrases = getPhrasesForLesson(moduleId, lessonId); - - if (availablePhrases.length === 0) { - throw createError({ - statusCode: 404, - statusMessage: `No phrases found for module "${moduleId}", lesson "${lessonId}"` - }); - } - - // Filter nach Typ wenn angegeben - const filteredPhrases = type - ? availablePhrases.filter(p => p.type === type) - : availablePhrases; - - if (filteredPhrases.length === 0) { - throw createError({ - statusCode: 404, - statusMessage: `No phrases of type "${type}" found for module "${moduleId}", lesson "${lessonId}"` - }); - } - - // Generiere die gewünschte Anzahl von Phrasen - for (let i = 0; i < Math.min(count, 10); i++) { // Max 10 Phrasen pro Request - const randomPhrase = filteredPhrases[Math.floor(Math.random() * filteredPhrases.length)]; - const generated = generateATCPhrase(randomPhrase.id, customVariables); - phrases.push(generated); - } - } - - // Normalisiere alle Phrasen für TTS - const normalizedPhrases = phrases.map(phrase => ({ - original: phrase, - normalized: normalizeATC(phrase), - length: phrase.length - })); - - return { - success: true, - moduleId, - lessonId, - type: type || 'any', - count: phrases.length, - phrases: normalizedPhrases, - availableTypes: getPhrasesForLesson(moduleId, lessonId) - .map(p => p.type) - .filter((type, index, arr) => arr.indexOf(type) === index), // Unique types - meta: { - totalAvailablePhrases: getPhrasesForLesson(moduleId, lessonId).length, - generatedAt: new Date().toISOString() - } - }; - - } catch (error) { - if (error.statusCode) { - throw error; // Re-throw HTTP errors - } - - throw createError({ - statusCode: 500, - statusMessage: `Phrase generation failed: ${error}` - }); - } -}); diff --git a/server/api/atc/say.post.ts b/server/api/atc/say.post.ts index bfc9dcb..13dc9e0 100644 --- a/server/api/atc/say.post.ts +++ b/server/api/atc/say.post.ts @@ -4,7 +4,7 @@ import {writeFile, mkdir} from "node:fs/promises"; import {existsSync} from "node:fs"; import {join} from "node:path"; import {randomUUID} from "node:crypto"; -import {openaiOld, TTS_MODEL, normalizeATC} from "../../utils/openaiOld"; +import {normalize, TTS_MODEL, normalizeATC} from "../../utils/normalize"; import {request} from "node:http"; // dotenv config @@ -100,7 +100,7 @@ export default defineEventHandler(async (event) => { audioBuffer = await piperTTS(normalized, voice); } else { // --- OpenAI Fallback --- - const tts = await openaiOld.audio.speech.create({ + const tts = await normalize.audio.speech.create({ model: TTS_MODEL, voice, format: "wav", @@ -110,7 +110,7 @@ export default defineEventHandler(async (event) => { audioBuffer = Buffer.from(await tts.arrayBuffer()); } - await ensureDir(baseDir); + // await ensureDir(baseDir); // await writeFile(fileWav, audioBuffer); const meta = { @@ -130,7 +130,7 @@ export default defineEventHandler(async (event) => { format: "audio/wav" }; - await writeFile(fileJson, JSON.stringify(meta, null, 2), "utf-8"); + // await writeFile(fileJson, JSON.stringify(meta, null, 2), "utf-8"); return { success: true, diff --git a/server/utils/normalize.ts b/server/utils/normalize.ts new file mode 100644 index 0000000..b1da124 --- /dev/null +++ b/server/utils/normalize.ts @@ -0,0 +1,294 @@ +// yarn add openai dotenv +import OpenAI from "openai"; +import dotenv from "dotenv"; +import fs from "node:fs"; + +dotenv.config(); + +export const normalize = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY!, + project: process.env.OPENAI_PROJECT, // optional +}); + +export const LLM_MODEL = process.env.LLM_MODEL || "gpt-5-nano"; +export const TTS_MODEL = process.env.TTS_MODEL || "tts-1"; + +/* ========================= + LLM PROMPTS (überarbeitet) + ========================= + Ziel: LLM liefert kompakte, maschinenfreundliche ICAO-Zeile, die unser Normalizer→TTS perfekt erweitert. + WICHTIG: Zahlen/Marker exakt im unten definierten Output-Format, keine ausgeschriebenen Wörter. +*/ + +export const ATC_OUTPUT_SPEC = ` +OUTPUT RULES (STRICT): +- ONE instruction only. No chit-chat. No readback. No explanations. +- English ICAO phraseology; keep it concise. +- Use the following tokens exactly; numbers as digits: + * Callsign: AAA123[Letter] (e.g., DLH359, BAW12A). + * Runway: "RWY" + two digits + optional L/C/R (e.g., RWY 25R, RWY 08L). + * Heading: "HDG" + 3 digits (e.g., HDG 270). + * Flight level: "FL" + 2–3 digits (e.g., FL120, FL90). + * Altitude (feet): " ft" (e.g., 2000 ft, 5000 ft). + * Squawk: "squawk" + 4 digits (e.g., squawk 4723). + * QNH: "QNH" + 3–4 digits (e.g., QNH 1013). + * Frequency: 3 digits "." 3 digits (e.g., 112.955, 121.800). Use 3+3 format. + * ICAO airport: 4 capital letters (e.g., EDDF, EHAM). + * Taxi: "via" + space-separated TWY designators (e.g., via A3 A N2). +- Separate segments by ". " (period + space). Keep line to <= 220 chars. +- Only include items relevant to current phase (clearance/taxi/line-up/dep/approach/landing). +- If you must give a contact instruction: "Contact ." +- Use standard order for the phase (e.g., taxi: destination RWY first, then route, then hold short). +`.trim(); + +/** System-Prompt: legt Rolle/Regeln fest */ +export function atcSystemPrompt(opts?: { + regionHint?: "EUR" | "US" | "INTL"; // nur als Soft-Hinweis, default INTL +}) { + const region = opts?.regionHint ?? "INTL"; + return [ + `You are an ICAO-compliant ATC controller for ${region}.`, + `Adhere to standard phraseology, brevity, and safety-critical ordering.`, + `Assume you have access to current airport config (active runway, SIDs), unless contradicted by user context.`, + ATC_OUTPUT_SPEC, + ].join("\n\n"); +} + +/** Seed-ATC ohne Pilot-Input (rückwärtskompatible Signatur, aber reicherer Prompt) */ +export function atcSeedPrompt(s: { + airport: string; // e.g., "EDDF" + aircraft: string; // e.g., "A320" + type: string; // e.g., "IFR" + stand: string; // e.g., "V155" + dep: string; // destination ICAO, e.g., "EHAM" + sid?: string; // e.g., "MARUN 7F" + squawk?: string; // "4723" + freq?: string; // "121.800" + runway?: string; // "25R" (optional: falls bekannt) + phase?: "clearance" | "taxi" | "lineup" | "departure" | "handoff" | "approach" | "landing"; + notes?: string; // z.B. "TWY N closed between N2–N4" +}) { + // Default-Phase: clearance + const phase = s.phase || "clearance"; + const ctx = [ + `Airport ${s.airport}`, + `${s.aircraft} ${s.type} at stand ${s.stand}`, + `IFR departure ${s.dep}`, + s.runway ? `planned RWY ${s.runway}` : null, + s.sid ? `planned SID ${s.sid}` : null, + s.squawk ? `preassigned squawk ${s.squawk}` : null, + s.freq ? `next frequency ${s.freq}` : null, + s.notes ? `NOTAM/ATC notes: ${s.notes}` : null, + ].filter(Boolean).join(", "); + + const need = phase === "clearance" + ? "Issue an IFR clearance (route/SID if given, initial altitude, squawk, and current QNH if applicable)." + : phase === "taxi" + ? "Issue a taxi instruction to the departure RWY with a realistic taxi route and 'hold short'." + : phase === "lineup" + ? "Issue line-up and wait (or immediate takeoff if appropriate)." + : phase === "departure" + ? "Issue initial heading/speed/altitude or 'climb via SID' as appropriate." + : phase === "handoff" + ? "Issue handoff to next frequency." + : phase === "approach" + ? "Issue approach clearance with runway, altitude/FL, QNH if appropriate." + : "Issue landing clearance with runway and any exit/roll-out instructions."; + + return [ + `Generate ONE realistic ICAO ATC instruction in English. No extra commentary.`, + `Context: ${ctx}`, + `Phase: ${phase}`, + `Task: ${need}`, + ATC_OUTPUT_SPEC, + ].join("\n"); +} + +/** Pilot→ATC (rückwärtskompatibler Name, aber mit robustem Rahmen) */ +export function atcReplyPrompt(userText: string, state?: { + airport?: string; runway?: string; sid?: string; dep?: string; + lastSquawk?: string; lastFreq?: string; lastQNH?: string; + phase?: "clearance" | "taxi" | "lineup" | "departure" | "handoff" | "approach" | "landing"; + constraints?: string; // z.B. "TWY N closed", "no intersection deps on 25C" +}) { + const ctx = [ + state?.airport ? `Airport ${state.airport}` : null, + state?.runway ? `Active RWY ${state.runway}` : null, + state?.dep ? `Destination ${state.dep}` : null, + state?.sid ? `SID ${state.sid}` : null, + state?.lastSquawk ? `Last squawk ${state.lastSquawk}` : null, + state?.lastFreq ? `Next/last freq ${state.lastFreq}` : null, + state?.lastQNH ? `QNH ${state.lastQNH}` : null, + state?.constraints ? `Constraints: ${state.constraints}` : null, + ].filter(Boolean).join(", "); + + const phase = state?.phase ?? "clearance"; + + return [ + `You are an ICAO-compliant ATC controller. Reply concisely in standard phraseology.`, + ctx ? `Context: ${ctx}` : null, + `Pilot said: "${userText}"`, + `Phase: ${phase}`, + `Respond with ONE instruction following the rules.`, + ATC_OUTPUT_SPEC, + ].filter(Boolean).join("\n"); +} + +/* ========================= + Normalizer → TTS (wie zuvor) + ========================= */ + +const DIGIT: Record = { + "0": "zero", "1": "wun", "2": "too", "3": "tree", "4": "fower", + "5": "fife", "6": "six", "7": "seven", "8": "eight", "9": "niner", +}; + +const NATO: Record = { + A:"Alfa",B:"Bravo",C:"Charlie",D:"Delta",E:"Echo",F:"Foxtrot",G:"Golf",H:"Hotel", + I:"India",J:"Juliett",K:"Kilo",L:"Lima",M:"Mike",N:"November",O:"Oscar",P:"Papa", + Q:"Quebec",R:"Romeo",S:"Sierra",T:"Tango",U:"Uniform",V:"Victor",W:"Whiskey", + X:"X-ray",Y:"Yankee",Z:"Zulu" +}; + +// Airline-Telephony (erweiterbar) +export const CALLSIGN_MAP: Record = { + DLH: "Lufthansa", + BAW: "Speedbird", + AFR: "Air France", + KLM: "KLM", + AAL: "American", + UAL: "United", + DAL: "Delta", + RYR: "Ryanair", + EZY: "Easy", +}; + +const spellDigits = (s: string) => + s.split("").map(ch => DIGIT[ch] ?? ch).join(" "); + +const toNato = (s: string) => + s.toUpperCase().split("").map(ch => NATO[ch] ?? ch).join("-"); + +const runwaySpeak = (rw: string) => { + const m = rw.match(/^(\d{2})([LCR])?$/i); + if (!m) return rw; + const num = spellDigits(m[1]); + const side = m[2]?.toUpperCase() === "L" ? "left" + : m[2]?.toUpperCase() === "C" ? "center" + : m[2]?.toUpperCase() === "R" ? "right" : ""; + return `runway ${num}${side ? " " + side : ""}`; +}; + +const headingSpeak = (hdg: string) => `heading ${spellDigits(hdg.padStart(3, "0"))}`; +const squawkSpeak = (code: string) => `squawk ${spellDigits(code)}`; + +const freqSpeak = (f: string) => { + const [a,b] = f.split("."); + const left = spellDigits(a); + const right = b ? spellDigits(b) : ""; + return `${left}${b ? " decimal " + right : ""}`; +}; + +const altitudeSpeak = (ft: number) => { + if (!Number.isFinite(ft)) return `${ft} feet`; + const thousands = Math.floor(ft/1000); + const hundreds = Math.round((ft % 1000)/100)*100; + const parts: string[] = []; + if (thousands) parts.push(`${spellDigits(String(thousands))} thousand`); + if (hundreds) { + const h = hundreds === 900 ? "nine hundred" + : hundreds === 800 ? "eight hundred" + : hundreds === 700 ? "seven hundred" + : hundreds === 600 ? "six hundred" + : hundreds === 500 ? "five hundred" + : hundreds === 400 ? "fower hundred" + : hundreds === 300 ? "tree hundred" + : hundreds === 200 ? "too hundred" + : hundreds === 100 ? "wun hundred" + : spellDigits(String(hundreds)); + parts.push(h); + } + return `${parts.join(" ")} feet`.trim(); +}; + +const flightLevelSpeak = (fl: string) => + `flight level ${spellDigits(fl.replace(/^0+/, ""))}`; + +const qnhSpeak = (q: string) => `QNH ${spellDigits(q)}`; + +const callsignSpeak = (raw: string, map: Record) => { + const up = raw.toUpperCase(); + const m = up.match(/^([A-Z]{2,3})(\d{1,4}[A-Z]?)$/); + if (!m) return raw; + const telephony = map[m[1]] ?? toNato(m[1]).replace(/-/g," "); + const suffix = spellDigits(m[2].replace(/[A-Z]$/, (l) => " " + (NATO[l] ?? l))); + return `${telephony} ${suffix}`; +}; + +const icaoAirportSpeak = (code: string) => + /^[A-Z]{4}$/.test(code) ? toNato(code) : code; + +// Public Normalizer +export function normalizeATC( + text: string, + opts?: { airlineMap?: Record; } +) { + let out = text; + + out = out.replace(/\b(\d{3})\.(\d{3})\b/g, (_,a,b)=> `${freqSpeak(`${a}.${b}`)}`); + out = out.replace(/\b(?:HDG|heading)\s*(\d{2,3})\b/gi, (_,h)=> headingSpeak(h)); + out = out.replace(/\b(?:RWY|runway)\s*(\d{2}[LCR]?)\b/gi, (_,rw)=> runwaySpeak(rw)); + out = out.replace(/\b(?:squawk|code)\s*(\d{4})\b/gi, (_,c)=> squawkSpeak(c)); + out = out.replace(/\bFL\s*(\d{2,3})\b/gi, (_,fl)=> flightLevelSpeak(fl)); + out = out.replace(/\b(\d{3,5})\s*(?:ft|feet)\b/gi, (_,ft)=> altitudeSpeak(Number(ft))); + out = out.replace(/\bQNH\s*(\d{3,4})\b/gi, (_,q)=> qnhSpeak(q)); + out = out.replace(/\b([A-Z]{4})\b/g, (_,code)=> icaoAirportSpeak(code)); + out = out.replace(/\b([A-Z]{2,3}\d{1,4}[A-Z]?)\b/g, (m)=> callsignSpeak(m, opts?.airlineMap ?? CALLSIGN_MAP)); + + return out.replace(/\s+/g," ").trim(); +} + +// TTS Wrapper (mp3) +export async function speakATC(text: string, filePath = "atc.mp3") { + const input = normalizeATC(text); + const resp = await (normalize as any).audio.speech.create({ + model: TTS_MODEL, + voice: "alloy", + input, + format: "mp3", + }); + const buf = Buffer.from(await resp.arrayBuffer()); + fs.writeFileSync(filePath, buf); + return { filePath, spoken: input }; +} + +/* ========================= + Beispiele + ========================= + +— Seed (Clearance): +const sys = atcSystemPrompt(); +const usr = atcSeedPrompt({ + airport: "EDDF", aircraft: "A320", type: "IFR", stand: "V155", + dep: "EHAM", sid: "MARUN 7F", runway: "25R", freq: "121.800" +}); +// → LLM antwortet z.B.: +// "DLH359, cleared to EHAM via MARUN 7F, initial 5000 ft, squawk 4723. QNH 1013." + +— Taxi: +const usrTaxi = atcSeedPrompt({ + airport: "EDDF", aircraft: "A320", type: "IFR", stand: "V155", + dep: "EHAM", runway: "25R", phase: "taxi", + notes: "TWY N closed between N2–N4" +}); +// → "DLH359, taxi to RWY 25R via A3 A N2, hold short." + +— Pilot→ATC: +const usrReply = atcReplyPrompt( + "DLH359 ready for departure RWY 25R", + { airport: "EDDF", runway: "25R", phase: "lineup", lastFreq: "121.800" } +); +// → "DLH359, line up and wait RWY 25R." + +Nach dem LLM-Output: `speakATC(llmText)` ruft Normalizer→TTS. +*/ diff --git a/shared/data/learnModules.ts b/shared/data/learnModules.ts new file mode 100644 index 0000000..42c15f2 --- /dev/null +++ b/shared/data/learnModules.ts @@ -0,0 +1,130 @@ +import {ref} from "vue"; + +/** MODULES **/ +export type Lesson = { id: string; title: string; desc: string; target: string; hints: string[]; keywords: string[] } +export type ModuleDef = { id: string; title: string; subtitle: string; art: string; lessons: Lesson[] } + +const modules = ref([ + // NEW: ICAO Kapitel + { + id: 'icao', + title: 'ICAO Alphabet', + subtitle: 'Alphabets & Numbers', + art: 'https://images.unsplash.com/photo-1488085061387-422e29b40080?q=80&w=1600&auto=format&fit=crop', + lessons: [ + { + id: 'alpha', + title: 'Alphabet A–M', + desc: 'Alpha bis Mike sprechen.', + target: 'Alpha Bravo Charlie Delta Echo Foxtrot Golf Hotel India Juliett Kilo Lima Mike.', + hints: ['konstant sprechen', 'deutlich trennen'], + keywords: ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf', 'Hotel', 'India', 'Juliett', 'Kilo', 'Lima', 'Mike'] + }, + { + id: 'alpha2', + title: 'Alphabet N–Z', + desc: 'November bis Zulu.', + target: 'November Oscar Papa Quebec Romeo Sierra Tango Uniform Victor Whiskey X-ray Yankee Zulu.', + hints: ['X-ray mit hyphen', 'Juliett mit zwei t'], + keywords: ['November', 'Oscar', 'Papa', 'Quebec', 'Romeo', 'Sierra', 'Tango', 'Uniform', 'Victor', 'Whiskey', 'X-ray', 'Yankee', 'Zulu'] + }, + { + id: 'numbers', + title: 'Zahlen', + desc: 'ICAO-Nummernlesen.', + target: 'Tree Fower Fife Six Seven Eight Niner Zero.', + hints: ['Nine → Niner', 'Three → Tree', 'Four → Fower', 'Five → Fife'], + keywords: ['Tree', 'Fower', 'Fife', 'Niner'] + }, + { + id: 'callsign-icao', + title: 'Callsign Buchstabieren', + desc: 'Beispiel-Callsign.', + target: 'DLH one two three, Lufthansa one two three.', + hints: ['DLH → Lufthansa', 'Nummern ICAO'], + keywords: ['Lufthansa', 'DLH', 'one', 'two', 'three'] + } + ] + }, + { + id: 'basics', + title: 'Basics', + subtitle: 'Callsign · Struktur · Zahlen', + art: 'https://images.unsplash.com/photo-1541392822270-85b2ff6c4577?q=80&w=1600&auto=format&fit=crop', + lessons: [ + { + id: 'checkin', + title: 'Check-in', + desc: 'Erster Call korrekt.', + target: 'Frankfurt Ground, Lufthansa one two three at stand A12, request taxi.', + hints: ['Station • Callsign • Position • Intent'], + keywords: ['Frankfurt Ground', 'Lufthansa', 'stand', 'request taxi'] + }, + { + id: 'readback', title: 'Short Readback', desc: 'Kurz bestätigen.', + target: 'Lufthansa one two three, roger.', + hints: ['Callsign + roger/affirm'], keywords: ['roger', 'affirm'] + } + ] + }, + { + id: 'ground', + title: 'Ground', + subtitle: 'Taxi • Hold Short • Handoff', + art: 'https://images.unsplash.com/photo-1523961131990-5ea7c61b2107?q=80&w=1600&auto=format&fit=crop', + lessons: [ + { + id: 'taxi1', title: 'Taxi-Clearance', desc: 'Via A, A5, B2.', + target: 'Lufthansa one two three, taxi to runway two five via A, A five, B two, hold short runway two five.', + hints: ['Taxi to runway • via • hold short'], keywords: ['taxi to runway', 'via', 'hold short'] + }, + { + id: 'handoff', title: 'Handoff', desc: 'Frequenzwechsel.', + target: 'Contact Tower on one one niner decimal five, Lufthansa one two three.', + hints: ['Contact Tower on … • decimal'], keywords: ['Contact Tower', 'decimal'] + } + ] + }, + { + id: 'departure', + title: 'Departure', + subtitle: 'Line up • Takeoff', + art: 'https://images.unsplash.com/photo-1494412685616-a5d310fbb07d?q=80&w=1600&auto=format&fit=crop', + lessons: [ + { + id: 'lineup', title: 'Line up', desc: 'Aufrollen und warten.', + target: 'Lufthansa one two three, line up and wait runway two five.', + hints: ['line up and wait'], keywords: ['line up and wait'] + } + ] + }, + { + id: 'arrival', + title: 'Arrival', + subtitle: 'Approach • Vacate', + art: 'https://images.unsplash.com/photo-1542089363-07b2d92aacc3?q=80&w=1600&auto=format&fit=crop', + lessons: [ + { + id: 'vacate', title: 'Vacate', desc: 'Verlasse Bahn, melde frei.', + target: 'Lufthansa one two three, vacated runway two five via A six.', + hints: ['vacated runway • via taxiway'], keywords: ['vacated', 'runway'] + } + ] + }, + { + id: 'vatsim', + title: 'VATSIM', + subtitle: 'Netiquette • Connect', + art: 'https://images.unsplash.com/photo-1508264769638-658b34d79f6e?q=80&w=1600&auto=format&fit=crop', + lessons: [ + { + id: 'checkin', title: 'IFR Check-in', desc: 'Erster Online-Call.', + target: 'Frankfurt Ground, Lufthansa one two three, A320 at stand A12, IFR to Munich, information Bravo, request clearance.', + hints: ['IFR/VFR • ATIS Info • Request'], keywords: ['IFR', 'information', 'request clearance'] + } + ] + } +]) + +export default modules + diff --git a/shared/utils/communicationsEngine.ts b/shared/utils/communicationsEngine.ts index e4aeb0d..1e4c0c2 100644 --- a/shared/utils/communicationsEngine.ts +++ b/shared/utils/communicationsEngine.ts @@ -1,6 +1,6 @@ // composables/communicationsEngine.ts import { ref, computed, readonly } from 'vue' -import atcDecisionTree from "./atcDecisionTree"; +import atcDecisionTree from "../data/atcDecisionTree"; // --- DecisionTree-Types (aus ~/data/atcDecisionTree.json abgeleitet) --- type Role = 'pilot' | 'atc' | 'system'