fix(cockpit): an application subject's wait for the floor is bounded, a late asynchronous hold is released, and a hold never counts as the user's selection

- A subject with no height at all waits at most
  COCKPIT_GROUND_WAIT_TIMEOUT_MS for the floor, its HUD updating and
  reading ACQUIRING SURFACE; then the view ends and the provider gets its
  subject back (there is no height of its own to fall back to).
- A hold that returns a promise is refused as before and released again
  once it settles, unless that subject is being ridden by then; release
  must tolerate a second call.
- Rail and Transit stamp selectedAtMs only for a user's selection (a click,
  focusTrain or focusVehicle); a deselection clears it, and a train or
  vehicle selected only by a hold reads null.
This commit is contained in:
Denis Groshev committed 2026-11-12 12:31:04 +01:00
1 parent 1e4060f6ba
commit 4c33552105
13 files changed
+239 -28

No files matched your search

+15
View File
@@ -306,3 +306,18 @@ for a provider placing a train along its line. The frame, HUD view and
candidate fields are documented in `src/cockpitSubjectProviders.js`; the Rail
and Transit read APIs in `src/layers/rail/cockpitSubject.js` and
`src/layers/transit/cockpitSubject.js`.
A candidate's `selectedAt` is a token that changes each time the user selects
the subject, even the same one again, so entry takes it first. The Rail and
Transit snapshots give one, `selectedAtMs`: when the user last selected the
train or vehicle by a click or `focusTrain`/`focusVehicle`. It is null after
a deselection and for one selected only by a hold, since a hold is not the
user's selection.
A frame with `heightM: null` stands on the shared floor, or where it last
stood while that floor is cold. If no height has been known since entry, the
view waits, showing ACQUIRING SURFACE, for at most five seconds
(`COCKPIT_GROUND_WAIT_TIMEOUT_MS`), then ends and releases the subject. A
`hold` that throws or returns a promise refuses entry and is followed by
`release(id, { restore: false })`, again once a returned promise settles, so
`release` must be harmless when called more than once.
+5 -2
View File
@@ -2011,7 +2011,9 @@ older subject's.
A frame places the eye `eye.upM` above `heightM` (the shared floor where the
frame gives none, or where it last stood while that floor is cold; with
neither the camera waits where it is), slews toward `headingDeg` at the provider's rate, and may
neither, the camera stays where it is and the HUD reads ACQUIRING SURFACE for
at most `COCKPIT_GROUND_WAIT_TIMEOUT_MS`, after which the view ends and the
subject is released: no height stands in for a floor never read), slews toward `headingDeg` at the provider's rate, and may
turn off the floor clamps (`groundClamp: false`, for a subject inside a
structure) or keep the anchor exactly on the subject (`anchor: 'exact'`, for a
layer that already moves it smoothly); `cameraIntervalMs: 0` updates the
@@ -2034,7 +2036,8 @@ The Rail layer offers a read API for such a view: `getSelectedTrain()` and
`getCockpitSubject()` (a snapshot of the train: where it is along its line,
which way it runs (`pathBinding.js` learns it), its rail height, speed as
reported or derived, position source, feed credit and stops, and
`selectedAtMs`, when the user last selected it, even again),
`selectedAtMs`, when the user last selected it by a click or `focusTrain`,
even again; null after a deselection and for a train only a hold selected),
`setCockpitSubject(key)` (its marker and card hidden, the selection kept, its
feed held, clicks and Escape left to the view) and `focusTrain(key)`; its pure
path module is exported as `@gods-eye-view/core/layers/rail/paths`. Transit
+19 -5
View File
@@ -170,8 +170,10 @@ function refusal(provider, kinds) {
* - `candidate()`, `isEntryAllowed(candidate)` (optional), `hold(id)`,
* `read(nowMs, ctx)`, `hud(frame, ctx)` and `release(id, {restore})`, all
* synchronous (createCockpitSubjectCaller). `isEntryAllowed` allows entry
* only by returning `true`. A `hold` that throws is followed by
* `release(id, {restore: false})`.
* only by returning `true`. A `hold` that throws or returns a promise
* refuses entry and is followed by `release(id, {restore: false})`, and by
* another once that promise settles; `release` must therefore be harmless
* when called again for a subject it already gave back.
*
* A provider that is not so is left out with one warning naming it.
* @param {unknown} list
@@ -501,7 +503,9 @@ export function cockpitSubjectHudView(raw) {
* Provider methods are synchronous: the Cockpit calls them on its render
* cadence and uses the answer at once. One that returns a promise (an async
* function, say) breaks that contract: the promise is refused as a throw
* would be (its rejection, if any, is swallowed), with one warning.
* would be (its rejection, if any, is swallowed), with one warning, and
* `onSettled`, when given, runs once the promise has settled, so a caller
* can undo work that lands late.
* @param {{warn?: (message: string, error: unknown) => void}} [options]
*/
export function createCockpitSubjectCaller({
@@ -517,7 +521,13 @@ export function createCockpitSubjectCaller({
error,
);
};
return function call(provider, method, args = [], fallback = null) {
return function call(
provider,
method,
args = [],
fallback = null,
onSettled = null,
) {
let result;
try {
result = provider[method](...args);
@@ -530,7 +540,11 @@ export function createCockpitSubjectCaller({
(typeof result === 'object' || typeof result === 'function') &&
typeof result.then === 'function'
) {
Promise.resolve(result).catch(() => {});
// Its work may still land: `onSettled` runs once it has, either way.
const settled = () => {
if (typeof onSettled === 'function') onSettled();
};
Promise.resolve(result).then(settled, settled);
warnOnce(
provider,
method,
+8 -3
View File
@@ -17,8 +17,9 @@ import { displayMotion } from '../transit/movement.js';
* view that waits for it holds it again if the feed lists it again.
* `focusTrain(key)` selects a train and hands it the camera, as a click does.
* A snapshot's `selectedAtMs` is when the user last selected the selected
* train, even again (null for any other), for a view's entry to tell a
* re-selection.
* train (a click, or `focusTrain`), even again, for a view's entry to tell a
* re-selection. It is null for any other train, after a deselection, and for
* a train selected only by a hold: a hold is not a user's selection.
*
* @param {object} context The layer's state, parts, services and timetable.
* @returns {{methods: object}}
@@ -181,7 +182,11 @@ export function createRailCockpitSubject({
}
const entry = state._trains.get(key);
if (!entry) return false;
if (state._selectedKey !== key) parts.selection.selectTrain(key);
if (state._selectedKey !== key) {
parts.selection.selectTrain(key);
// A hold is not a selection the user made (selectedAtMs).
state._selectedAtMs = null;
}
state._cockpitSubjectKey = key;
state._cockpitLostFeedId = null;
if (previous && previous !== entry) parts.rendering.paint(previous);
+30
View File
@@ -196,6 +196,36 @@ test('a held train its feed drops leaves the feed polled until the view lets go'
assert.equal(app.state()._trains.size, 0);
});
test('selectedAtMs is the user’s selection time, never a hold’s or another train’s', async (t) => {
const app = await onTheLine(t);
assert.equal(app.layer.focusTrain(T1), true);
const first = app.layer.getSelectedTrain().selectedAtMs;
assert.ok(Number.isFinite(first), 'a click or focusTrain stamps it');
// Selected again: a later stamp.
app.run(1000);
app.layer.focusTrain(T1);
assert.ok(app.layer.getSelectedTrain().selectedAtMs > first);
// A hold of another train is not the user's selection.
app.layer.setCockpitSubject(T2);
assert.equal(app.layer.getCockpitSubject().key, T2);
assert.equal(app.layer.getCockpitSubject().selectedAtMs, null);
app.layer.setCockpitSubject(null);
// Nor after a deselection (Escape), then a hold.
app.layer.focusTrain(T1);
globalThis.document.dispatchEvent(
Object.assign(new Event('keydown'), { key: 'Escape' }),
);
assert.equal(app.state()._selectedKey, null);
assert.equal(app.state()._selectedAtMs, null, 'deselection clears it');
app.layer.setCockpitSubject(T2);
assert.equal(app.layer.getCockpitSubject().selectedAtMs, null);
// Releasing twice is harmless: the selection stays.
assert.equal(app.layer.setCockpitSubject(null), true);
assert.equal(app.layer.setCockpitSubject(null), true);
assert.equal(app.state()._selectedKey, T2);
assert.equal(app.layer.getCockpitSubject(), null);
});
test('turning the layer off ends the hold', async (t) => {
const app = await onTheLine(t);
app.layer.setCockpitSubject(T1);
+1
View File
@@ -127,6 +127,7 @@ export function createSelection({ state, services, parts }) {
const hadSelection = state._selectedKey !== null;
parts.follow.release({ untrack });
state._selectedKey = null;
state._selectedAtMs = null;
state._selectedCardText = null;
if (hadSelection) {
parts.rendering.paintAll();
+5 -3
View File
@@ -17,8 +17,10 @@ import { displayMotion } from './movement.js';
* change the selection. The hold ends with `setCockpitSubject(null)`, and on
* its own when the vehicle is removed or the layer is turned off.
* `focusVehicle(key)` selects a vehicle, as a click does. A snapshot's
* `selectedAtMs` is when the user last selected the selected vehicle, even
* again (null for any other).
* `selectedAtMs` is when the user last selected the selected vehicle (a
* click, or `focusVehicle`), even again. It is null for any other vehicle,
* after a deselection, and for a vehicle selected only by a hold: a hold is
* not a user's selection.
*
* @param {object} context The layer's state, parts and services.
* @returns {{methods: object}}
@@ -165,7 +167,7 @@ export function createTransitCockpitSubject({ state, parts, services }) {
*/
focusVehicle(key) {
if (!state._vehicles.get(key)?.marker) return false;
parts.selection.selectVehicle(key);
parts.selection.selectVehicle(key, { byUser: true });
return true;
},
};
+26
View File
@@ -4196,6 +4196,32 @@ test('the selected vehicle reads as a snapshot of its place, motion and source',
assert.ok(Number.isFinite(view.position.latitude));
});
test('selectedAtMs is the user’s selection time; a hold and a deselection leave none', async (t) => {
const app = harness(t);
const key = await busRunningNorth(app);
// A vehicle only a hold selected reports none.
assert.equal(app.layer.setCockpitSubject(key), true);
assert.equal(app.layer.getCockpitSubject().selectedAtMs, null);
app.layer.setCockpitSubject(null);
assert.equal(app.layer.setCockpitSubject(null), true, 'twice is harmless');
assert.equal(app.state()._selectedKey, key);
// The user's selection stamps it, again on a re-selection.
assert.equal(app.layer.focusVehicle(key), true);
const first = app.layer.getSelectedVehicle().selectedAtMs;
assert.ok(Number.isFinite(first));
app.run(1000);
app.layer.focusVehicle(key);
assert.ok(app.layer.getSelectedVehicle().selectedAtMs > first);
// Deselected, then held: none.
globalThis.document.dispatchEvent(
Object.assign(new Event('keydown'), { key: 'Escape' }),
);
assert.equal(app.state()._selectedKey, null);
assert.equal(app.state()._selectedAtMs, null);
app.layer.setCockpitSubject(key);
assert.equal(app.layer.getCockpitSubject().selectedAtMs, null);
});
test('a held vehicle hides, keeps moving out of view, and lets go when it goes', async (t) => {
const app = harness(t);
const key = await busRunningNorth(app);
+11 -4
View File
@@ -97,6 +97,7 @@ export function createSelection({ state, services, parts }) {
: null;
parts.trails.clear();
state._selectedKey = null;
state._selectedAtMs = null;
state._selectedCardText = null;
state._detectRevision += 1;
// Cleared first: the styling path reads the selection from state.
@@ -104,12 +105,18 @@ export function createSelection({ state, services, parts }) {
state._overlayHost.clearSource(TRANSIT_SELECTED_OVERLAY_SOURCE_ID);
}
function selectVehicle(key) {
/**
* Select a vehicle. `byUser` marks a selection the user made (a click, or
* focusVehicle), even of the selected vehicle again: its time is the
* snapshot's `selectedAtMs` (cockpitSubject.js). A first-person view's hold
* selects without it.
*/
function selectVehicle(key, { byUser = false } = {}) {
clearSelection();
const entry = state._vehicles.get(key);
if (!entry?.marker) return;
state._selectedKey = key;
state._selectedAtMs = Date.now();
if (byUser) state._selectedAtMs = Date.now();
state._detectRevision += 1;
parts.rendering.paintSelected(entry, true);
parts.trails.select(entry);
@@ -190,11 +197,11 @@ export function createSelection({ state, services, parts }) {
typeof primitiveId === 'string' &&
state._vehicles.has(primitiveId)
) {
selectVehicle(primitiveId);
selectVehicle(primitiveId, { byUser: true });
return;
}
if (typeof picked.id === 'string' && state._vehicles.has(picked.id)) {
selectVehicle(picked.id);
selectVehicle(picked.id, { byUser: true });
return;
}
}
+24 -3
View File
@@ -349,12 +349,33 @@ function updateSubject(nowMs) {
}
if (!Number.isFinite(heightM)) {
// Nothing to stand on yet, from the first frame: the camera stays where
// it is while the floor is acquired, as the built-in views wait for the
// surface, rather than standing on the ellipsoid (kilometres underground
// on a plateau).
// it is while the floor is acquired, its HUD saying so, rather than
// standing on the ellipsoid (kilometres underground on a plateau). The
// wait is bounded as the built-in views' is. Unlike a parked aircraft,
// whose fallback is its own reported altitude, such a subject has no
// height at all to fall back to: any number would be a guess, so the
// view ends and the provider gets its subject back.
if (!this.surfaceWaitStartedMs) this.surfaceWaitStartedMs = nowMs;
if (
cockpitSurfaceWaitExpired(
nowMs,
this.surfaceWaitStartedMs,
COCKPIT_GROUND_WAIT_TIMEOUT_MS,
)
) {
this.exit({ restoreTracking: false });
return;
}
this.surfaceAcquiring = true;
if (
cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS)
) {
this.lastHudUpdateMs = nowMs;
this.updateHud(this.subjectInfo(frame), nowMs);
}
return;
}
this.surfaceWaitStartedMs = 0;
this.surfaceAcquiring = false;
this.subjectHeightM = heightM;
const target = Cesium.Cartesian3.fromDegrees(
+6 -3
View File
@@ -183,9 +183,12 @@ function updateSubjectHud(info, nowMs, forceContext) {
view.trackSource = null;
view.kicker = hud.kicker || provider.labels.kicker;
view.callsign = hud.title || info.name || '';
view.meta.text = hud.meta.text;
view.meta.tone = hud.meta.tone;
view.meta.emergency = hud.meta.tone === 'alert';
// While the floor under a subject with no height is acquired
// (cockpitCamera.js), the meta line says so in place of the provider's.
const acquiring = this.surfaceAcquiring === true;
view.meta.text = acquiring ? t('ACQUIRING SURFACE') : hud.meta.text;
view.meta.tone = acquiring ? 'warning' : hud.meta.tone;
view.meta.emergency = !acquiring && hud.meta.tone === 'alert';
const speed = hud.speed?.value ?? null;
view.speed = hud.speed
? {
+68 -3
View File
@@ -15,6 +15,7 @@ import CockpitView from './views/CockpitView.vue';
import ContextView from './views/ContextView.vue';
import { cockpitSubjectProviders } from '../cockpitSubjectProviders.js';
import { getLocale, setLocale } from '../i18n/index.js';
import { COCKPIT_GROUND_WAIT_TIMEOUT_MS } from './cockpitPresentation.js';
import { vesselCockpitInfo } from '../layers/vessels/cockpitSubject.js';
const LAT = 47;
@@ -528,18 +529,28 @@ test('a frame with no height and a cold floor never stands the eye on the ellips
const clock = manualClock(t, 10_000);
const ride = subjectProvider();
ride.subject.frame = { ...ride.subject.frame, heightM: null };
const { cockpit, shots, setFloor } = fixture(t, { providers: [ride] });
// Nothing to stand on from the first frame: the camera waits where it is.
const { $, cockpit, shots, setFloor } = fixture(t, { providers: [ride] });
// Nothing to stand on from the first frame: the camera waits where it is,
// its HUD saying so and still read from the provider.
setFloor(undefined);
cockpit.syncEntry();
assert.equal(cockpit.enter(), true);
const before = shots.length;
const huds = () => ride.calls.filter(([name]) => name === 'hud').length;
const hudsBefore = huds();
for (let i = 0; i < 5; i += 1) {
clock.advance(16);
clock.advance(200);
cockpit.update();
}
await settle();
assert.equal(shots.length, before, 'no camera placed');
assert.equal(cockpit.active, true, 'the view waits');
assert.ok(huds() > hudsBefore, 'the HUD is not frozen');
assert.equal($('#cockpit-aircraft-meta').textContent, 'ACQUIRING SURFACE');
assert.equal(
$('#cockpit-aircraft-meta').getAttribute('data-tone'),
'warning',
);
// The floor arrives: the eye stands on it.
setFloor(FLOOR_M);
clock.advance(16);
@@ -561,6 +572,60 @@ test('a frame with no height and a cold floor never stands the eye on the ellips
assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01);
});
test('a floor that never comes ends the view after the bounded wait, releasing the subject', async (t) => {
const clock = manualClock(t, 10_000);
const ride = subjectProvider();
ride.subject.frame = { ...ride.subject.frame, heightM: null };
const { $, cockpit, shots, setFloor } = fixture(t, { providers: [ride] });
setFloor(undefined);
cockpit.syncEntry();
assert.equal(cockpit.enter(), true);
const before = shots.length;
// Acquiring, the HUD updating, for just under the wait...
for (let elapsed = 0; elapsed < COCKPIT_GROUND_WAIT_TIMEOUT_MS - 250;) {
clock.advance(250);
elapsed += 250;
cockpit.update();
}
await settle();
assert.equal(cockpit.active, true);
assert.equal($('#cockpit-aircraft-meta').textContent, 'ACQUIRING SURFACE');
// ...then no guessed height: the view ends and the subject goes back.
clock.advance(500);
cockpit.update();
assert.equal(cockpit.active, false);
assert.equal(shots.length, before, 'never placed on a guess');
assert.deepEqual(lastRelease(ride.calls), ['release', 'a1', false]);
});
test('an asynchronous hold that lands after entry was refused is released again', async (t) => {
let heldId = null;
const releases = [];
const ride = subjectProvider({
hold: async (id) => {
await Promise.resolve();
heldId = id;
return true;
},
release: (id, options) => {
releases.push([id, options.restore]);
heldId = null;
},
});
const { cockpit, warned } = fixture(t, { providers: [ride] });
cockpit.syncEntry();
assert.equal(cockpit.enter(), false, 'refused at once');
assert.deepEqual(releases, [['a1', false]]);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(heldId, null, 'the late hold is let go');
assert.deepEqual(releases, [
['a1', false],
['a1', false],
]);
assert.equal(cockpit.active, false);
assert.match(warned[0], /hold\(\) returned a promise/);
});
test('a provider’s labels, entry face and look follow a change of language', async (t) => {
t.after(() => setLocale('en'));
setLocale('en');
+21 -2
View File
@@ -399,10 +399,29 @@ function enterSubject(provider) {
if (!candidate || !this.isSubjectEntryAllowed(provider, candidate))
return false;
const failed = {};
const held = this.callSubject(provider, 'hold', [candidate.id], failed);
const release = () =>
this.callSubject(provider, 'release', [candidate.id, { restore: false }]);
// An asynchronous hold may land after it was refused: it is released
// again once it settles, unless this subject is being ridden by then.
const releaseLate = () => {
if (
this.active &&
this.subjectProvider === provider &&
this.subjectId === candidate.id
)
return;
release();
};
const held = this.callSubject(
provider,
'hold',
[candidate.id],
failed,
releaseLate,
);
if (held === failed) {
// A hold that threw (or broke the contract) may have half happened.
this.callSubject(provider, 'release', [candidate.id, { restore: false }]);
release();
return false;
}
if (held !== true) return false;