fix(cockpit): a stalled water read is given up, and coarse reads only raise

Before the first full-detail read lands, a loaded-tile read could still
lower a river height partway (27 m to a 15 m bank). Loaded-tile reads now
only raise it; lowering is the full-detail read's to say.

That makes a full read that never lands (stalled tile requests) hold a
wrong height for the whole bridge, so one still running after 30 s is given
up for a fresh one; its late answer is dropped.

Also tests the other half of the double-click memory fix: another ship
selected within the memory is not entered through the first ship's spot.
This commit is contained in:
Sameh Khamis committed 2026-11-08 14:09:18 -08:00
1 parent 80a33ba52c
commit 1bc4f980e0
4 files changed
+110 -17

No files matched your search

+3 -2
View File
@@ -1574,8 +1574,9 @@ the full-detail read replaces it; it repeats every 5 s. Only one full-detail
read runs at a time: while one is still running, a refresh reads the loaded read runs at a time: while one is still running, a refresh reads the loaded
tiles instead, and the slow read applies whenever it lands. Once a tiles instead, and the slow read applies whenever it lands. Once a
full-detail read has answered, loaded-tile reads no longer change the height; full-detail read has answered, loaded-tile reads no longer change the height;
before then they may find raised water but never take it back to the sea. before then they may only raise it. A read that sampled nothing keeps the
A read that sampled nothing keeps the height it had. A failed read keeps height it had, and a full-detail read still running after 30 s
(`VESSEL_WATERLINE_READ_TIMEOUT_MS`) is given up for a fresh one. A failed read keeps
the sea surface. the sea surface.
The HUD keeps its frame, vision styles, briefing, weather and Display/Radio The HUD keeps its frame, vision styles, briefing, weather and Display/Radio
+7
View File
@@ -38,6 +38,13 @@ export const VESSEL_WATERLINE_PROBE_COUNT = 8;
/** How often the bridge reads the water under its ship again, ms. */ /** How often the bridge reads the water under its ship again, ms. */
export const VESSEL_WATERLINE_REFRESH_MS = 5000; export const VESSEL_WATERLINE_REFRESH_MS = 5000;
/**
* A full-detail water read still running after this long is given up and
* a fresh one started, ms: tile requests can stall, and one read that never
* lands would otherwise hold the height for the rest of the bridge.
*/
export const VESSEL_WATERLINE_READ_TIMEOUT_MS = 30000;
/** A surface less than this above sea level is the sea itself, metres. */ /** A surface less than this above sea level is the sea itself, metres. */
export const VESSEL_WATERLINE_MIN_RISE_M = 2; export const VESSEL_WATERLINE_MIN_RISE_M = 2;
+68
View File
@@ -53,6 +53,10 @@ function vesselLayer(record) {
deselect() { deselect() {
selected = null; selected = null;
}, },
/** Another ship selected in its place. */
select(next) {
selected = next;
},
/** Live AIS Vessels turned off: like the layer, it then has no subject. */ /** Live AIS Vessels turned off: like the layer, it then has no subject. */
disable() { disable() {
enabled = false; enabled = false;
@@ -1372,6 +1376,59 @@ test('before any full read lands, a coarse loaded read keeps the river it found'
ship.viewer.scene.sampleHeight = () => 0.5; ship.viewer.scene.sampleHeight = () => 0.5;
frames(ship.cockpit, clock, ship.shots, 240); frames(ship.cockpit, clock, ship.shots, 240);
near(eyeHeightM(ship.shots.at(-1)) - seaEyeM, 27, 'still on the river', 0.5); near(eyeHeightM(ship.shots.at(-1)) - seaEyeM, 27, 'still on the river', 0.5);
// Nor partway down: a probe on a 15 m bank is not the river either.
ship.viewer.scene.sampleHeight = () => 15;
frames(ship.cockpit, clock, ship.shots, 240);
near(eyeHeightM(ship.shots.at(-1)) - seaEyeM, 27, 'not lowered partway', 0.5);
});
test('a full water read that never lands is given up for a fresh one', async (t) => {
const clock = manualClock(t, START_MS);
const sea = await bridgeAt(t, clock);
frames(sea.cockpit, clock, sea.shots, 60);
const seaEyeM = eyeHeightM(sea.shots.at(-1));
sea.cockpit.exit();
clock.set(START_MS);
const record = { ...RECORD, lastPositionEpoch: START_MS / 1000 };
const ship = fixture(t, { record });
const reads = [];
ship.viewer.scene.sampleHeightMostDetailed = (positions) =>
new Promise((resolve) => reads.push({ positions, resolve }));
// Coarse tiles first put the ship 27 m up; it is really at sea.
ship.viewer.scene.sampleHeight = () => 27;
ship.cockpit.enter();
await settle();
ship.cockpit.update();
await settle();
assert.equal(reads.length, 1);
// The first read stalls. Twenty seconds on, still no second read.
frames(ship.cockpit, clock, ship.shots, 400);
await settle();
assert.equal(reads.length, 1, 'not stacked while it may still land');
// Past the timeout, a fresh read starts, and its answer applies.
frames(ship.cockpit, clock, ship.shots, 300);
await settle();
assert.equal(reads.length, 2, 'a fresh read after the stalled one');
const answer = (read, height) =>
read.resolve(
read.positions.map((position) => Object.assign(position, { height })),
);
answer(reads[1], 0.5);
await settle();
// The eye eases down; give it time to settle.
frames(ship.cockpit, clock, ship.shots, 400);
near(eyeHeightM(ship.shots.at(-1)), seaEyeM, 'back on the sea', 0.5);
// The stalled read landing now changes nothing.
answer(reads[0], 27);
await settle();
frames(ship.cockpit, clock, ship.shots, 60);
near(
eyeHeightM(ship.shots.at(-1)),
seaEyeM,
'the given-up read is dropped',
0.5,
);
}); });
test('a client clock minutes fast does not age the bridge into a stale feed', async (t) => { test('a client clock minutes fast does not age the bridge into a stale feed', async (t) => {
@@ -1481,6 +1538,17 @@ test("a ship's double-click memory does not carry over to an aircraft followed s
assert.equal(cockpit.active, false); assert.equal(cockpit.active, false);
}); });
test("a ship's double-click memory does not carry over to another ship", async (t) => {
const { cockpit, viewer, vessels } = fixture(t);
heightSensitiveProjection(viewer);
cockpit.onScenePointerDown(1000);
// Within the memory another ship is selected, drawn elsewhere.
vessels.select({ ...RECORD, mmsi: '211000009', name: 'OTHER' });
heightSensitiveProjection(viewer, { x: 900 });
assert.equal(cockpit.onSceneDoubleClick({ x: 400, y: 300 }, 1250), false);
assert.equal(cockpit.active, false);
});
test("a double-click's second press keeps the first press's picture of the ship", async (t) => { test("a double-click's second press keeps the first press's picture of the ship", async (t) => {
const { cockpit, viewer, store } = fixture(t); const { cockpit, viewer, store } = fixture(t);
heightSensitiveProjection(viewer); heightSensitiveProjection(viewer);
+32 -15
View File
@@ -9,6 +9,7 @@ import {
} from '../cockpitMath.js'; } from '../cockpitMath.js';
import { import {
VESSEL_DEAD_RECKON_MAX_S, VESSEL_DEAD_RECKON_MAX_S,
VESSEL_WATERLINE_READ_TIMEOUT_MS,
VESSEL_WATERLINE_REFRESH_MS, VESSEL_WATERLINE_REFRESH_MS,
deadReckonVesselFix, deadReckonVesselFix,
vesselCameraProfile, vesselCameraProfile,
@@ -110,10 +111,11 @@ export function update() {
* every frame until it settles): while one is still running, a refresh * every frame until it settles): while one is still running, a refresh
* reads the loaded tiles instead, and the slow read applies whenever it * reads the loaded tiles instead, and the slow read applies whenever it
* lands. Once a full-detail read has answered, only full-detail reads * lands. Once a full-detail read has answered, only full-detail reads
* change the height; before then a loaded-tile read may find raised water * change the height; before then a loaded-tile read may only raise it, so
* but never takes a raised height back to the sea. One coarse probe cannot * one coarse probe cannot drop a river bridge, and a read that sampled
* drop a river bridge to the sea, and a read that sampled nothing keeps * nothing keeps the height it had. A full-detail read still running after
* the height it had. * VESSEL_WATERLINE_READ_TIMEOUT_MS is given up for a fresh one, so a
* stalled read cannot hold a wrong height for the whole bridge.
* @param {{latitude: number, longitude: number}} [at] Where to read the * @param {{latitude: number, longitude: number}} [at] Where to read the
* water: the dead-reckoned position the camera rides. * water: the dead-reckoned position the camera rides.
* @returns {number} Ellipsoidal metres. * @returns {number} Ellipsoidal metres.
@@ -140,35 +142,49 @@ function vesselSurfaceM(info, nowMs, at = info) {
// Whether a full-detail read has answered for this ship. // Whether a full-detail read has answered for this ship.
landed: false, landed: false,
inFlight: false, inFlight: false,
// The running full-detail read: its number and start time.
attempt: 0,
startedMs: 0,
}; };
this.vesselWaterline = read; this.vesselWaterline = read;
} else { } else {
read.sampledMs = nowMs; read.sampledMs = nowMs;
if (read.inFlight && !read.landed) { if (read.inFlight && !read.landed) {
const loadedM = loadedWaterlineM(scene, probes, seaM); const loadedM = loadedWaterlineM(scene, probes, seaM);
// Taking a river back to the sea is the full-detail read's to say. // Lowering the water is the full-detail read's to say.
const lowers = if (
Number.isFinite(read.heightM) && Number.isFinite(loadedM) &&
read.heightM > seaM && !(Number.isFinite(read.heightM) && loadedM < read.heightM)
!(loadedM > seaM); )
if (Number.isFinite(loadedM) && !lowers) read.heightM = loadedM; read.heightM = loadedM;
} }
if (
read.inFlight &&
nowMs - read.startedMs >= VESSEL_WATERLINE_READ_TIMEOUT_MS
)
read.inFlight = false;
} }
if (!read.inFlight) readWaterline.call(this, read, scene, probes, seaM); if (!read.inFlight)
readWaterline.call(this, read, scene, probes, seaM, nowMs);
} }
return read && Number.isFinite(read.heightM) return read && Number.isFinite(read.heightM)
? Math.max(seaM, read.heightM) ? Math.max(seaM, read.heightM)
: seaM; : seaM;
} }
/** Start one full-detail water read for `read`; its answer lands on it. */ /**
function readWaterline(read, scene, probes, seaM) { * Start one full-detail water read for `read`; its answer lands on it
* unless it was given up for a newer read.
*/
function readWaterline(read, scene, probes, seaM, nowMs) {
const attempt = ++read.attempt;
read.inFlight = true; read.inFlight = true;
read.startedMs = nowMs;
Promise.resolve() Promise.resolve()
.then(() => scene.sampleHeightMostDetailed(probes)) .then(() => scene.sampleHeightMostDetailed(probes))
.then( .then(
(samples) => { (samples) => {
if (this.vesselWaterline !== read) return; if (this.vesselWaterline !== read || attempt !== read.attempt) return;
read.inFlight = false; read.inFlight = false;
// However late it lands, it applies; one that sampled nothing (tiles // However late it lands, it applies; one that sampled nothing (tiles
// not loaded yet) is no answer and keeps the height already read. // not loaded yet) is no answer and keeps the height already read.
@@ -178,7 +194,8 @@ function readWaterline(read, scene, probes, seaM) {
read.heightM = vesselWaterlineM(seaM, heights); read.heightM = vesselWaterlineM(seaM, heights);
}, },
() => { () => {
if (this.vesselWaterline === read) read.inFlight = false; if (this.vesselWaterline === read && attempt === read.attempt)
read.inFlight = false;
}, },
); );
} }