Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 19 additions & 0 deletions playwright.config.js
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
8 changes: 8 additions & 0 deletions src/game-loop.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
93 changes: 93 additions & 0 deletions src/sites.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/sites/resources/gameplay.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"sourceDigest": "sha256:277d0480b0704d6d17599a4a07c89570f4bda43dd624fc3ea3b72479f634952b",
"sourceDigest": "sha256:865186623179124906cd5a852b03d92670636f15cb1b785cf39d931f3eb568ba",
"generator": "npm run sites:generate",
"kind": "diving-simulator-site-gameplay",
"sites": {
Expand Down
2 changes: 1 addition & 1 deletion src/sites/resources/presentation.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"sourceDigest": "sha256:277d0480b0704d6d17599a4a07c89570f4bda43dd624fc3ea3b72479f634952b",
"sourceDigest": "sha256:865186623179124906cd5a852b03d92670636f15cb1b785cf39d931f3eb568ba",
"generator": "npm run sites:generate",
"kind": "diving-simulator-site-presentation",
"sites": {
Expand Down
113 changes: 113 additions & 0 deletions tests/collision.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 16 additions & 2 deletions tests/global-setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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;
}
Expand Down
33 changes: 31 additions & 2 deletions tests/wreck-slice.spec.js
Original file line number Diff line number Diff line change
@@ -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' }),
Expand Down Expand Up @@ -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 }) => {
Expand Down