From 725ebb0dbb8e014f9aa45a004e53a81a5a577cb9 Mon Sep 17 00:00:00 2001 From: Niklas Gorman Date: Mon, 24 Aug 2026 14:26:59 +0200 Subject: [PATCH 1/3] fix: stop the e2e suite racing itself, and resolve diver overlap up front MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #130, #126, #131. [#130 / #126] Three specs failed intermittently under full-suite load and never in isolation: reload-resume (30 s timeout), wreck-slice (depth off by ~1 m) and game.spec. The cause was not any of them. Playwright defaults to one worker per two cores, so on a 14-core machine seven headless Chromium instances ran WebGL and canvas work concurrently against a single-threaded static server. Measured, full suite: 7 workers ~40% of runs failed ~31 s 4 workers 6 of 6 clean ~21 s <- also fastest 2 workers 3 of 3 clean ~35 s 1 worker 2 of 2 clean ~36 s Parallelism past four bought nothing and cost reliability, so workers is now one per four cores capped at four. My earlier claim on #130 that the suite ran `workers: 1` was simply wrong; it was running seven. The static server also did four synchronous filesystem round-trips per request (existsSync + statSync, twice) on the event loop that is the whole server. Now one. Measured separately this is worth about 9% under concurrency — real, but not the fix; the queueing is dominated by streaming the 648 KB harness. [#126] The depth assertion compared two different moments: it read the HUD, a rendered snapshot, then reloaded — and `pagehide` saves controller.authoritativeState, which has kept moving. Buoyancy momentum explains 27 m read against 28.1 m restored. It now reads back what was actually persisted and asserts restoration reproduces that, which is the property worth testing rather than "the HUD did not change". [#131] A fully engulfed diver could slide along inside a slab. Escaping during movement requires equal-area steps to be legal, because a fully engulfed diver has no strictly-reducing step available, and that allowance is what permitted sliding. resolveDiverOverlap() now pushes the diver out along whichever axis needs least movement before physics runs, making the engulfed state transient instead of somewhere it can travel. It overshoots the face by 1e-6 m because solidAt is inclusive on its bounds, and is a no-op on every normal tick. Each fix fails its own test: removing the resolve call from the tick, landing the push flush instead of clearing, and reverting the HUD-snapshot comparison all break exactly one thing. Verified: 3 of 3 clean full runs at 4 workers, 35 tests, ~26 s. Co-Authored-By: Claude Opus 5 --- eslint.config.mjs | 1 + playwright.config.js | 19 ++++++++ src/game-loop.js | 8 ++++ src/sites.js | 55 +++++++++++++++++++++++ src/sites/resources/gameplay.json | 2 +- src/sites/resources/presentation.json | 2 +- tests/collision.spec.js | 65 +++++++++++++++++++++++++++ tests/global-setup.js | 18 +++++++- tests/wreck-slice.spec.js | 33 +++++++++++++- 9 files changed, 197 insertions(+), 6 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index f0c190d..b621800 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -210,6 +210,7 @@ const gameGlobals = { solidBoxAt: "readonly", diverSolidAt: "readonly", solidOverlapArea: "readonly", diverOverlapArea: "readonly", diverOverlapGrew: "readonly", + resolveDiverOverlap: "readonly", // Issue #53: Visual zones (sites.js + state.js + renderer.js) visualZoneAt: "readonly", zoneBlendWeight: "readonly", VISUAL_ZONE_DEFAULT_PRIORITY: "readonly", VISUAL_ZONE_DEFAULT_BLEND: "readonly", diff --git a/playwright.config.js b/playwright.config.js index 1dd3b74..003390f 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -1,9 +1,28 @@ +const os = require('node:os'); const { defineConfig } = require('@playwright/test'); +// Playwright's default is one worker per two cores, which on a 14-core machine +// means seven headless Chromium instances doing WebGL and canvas work against a +// single-threaded static server. Under that load three different specs timed +// out intermittently — `reload-resume` (#130), `wreck-slice` (#126) and +// `game.spec` — none of which ever failed in isolation. Measured full-suite +// runs on 14 cores: +// +// 7 workers ~40% of runs failed ~31 s +// 4 workers 6 of 6 runs clean ~21 s <- also the fastest +// 2 workers 3 of 3 runs clean ~35 s +// 1 worker 2 of 2 runs clean ~36 s +// +// Parallelism past four was buying nothing and costing reliability. One worker +// per four cores, capped at four, keeps the fast case and stays conservative on +// a small CI runner. +const WORKERS = Math.max(1, Math.min(4, Math.ceil(os.cpus().length / 4))); + module.exports = defineConfig({ testDir: './tests', testMatch: '**/*.spec.js', timeout: 60000, + workers: WORKERS, globalSetup: require.resolve('./tests/global-setup.js'), use: { baseURL: 'http://127.0.0.1:8080', diff --git a/src/game-loop.js b/src/game-loop.js index 664919a..11cf194 100644 --- a/src/game-loop.js +++ b/src/game-loop.js @@ -419,6 +419,13 @@ function updateDiving(dtReal) { // Same pattern as the existing collision sub-stepping inside // updateBuoyancyPhysics() (line ~128) and updateHorizontalPhysics() // (line ~177) — those are spatial, this is temporal. + // Issue #131: clear any pre-existing overlap before the step, so physics + // never runs from inside a structure. Escaping during movement needs + // equal-area steps to be legal (a fully engulfed diver has no strictly + // reducing step), and that same allowance would otherwise let it slide + // along inside a slab. A no-op on every normal tick. + resolveDiverOverlap(); + var prevDepth = depth; var _physRemaining = dtDiveSeconds; while (_physRemaining > 1e-9) { @@ -2166,6 +2173,7 @@ window.gameAPI = { solidOverlapArea: solidOverlapArea, diverOverlapArea: diverOverlapArea, diverOverlapGrew: diverOverlapGrew, + resolveDiverOverlap: resolveDiverOverlap, get DIVER_HALF_WIDTH_M() { return DIVER_HALF_WIDTH_M; }, get DIVER_HALF_HEIGHT_M() { return DIVER_HALF_HEIGHT_M; }, overheadAt: overheadAt, diff --git a/src/sites.js b/src/sites.js index 2ec6bd7..0c2247b 100644 --- a/src/sites.js +++ b/src/sites.js @@ -874,6 +874,61 @@ function diverOverlapArea(x, d) { // Scaled to the diver's own box so it stays correct if the extents change, and // ~1e-9 of it: far above double noise (~1e-16 at this magnitude), far below any // overlap change a movement step could actually produce. +// Push the diver out of any structure it is overlapping, along whichever axis +// needs the least movement, and report whether it moved. +// +// Issue #131: escaping an overlap during movement requires allowing equal-area +// steps, because a fully engulfed diver sits on a flat gradient and has no +// strictly-reducing step available. That allowance also lets it slide along +// inside a slab. Resolving the overlap before physics runs makes the engulfed +// state transient instead of somewhere the diver can travel. +// +// An overlap is only reachable anomalously — a restored save, a site switch, +// edited geometry — so a discontinuous nudge is the right shape of fix. It is +// also less strange to watch than a diver swimming out through solid steel. +function resolveDiverOverlap() { + var s = activeSite(); + if (!s) return false; + var moved = false; + // Bounded: each pass clears at least one structure, and a nudge can push the + // diver into another one. Ten is far more than the authored geometry stacks. + for (var pass = 0; pass < 10; pass++) { + var worst = null; + var worstArea = 0; + for (var i = 0; i < s.structures.length; i++) { + var w = s.structures[i]; + var dx = Math.min(diverX + DIVER_HALF_WIDTH_M, w.x2) - Math.max(diverX - DIVER_HALF_WIDTH_M, w.x1); + if (dx <= 0) continue; + var dd = Math.min(depth + DIVER_HALF_HEIGHT_M, w.dBottom) - Math.max(depth - DIVER_HALF_HEIGHT_M, w.dTop); + if (dd <= 0) continue; + if (dx * dd > worstArea) { worstArea = dx * dd; worst = w; } + } + if (!worst) return moved; + + // Minimal translation: the four ways out, smallest wins. Each overshoots + // the face by a hair, because solidAt/solidBoxAt are inclusive on their + // bounds — landing exactly flush still reads as "inside", which would leave + // the diver touching and this loop declaring victory. + var CLEAR = 1e-6; + var outLeft = (worst.x1 - DIVER_HALF_WIDTH_M - CLEAR) - diverX; // negative + var outRight = (worst.x2 + DIVER_HALF_WIDTH_M + CLEAR) - diverX; // positive + var outUp = (worst.dTop - DIVER_HALF_HEIGHT_M - CLEAR) - depth; // negative + var outDown = (worst.dBottom + DIVER_HALF_HEIGHT_M + CLEAR) - depth; // positive + var best = outLeft; + if (Math.abs(outRight) < Math.abs(best)) best = outRight; + var bestVertical = Math.abs(outUp) < Math.abs(outDown) ? outUp : outDown; + if (Math.abs(bestVertical) < Math.abs(best)) { + depth += bestVertical; + verticalVelocity = 0; + } else { + diverX += best; + horizontalVelocity = 0; + } + moved = true; + } + return moved; +} + function diverOverlapGrew(fromX, fromD, toX, toD) { var tolerance = (2 * DIVER_HALF_WIDTH_M) * (2 * DIVER_HALF_HEIGHT_M) * 1e-9; return diverOverlapArea(toX, toD) > diverOverlapArea(fromX, fromD) + tolerance; diff --git a/src/sites/resources/gameplay.json b/src/sites/resources/gameplay.json index eac0f2e..dea4a3e 100644 --- a/src/sites/resources/gameplay.json +++ b/src/sites/resources/gameplay.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceDigest": "sha256:277d0480b0704d6d17599a4a07c89570f4bda43dd624fc3ea3b72479f634952b", + "sourceDigest": "sha256:20935193b2e8270543ac7f6974e430456893398e041d0b346dd0647a0debe7c7", "generator": "npm run sites:generate", "kind": "diving-simulator-site-gameplay", "sites": { diff --git a/src/sites/resources/presentation.json b/src/sites/resources/presentation.json index 396fd1b..96646f8 100644 --- a/src/sites/resources/presentation.json +++ b/src/sites/resources/presentation.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceDigest": "sha256:277d0480b0704d6d17599a4a07c89570f4bda43dd624fc3ea3b72479f634952b", + "sourceDigest": "sha256:20935193b2e8270543ac7f6974e430456893398e041d0b346dd0647a0debe7c7", "generator": "npm run sites:generate", "kind": "diving-simulator-site-presentation", "sites": { diff --git a/tests/collision.spec.js b/tests/collision.spec.js index 273f490..8a2b5ec 100644 --- a/tests/collision.spec.js +++ b/tests/collision.spec.js @@ -235,6 +235,71 @@ test('a fully engulfed diver is not pinned by floating-point noise', async ({ pa expect(errors).toEqual([]); }); +test('an engulfed diver is pushed out before physics runs', async ({ page }) => { + // Issue #131. Escaping during movement requires equal-area steps to be legal, + // because a fully engulfed diver has no strictly reducing step available — + // and that allowance also let it slide the length of a slab at constant + // depth. Resolving the overlap up front makes the engulfed state transient, + // so there is nowhere to slide from. + const errors = await bootGame(page); + + const result = await page.evaluate(() => { + const A = window.gameAPI; + A.diveSite = 'wreck'; + + // Deep inside the vehicle-deck floor slab (x=14..78, d=39..40). The nearest + // way out is vertical: 0.2 m up beats 44 m sideways. + const startX = 46, startD = 39.5; + diverX = startX; depth = startD; + horizontalVelocity = 0; verticalVelocity = 0; + const engulfedBefore = A.diverSolidAt(diverX, depth); + + // Drive the REAL dive tick, not resolveDiverOverlap() directly — otherwise + // this passes just as happily with the call removed from the loop, which is + // the wiring the fix actually depends on. + gameState = 'diving'; + updateDiving(1 / 60); + const after = { x: +diverX.toFixed(3), d: +depth.toFixed(3) }; + const movedOut = after.x !== startX || after.d !== startD; + + return { + startX, startD, engulfedBefore, movedOut, after, + stillOverlapping: A.diverSolidAt(diverX, depth), + overlapArea: A.diverOverlapArea(diverX, depth), + // Pushed out the near side (up), not dragged the length of the slab. + horizontalDrift: Math.abs(after.x - startX), + }; + }); + + expect(result.engulfedBefore, 'probe must start engulfed').toBe(true); + expect(result.movedOut).toBe(true); + expect(result.stillOverlapping, `still inside at ${JSON.stringify(result.after)}`).toBe(false); + expect(result.overlapArea).toBe(0); + // Minimal translation: out through the nearest face, not along the slab. + expect(result.horizontalDrift, 'should exit vertically, not slide 30m').toBeLessThan(0.01); + expect(errors).toEqual([]); +}); + +test('resolving an overlap is a no-op in open water', async ({ page }) => { + // It runs every dive tick, so it must not nudge a diver who is fine. + const errors = await bootGame(page); + const result = await page.evaluate(() => { + const A = window.gameAPI; + A.diveSite = 'wreck'; + diverX = 100; depth = 33; // clear of the wreck structures + horizontalVelocity = 1.2; verticalVelocity = -0.4; + const moved = A.resolveDiverOverlap(); + return { moved, x: diverX, d: depth, hv: horizontalVelocity, vv: verticalVelocity }; + }); + expect(result.moved).toBe(false); + expect(result.x).toBe(100); + expect(result.d).toBe(33); + // Velocities untouched, so a normal tick is unaffected. + expect(result.hv).toBeCloseTo(1.2, 5); + expect(result.vv).toBeCloseTo(-0.4, 5); + expect(errors).toEqual([]); +}); + test('every authored passage stays navigable with the diver extent applied', async ({ page }) => { // The guard against over-correcting. Measured openings before this change: // wreck bulkhead doorways 1.5m in depth, mess/cabin door 2.0m in x; cave diff --git a/tests/global-setup.js b/tests/global-setup.js index 8694dde..78258ea 100644 --- a/tests/global-setup.js +++ b/tests/global-setup.js @@ -15,6 +15,18 @@ const MIME_TYPES = { '.woff2': 'font/woff2', }; +// One stat instead of up to four. The handler called existsSync + statSync +// twice over, so every request did four synchronous filesystem round-trips on +// the event loop — and the loop is the whole server. Under parallel workers +// that serialised every asset fetch behind every other one. +function statOrNull(filePath) { + try { + return fs.statSync(filePath); + } catch { + return null; + } +} + module.exports = async function startStaticTestServer() { const root = path.resolve(__dirname, '..'); const server = http.createServer((request, response) => { @@ -27,10 +39,12 @@ module.exports = async function startStaticTestServer() { response.writeHead(403).end(); return; } - if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) { + let stats = statOrNull(filePath); + if (stats && stats.isDirectory()) { filePath = path.join(filePath, 'index.html'); + stats = statOrNull(filePath); } - if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + if (!stats || !stats.isFile()) { response.writeHead(404).end(); return; } diff --git a/tests/wreck-slice.spec.js b/tests/wreck-slice.spec.js index 3363e20..36f6b98 100644 --- a/tests/wreck-slice.spec.js +++ b/tests/wreck-slice.spec.js @@ -1,5 +1,11 @@ const { expect, test } = require('@playwright/test'); +/** "28.1 m" -> 28.1. Returns NaN for a placeholder such as the em dash. */ +function parseMetres(text) { + const match = /(-?\d+(?:\.\d+)?)/.exec(String(text ?? '')); + return match ? Number(match[1]) : NaN; +} + const CROSS_CLIENT_TRACE = Object.freeze([ Object.freeze({ kind: 'hold', key: 'ArrowDown', durationMs: 1250 }), Object.freeze({ kind: 'press', key: 't' }), @@ -61,12 +67,35 @@ test('production starts the Pixi wreck shell with semantic HUD and controls', as await expect(depthValue).not.toHaveText(initialDepth || ''); await expect(page.locator('[role="alert"]')).toBeHidden(); - const savedDepth = await depthValue.textContent(); + // Compare like with like. Reading the HUD here and asserting the restored HUD + // matches it raced the simulation: `pagehide` saves + // controller.authoritativeState, which keeps moving after the frame the HUD + // was painted from, so buoyancy momentum made the two differ by about a metre + // (27 m read, 28.1 m restored) and failed roughly one full-suite run in two. + // + // The property worth testing is that restoration reproduces what was SAVED, + // so read that back rather than a snapshot taken before the save happened. + const depthBeforeReload = parseMetres(await depthValue.textContent()); await page.reload(); + + const savedDepthM = await page.evaluate(() => { + const raw = localStorage.getItem('diving-simulator.save-game'); + return raw === null ? null : JSON.parse(raw).state.depthM; + }); + expect(savedDepthM, 'the dive should have been persisted on pagehide').not.toBeNull(); + // The descent really happened, so restoration has something to prove. + expect(savedDepthM).toBeGreaterThan(parseMetres(initialDepth)); + await page .getByRole('button', { name: 'I understand — start simulation' }) .click(); - await expect(page.locator('.wreck-hud dd').first()).toHaveText(savedDepth || ''); + const restored = page.locator('.wreck-hud dd').first(); + await expect(restored).toBeVisible(); + await expect + .poll(async () => parseMetres(await restored.textContent())) + .toBeCloseTo(savedDepthM, 0); + // And it is the saved dive, not a fresh one at the starting depth. + expect(Math.abs(savedDepthM - depthBeforeReload)).toBeLessThan(5); }); test('persisted safety states produce visible semantic warnings', async ({ page }) => { From 8481d8b1a306b27fec030057b49d918584ec1d09 Mon Sep 17 00:00:00 2001 From: Niklas Gorman Date: Mon, 24 Aug 2026 15:11:45 +0200 Subject: [PATCH 2/3] fix: pick overlap exits the site clamp will not undo, across stacked geometry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overlap resolution chose the smallest translation out of the single deepest structure, judged on that box alone. Two ways that fails, both leaving the diver permanently stuck: The site clamp undoes the exit. On the wreck keel (x=14..170, d=65..66, site floor 66) at (100, 65.5), up and down tie on distance, "down" won, resolution moved to d=66.300001 and updateBuoyancyPhysics clamped straight back to d=66, still inside. After 120 real updateDiving ticks the diver was still there with 0.27 m^2 of overlap and zero vertical velocity. Stacked geometry ping-pongs. The mast (x=75..76, d=10..18) sits on the bridge deck (x=72..108, d=18..19) forming one continuous column; leaving the mast downward lands in the deck, whose own cheapest exit is back up into the mast. The diver oscillated 17.7 <-> 18.3 until the pass limit gave up. Exits are now scored by the buried area they would actually leave the diver in, then by distance, and rejected if the site clamp would undo them. Sweeping every authored structure in every site: 30 of 513 buried start positions stayed stuck before, 0 after. The single-probe test is replaced by that sweep. The slab it used (x=14..78, d=39..40) has open water above and below, so both exits are legal and it could not have caught either failure — the same shape of gap as testing one coordinate against an exact float comparison. The sweep also asserts it found buried positions to test, so it cannot pass vacuously, and drives the real updateDiving loop rather than resolveDiverOverlap() directly. Scoring by resulting overlap and checking clamp legality are both load-bearing: reverting either leaves 14 and 12 positions stuck respectively. Generating candidates from every overlapping structure rather than one is belt-and-braces — with overlap scoring in place I could not construct a case that needs it. Co-Authored-By: Claude Opus 5 --- src/sites.js | 74 ++++++++++++++++++------- src/sites/resources/gameplay.json | 2 +- src/sites/resources/presentation.json | 2 +- tests/collision.spec.js | 80 +++++++++++++++------------ 4 files changed, 99 insertions(+), 59 deletions(-) diff --git a/src/sites.js b/src/sites.js index 0c2247b..fdada85 100644 --- a/src/sites.js +++ b/src/sites.js @@ -890,38 +890,70 @@ function resolveDiverOverlap() { var s = activeSite(); if (!s) return false; var moved = false; - // Bounded: each pass clears at least one structure, and a nudge can push the - // diver into another one. Ten is far more than the authored geometry stacks. + + // Bounded: each pass strictly reduces buried area, so it terminates. Ten is + // far more than the authored geometry stacks. for (var pass = 0; pass < 10; pass++) { - var worst = null; - var worstArea = 0; + var here = diverOverlapArea(diverX, depth); + if (here <= 0) return moved; + + // Candidate exits from EVERY structure the diver is inside, not just the + // deepest one. Exiting one box at a time ping-pongs through stacked + // geometry: at the wreck mast (x=75..76, d=10..18) sitting on the bridge + // deck (x=72..108, d=18..19) the two form one continuous column, and + // leaving the mast downward lands in the deck, whose own cheapest exit is + // straight back up into the mast. The diver oscillated 17.7 <-> 18.3 until + // the pass limit gave up. + var CLEAR = 1e-6; + var candidates = []; for (var i = 0; i < s.structures.length; i++) { var w = s.structures[i]; var dx = Math.min(diverX + DIVER_HALF_WIDTH_M, w.x2) - Math.max(diverX - DIVER_HALF_WIDTH_M, w.x1); if (dx <= 0) continue; var dd = Math.min(depth + DIVER_HALF_HEIGHT_M, w.dBottom) - Math.max(depth - DIVER_HALF_HEIGHT_M, w.dTop); if (dd <= 0) continue; - if (dx * dd > worstArea) { worstArea = dx * dd; worst = w; } + candidates.push({ x: w.x1 - DIVER_HALF_WIDTH_M - CLEAR, d: depth, vertical: false }); + candidates.push({ x: w.x2 + DIVER_HALF_WIDTH_M + CLEAR, d: depth, vertical: false }); + candidates.push({ x: diverX, d: w.dTop - DIVER_HALF_HEIGHT_M - CLEAR, vertical: true }); + candidates.push({ x: diverX, d: w.dBottom + DIVER_HALF_HEIGHT_M + CLEAR, vertical: true }); } - if (!worst) return moved; - // Minimal translation: the four ways out, smallest wins. Each overshoots - // the face by a hair, because solidAt/solidBoxAt are inclusive on their - // bounds — landing exactly flush still reads as "inside", which would leave - // the diver touching and this loop declaring victory. - var CLEAR = 1e-6; - var outLeft = (worst.x1 - DIVER_HALF_WIDTH_M - CLEAR) - diverX; // negative - var outRight = (worst.x2 + DIVER_HALF_WIDTH_M + CLEAR) - diverX; // positive - var outUp = (worst.dTop - DIVER_HALF_HEIGHT_M - CLEAR) - depth; // negative - var outDown = (worst.dBottom + DIVER_HALF_HEIGHT_M + CLEAR) - depth; // positive - var best = outLeft; - if (Math.abs(outRight) < Math.abs(best)) best = outRight; - var bestVertical = Math.abs(outUp) < Math.abs(outDown) ? outUp : outDown; - if (Math.abs(bestVertical) < Math.abs(best)) { - depth += bestVertical; + // Score by what the diver would actually be left buried in, then by how far + // it has to travel. Distance alone picked exits that were cheap for one box + // and useless overall. + // + // Legality is checked against the site clamp, because an exit the clamp + // undoes is not an exit. On the wreck keel (x=14..170, d=65..66, floor 66) + // at (100, 65.5), up and down tie on distance; "down" reached d=66.300001 + // and the buoyancy clamp put it straight back to d=66, still inside, where + // it stayed for as long as anything cared to tick. + var best = null; + for (var c = 0; c < candidates.length; c++) { + var cand = candidates[c]; + var legal = cand.vertical + ? (cand.d >= ceilingAt(diverX) && cand.d <= floorAt(diverX) && + cand.d >= 0 && cand.d <= MAX_DEPTH) + : (depth >= ceilingAt(cand.x) && depth <= floorAt(cand.x)); + if (!legal) continue; + var after = diverOverlapArea(cand.x, cand.d); + var move = Math.abs(cand.vertical ? cand.d - depth : cand.x - diverX); + if (best === null || after < best.after - 1e-12 || + (Math.abs(after - best.after) <= 1e-12 && move < best.move)) { + best = { cand: cand, after: after, move: move }; + } + } + + // Nothing legal, or nothing that improves matters: the diver is buried in + // geometry with no way out the site clamp will allow. Shoving it somewhere + // illegal would trade one stuck state for another, so leave it to the + // movement rule, which still permits overlap-reducing steps. + if (best === null || best.after >= here) return moved; + + if (best.cand.vertical) { + depth = best.cand.d; verticalVelocity = 0; } else { - diverX += best; + diverX = best.cand.x; horizontalVelocity = 0; } moved = true; diff --git a/src/sites/resources/gameplay.json b/src/sites/resources/gameplay.json index dea4a3e..16e465f 100644 --- a/src/sites/resources/gameplay.json +++ b/src/sites/resources/gameplay.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceDigest": "sha256:20935193b2e8270543ac7f6974e430456893398e041d0b346dd0647a0debe7c7", + "sourceDigest": "sha256:d882f93586c0906273d6cb2bbd671c5c43446425d6fae11fb4074841e10b9f07", "generator": "npm run sites:generate", "kind": "diving-simulator-site-gameplay", "sites": { diff --git a/src/sites/resources/presentation.json b/src/sites/resources/presentation.json index 96646f8..0583baa 100644 --- a/src/sites/resources/presentation.json +++ b/src/sites/resources/presentation.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceDigest": "sha256:20935193b2e8270543ac7f6974e430456893398e041d0b346dd0647a0debe7c7", + "sourceDigest": "sha256:d882f93586c0906273d6cb2bbd671c5c43446425d6fae11fb4074841e10b9f07", "generator": "npm run sites:generate", "kind": "diving-simulator-site-presentation", "sites": { diff --git a/tests/collision.spec.js b/tests/collision.spec.js index 8a2b5ec..d40af5e 100644 --- a/tests/collision.spec.js +++ b/tests/collision.spec.js @@ -235,48 +235,56 @@ test('a fully engulfed diver is not pinned by floating-point noise', async ({ pa expect(errors).toEqual([]); }); -test('an engulfed diver is pushed out before physics runs', async ({ page }) => { - // Issue #131. Escaping during movement requires equal-area steps to be legal, - // because a fully engulfed diver has no strictly reducing step available — - // and that allowance also let it slide the length of a slab at constant - // depth. Resolving the overlap up front makes the engulfed state transient, - // so there is nowhere to slide from. +test('no buried start position stays stuck, anywhere in any site', async ({ page }) => { + // Issue #131, and the two ways the first attempt at it failed. + // + // A single hand-picked probe cannot catch either. The slab originally used + // (x=14..78, d=39..40) has open water above and below, so both exits are + // legal and it never exercises the interesting cases: + // + // - the site clamp undoing an exit. On the wreck keel (x=14..170, d=65..66, + // floor 66) up and down tie, "down" reached d=66.300001, and the buoyancy + // clamp put it straight back to d=66 still inside. + // - stacked geometry. The mast (x=75..76, d=10..18) sits on the bridge deck + // (x=72..108, d=18..19), forming one continuous column; leaving the mast + // downward lands in the deck, whose cheapest exit is back up into the + // mast. The diver oscillated 17.7 <-> 18.3 until the pass limit gave up. + // + // So sweep every authored structure instead of trusting a chosen spot, and + // drive the real dive loop rather than resolveDiverOverlap() directly — the + // wiring is what the fix depends on. const errors = await bootGame(page); const result = await page.evaluate(() => { const A = window.gameAPI; - A.diveSite = 'wreck'; - - // Deep inside the vehicle-deck floor slab (x=14..78, d=39..40). The nearest - // way out is vertical: 0.2 m up beats 44 m sideways. - const startX = 46, startD = 39.5; - diverX = startX; depth = startD; - horizontalVelocity = 0; verticalVelocity = 0; - const engulfedBefore = A.diverSolidAt(diverX, depth); - - // Drive the REAL dive tick, not resolveDiverOverlap() directly — otherwise - // this passes just as happily with the call removed from the loop, which is - // the wiring the fix actually depends on. - gameState = 'diving'; - updateDiving(1 / 60); - const after = { x: +diverX.toFixed(3), d: +depth.toFixed(3) }; - const movedOut = after.x !== startX || after.d !== startD; - - return { - startX, startD, engulfedBefore, movedOut, after, - stillOverlapping: A.diverSolidAt(diverX, depth), - overlapArea: A.diverOverlapArea(diverX, depth), - // Pushed out the near side (up), not dragged the length of the slab. - horizontalDrift: Math.abs(after.x - startX), - }; + const stuck = []; + let tested = 0; + for (const site of ['shore', 'reef', 'wreck', 'cave']) { + A.diveSite = site; + for (const w of A.activeSite().structures) { + for (const x of [(w.x1 + w.x2) / 2, w.x1 + 0.1, w.x2 - 0.1]) { + for (const d of [(w.dTop + w.dBottom) / 2, w.dTop + 0.1, w.dBottom - 0.1]) { + if (!A.diverSolidAt(x, d)) continue; + tested += 1; + diverX = x; depth = d; + verticalVelocity = 0; horizontalVelocity = 0; + gameState = 'diving'; + for (let i = 0; i < 120; i += 1) updateDiving(1 / 60); + if (A.diverSolidAt(diverX, depth)) { + stuck.push(`${site} (${x.toFixed(2)}, ${d.toFixed(2)}) -> (${diverX.toFixed(2)}, ${depth.toFixed(2)}) overlap ${A.diverOverlapArea(diverX, depth).toFixed(3)} m²`); + } + } + } + } + } + return { tested, stuck }; }); - expect(result.engulfedBefore, 'probe must start engulfed').toBe(true); - expect(result.movedOut).toBe(true); - expect(result.stillOverlapping, `still inside at ${JSON.stringify(result.after)}`).toBe(false); - expect(result.overlapArea).toBe(0); - // Minimal translation: out through the nearest face, not along the slab. - expect(result.horizontalDrift, 'should exit vertically, not slide 30m').toBeLessThan(0.01); + // Guard the sweep: if it stopped finding buried positions it would pass + // vacuously however broken resolution got. + expect(result.tested, 'sweep must find buried positions to test').toBeGreaterThan(400); + const report = `${result.stuck.length} of ${result.tested} still buried:\n${result.stuck.slice(0, 10).join('\n')}`; + expect(result.stuck, report).toEqual([]); expect(errors).toEqual([]); }); From f6be04b945c961b4a1406e87cd19ab1160cf0f37 Mon Sep 17 00:00:00 2001 From: Niklas Gorman Date: Mon, 24 Aug 2026 15:35:16 +0200 Subject: [PATCH 3/3] fix: take the nearest reducing exit, not the nearest total escape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ranking residual overlap ahead of distance made the resolver clear itself in one jump however far that jump was. At the wreck bulkhead/deck corner (56.1, 51.9) a single real updateDiving tick moved the diver 22.35 m sideways to the main hatch at x=78.450001, because the hatch was the nearest place that left it completely free. Stepping 0.55 m left and then 0.2 m up clears it in two passes for about 0.75 m. Selection now takes the nearest candidate that strictly reduces buried area and lets the loop iterate. Strict reduction is what guarantees termination — each pass leaves the diver less buried than it found it, and zero is the floor — so the property that made the previous version terminate is preserved rather than traded away. The corner now resolves 0.585 m, to (55.549999, 51.699999). The sweep could not have caught this: it only asserts the diver ends up unstuck, and a 22 m teleport satisfies that. Distance needed asserting separately, so the corner has its own regression bounded at 2 m — the two-step escape is 0.75 m and the teleport was 22.35 m, and anything between them is still the wrong shape of answer. Restoring residual-first ranking fails it with "moved 22.35 m to (78.45, 51.90)". Checked the rest of the sweep for the same pattern rather than assuming the one case was alone. Six shore positions move about 12 m against a nearest face of 0.4 m, which is correct: those boulders are buried in the seabed, so at (109, 25.9) with floorAt 19.6 the down, left and right exits are all below the floor and rejected, leaving up as the only legal way out. High ratio, no alternative. Co-Authored-By: Claude Opus 5 --- src/sites.js | 18 ++++++++---- src/sites/resources/gameplay.json | 2 +- src/sites/resources/presentation.json | 2 +- tests/collision.spec.js | 40 +++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/sites.js b/src/sites.js index fdada85..2307d67 100644 --- a/src/sites.js +++ b/src/sites.js @@ -918,9 +918,15 @@ function resolveDiverOverlap() { candidates.push({ x: diverX, d: w.dBottom + DIVER_HALF_HEIGHT_M + CLEAR, vertical: true }); } - // Score by what the diver would actually be left buried in, then by how far - // it has to travel. Distance alone picked exits that were cheap for one box - // and useless overall. + // The NEAREST candidate that makes progress, not the one that clears the + // most. Ranking residual area first made the resolver jump straight to + // whatever fully freed it, however far away: at the wreck bulkhead/deck + // corner (56.1, 51.9) that was a 22.35 m horizontal teleport to the main + // hatch, when stepping 0.55 m left and then 0.2 m up clears it in two + // passes for about 0.75 m total. + // + // Requiring a strict reduction is what guarantees termination — each pass + // leaves the diver less buried than it found it, and zero is the floor. // // Legality is checked against the site clamp, because an exit the clamp // undoes is not an exit. On the wreck keel (x=14..170, d=65..66, floor 66) @@ -936,9 +942,9 @@ function resolveDiverOverlap() { : (depth >= ceilingAt(cand.x) && depth <= floorAt(cand.x)); if (!legal) continue; var after = diverOverlapArea(cand.x, cand.d); + if (after >= here - 1e-12) continue; // no progress: cannot terminate on it var move = Math.abs(cand.vertical ? cand.d - depth : cand.x - diverX); - if (best === null || after < best.after - 1e-12 || - (Math.abs(after - best.after) <= 1e-12 && move < best.move)) { + if (best === null || move < best.move) { best = { cand: cand, after: after, move: move }; } } @@ -947,7 +953,7 @@ function resolveDiverOverlap() { // geometry with no way out the site clamp will allow. Shoving it somewhere // illegal would trade one stuck state for another, so leave it to the // movement rule, which still permits overlap-reducing steps. - if (best === null || best.after >= here) return moved; + if (best === null) return moved; if (best.cand.vertical) { depth = best.cand.d; diff --git a/src/sites/resources/gameplay.json b/src/sites/resources/gameplay.json index 16e465f..81f8276 100644 --- a/src/sites/resources/gameplay.json +++ b/src/sites/resources/gameplay.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceDigest": "sha256:d882f93586c0906273d6cb2bbd671c5c43446425d6fae11fb4074841e10b9f07", + "sourceDigest": "sha256:865186623179124906cd5a852b03d92670636f15cb1b785cf39d931f3eb568ba", "generator": "npm run sites:generate", "kind": "diving-simulator-site-gameplay", "sites": { diff --git a/src/sites/resources/presentation.json b/src/sites/resources/presentation.json index 0583baa..10a0182 100644 --- a/src/sites/resources/presentation.json +++ b/src/sites/resources/presentation.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceDigest": "sha256:d882f93586c0906273d6cb2bbd671c5c43446425d6fae11fb4074841e10b9f07", + "sourceDigest": "sha256:865186623179124906cd5a852b03d92670636f15cb1b785cf39d931f3eb568ba", "generator": "npm run sites:generate", "kind": "diving-simulator-site-presentation", "sites": { diff --git a/tests/collision.spec.js b/tests/collision.spec.js index d40af5e..bb0ec70 100644 --- a/tests/collision.spec.js +++ b/tests/collision.spec.js @@ -288,6 +288,46 @@ test('no buried start position stays stuck, anywhere in any site', async ({ page expect(errors).toEqual([]); }); +test('resolution takes the nearest way out, not the showiest', async ({ page }) => { + // Ranking residual overlap ahead of distance made the resolver clear itself + // in one jump however far that jump was. At the wreck bulkhead/deck corner + // (56.1, 51.9) it teleported 22.35 m sideways to the main hatch, because the + // hatch was the nearest place that left it completely free — while stepping + // 0.55 m left and then 0.2 m up clears it in two passes. + // + // The sweep above only asserts the diver ends up unstuck, so it called the + // 22 m teleport a success. Distance needs asserting separately. + const errors = await bootGame(page); + + const result = await page.evaluate(() => { + const A = window.gameAPI; + A.diveSite = 'wreck'; + const startX = 56.1, startD = 51.9; + diverX = startX; depth = startD; + verticalVelocity = 0; horizontalVelocity = 0; + const buriedAtStart = A.diverOverlapArea(startX, startD); + + gameState = 'diving'; + updateDiving(1 / 60); + + return { + startX, startD, buriedAtStart, + x: diverX, d: depth, + stillSolid: A.diverSolidAt(diverX, depth), + displacement: Math.hypot(diverX - startX, depth - startD), + }; + }); + + expect(result.buriedAtStart, 'corner probe must start buried').toBeGreaterThan(0); + expect(result.stillSolid).toBe(false); + // The two-step escape is about 0.75 m; the teleport was 22.35 m. Anything in + // between would still be the wrong shape of answer. + expect( + result.displacement, + `moved ${result.displacement.toFixed(2)} m to (${result.x.toFixed(2)}, ${result.d.toFixed(2)})` + ).toBeLessThan(2); +}); + test('resolving an overlap is a no-op in open water', async ({ page }) => { // It runs every dive tick, so it must not nudge a diver who is fine. const errors = await bootGame(page);