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..2307d67 100644 --- a/src/sites.js +++ b/src/sites.js @@ -874,6 +874,99 @@ 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 strictly reduces buried area, so it terminates. Ten is + // far more than the authored geometry stacks. + for (var pass = 0; pass < 10; pass++) { + 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; + 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 }); + } + + // 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) + // 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); + 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 || 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) return moved; + + if (best.cand.vertical) { + depth = best.cand.d; + verticalVelocity = 0; + } else { + diverX = best.cand.x; + 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..81f8276 100644 --- a/src/sites/resources/gameplay.json +++ b/src/sites/resources/gameplay.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceDigest": "sha256:277d0480b0704d6d17599a4a07c89570f4bda43dd624fc3ea3b72479f634952b", + "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 396fd1b..10a0182 100644 --- a/src/sites/resources/presentation.json +++ b/src/sites/resources/presentation.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceDigest": "sha256:277d0480b0704d6d17599a4a07c89570f4bda43dd624fc3ea3b72479f634952b", + "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 273f490..bb0ec70 100644 --- a/tests/collision.spec.js +++ b/tests/collision.spec.js @@ -235,6 +235,119 @@ test('a fully engulfed diver is not pinned by floating-point noise', async ({ pa expect(errors).toEqual([]); }); +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; + 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 }; + }); + + // 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([]); +}); + +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); + 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 }) => {