mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
fix(director): keep rejected loads from cancelling LOAD; roll back seek only on refusal
- Superseding a seek aborts only the seek's own layer reconcile, so a loadShot that is later rejected (shot-not-found) no longer cancels an in-flight LOAD. Stop still aborts any LOAD. - A refused seek rolls back to each layer's effective intent, so a manual toggle still in flight is kept. - A cancelled seek leaves layers to whatever superseded it, as a cancelled LOAD does; only refusal rolls back. - Move the _seekLoadedShot JSDoc back onto its method.
This commit is contained in:
1 parent
01c35a2c11
commit
3b57299231
2 files changed
+106
-21
No files matched your search
+35
-21
@@ -153,6 +153,8 @@ export class SceneDirector {
|
||||
this._runAbort = null;
|
||||
/** @type {AbortController|null} Aborts the in-flight LOAD's layer transitions */
|
||||
this._loadAbort = null;
|
||||
/** @type {AbortController|null} Aborts a same-shot seek's layer reconcile */
|
||||
this._seekAbort = null;
|
||||
this._clock = createPlaybackClock({
|
||||
isRunning: () => this._running,
|
||||
timingForShot: (scene, shot) => this._sceneTimingForShot(scene, shot),
|
||||
@@ -1516,17 +1518,24 @@ export class SceneDirector {
|
||||
return new Promise((resolve) => this._runIdleResolvers.add(resolve));
|
||||
}
|
||||
|
||||
/**
|
||||
* Supersede any pending seek. Only a seek's own layer reconcile is aborted:
|
||||
* a request that is later rejected must not cancel an in-flight LOAD.
|
||||
*/
|
||||
_bumpSceneSeekGeneration() {
|
||||
if (this._seekAbort) {
|
||||
this._seekAbort.abort();
|
||||
if (this._loadAbort === this._seekAbort) this._loadAbort = null;
|
||||
this._seekAbort = null;
|
||||
}
|
||||
return ++this._sceneSeekGeneration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a new time inside the already-loaded shot without rebuilding its stack.
|
||||
* @returns {Promise<boolean|null>} Null when the shortcut does not apply and
|
||||
* LOAD must rebuild the shot; otherwise whether the seek settled.
|
||||
*/
|
||||
_bumpSceneSeekGeneration() {
|
||||
this._loadAbort?.abort();
|
||||
this._loadAbort = null;
|
||||
return ++this._sceneSeekGeneration;
|
||||
}
|
||||
|
||||
async _seekLoadedShot(
|
||||
scene,
|
||||
seekState,
|
||||
@@ -1578,32 +1587,35 @@ export class SceneDirector {
|
||||
const previousEnabled = new Map(
|
||||
Object.keys(staleStates).map((id) => [
|
||||
id,
|
||||
Boolean(this.dataManager.getLayerLifecycleState(id)?.enabled),
|
||||
this.dataManager.isEffectivelyEnabled(id),
|
||||
]),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
this._loadAbort = controller;
|
||||
this._seekAbort = controller;
|
||||
const layerResult = await this._applyLayerStates(
|
||||
staleStates,
|
||||
this._loadToken(generation, controller.signal, seekGeneration),
|
||||
{ stopOnRefusal: true, exitContextMode: false },
|
||||
);
|
||||
if (this._loadAbort === controller) this._loadAbort = null;
|
||||
if (
|
||||
layerResult.cancelled ||
|
||||
layerResult.refused.length ||
|
||||
if (this._seekAbort === controller) this._seekAbort = null;
|
||||
const superseded = () =>
|
||||
this._destroyed ||
|
||||
seekGeneration !== this._sceneSeekGeneration
|
||||
) {
|
||||
if (!this._destroyed && !this._loadAbort && !this._running) {
|
||||
for (const id of [...layerResult.applied].reverse()) {
|
||||
if (this._destroyed || this._loadAbort || this._running) break;
|
||||
await this.dataManager.setEnabled(
|
||||
id,
|
||||
previousEnabled.get(id) ?? false,
|
||||
{ origin: 'scene' },
|
||||
);
|
||||
}
|
||||
this._loadAbort ||
|
||||
this._running ||
|
||||
seekGeneration !== this._sceneSeekGeneration;
|
||||
if (layerResult.cancelled || superseded()) return false;
|
||||
if (layerResult.refused.length) {
|
||||
// Only a refusal rolls back. A cancelled reconcile leaves layer state
|
||||
// to whatever superseded it, as a cancelled LOAD does.
|
||||
for (const id of [...layerResult.applied].reverse()) {
|
||||
if (superseded()) break;
|
||||
await this.dataManager.setEnabled(
|
||||
id,
|
||||
previousEnabled.get(id) ?? false,
|
||||
{ origin: 'scene' },
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1998,6 +2010,8 @@ export class SceneDirector {
|
||||
this._interactions?.clear();
|
||||
this._dataPacks?.clear();
|
||||
this._bumpSceneSeekGeneration();
|
||||
this._loadAbort?.abort();
|
||||
this._loadAbort = null;
|
||||
this._loadGeneration++;
|
||||
this._clock.stopShot();
|
||||
this._cancelActiveSceneTravel();
|
||||
|
||||
@@ -428,6 +428,77 @@ test('same-shot seeking aborts in-flight admission when superseded by an early-r
|
||||
}
|
||||
});
|
||||
|
||||
test('a rejected loadShot does not cancel an in-flight LOAD', async () => {
|
||||
const { styleManager, viewer, restore } = makeDirector();
|
||||
let holdEnable = true;
|
||||
let releaseEnable;
|
||||
const enableGate = new Promise((resolve) => {
|
||||
releaseEnable = resolve;
|
||||
});
|
||||
const lifecycle = new LayerLifecycle(viewer);
|
||||
lifecycle.register({
|
||||
id: 'flights',
|
||||
name: 'flights',
|
||||
init() {},
|
||||
async enable() {
|
||||
if (holdEnable) await enableGate;
|
||||
},
|
||||
disable() {},
|
||||
update() {},
|
||||
});
|
||||
const director = new SceneDirector(viewer, styleManager, lifecycle);
|
||||
try {
|
||||
const load = director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
||||
for (
|
||||
let i = 0;
|
||||
i < 20 && lifecycle.getLayerLifecycleState('flights').lifecycleState !== 'enabling';
|
||||
i++
|
||||
) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
assert.equal(lifecycle.getLayerLifecycleState('flights').lifecycleState, 'enabling');
|
||||
|
||||
assert.equal((await director.loadShot('missing', 'nope')).reason, 'shot-not-found');
|
||||
holdEnable = false;
|
||||
releaseEnable();
|
||||
assert.equal((await load)?.started, true, 'the valid LOAD still lands');
|
||||
assert.equal(lifecycle.isEnabled('flights'), true);
|
||||
} finally {
|
||||
releaseEnable?.();
|
||||
await director.destroy();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('refused same-shot seek rollback keeps a manual toggle still in flight', async () => {
|
||||
let refuse = () => false;
|
||||
const { director, dataManager, restore } = makeDirector({
|
||||
data: { refuse: (id, on) => refuse(id, on) },
|
||||
});
|
||||
try {
|
||||
const shot = director._project.scenes[0].shots[0];
|
||||
shot.layers = { flights: { enabled: true }, traffic: { enabled: true } };
|
||||
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
||||
assert.equal(await director.seekScene('scene-1', 0.1), true);
|
||||
await dataManager.setEnabled('flights', false);
|
||||
await dataManager.setEnabled('traffic', false);
|
||||
refuse = (id) => id === 'traffic';
|
||||
const manualEnable = dataManager.setEnabled('flights', true);
|
||||
|
||||
assert.equal(await director.seekScene('scene-1', 0.25), false);
|
||||
await manualEnable;
|
||||
await settle();
|
||||
assert.equal(
|
||||
dataManager.getAll().find(({ id }) => id === 'flights').enabled,
|
||||
true,
|
||||
'rollback restores the operator\'s intended state, not the stale committed one',
|
||||
);
|
||||
} finally {
|
||||
await director.destroy();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('same-shot seeking keeps the shortcut when declared visibility is unchanged', async () => {
|
||||
const { director, dataManager, styleManager, restore } = makeDirector();
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user