From 1bc4f980e0651008935c0729e9101ad1679e9baf Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 8 Nov 2026 13:56:41 -0800 Subject: [PATCH] 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. --- docs/CURRENT-STATE.md | 5 +-- src/cockpitSubjects.js | 7 ++++ src/ui/cockpitBridge.test.mjs | 68 +++++++++++++++++++++++++++++++++++ src/ui/cockpitCamera.js | 47 ++++++++++++++++-------- 4 files changed, 110 insertions(+), 17 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b5174c081..91a1f8028 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 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; -before then they may find raised water but never take it back to the sea. -A read that sampled nothing keeps the height it had. A failed read keeps +before then they may only raise it. A read that sampled nothing keeps the +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 HUD keeps its frame, vision styles, briefing, weather and Display/Radio diff --git a/src/cockpitSubjects.js b/src/cockpitSubjects.js index aab08ff72..aff25b680 100644 --- a/src/cockpitSubjects.js +++ b/src/cockpitSubjects.js @@ -38,6 +38,13 @@ export const VESSEL_WATERLINE_PROBE_COUNT = 8; /** How often the bridge reads the water under its ship again, ms. */ 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. */ export const VESSEL_WATERLINE_MIN_RISE_M = 2; diff --git a/src/ui/cockpitBridge.test.mjs b/src/ui/cockpitBridge.test.mjs index 13a0ced35..d5b4d2789 100644 --- a/src/ui/cockpitBridge.test.mjs +++ b/src/ui/cockpitBridge.test.mjs @@ -53,6 +53,10 @@ function vesselLayer(record) { deselect() { 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. */ disable() { 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; frames(ship.cockpit, clock, ship.shots, 240); 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) => { @@ -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); }); +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) => { const { cockpit, viewer, store } = fixture(t); heightSensitiveProjection(viewer); diff --git a/src/ui/cockpitCamera.js b/src/ui/cockpitCamera.js index d5c268b35..e3efdfa99 100644 --- a/src/ui/cockpitCamera.js +++ b/src/ui/cockpitCamera.js @@ -9,6 +9,7 @@ import { } from '../cockpitMath.js'; import { VESSEL_DEAD_RECKON_MAX_S, + VESSEL_WATERLINE_READ_TIMEOUT_MS, VESSEL_WATERLINE_REFRESH_MS, deadReckonVesselFix, vesselCameraProfile, @@ -110,10 +111,11 @@ export function update() { * every frame until it settles): while one is still running, a refresh * reads the loaded tiles instead, and the slow read applies whenever it * 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 - * but never takes a raised height back to the sea. One coarse probe cannot - * drop a river bridge to the sea, and a read that sampled nothing keeps - * the height it had. + * change the height; before then a loaded-tile read may only raise it, so + * one coarse probe cannot drop a river bridge, and a read that sampled + * nothing keeps the height it had. A full-detail read still running after + * 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 * water: the dead-reckoned position the camera rides. * @returns {number} Ellipsoidal metres. @@ -140,35 +142,49 @@ function vesselSurfaceM(info, nowMs, at = info) { // Whether a full-detail read has answered for this ship. landed: false, inFlight: false, + // The running full-detail read: its number and start time. + attempt: 0, + startedMs: 0, }; this.vesselWaterline = read; } else { read.sampledMs = nowMs; if (read.inFlight && !read.landed) { const loadedM = loadedWaterlineM(scene, probes, seaM); - // Taking a river back to the sea is the full-detail read's to say. - const lowers = - Number.isFinite(read.heightM) && - read.heightM > seaM && - !(loadedM > seaM); - if (Number.isFinite(loadedM) && !lowers) read.heightM = loadedM; + // Lowering the water is the full-detail read's to say. + if ( + Number.isFinite(loadedM) && + !(Number.isFinite(read.heightM) && loadedM < read.heightM) + ) + 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) ? Math.max(seaM, read.heightM) : 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.startedMs = nowMs; Promise.resolve() .then(() => scene.sampleHeightMostDetailed(probes)) .then( (samples) => { - if (this.vesselWaterline !== read) return; + if (this.vesselWaterline !== read || attempt !== read.attempt) return; read.inFlight = false; // However late it lands, it applies; one that sampled nothing (tiles // 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); }, () => { - if (this.vesselWaterline === read) read.inFlight = false; + if (this.vesselWaterline === read && attempt === read.attempt) + read.inFlight = false; }, ); }