From 71d148e156a811667640f8bc4e5b94e483716645 Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Fri, 10 Jul 2026 15:45:11 +0200 Subject: [PATCH] refactor(live-atc): move scenario catalogue to shared/constants/scenarios.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SCENARIOS, CHAIN_DEFS and the Scenario type are pure data with no reactive inputs, but the session controller needs them — leaving them in the page would mean threading the catalogue through as a dependency. drillScenarios/chainGroups were computed() over module constants only, so they can never invalidate; they become plain DRILL_SCENARIOS/CHAIN_GROUPS consts, evaluated once at module load. Verified the derived shape is unchanged: 15 scenarios, 2 drills, 3 chains with 4/4/3 segments. Co-Authored-By: Claude Opus 4.8 --- app/pages/live-atc.vue | 236 +--------------------------------- shared/constants/scenarios.ts | 232 +++++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+), 232 deletions(-) create mode 100644 shared/constants/scenarios.ts diff --git a/app/pages/live-atc.vue b/app/pages/live-atc.vue index f84539c..bb68168 100644 --- a/app/pages/live-atc.vue +++ b/app/pages/live-atc.vue @@ -152,7 +152,7 @@ -
+

{{ grp.category }}

@@ -200,12 +200,12 @@
-
+

Drills

DRILL_IDS - .map(id => SCENARIOS.find(s => s.id === id)) - .filter((s): s is Scenario => !!s)) - -const chainGroups = computed(() => { - const byCategory = new Map>() - for (const def of CHAIN_DEFS) { - const complete = SCENARIOS.find(s => s.id === def.completeId) - if (!complete) continue - const segments = def.segmentIds - .map(id => SCENARIOS.find(s => s.id === id)) - .filter((s): s is Scenario => !!s) - if (!byCategory.has(def.category)) byCategory.set(def.category, []) - byCategory.get(def.category)!.push({ id: def.completeId, complete, segments }) - } - return Array.from(byCategory, ([category, chains]) => ({ category, chains })) -}) - /** The scenario the user just finished (used on the completion screen). */ const completedScenario = ref(null) /** The scenario currently being flown. */ diff --git a/shared/constants/scenarios.ts b/shared/constants/scenarios.ts new file mode 100644 index 0000000..b872bdf --- /dev/null +++ b/shared/constants/scenarios.ts @@ -0,0 +1,232 @@ +/** + * Scenario catalogue for /live-atc: the flows a pilot can fly, and how the + * single-phase practice scenarios map onto the complete journey chains. + */ + +export interface Scenario { + id: string + name: string + subtitle: string + icon: string + startFlow: string + /** Display-only chain label, e.g. "Clearance → Taxi → Tower → Departure" */ + chain: string + isComplete: boolean + /** When true, backend will NOT follow next_flow links (single-phase practice). */ + noChain: boolean + /** + * Which airport from the flight plan to use for frequency lookups. + * 'dep' = departure airport, 'arr' = arrival/destination airport. + */ + airport: 'dep' | 'arr' +} + +export const SCENARIOS: Scenario[] = [ + // ── Complete chains ────────────────────────────────────────────────────── + { + id: 'ifr-departure', + name: 'IFR Departure', + subtitle: 'Clearance → Taxi → Tower → Departure', + chain: 'clearance-v1 → taxi-v1 → tower-v1 → departure-v1', + icon: 'mdi-airplane-takeoff', + startFlow: 'clearance-v1', + isComplete: true, + noChain: false, + airport: 'dep', + }, + { + id: 'vfr-arrival', + name: 'VFR Arrival', + subtitle: 'Approach → Circuit → Landing → Taxi-in', + chain: 'vfr-arrival-v1 → vfr-circuit-landing-v1 → taxi-in-v1', + icon: 'mdi-airplane-landing', + startFlow: 'vfr-arrival-v1', + isComplete: true, + noChain: false, + airport: 'arr', + }, + { + id: 'ifr-arrival', + name: 'IFR Arrival', + subtitle: 'Enroute → Approach → Landing → Taxi-in', + chain: 'ifr-enroute-arrival-v1 → ifr-arrival-v1 → ifr-tower-landing-v1 → taxi-in-v1', + icon: 'mdi-airplane-landing', + startFlow: 'ifr-enroute-arrival-v1', + isComplete: true, + noChain: false, + airport: 'arr', + }, + // ── Individual phases ──────────────────────────────────────────────────── + { + id: 'clearance', + name: 'Clearance', + subtitle: 'Request IFR clearance', + chain: 'clearance-v1', + icon: 'mdi-file-document-outline', + startFlow: 'clearance-v1', + isComplete: false, + noChain: true, + airport: 'dep', + }, + { + id: 'taxi', + name: 'Startup & Taxi', + subtitle: 'Startup → Pushback → Taxi', + chain: 'taxi-v1', + icon: 'mdi-car-side', + startFlow: 'taxi-v1', + isComplete: false, + noChain: true, + airport: 'dep', + }, + { + id: 'tower', + name: 'Tower', + subtitle: 'Line-up and takeoff', + chain: 'tower-v1', + icon: 'mdi-tower-fire', + startFlow: 'tower-v1', + isComplete: false, + noChain: true, + airport: 'dep', + }, + { + id: 'departure', + name: 'Departure', + subtitle: 'Initial climb check-in', + chain: 'departure-v1', + icon: 'mdi-radar', + startFlow: 'departure-v1', + isComplete: false, + noChain: true, + airport: 'dep', + }, + { + id: 'vfr-approach', + name: 'VFR Approach', + subtitle: 'Initial contact & joining', + chain: 'vfr-arrival-v1', + icon: 'mdi-binoculars', + startFlow: 'vfr-arrival-v1', + isComplete: false, + noChain: true, + airport: 'arr', + }, + { + id: 'circuit-landing', + name: 'Circuit & Landing', + subtitle: 'Traffic circuit and landing', + chain: 'vfr-circuit-landing-v1', + icon: 'mdi-airport', + startFlow: 'vfr-circuit-landing-v1', + isComplete: false, + noChain: true, + airport: 'arr', + }, + { + id: 'ifr-enroute', + name: 'Enroute Descent', + subtitle: 'Area Control stepped descent', + chain: 'ifr-enroute-arrival-v1', + icon: 'mdi-arrow-down-bold-circle-outline', + startFlow: 'ifr-enroute-arrival-v1', + isComplete: false, + noChain: true, + airport: 'arr', + }, + { + id: 'ifr-approach', + name: 'IFR Approach', + subtitle: 'STAR, vectors & ILS', + chain: 'ifr-arrival-v1', + icon: 'mdi-radar', + startFlow: 'ifr-arrival-v1', + isComplete: false, + noChain: true, + airport: 'arr', + }, + { + id: 'ifr-landing', + name: 'IFR Landing', + subtitle: 'Established, land and vacate', + chain: 'ifr-tower-landing-v1', + icon: 'mdi-airport', + startFlow: 'ifr-tower-landing-v1', + isComplete: false, + noChain: true, + airport: 'arr', + }, + { + id: 'taxi-in', + name: 'Taxi-in', + subtitle: 'Post-landing ground movement', + chain: 'taxi-in-v1', + icon: 'mdi-parking', + startFlow: 'taxi-in-v1', + isComplete: false, + noChain: true, + airport: 'arr', + }, + // ── Drills (standalone, not part of a journey chain) ───────────────────── + { + id: 'rto', + name: 'Rejected Take-Off', + subtitle: 'Tower cancels takeoff — reject and stop', + chain: 'rto-v1', + icon: 'mdi-airplane-alert', + startFlow: 'rto-v1', + isComplete: false, + noChain: true, + airport: 'dep', + }, + { + id: 'info-arrival', + name: 'Uncontrolled Field (Info)', + subtitle: 'Self-announce arrival, no clearances', + chain: 'info-arrival-v1', + icon: 'mdi-account-voice', + startFlow: 'info-arrival-v1', + isComplete: false, + noChain: true, + airport: 'arr', + }, +] + +// --------------------------------------------------------------------------- +// Chooser layout: each complete chain is shown as a flow of its phases, so it's +// clear which single-phase practice maps to which segment of which journey. +// --------------------------------------------------------------------------- +export interface ChainDef { + category: 'Departure' | 'Arrival' + completeId: string // the isComplete scenario that flies the whole chain + segmentIds: string[] // individual-phase scenario ids, in order +} + +export const CHAIN_DEFS: ChainDef[] = [ + { category: 'Departure', completeId: 'ifr-departure', segmentIds: ['clearance', 'taxi', 'tower', 'departure'] }, + { category: 'Arrival', completeId: 'ifr-arrival', segmentIds: ['ifr-enroute', 'ifr-approach', 'ifr-landing', 'taxi-in'] }, + { category: 'Arrival', completeId: 'vfr-arrival', segmentIds: ['vfr-approach', 'circuit-landing', 'taxi-in'] }, +] + +// Standalone drills shown in their own section, separate from the journey chains. +export const DRILL_IDS = ['rto', 'info-arrival'] +/** Standalone drills, resolved once — the source arrays are module constants. */ +export const DRILL_SCENARIOS: Scenario[] = DRILL_IDS + .map(id => SCENARIOS.find(s => s.id === id)) + .filter((s): s is Scenario => !!s) + +function buildChainGroups() { + const byCategory = new Map>() + for (const def of CHAIN_DEFS) { + const complete = SCENARIOS.find(s => s.id === def.completeId) + if (!complete) continue + const segments = def.segmentIds + .map(id => SCENARIOS.find(s => s.id === id)) + .filter((s): s is Scenario => !!s) + if (!byCategory.has(def.category)) byCategory.set(def.category, []) + byCategory.get(def.category)!.push({ id: def.completeId, complete, segments }) + } + return Array.from(byCategory, ([category, chains]) => ({ category, chains })) +} + +export const CHAIN_GROUPS = buildChainGroups()