mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-04 08:06:26 +08:00
refactor(live-atc): move scenario catalogue to shared/constants/scenarios.ts
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 <noreply@anthropic.com>
This commit is contained in:
@@ -152,7 +152,7 @@
|
||||
</v-alert>
|
||||
|
||||
<!-- Scenarios grouped by journey, each chain shown as a flow -->
|
||||
<div v-for="grp in chainGroups" :key="grp.category" class="space-y-3">
|
||||
<div v-for="grp in CHAIN_GROUPS" :key="grp.category" class="space-y-3">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-widest text-white/35">
|
||||
{{ grp.category }}
|
||||
</p>
|
||||
@@ -200,12 +200,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Standalone drills -->
|
||||
<div v-if="drillScenarios.length" class="space-y-3">
|
||||
<div v-if="DRILL_SCENARIOS.length" class="space-y-3">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-widest text-white/35">
|
||||
Drills
|
||||
</p>
|
||||
<v-card
|
||||
v-for="drill in drillScenarios"
|
||||
v-for="drill in DRILL_SCENARIOS"
|
||||
:key="drill.id"
|
||||
class="bg-white/5 border border-white/10 backdrop-blur"
|
||||
rounded="lg"
|
||||
@@ -1530,6 +1530,7 @@ import { usePttRecording } from '~/composables/usePttRecording'
|
||||
import { useBugReport } from '~/composables/useBugReport'
|
||||
import { useDebugSimulation } from '~/composables/useDebugSimulation'
|
||||
import { useSimBridgeSync } from '~/composables/useSimBridgeSync'
|
||||
import { SCENARIOS, CHAIN_GROUPS, DRILL_SCENARIOS, type Scenario } from '../../shared/constants/scenarios'
|
||||
import { useSessionState } from '~/composables/useSessionState'
|
||||
import { useSpeechInterrupt } from '~/composables/useSpeechInterrupt'
|
||||
import { useRadioSpeech } from '~/composables/useRadioSpeech'
|
||||
@@ -1680,235 +1681,6 @@ const {
|
||||
sessionLabel,
|
||||
} = useSessionState(engine)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
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.
|
||||
// ---------------------------------------------------------------------------
|
||||
interface ChainDef {
|
||||
category: 'Departure' | 'Arrival'
|
||||
completeId: string // the isComplete scenario that flies the whole chain
|
||||
segmentIds: string[] // individual-phase scenario ids, in order
|
||||
}
|
||||
|
||||
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.
|
||||
const DRILL_IDS = ['rto', 'info-arrival']
|
||||
const drillScenarios = computed(() => DRILL_IDS
|
||||
.map(id => SCENARIOS.find(s => s.id === id))
|
||||
.filter((s): s is Scenario => !!s))
|
||||
|
||||
const chainGroups = computed(() => {
|
||||
const byCategory = new Map<string, Array<{ id: string; complete: Scenario; segments: Scenario[] }>>()
|
||||
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<Scenario | null>(null)
|
||||
/** The scenario currently being flown. */
|
||||
|
||||
232
shared/constants/scenarios.ts
Normal file
232
shared/constants/scenarios.ts
Normal file
@@ -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<string, Array<{ id: string; complete: Scenario; segments: Scenario[] }>>()
|
||||
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()
|
||||
Reference in New Issue
Block a user