diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index d837853d6..317338ea8 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -39,7 +39,7 @@ export function navaraModeNotice(availability) { return Object.freeze({ label: t('Experimental Navara engine'), detail: names.length - ? `Data layers: ${names.join(', ')}; the other layers, cockpit and director are unavailable` + ? `Data layers: ${names.join(', ')}; the other layers and director are unavailable` : 'No data layers on this engine yet; cockpit and director are unavailable', }); } @@ -347,7 +347,7 @@ export function createViewerlessTools({ if (window.__godsEyeView === debug) delete window.__godsEyeView; }); // Voice on the engine (phase 3, Task 9): the W1 tools drive the engine; - // the viewer-only ones (director, annotations, cockpit…) refuse. + // the viewer-only ones (director, annotations…) refuse. const voiceCommands = initGevVoiceCommands({ ...voice, annotationResolver: operations.annotationResolver, diff --git a/src/app/viewerlessTools.test.mjs b/src/app/viewerlessTools.test.mjs index 2038ab8f7..5f246f3ac 100644 --- a/src/app/viewerlessTools.test.mjs +++ b/src/app/viewerlessTools.test.mjs @@ -119,7 +119,7 @@ test('without a viewer the tools run the governor on the engine, show the notice registry.register('navara', { satellites: () => ({}), flights: () => ({}) }); assert.equal( navaraModeNotice(createLayerAvailability('navara', { registry })).detail, - 'Data layers: Live Flights, Satellites; the other layers, cockpit and director are unavailable', + 'Data layers: Live Flights, Satellites; the other layers and director are unavailable', ); // The governor drives the engine's continuous rendering. assert.equal(isRenderGovernorInstalled(engine), true); diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 72f9bc664..6448bda86 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -42,10 +42,10 @@ function ordered(source, needles, label) { test('Cockpit takeover invalidates deferred work before camera cancellation', () => { const enter = cockpitEnter.toString(); ordered(enter, [ - 'if (!info || !entity?.position) return false;', + 'if (!info || !entity) return false;', 'this.onCameraTakeover?.();', - 'this.viewer.camera.cancelFlight();', - 'this.viewer.trackedEntity = undefined;', + 'this.host.camera.cancelFlight();', + 'this.host.release();', ], 'Cockpit takeover'); assert.match( ui, diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index be90dd75c..8bf26b655 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -223,7 +223,7 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc assert.match(enter, /activeElement/); assert.match(enter, /mapViewButton.*focus|focus.*mapViewButton/s); assert.match(exit, /focus\(\{ preventScroll: true \}\)/); - assert.match(exit, /this\.restoreTrackingFrame\(entity\)/); + assert.match(exit, /this\.host\.restore\(entity, this\.restoreTrackingFrame\)/); assert.doesNotMatch(exit, /applyTrackedCameraFrame/); assert.match( ui, @@ -238,7 +238,7 @@ test('Cockpit shortcut failures do not leak and open utilities own the first Esc assert.match(keydown, /if \(this\.isUtilityDisclosureOpen\?\.\(\)\) return;/); assert.match( keydown, - /const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/, + /const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && this\.host\.current\(\)\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/, ); }); @@ -302,7 +302,7 @@ test('the cockpit reads its aircraft from the layer that owns Cesium tracking', assert.match(read, /gevTrackedId/); // In cockpit mode the controller moves the entity off viewer.trackedEntity, // so its own handle is the tracked identity there. - assert.match(read, /this\.viewer\?\.trackedEntity \|\| this\.trackedEntity/); + assert.match(read, /this\.host\.followed\(\) \|\| this\.trackedEntity/); }); test('programmatic Context layer changes cannot bypass explicit expansion policy', () => { @@ -1165,7 +1165,7 @@ test('cockpit route direction uses self-contained vector artwork, not a font lig test('cockpit aircraft handoff invalidates the prior world-position anchor', () => { const match = _adoptTrackedEntity.toString(); assert.ok(match, 'cockpit tracked-entity handoff block is missing'); - assert.match(match, /this\.viewer\.trackedEntity = undefined;/); + assert.match(match, /this\.host\.release\(\);/); assert.match(match, /this\.cockpitAnchorValid = false;/); assert.match( match, diff --git a/src/contextModePolicy.js b/src/contextModePolicy.js index 4c0b7c9cb..e40b9eb52 100644 --- a/src/contextModePolicy.js +++ b/src/contextModePolicy.js @@ -412,12 +412,11 @@ export function contextRestoreLayerIds(snapshot = {}) { } /** - * Cockpit entry belongs to the operational Contacts context bundle. A tracked - * aircraft alone is not sufficient: both observed-flight feeds must still be - * active so the cockpit's surrounding-contact picture is not presented as a - * complete context view when one source is disabled. + * Contacts mode requires both observed-flight feeds. Navara also supports + * first-person flight directly from a single selected aircraft layer. * * @param {object} input Current context and dependency visibility. + * @param {boolean} [input.standaloneAircraft] Allow direct entry outside a Context mode. * @param {string|null} input.contextMode Active Context mode. * @param {boolean} input.contextModeChanging Whether the Context transaction is unsettled. * @param {boolean} input.flightsEnabled Whether Live Flights is enabled. @@ -425,11 +424,14 @@ export function contextRestoreLayerIds(snapshot = {}) { * @returns {boolean} Whether cockpit entry may be offered or activated. */ export function cockpitEntryAllowed({ + standaloneAircraft = false, contextMode, contextModeChanging, flightsEnabled, militaryEnabled, }) { + if (standaloneAircraft && !contextMode) + return !contextModeChanging && Boolean(flightsEnabled || militaryEnabled); return ( contextMode === 'flights' && !contextModeChanging && diff --git a/src/contextModePolicy.test.mjs b/src/contextModePolicy.test.mjs index 40a7b291e..57e9e8b6c 100644 --- a/src/contextModePolicy.test.mjs +++ b/src/contextModePolicy.test.mjs @@ -587,3 +587,12 @@ test('session bookkeeping ignores programmatic origins, non-visibility events, a }), false); assert.equal(snapshot.userAdded.size, 0); }); + +test('direct aircraft entry on Navara needs an enabled feed and an idle Context', () => { + const input = { standaloneAircraft: true, contextMode: null, flightsEnabled: true }; + assert.equal(cockpitEntryAllowed(input), true); + assert.equal(cockpitEntryAllowed({ ...input, flightsEnabled: false, militaryEnabled: true }), true); + assert.equal(cockpitEntryAllowed({ ...input, flightsEnabled: false }), false); + assert.equal(cockpitEntryAllowed({ ...input, contextModeChanging: true }), false); + assert.equal(cockpitEntryAllowed({ ...input, contextMode: 'space-missions' }), false); +}); diff --git a/src/layers/aircraft/render/navara.js b/src/layers/aircraft/render/navara.js index 6b1c13afe..d9771f45b 100644 --- a/src/layers/aircraft/render/navara.js +++ b/src/layers/aircraft/render/navara.js @@ -738,6 +738,7 @@ export function createAircraftNavaraRenderer({ // The tracked glyph's distance scale is its bank's (as Cesium's). sprite._scaleByDistance = spec.scaleByDistance; sprite.spec = spec; + sprite.resumeAircraftFollow = () => follow.start(sprite); sprite.viewFrom = null; trackedSprites.add(sprite); placeTracked(sprite); @@ -783,6 +784,7 @@ export function createAircraftNavaraRenderer({ position: () => handle.gevDisplayPosition?.() ?? handle.spec.position(), }; return engine.camera.follow({ + native: handle, position: () => handle.spec.position(), viewFrom: () => handle.viewFrom, label: card, diff --git a/src/layers/aircraft/render/navara.test.mjs b/src/layers/aircraft/render/navara.test.mjs index 88b7d31e4..9f2ca16ab 100644 --- a/src/layers/aircraft/render/navara.test.mjs +++ b/src/layers/aircraft/render/navara.test.mjs @@ -190,6 +190,7 @@ test('the tracked glyph reads its callbacks every frame; follow carries its read assert.equal(typeof stop, 'function'); const following = engine.camera.following(); assert.equal(following, handle.gevFollowHandle); + assert.equal(following.native, handle, 'cockpit follows the rendered aircraft'); assert.equal(following.label.id, 'flights:abc123'); assert.equal(following.label.model(), handle.gevLabelModel); assert.equal(renderer.follow.isFollowing(handle), true); @@ -197,7 +198,10 @@ test('the tracked glyph reads its callbacks every frame; follow carries its read renderer.follow.release(); assert.equal(engine.camera.following(), null); assert.equal(renderer.follow.current(), undefined); + handle.resumeAircraftFollow(); + assert.equal(renderer.follow.current(), handle); renderer.tracked.remove(handle); + assert.equal(engine.camera.following(), null); assert.equal(renderer.tracked.isLive(handle), false); renderer.destroy(); engine.destroy(); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index a838fbf66..da867a029 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -371,6 +371,7 @@ export class StyleManager extends ShellFacade { this._recording.hud = this.hud; this._cockpitCoordinator = new CockpitCoordinator({ viewer, + engine, services: { flightsLayer: services.flightsLayer, militaryFlightsLayer: services.militaryFlightsLayer, diff --git a/src/ui/cockpitCamera.js b/src/ui/cockpitCamera.js index 90b8eb022..66ee09087 100644 --- a/src/ui/cockpitCamera.js +++ b/src/ui/cockpitCamera.js @@ -45,7 +45,7 @@ export function update() { if ( !info || !this.trackedEntity || - !this.viewer.entities.contains(this.trackedEntity) + !this.host.contains(this.trackedEntity) ) { if (nowMs < this.contextNavigationDeadlineMs) return; this.exit({ restoreTracking: false }); @@ -61,8 +61,8 @@ export function update() { return; this.lastCameraUpdateMs = nowMs; - const target = this.trackedEntity.position.getValue( - this.viewer.clock.currentTime, + const target = this.host.position( + this.trackedEntity, this.scratchTarget, ); if (!target) return; @@ -182,8 +182,8 @@ export function update() { ) ) { this.lastGroundProbeMs = nowMs; - const viewerCartographic = this.viewer.camera.positionCartographic; - this.services.sampleMeshFloorCells(this.viewer.scene, groundPoint, { + const viewerCartographic = this.host.cartographic(); + this.services.sampleMeshFloorCells(this.host.surface, groundPoint, { excludeObjects: [this.trackedEntity], viewerLat: geo.math.toDegrees(viewerCartographic.latitude), viewerLon: geo.math.toDegrees(viewerCartographic.longitude), @@ -345,7 +345,7 @@ export function update() { } } - this.viewer.camera.setView({ + this.host.camera.setView({ destination: this.scratchCamera, orientation: { direction: this.scratchForward, up: this.scratchUp }, }); diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js index d1372d693..d70f59a6c 100644 --- a/src/ui/cockpitController.js +++ b/src/ui/cockpitController.js @@ -63,11 +63,14 @@ import { import { onKeyDown } from './cockpitInput.js'; import * as geo from '../geo/index.js'; +import { cockpitHost } from './cockpitHost.js'; + export class CockpitViewController { constructor( viewer, { services, + engine = null, onVisionChange = null, getInitialVisionMode = null, onCameraTakeover = null, @@ -79,7 +82,7 @@ export class CockpitViewController { publish = {}, } = {}, ) { - this.viewer = viewer; + this.host = cockpitHost(viewer, engine); this.services = services; this.destroyed = false; this.disposed = false; @@ -198,8 +201,8 @@ export class CockpitViewController { // from preRender makes 3D Tiles discover a new view after traversal and can // create a self-sustaining refinement loop under a moving cockpit camera. this._listenerRemovers.push( - viewer.scene.preUpdate.addEventListener(() => this.update()), - viewer.trackedEntityChanged.addEventListener(() => { + this.host.preUpdate.addEventListener(() => this.update()), + this.host.followChanged.addEventListener(() => { if (this.active) this._adoptTrackedEntity(performance.now()); else this.syncEntry(); }), diff --git a/src/ui/cockpitCoordinator.js b/src/ui/cockpitCoordinator.js index 93f7befd9..f82241ab2 100644 --- a/src/ui/cockpitCoordinator.js +++ b/src/ui/cockpitCoordinator.js @@ -9,6 +9,7 @@ import { cockpitVisionModeForStyle } from '../cockpitVisionPolicy.js'; export class CockpitCoordinator { constructor({ viewer, + engine, services, elements, operations, @@ -23,6 +24,7 @@ export class CockpitCoordinator { this, { viewer, + engine, services, readDataManager, readContext, @@ -35,10 +37,7 @@ export class CockpitCoordinator { operations, ); this._cockpitDisplayPortal = null; - // The cockpit flies the Cesium camera after tracked aircraft; without a - // viewer (the Navara engine) there is no cockpit and controlCockpit - // reports it unavailable. - this.cockpitView = viewer + this.cockpitView = viewer || engine?.kind === 'navara' ? this._createCockpitView(viewer, services) : null; } @@ -62,6 +61,7 @@ export class CockpitCoordinator { weatherCodeLabel, } = services; return new CockpitViewController(viewer, { + engine: this.engine, services: { flightsLayer, militaryFlightsLayer, @@ -88,6 +88,7 @@ export class CockpitCoordinator { this._stampNavigation({ cancelPendingSelection: false }), isEntryAllowed: () => cockpitEntryAllowed({ + standaloneAircraft: this.engine?.kind === 'navara', contextMode: this._contextMode, contextModeChanging: this._contextModeChanging, flightsEnabled: !!this._dataManager?.isEnabled('flights'), @@ -136,7 +137,7 @@ export class CockpitCoordinator { // (including the voice model) read that as a broken half-entered state. const entryAllowed = !active && - Boolean(gateOpen && info && this.viewer?.trackedEntity?.position); + Boolean(gateOpen && info && this.cockpitView?.host.current()); return { active, entryAllowed, @@ -278,7 +279,9 @@ export class CockpitCoordinator { action: 'control_cockpit', error: this._contextModeChanging ? 'Contacts is still starting up — try Cockpit again in a moment' - : 'Contacts must be active to enter Cockpit — say "open contacts" first', + : this.engine?.kind === 'navara' + ? 'Enable an aircraft layer and track a flight to enter Cockpit' + : 'Contacts must be active to enter Cockpit — say "open contacts" first', state: this.getCockpitState(), }; } diff --git a/src/ui/cockpitHost.js b/src/ui/cockpitHost.js new file mode 100644 index 000000000..62cc2c21f --- /dev/null +++ b/src/ui/cockpitHost.js @@ -0,0 +1,46 @@ +// Camera and aircraft ownership used by the shared first-person controller. +export function cockpitHost(viewer, engine) { + if (viewer) { + return { + preUpdate: viewer.scene.preUpdate, + followChanged: viewer.trackedEntityChanged, + followed: () => viewer.trackedEntity, + current: () => + viewer.trackedEntity?.position ? viewer.trackedEntity : null, + contains: (entity) => viewer.entities.contains(entity), + position: (entity, result) => + entity.position.getValue(viewer.clock.currentTime, result), + release: () => { + viewer.trackedEntity = undefined; + }, + restore: (entity, refocus) => { + viewer.trackedEntity = entity; + refocus(entity); + }, + camera: viewer.camera, + cartographic: () => viewer.camera.positionCartographic, + surface: viewer.scene, + inputs: (enabled) => { + viewer.scene.screenSpaceCameraController.enableInputs = enabled; + }, + }; + } + const aircraft = () => { + const target = engine.camera.following()?.native; + return target?.resumeAircraftFollow ? target : null; + }; + return { + preUpdate: engine.frames.preUpdate, + followChanged: engine.camera.followChanged, + followed: aircraft, + current: aircraft, + contains: (target) => target.live, + position: (target) => target.spec.position(), + release: () => engine.camera.following()?.stop(), + restore: (target) => target.resumeAircraftFollow(), + camera: engine.camera, + cartographic: () => engine.camera.pose().cartographic, + surface: engine, + inputs: (enabled) => engine.input.setCameraInputs(enabled), + }; +} diff --git a/src/ui/cockpitInput.js b/src/ui/cockpitInput.js index 54b8f56a9..ca8d1d89d 100644 --- a/src/ui/cockpitInput.js +++ b/src/ui/cockpitInput.js @@ -49,7 +49,7 @@ export function onKeyDown(event) { if (key === 'c' && !event.metaKey && !event.ctrlKey && !event.altKey) { if (!this.active) { const cockpitAttempt = !!( - this.readAircraftInfo() && this.viewer.trackedEntity?.position + this.readAircraftInfo() && this.host.current() ); if (!cockpitAttempt) return; } diff --git a/src/ui/cockpitTrackingController.js b/src/ui/cockpitTrackingController.js index f308b0d2f..b6fe5473a 100644 --- a/src/ui/cockpitTrackingController.js +++ b/src/ui/cockpitTrackingController.js @@ -9,7 +9,7 @@ import { export function readAircraftInfo() { // In cockpit mode the controller takes the entity off `viewer.trackedEntity` // (see update()), so the cockpit's own handle is the tracked identity there. - const trackedEntity = this.viewer?.trackedEntity || this.trackedEntity; + const trackedEntity = this.host.followed() || this.trackedEntity; return resolveTrackedAircraftInfo({ civilian: this.services.flightsLayer.getTrackedInfo?.() || null, military: this.services.militaryFlightsLayer.getTrackedInfo?.() || null, @@ -77,7 +77,7 @@ export function syncEntry() { if (this.destroyed) return false; if (this.active) return; const info = this.readAircraftInfo(); - const trackedContact = !!(info && this.viewer.trackedEntity?.position); + const trackedContact = !!(info && this.host.current()); this.syncTr3bToggle(trackedContact ? info : null); const available = !!(this.isEntryAllowed() && trackedContact); // Change-only publishes: this runs on a preUpdate cadence, and a fresh @@ -108,22 +108,22 @@ export function navigateContext(direction, options = {}) { } export function _adoptTrackedEntity(nowMs, suppliedInfo = null) { - const nextEntity = this.viewer.trackedEntity; + const nextEntity = this.host.current(); if ( !this.active || - !nextEntity?.position || + !nextEntity || nextEntity === this.trackedEntity ) return false; const info = suppliedInfo || this.readAircraftInfo(); if (!info) return false; - if (this.trackedEntity && this.viewer.entities.contains(this.trackedEntity)) { + if (this.trackedEntity && this.host.contains(this.trackedEntity)) { this.trackedEntity.show = this.trackedEntityWasShown; } this.trackedEntity = nextEntity; this.trackedEntityWasShown = nextEntity.show; nextEntity.show = false; - this.viewer.trackedEntity = undefined; + this.host.release(); this.cockpitAnchorValid = false; this.heading = normalizeHeading(info.track ?? 0); this.lastFrameMs = nowMs; @@ -140,15 +140,15 @@ export function enter() { if (this.active) return false; if (!this.isEntryAllowed()) return false; const info = this.readAircraftInfo(); - const entity = this.viewer.trackedEntity; - if (!info || !entity?.position) return false; + const entity = this.host.current(); + if (!info || !entity) return false; this.entryFocusOrigin = document.activeElement instanceof HTMLElement ? document.activeElement : null; // Retire deferred navigation before cancelFlight can run its callbacks. this.onCameraTakeover?.(); - this.viewer.camera.cancelFlight(); + this.host.camera.cancelFlight(); this.trackedEntity = entity; this.trackedEntityWasShown = entity.show; entity.show = false; @@ -169,8 +169,8 @@ export function enter() { // runs on rendered frames, so idle mode would freeze the cockpit solid. // (perf wave 2) this.services.holdContinuousRender('cockpit'); - this.viewer.trackedEntity = undefined; - this.viewer.scene.screenSpaceCameraController.enableInputs = false; + this.host.release(); + this.host.inputs(false); document.body.classList.add('cockpit-mode'); // Activation hides the entry and TR-3B chip; clearing the chip's signature // lets the exit-path syncEntry publish it again. @@ -237,14 +237,13 @@ export function exit({ restoreTracking = true } = {}) { this.routeView.visible = false; this.clearPredictiveRoute(); this.setVisionMode('optical'); - this.viewer.scene.screenSpaceCameraController.enableInputs = true; - if (entity && this.viewer.entities.contains(entity)) + this.host.inputs(true); + if (entity && this.host.contains(entity)) entity.show = this.trackedEntityWasShown; this.trackedEntityWasShown = true; this.dispatchCockpitModeChanged(false); - if (restoreTracking && entity && this.viewer.entities.contains(entity)) { - this.viewer.trackedEntity = entity; - this.restoreTrackingFrame(entity); + if (restoreTracking && entity && this.host.contains(entity)) { + this.host.restore(entity, this.restoreTrackingFrame); } this.syncEntry(); const restoreTarget = diff --git a/src/ui/contextLayerChanges.js b/src/ui/contextLayerChanges.js index fc5abe3a7..c5a206577 100644 --- a/src/ui/contextLayerChanges.js +++ b/src/ui/contextLayerChanges.js @@ -1,7 +1,6 @@ import { t, translateKnown } from '../i18n/index.js'; import { SPACE_MISSIONS_LAYER_BLOCK_REASON, - cockpitEntryAllowed, isExplicitUserIntentOrigin, parseContextLayerBlockReason, recordContextRestoreExplicitChange, @@ -245,15 +244,7 @@ export function _handleContextLayerChange(change) { ); } } - if ( - this.cockpitView?.active && - !cockpitEntryAllowed({ - contextMode: this._contextMode, - contextModeChanging: this._contextModeChanging, - flightsEnabled: !!this._dataManager?.isEnabled('flights'), - militaryEnabled: !!this._dataManager?.isEnabled('military'), - }) - ) { + if (this.cockpitView?.active && !this.cockpitView.isEntryAllowed()) { this.cockpitView.exit({ restoreTracking: false }); } this._syncContextModeButtons(); diff --git a/src/ui/views/ContextView.vue b/src/ui/views/ContextView.vue index f330391e9..9789657d0 100644 --- a/src/ui/views/ContextView.vue +++ b/src/ui/views/ContextView.vue @@ -55,10 +55,12 @@ const sceneRuntime = computed(() => scenePanel.value?.runtime ?? '');
- + + + 🛸
diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 8eb2d741b..9517d5327 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -229,7 +229,7 @@ const FRAME_TARGETS = new Map([ /** * The tools that still need the Cesium viewer or a subsystem built on it: * the scene director, annotations and routes over them, the camera verbs - * (orbit, pan, tilt), the cockpit, Contacts (the context mode), Radio and + * (orbit, pan, tilt), Contacts (the context mode), Radio and * CCTV. Without a viewer (the Navara engine) each answers a structured * refusal (UNAVAILABLE_ON_ENGINE) instead of running. */ @@ -239,7 +239,6 @@ export const VIEWER_ONLY_ACTIONS = Object.freeze([ 'clear_annotations', 'fly_route', 'move_camera', - 'control_cockpit', 'set_context_mode', 'control_radio', 'control_cctv', @@ -265,7 +264,6 @@ export const UNAVAILABLE_FEATURES = Object.freeze({ clear_annotations: 'Map annotation', fly_route: 'Route flying', move_camera: 'Camera motion (orbit, pan, tilt)', - control_cockpit: 'The cockpit', set_context_mode: 'The Contacts mode', control_radio: 'Radio', control_cctv: 'CCTV', @@ -1078,7 +1076,7 @@ export function createGevActionRunner({ contextState?.mode === 'flights' && contextState?.active !== false && contextState?.changing !== true; - if (!contactsReady) { + if (!contactsReady && engine?.kind !== 'navara') { const contextResult = await styleManager.setContextMode('flights', { signal: runOptions.signal, isCurrent: runOptions.isCurrent, diff --git a/src/voice/gevActionsEngine.test.mjs b/src/voice/gevActionsEngine.test.mjs index 68e2965ad..f640cc9a8 100644 --- a/src/voice/gevActionsEngine.test.mjs +++ b/src/voice/gevActionsEngine.test.mjs @@ -94,7 +94,6 @@ test('the viewer-only actions are one exported list of real tools', () => { 'clear_annotations', 'fly_route', 'move_camera', - 'control_cockpit', 'set_context_mode', 'control_radio', 'control_cctv', @@ -105,6 +104,24 @@ test('the viewer-only actions are one exported list of real tools', () => { assert.ok(tools.has(name), `${name} is a voice tool`); }); +test('Navara enters cockpit on the tracked aircraft without starting Contacts', async () => { + const engine = engineOverAustin({ kind: 'navara' }); + const runner = runnerOn(engine, { + styleManager: { + getAircraftTrackingTarget: () => ({ layerId: 'flights', id: '507c84' }), + setContextMode: () => assert.fail('the selected flight already owns the camera'), + controlCockpit: (action, { rollbackTarget }) => { + assert.equal(action, 'enter'); + assert.equal(rollbackTarget.id, '507c84'); + return { ok: true, state: { active: true } }; + }, + }, + }); + const result = await runner('control_cockpit', { action: 'enter' }); + assert.equal(result.ok, true); + assert.equal(result.state.active, true); +}); + test('without a viewer the viewer-only actions refuse with a structured result', async () => { const engine = engineOverAustin({ kind: 'navara' }); const touched = [];