diff --git a/src/scenes/director.js b/src/scenes/director.js index 7a78ef6b2..cb9c0b55c 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -1533,6 +1533,12 @@ export class SceneDirector { this._selectedShotId !== shot.id ) return null; + if (!this._claimCameraOwnership()) return false; + this._cancelActiveSceneTravel(); + this._loadAbort?.abort(); + this._loadAbort = null; + const generation = ++this._loadGeneration; + this._clock.stopShot(); const states = this._layerStatesForShot(scene, shot, { cameraSettled: seekState.cameraProgress >= 1, sceneSeek: seekState, @@ -1542,21 +1548,18 @@ export class SceneDirector { // those through the cancellable admission path. const staleStates = Object.fromEntries( Object.entries(states).filter(([id, state]) => { + const wantEnabled = !!state?.enabled; const lifecycle = this.dataManager.getLayerLifecycleState(id); return ( lifecycle && - (lifecycle.lifecycleState === 'enabling' || + (lifecycle.enabled !== wantEnabled || + lifecycle.lifecycleState === 'enabling' || lifecycle.lifecycleState === 'disabling' || - this.dataManager.isEffectivelyEnabled(id) !== !!state?.enabled) + lifecycle.uncertain || + this.dataManager.isEffectivelyEnabled(id) !== wantEnabled) ); }), ); - if (!this._claimCameraOwnership()) return null; - this._cancelActiveSceneTravel(); - this._loadAbort?.abort(); - this._loadAbort = null; - const generation = ++this._loadGeneration; - this._clock.stopShot(); if (Object.keys(staleStates).length) { const controller = new AbortController(); this._loadAbort = controller; diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index 968ffa1ed..b874612ae 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -14,6 +14,7 @@ import { readShellSource } from '../testSupport/readShellSource.mjs'; import assert from 'node:assert/strict'; import test from 'node:test'; +import { LayerLifecycle } from '../data/lifecycle.js'; import { SceneDirector } from './director.js'; import { SCENE_TRACKING_PARAM_KEYS } from './scenePolicy.js'; import { SCENE_RECIPES, getSceneAppendRecipeById } from './recipes.js'; @@ -241,7 +242,19 @@ test('same-shot seeking restores visibility a manual toggle is still changing', }); test('same-shot seeking restores a layer whose superseded LOAD transition rolls back', async () => { - const { director, dataManager, restore } = makeDirector(); + const { director, dataManager, styleManager, viewer, restore } = makeDirector(); + const lifecycle = new LayerLifecycle(viewer); + for (const id of ['flights', 'traffic', 'satellites']) { + lifecycle.register({ + id, + name: id, + init() {}, + enable() {}, + disable() {}, + update() {}, + }); + } + const realDirector = new SceneDirector(viewer, styleManager, lifecycle); try { await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); await dataManager.setEnabled('flights', false); @@ -254,7 +267,36 @@ test('same-shot seeking restores a layer whose superseded LOAD transition rolls await reload; await settle(); assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, true); + + await realDirector.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); + await lifecycle.setEnabled('flights', false); + const queuedReload = realDirector.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); + for (let i = 0; i < 20 && !lifecycle.isEffectivelyEnabled('flights'); i++) { + await Promise.resolve(); + } + assert.deepEqual(lifecycle.getLayerLifecycleState('flights'), { + enabled: false, + lifecycleState: 'disabled', + uncertain: false, + }); + assert.equal(await realDirector.seekScene('scene-1', 0.25), true); + await queuedReload; + await settle(); + assert.equal(lifecycle.isEnabled('flights'), true); + + lifecycle.layers.get('flights').lifecycleUncertain = true; + assert.equal(await realDirector.seekScene('scene-1', 0.3), true); + assert.equal(lifecycle.getLayerLifecycleState('flights').uncertain, false); + + let navClaims = 0; + styleManager.runImmediateNavigation = () => { + navClaims += 1; + return false; + }; + assert.equal(await realDirector.seekScene('scene-1', 0.4), false); + assert.equal(navClaims, 1, 'camera refusal fails the seek without falling back to LOAD'); } finally { + await realDirector.destroy(); await director.destroy(); restore(); }