diff --git a/PLANS.md b/PLANS.md index 55306e8..d454189 100644 --- a/PLANS.md +++ b/PLANS.md @@ -1684,3 +1684,12 @@ map moves below it; displacement controls speed; approximately 100 ms is ideal, isolated-network 200-sample runs, and maximum-session diagnostics are still required for acceptance criteria 6, 7, 9, and 10. Milestone 017 remains `IN_PROGRESS`; Milestone 018 has not been started. +- On 2026-09-09, an edge-projection correction translated the received maze so + the authoritative `self` cell remains below the centered avatar. Drag + displacement now starts at pointer-down, preventing a touch near the + transparent target edge from selecting an unintended direction. `bash + scripts/run_host_tests.sh` passed with 18 browser tests and deterministic + web-asset verification (`907d0323bd48120dd51034014df6bb21de375631adf9559af88d16a52f9958cf`, + 25,346 of 1,572,864 allowed compressed bytes). Firmware and filesystem + rebuilds passed with 58,868 of 327,680 RAM bytes, 1,027,966 of 2,097,152 + application bytes, and a 2,031,616-byte LittleFS image. diff --git a/docs/M017_LATENCY_REPORT.md b/docs/M017_LATENCY_REPORT.md index 659f94d..6ad9c3d 100644 --- a/docs/M017_LATENCY_REPORT.md +++ b/docs/M017_LATENCY_REPORT.md @@ -24,6 +24,12 @@ intention. Release removes the unsent intention. Consequently a slow socket cannot accumulate a client-side movement backlog. No browser code predicts a maze position, collision result, inventory, visibility, timer, or outcome. +Each received projection is translated so its authoritative `self` cell is +directly below the fixed avatar. This replaces rectangle-centering, which could +put the fixed avatar over a wall when a visible projection reached a maze edge. +Drag displacement is measured from the pointer-down position rather than from +the edge of the transparent target, preventing an unintended move on touch. + ## Required deployment-network remediation and retest Do not use the M016 Wi-Fi path for latency acceptance. Place the ESP and both diff --git a/docs/PLAYER_UI.md b/docs/PLAYER_UI.md index 9c4d388..acb142f 100644 --- a/docs/PLAYER_UI.md +++ b/docs/PLAYER_UI.md @@ -29,6 +29,9 @@ cooldown. The browser keeps one sent move in flight and coalesces all later drag changes into one latest intention; release discards that unsent intention. The deflection is local input feedback only. The maze redraws only after a server-authoritative projection and the browser never predicts a cell change. +For every projection, the browser translates only the received cells so the +authoritative `self` cell stays under the centered avatar; this also keeps the +avatar off a wall when the visible projection reaches a maze boundary. Commands use the version-one protocol envelope and contain intention data only: diff --git a/test/web/test_player_app.js b/test/web/test_player_app.js index 7bcd42f..d1e4ddb 100644 --- a/test/web/test_player_app.js +++ b/test/web/test_player_app.js @@ -95,6 +95,13 @@ test("player map cells enlarge small projections without exceeding a readable bo assert.ok(fourByFour <= 72); }); +test("authoritative self cell remains below the centered avatar at map edges", () => { + assert.deepEqual(player.playerMapOffset(0, 7, 0, 0, 7, 7, 46), {x: 138, y: 138}); + assert.deepEqual(player.playerMapOffset(48, 7, 0, 0, 7, 7, 46), {x: -138, y: -138}); + assert.deepEqual(player.playerMapOffset(24, 7, 1, 1, 5, 5, 46), {x: 0, y: 0}); + assert.deepEqual(player.playerMapOffset(-1, 7, 0, 0, 7, 7, 46), {x: 0, y: 0}); +}); + test("onboarding has bounded, replayable steps", () => { assert.equal(player.ONBOARDING_STEPS.length, 8); assert.equal(player.nextOnboardingStep(0), 1); @@ -111,6 +118,8 @@ test("avatar control resets safely and retains accessible alternatives", () => { assert.match(script, /addEventListener\("blur", resetAvatarControl\)/); assert.match(script, /connectionState !== "online"\) resetAvatarControl\(\)/); assert.match(script, /snapshot\.phase === "paused"\) \{ resetAvatarControl\(\)/); + assert.match(script, /state\.avatar\.startX = event\.clientX/); + assert.match(script, /event\.clientX - state\.avatar\.startX/); assert.match(script, /setMuted\(!state\.muted\)/); assert.match(script, /setReducedMotion\(!state\.reducedMotion\)/); assert.match(html, /id="avatar-control"/); diff --git a/web/player.js b/web/player.js index 94c5151..428e3a2 100644 --- a/web/player.js +++ b/web/player.js @@ -179,6 +179,16 @@ return Math.round(clamp(Math.min(stageWidth / (columns + 1), stageHeight / (rows + 1)), 32, 72)); } + function playerMapOffset(selfIndex, width, minX, minY, columns, rows, cellSize) { + if (![selfIndex, width, minX, minY, columns, rows, cellSize].every(Number.isFinite) || !Number.isInteger(selfIndex) || selfIndex < 0 || width < 1 || columns < 1 || rows < 1 || cellSize < 1) return {x: 0, y: 0}; + const selfX = selfIndex % width - minX; + const selfY = Math.floor(selfIndex / width) - minY; + return { + x: (columns / 2 - selfX - .5) * cellSize, + y: (rows / 2 - selfY - .5) * cellSize + }; + } + function nextOnboardingStep(currentStep) { return currentStep >= ONBOARDING_STEPS.length - 1 ? null : currentStep + 1; } @@ -290,7 +300,7 @@ function boot(documentObject, windowObject) { const byId = id => documentObject.getElementById(id); - const state = {socket: null, clientSequence: 0, serverSequence: 0, viewSequence: -1, session: null, lobby: null, snapshot: null, reconnectTimer: null, noticeTimer: null, touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), latency: new LatencyTracker(() => windowObject.performance.now()), audio: null, feedbackReady: false, muted: false, reducedMotion: false, onboardingSeen: false, onboardingStep: 0, avatar: {pointerId: null, direction: null, repeatTimer: null, repeatMs: null}, observer: {scale: 1, x: 0, y: 0, dragging: null}}; + const state = {socket: null, clientSequence: 0, serverSequence: 0, viewSequence: -1, session: null, lobby: null, snapshot: null, reconnectTimer: null, noticeTimer: null, touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), latency: new LatencyTracker(() => windowObject.performance.now()), audio: null, feedbackReady: false, muted: false, reducedMotion: false, onboardingSeen: false, onboardingStep: 0, avatar: {pointerId: null, startX: null, startY: null, direction: null, repeatTimer: null, repeatMs: null}, observer: {scale: 1, x: 0, y: 0, dragging: null}}; state.moveFlow = new LatestIntentFlow(direction => sendMoveNow(direction)); function showNotice(text, kind) { @@ -448,9 +458,12 @@ const columns = maxX - minX + 1; const rows = maxY - minY + 1; const mapShell = byId("map-shell"); - maze.style.setProperty("--cell", `${playerCellSize(mapShell.clientWidth, mapShell.clientHeight, columns, rows)}px`); + const cellSize = playerCellSize(mapShell.clientWidth, mapShell.clientHeight, columns, rows); + const offset = playerMapOffset(snapshot.self, snapshot.width, minX, minY, columns, rows, cellSize); + maze.style.setProperty("--cell", `${cellSize}px`); maze.style.gridTemplateColumns = `repeat(${columns}, var(--cell))`; maze.style.gridTemplateRows = `repeat(${rows}, var(--cell))`; + maze.style.transform = `translate(calc(-50% + ${offset.x}px), calc(-50% + ${offset.y}px))`; const objects = new Map(); snapshot.objects.forEach(object => { const list = objects.get(object[3]) || []; list.push(object); objects.set(object[3], list); }); coordinates.forEach(data => { @@ -669,6 +682,8 @@ function resetAvatarControl() { windowObject.clearTimeout(state.avatar.repeatTimer); state.avatar.pointerId = null; + state.avatar.startX = null; + state.avatar.startY = null; state.avatar.direction = null; state.avatar.repeatTimer = null; state.avatar.repeatMs = null; @@ -695,7 +710,7 @@ const control = byId("avatar-control"); const rect = control.getBoundingClientRect(); const radius = Math.max(AVATAR_DEAD_ZONE + 1, Math.min(rect.width, rect.height) / 2); - const vector = avatarVector(event.clientX - (rect.left + rect.width / 2), event.clientY - (rect.top + rect.height / 2), radius, state.avatar.direction); + const vector = avatarVector(event.clientX - state.avatar.startX, event.clientY - state.avatar.startY, radius, state.avatar.direction); const handle = byId("avatar-handle"); handle.style.transform = `translate(calc(-50% + ${vector.x}px), calc(-50% + ${vector.y}px))`; const directionChanged = vector.direction !== state.avatar.direction; @@ -741,6 +756,8 @@ event.preventDefault(); beginFeedback(); state.avatar.pointerId = event.pointerId; + state.avatar.startX = event.clientX; + state.avatar.startY = event.clientY; byId("avatar-control").classList.add("active"); if (byId("avatar-control").setPointerCapture) byId("avatar-control").setPointerCapture(event.pointerId); updateAvatarControl(event); @@ -797,7 +814,7 @@ return {state, handleMessage, move, resetAvatarControl, latencySamples: () => state.latency.snapshot(), pointerFeedbackSamples: () => state.latency.pointerSnapshot()}; } - const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, NAME_COMBINATION_COUNT, LOGIN_HEADLINES, ONBOARDING_STEPS, LATENCY_SAMPLE_LIMIT, AVATAR_TOUCH_TARGET_SIZE, AVATAR_MAX_DEFLECTION, AVATAR_DEAD_ZONE, AVATAR_DIRECTION_HYSTERESIS, AVATAR_SLOW_REPEAT_MS, AVATAR_FAST_REPEAT_MS, LatestIntentFlow, LatencyTracker, directionFromKey, directionFromSwipe, avatarDirection, avatarRepeatMs, avatarVector, playerCellSize, nextOnboardingStep, randomIndex, randomSuggestedName, randomLoginHeadline, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, clampObserverZoom, observerFitScale, boot}); + const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, NAME_COMBINATION_COUNT, LOGIN_HEADLINES, ONBOARDING_STEPS, LATENCY_SAMPLE_LIMIT, AVATAR_TOUCH_TARGET_SIZE, AVATAR_MAX_DEFLECTION, AVATAR_DEAD_ZONE, AVATAR_DIRECTION_HYSTERESIS, AVATAR_SLOW_REPEAT_MS, AVATAR_FAST_REPEAT_MS, LatestIntentFlow, LatencyTracker, directionFromKey, directionFromSwipe, avatarDirection, avatarRepeatMs, avatarVector, playerCellSize, playerMapOffset, nextOnboardingStep, randomIndex, randomSuggestedName, randomLoginHeadline, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, clampObserverZoom, observerFitScale, boot}); if (typeof module !== "undefined" && module.exports) module.exports = api; root.Maze32Player = api; if (root.document) root.document.addEventListener("DOMContentLoaded", () => { root.maze32PlayerApp = boot(root.document, root); });