diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index c107ad8..3924481 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -351,6 +351,13 @@ visible edge and an adjacent server-approved self-cell move across that exact
edge; it is bounded, replaceable by a newer projection, and never delays the
received state.
+The browser may keep one low-priority heartbeat in flight after a session is
+established. It measures complete round trip with its own monotonic clock; the
+matching acknowledgement contains only a bounded ESP-monotonic handling
+interval. The displayed communication remainder is `max(0, round trip - ESP
+handling)` and is never represented as a synchronized-clock measurement or
+pure Wi-Fi diagnosis.
+
Player projections are translated only within their received cells so the
authoritative `self` cell aligns with the responsive local focal point. That
point is centered horizontally and 30% down the usable portrait map region;
diff --git a/PLANS.md b/PLANS.md
index bcda886..1481215 100644
--- a/PLANS.md
+++ b/PLANS.md
@@ -3265,7 +3265,7 @@ indicator.
## Milestone 029 — Low-overhead live connection quality
-**Status:** `NOT_STARTED`
+**Status:** `COMPLETE`
### Objective
@@ -3412,5 +3412,205 @@ resources.
measured ESP handling interval and an honestly labelled communication
remainder, while keeping the displayed headline value equal to the complete
browser-to-ESP application round trip.
-- Implementation and verification have not started, as requested by the
- project owner.
+- On 2026-09-13, implementation began with a single in-flight browser
+ heartbeat sampled no more often than every five seconds, a five-sample
+ median, 15-second stale timeout, and explicit unknown, stale, and offline
+ indicator states. Heartbeat acknowledgements now include bounded ESP-only
+ `handlingMs`, and the browser derives the non-negative `Связь` remainder.
+- On 2026-09-13, `node --test test/web/test_player_app.js
+ test/web/test_hedge_svg.js` passed all 33 tests, including bounded
+ heartbeat correlation, malformed-field rejection, smoothing, stale state,
+ indicator copy, and compact header placement.
+- On 2026-09-13, `bash scripts/run_host_tests.sh` passed. `python3
+ scripts/verify_web_assets.py` passed with a deterministic 32,503 /
+ 1,572,864 byte compressed payload. `/home/sasa/.platformio/penv/bin/pio
+ run` passed with firmware at 1,029,808 / 2,097,152 bytes and
+ `/home/sasa/.platformio/penv/bin/pio run --target buildfs` passed with a
+ 2,031,616 / 2,031,616 byte LittleFS image.
+- Physical-device latency measurements and mobile and desktop visual
+ acceptance were pending until the project-owner check below.
+- On 2026-09-13, the project owner manually checked the quality indicator and
+ accepted `ESP: 0 мс` as the correct rounded display for the measured
+ sub-millisecond heartbeat handling interval. The project owner found no UI
+ issue and accepted the indicator on the available application screens.
+
+## Milestone 030 — Explicit session exit and slot release
+
+**Status:** `IN_PROGRESS`
+
+### Objective
+
+Let an authenticated player or observer deliberately leave through the
+top-right settings menu, revoke only that browser's session, immediately free
+its slot, and return it to the name-entry screen with its previous display name
+prefilled. A player leaving an unfinished match must authoritatively cancel
+that match and return every remaining participant to the lobby, while an
+observer departure or a lobby departure must not reset gameplay.
+
+### Scope
+
+- Do not start implementation until every preceding milestone is `COMPLETE`.
+ Milestone 029 is `COMPLETE` when this plan is created; this milestone remains
+ `NOT_STARTED` because implementation was not requested.
+- Add one clearly labelled Russian `Выйти из игры` action to the existing
+ gear-triggered settings menu. Show it only while the browser owns an
+ authenticated player or observer session; do not show it on the unauthenticated
+ welcome and name-entry screen.
+- Require a modal confirmation before sending an exit command. For a player in
+ countdown, active, finish-pending, or paused state, clearly warn that the
+ current match will end for everyone and the player's slot will be freed. For
+ a player in the lobby or an observer in any phase, explain that only this
+ browser's place will be released.
+- Treat opening or cancelling the confirmation as browser-local UI only. While
+ it is open, suppress input from that browser without pausing or otherwise
+ mutating authoritative state; cancelling must restore the previous screen,
+ focus, controls, and session unchanged.
+- Introduce a versioned authenticated explicit-leave protocol command and a
+ correlated success response. It must be distinct from an unexpected socket
+ disconnect, page reload, or transient connection loss, which retain the
+ existing 60-second player reconnection contract.
+- On an accepted player leave during countdown, active, finish-pending, or
+ paused state, stop all match timers, clear the current authoritative match,
+ return the lobby to its waiting state, retain every other connected session
+ and role, revoke the leaving session, free its player slot immediately, and
+ broadcast the cancellation and fresh lobby state to the remaining players
+ and observers.
+- On an accepted player leave while already in the waiting lobby or after a
+ completed result, revoke only that player's session and free its slot. Do not
+ reset an unfinished match because none is active in those phases.
+- On an accepted observer leave in any phase, revoke only that observer's
+ session and free its observer slot. Never pause, reset, finish, restart, or
+ otherwise mutate the match because an observer leaves.
+- Preserve the roles and sessions of all participants who did not leave. If
+ Player 1 leaves, Player 2 remains in its existing slot and a later player may
+ claim the newly free Player 1 slot and become the lobby owner under the
+ existing ownership rule.
+- Invalidate the departed session token on the ESP before confirming success.
+ The revoked token must never restore its former role, slot, position,
+ inventory, or reconnect reservation, and a duplicate or replayed leave must
+ be harmless.
+- After confirmed success, remove the session token from browser-local storage,
+ clear all session, match, input, timer, heartbeat, animation, and pending
+ command state, and show the welcome/name-entry screen without requiring a
+ page reload.
+- Store the last successfully used display name separately from the temporary
+ session token in a bounded browser-local value. Prefill it on return to the
+ name-entry screen, but still apply normal name validation and duplicate-name
+ rejection if the user tries to join again.
+- Do not claim that the slot was released before the ESP acknowledges the
+ explicit leave. If the command cannot be delivered or confirmed, show a
+ concise Russian error and retain honest reconnect behavior rather than
+ silently turning a network failure into a successful exit.
+- Bound command size, correlation state, acknowledgement waiting, and cleanup.
+ The leave path must not disclose hidden maze state, permit one participant to
+ revoke another session, bypass observer read-only gameplay rules, or create
+ a new unbounded queue or retry loop.
+- Preserve the compact header, live connection-quality indicator, Wi-Fi and
+ preference actions, responsive layouts, offline operation, Russian
+ player-facing copy, and firmware and LittleFS budgets.
+
+### Deliverables
+
+- Updated explicit-leave, match-cancellation, slot-release, session-revocation,
+ and reconnect contracts in `MVP.md`, `ARCHITECTURE.md`, `docs/PROTOCOL.md`,
+ `docs/UI_STYLE.md`, and `docs/PLAYER_UI.md`.
+- A bounded authenticated leave command and response implemented across the
+ protocol parser, lobby, authoritative game lifecycle, gateway, and browser.
+- A settings-menu exit action with role- and phase-appropriate confirmation,
+ failure handling, focus restoration, and successful return to the
+ name-entry screen.
+- Separate bounded persistence for the last player display name and complete
+ cleanup of the revoked temporary session.
+- Deterministic protocol, lobby, gateway, game-lifecycle, browser,
+ accessibility, race, replay, reconnect, responsive-layout, and regression
+ tests.
+- Host, browser, web-asset, firmware, LittleFS, maximum-session, and
+ physical-device verification evidence.
+
+### Acceptance criteria
+
+1. An authenticated player or observer sees exactly one `Выйти из игры` item
+ in the gear menu on every applicable screen. An unauthenticated browser sees
+ no exit item, and primary game controls remain outside the menu.
+2. Selecting the item opens a keyboard- and touch-accessible confirmation
+ modal and sends no protocol command until explicit confirmation. Cancelling,
+ pressing Escape, or activating the modal's cancel action leaves the session
+ and authoritative state unchanged and safely restores focus.
+3. A player confirmation during countdown, active, finish-pending, or paused
+ state explicitly says that the match will end for everyone and the slot will
+ be freed. Confirmation produces exactly one authoritative cancellation,
+ stops every relevant timer, clears the current match, and returns all
+ remaining connected participants to a fresh waiting lobby.
+4. The leaving player's session is removed and its slot becomes available in
+ the same accepted server transition. Every other player's and observer's
+ session, role, and connection remain valid, and the resulting lobby snapshot
+ consistently shows the newly free slot.
+5. A player leaving the waiting lobby or a completed result releases only that
+ player's session and slot. It does not disconnect other participants or
+ perform an additional match reset.
+6. An observer leaving during any phase releases only that observer session and
+ observer slot. Match phase, players, timers, maze, objects, inventory, and
+ results remain byte-for-byte unchanged except for the expected lobby
+ connection revision or participant count.
+7. The explicit leave command authenticates the sending session and cannot
+ name or revoke another token or slot. Duplicate, stale, reordered, malformed,
+ unauthorized, and replayed requests cause no second cancellation, no extra
+ slot release, and no unrelated state mutation.
+8. An accepted explicit leave never starts the 60-second reconnect reservation.
+ A normal page reload, accidental socket loss, or unconfirmed exit continues
+ to follow the existing reconnect and pause rules without behavioral change.
+9. Only after the correlated success response, the departing browser deletes
+ its temporary session token, clears all role and gameplay state, stops all
+ input, animation, heartbeat, reconnect, and pending-command work, and shows
+ the welcome/name-entry screen without a full reload or stale map flash.
+10. The last successfully used display name survives separately from the
+ revoked token and is prefilled in the name field after exit and on a later
+ visit. It is bounded to the existing name limit, contains no token data,
+ and receives the normal validation and uniqueness checks when reused.
+11. If the leave request cannot be sent, times out, loses its socket, or receives
+ a rejection, the browser does not report a successful release. It presents
+ concise Russian recovery information, retains bounded state, and neither
+ retries indefinitely nor corrupts a later reconnect or successful leave.
+12. If Player 1 leaves, Player 2 remains in Player 2's slot and a new player can
+ immediately claim the free Player 1 slot, becoming the lobby owner and
+ gaining start authority only through the existing server rule. Equivalent
+ behavior applies when Player 2 leaves.
+13. The exit item and confirmation fit supported narrow portrait,
+ compact-landscape, tablet, and desktop layouts, respect safe areas, avoid
+ page and map overflow, and remain distinguishable without relying only on
+ color, sound, vibration, or animation.
+14. Automated tests cover every role and phase, confirmation and cancellation,
+ authoritative match cancellation, retained participants, immediate slot
+ reuse, token revocation, remembered-name prefill, disconnect races,
+ idempotence, authorization, cleanup, accessibility, and responsive layout.
+15. All host and browser tests pass; compressed-web-asset validation passes;
+ the payload remains within the 1.5 MiB web budget and LittleFS partition;
+ and firmware remains within the factory partition.
+16. Physical-device testing confirms that a player can cancel an unfinished
+ match and immediately release its slot, an observer can leave without
+ affecting play, the remaining browsers receive the correct lobby state,
+ and the departing browser returns to a prefilled name-entry screen without
+ stale controls, delayed input, or reconnect side effects.
+17. The project owner confirms on representative mobile and desktop browsers
+ that the exit action is easy to find but difficult to trigger accidentally,
+ its consequences are clear, and all role-specific outcomes match the
+ intended behavior, or explicitly approves a documented evidence deviation.
+
+### Evidence
+
+- On 2026-09-13, planning inspection confirmed that unexpected player socket
+ loss currently pauses an active match and reserves the player session for a
+ 60-second reconnect, while observer disconnect does not affect gameplay.
+ Therefore immediate voluntary slot release requires an explicit authenticated
+ lifecycle command rather than client-only token deletion or socket closure.
+- On 2026-09-13, the project owner required a settings-menu exit action with a
+ confirmation modal, complete session-token revocation, immediate slot
+ release, and return to the name-entry screen with the previous player name
+ prefilled.
+- The project owner also required an unfinished match to be cancelled for all
+ participants when a player leaves, while a lobby player or observer departure
+ must affect only the departing session.
+- On 2026-09-13, implementation began with an authenticated `leave` command,
+ immediate server-side session revocation, player-match cancellation, and a
+ browser confirmation flow that clears the temporary token only after the
+ correlated acknowledgement.
diff --git a/components/maze32_lobby/include/maze32_lobby.h b/components/maze32_lobby/include/maze32_lobby.h
index 633047f..29c7fd1 100644
--- a/components/maze32_lobby/include/maze32_lobby.h
+++ b/components/maze32_lobby/include/maze32_lobby.h
@@ -66,6 +66,8 @@ maze32_lobby_result_t maze32_lobby_join_observer(maze32_lobby_t* lobby, int conn
maze32_lobby_result_t maze32_lobby_restore(maze32_lobby_t* lobby, int connection_id,
const char* token, maze32_session_t** session);
void maze32_lobby_disconnect(maze32_lobby_t* lobby, int connection_id);
+int maze32_lobby_leave(maze32_lobby_t* lobby, int connection_id, maze32_role_t* role,
+ unsigned int* player_slot);
maze32_lobby_result_t maze32_lobby_start(maze32_lobby_t* lobby, int connection_id, uint64_t now_ms);
int maze32_lobby_activate_if_due(maze32_lobby_t* lobby, uint64_t now_ms);
void maze32_lobby_reset_match(maze32_lobby_t* lobby);
diff --git a/components/maze32_lobby/include/maze32_protocol.h b/components/maze32_lobby/include/maze32_protocol.h
index 7eba48b..c88ea74 100644
--- a/components/maze32_lobby/include/maze32_protocol.h
+++ b/components/maze32_lobby/include/maze32_protocol.h
@@ -22,6 +22,7 @@ typedef enum {
MAZE32_MESSAGE_MOVE,
MAZE32_MESSAGE_DROP_KEY,
MAZE32_MESSAGE_RESTART,
+ MAZE32_MESSAGE_LEAVE,
MAZE32_MESSAGE_HEARTBEAT,
} maze32_message_type_t;
diff --git a/components/maze32_lobby/maze32_lobby.c b/components/maze32_lobby/maze32_lobby.c
index e39ffe5..9331da6 100644
--- a/components/maze32_lobby/maze32_lobby.c
+++ b/components/maze32_lobby/maze32_lobby.c
@@ -167,6 +167,30 @@ void maze32_lobby_disconnect(maze32_lobby_t* lobby, int connection_id) {
}
}
+int maze32_lobby_leave(maze32_lobby_t* lobby, int connection_id, maze32_role_t* role,
+ unsigned int* player_slot) {
+ if (lobby == NULL) {
+ return 0;
+ }
+ for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) {
+ maze32_session_t* session = &lobby->sessions[index];
+ if (session->role != MAZE32_ROLE_NONE && session->connected &&
+ session->connection_id == connection_id) {
+ if (role != NULL) {
+ *role = session->role;
+ }
+ if (player_slot != NULL) {
+ *player_slot = session->player_slot;
+ }
+ memset(session, 0, sizeof(*session));
+ session->connection_id = -1;
+ lobby->revision++;
+ return 1;
+ }
+ }
+ return 0;
+}
+
maze32_lobby_result_t maze32_lobby_start(maze32_lobby_t* lobby, int connection_id,
uint64_t now_ms) {
if (lobby == NULL || lobby->phase != MAZE32_LOBBY_WAITING) {
diff --git a/components/maze32_lobby/maze32_protocol.c b/components/maze32_lobby/maze32_protocol.c
index 39f5e45..db2bd82 100644
--- a/components/maze32_lobby/maze32_protocol.c
+++ b/components/maze32_lobby/maze32_protocol.c
@@ -193,6 +193,9 @@ static maze32_message_type_t message_type(const char* value) {
if (strcmp(value, "restart") == 0) {
return MAZE32_MESSAGE_RESTART;
}
+ if (strcmp(value, "leave") == 0) {
+ return MAZE32_MESSAGE_LEAVE;
+ }
if (strcmp(value, "heartbeat") == 0) {
return MAZE32_MESSAGE_HEARTBEAT;
}
diff --git a/components/maze32_web/maze32_gateway.c b/components/maze32_web/maze32_gateway.c
index 9b07eb4..f31848f 100644
--- a/components/maze32_web/maze32_gateway.c
+++ b/components/maze32_web/maze32_gateway.c
@@ -948,6 +948,40 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_
"Сначала выберите роль или восстановите сессию");
return;
}
+ if (message->type == MAZE32_MESSAGE_LEAVE) {
+ maze32_role_t leaving_role = session->role;
+ int cancel_match = leaving_role == MAZE32_ROLE_PLAYER &&
+ gateway->lobby.phase != MAZE32_LOBBY_WAITING &&
+ gateway->game.phase != MAZE32_GAME_PHASE_FINISHED;
+ char acknowledgement[192] = "";
+ size_t length = 0U;
+ char prefix[96];
+ int written = snprintf(prefix, sizeof(prefix),
+ "{\"v\":1,\"type\":\"ack\",\"seq\":%" PRIu32 ",\"requestId\":",
+ next_server_sequence(gateway));
+ if (written >= 0 && (size_t)written < sizeof(prefix) &&
+ append_text(acknowledgement, sizeof(acknowledgement), &length, prefix) &&
+ append_json_string(acknowledgement, sizeof(acknowledgement), &length,
+ message->request_id) &&
+ append_text(acknowledgement, sizeof(acknowledgement), &length,
+ ",\"payload\":{\"command\":\"leave\"}}")) {
+ enqueue(gateway, socket_fd, acknowledgement);
+ }
+ if (cancel_match) {
+ esp_timer_stop(gateway->countdown_timer);
+ esp_timer_stop(gateway->reconnect_timer);
+ esp_timer_stop(gateway->finish_timer);
+ initialize_game(gateway);
+ maze32_lobby_reset_match(&gateway->lobby);
+ }
+ maze32_lobby_leave(&gateway->lobby, socket_fd, NULL, NULL);
+ if (cancel_match) {
+ broadcast_match_event(gateway, "reset", "{}");
+ }
+ broadcast_snapshot(gateway);
+ drain_queue(gateway);
+ return;
+ }
if (message->type == MAZE32_MESSAGE_START) {
if (session->role == MAZE32_ROLE_OBSERVER) {
ESP_LOGW(TAG, "Observer mutation rejected: fd=%d, type=start", socket_fd);
@@ -977,18 +1011,29 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_
return;
}
if (message->type == MAZE32_MESSAGE_HEARTBEAT) {
+ int64_t heartbeat_started_us = esp_timer_get_time();
char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = "";
size_t length = 0U;
char prefix[128];
+ char handling_text[32];
int written = snprintf(prefix, sizeof(prefix), "{\"v\":1,\"type\":\"ack\",\"seq\":%" PRIu32,
next_server_sequence(gateway));
- if (written >= 0 && (size_t)written < sizeof(prefix) &&
+ uint64_t handling_ms = (uint64_t)(esp_timer_get_time() - heartbeat_started_us) / 1000U;
+ if (handling_ms > 1000U) {
+ handling_ms = 1000U;
+ }
+ int handling_written =
+ snprintf(handling_text, sizeof(handling_text), "%" PRIu64, handling_ms);
+ if (written >= 0 && (size_t)written < sizeof(prefix) && handling_written >= 0 &&
+ (size_t)handling_written < sizeof(handling_text) &&
append_text(response, sizeof(response), &length, prefix) &&
append_text(response, sizeof(response), &length, ",\"requestId\":") &&
append_json_string(response, sizeof(response), &length, message->request_id) &&
append_text(response, sizeof(response), &length,
- ",\"payload\":{\"command\":\"heartbeat\"}}")) {
- enqueue(gateway, socket_fd, response);
+ ",\"payload\":{\"command\":\"heartbeat\",\"handlingMs\":") &&
+ append_text(response, sizeof(response), &length, handling_text) &&
+ append_text(response, sizeof(response), &length, "}}")) {
+ enqueue_with_policy(gateway, socket_fd, response, 1);
}
}
}
diff --git a/data/index.html.gz b/data/index.html.gz
index 0093968..43a0a42 100644
Binary files a/data/index.html.gz and b/data/index.html.gz differ
diff --git a/docs/M016_LATENCY_PROCEDURE.md b/docs/M016_LATENCY_PROCEDURE.md
index 849a690..db265d9 100644
--- a/docs/M016_LATENCY_PROCEDURE.md
+++ b/docs/M016_LATENCY_PROCEDURE.md
@@ -14,6 +14,7 @@ browser timestamp from an ESP timestamp.
| command to painted view | browser | `move` send to next frame after matching view render |
| handler and send | ESP | existing `/diagnostics` maxima |
| network round trip | client | ICMP and WebSocket heartbeat samples |
+| ESP heartbeat handling | ESP | bounded acknowledgement preparation interval |
The accepted command acknowledgement contains the authoritative `stateSequence`.
The browser records a sample only after the complete view with that sequence is
@@ -38,6 +39,10 @@ rendered and the next animation frame occurs. Samples are local-only, capped at
and send maxima, queue high-water marks, observer view chunk count, and
ICMP/WebSocket round trips. Attribute a delay only when the matching
interval is elevated.
+7. For the compact quality indicator, record the rounded `Отклик`, `Связь`,
+ and `ESP` values after the five-second heartbeat cadence has collected a
+ fresh sample. `Связь` is the non-negative round-trip remainder, not a pure
+ Wi-Fi measurement.
## Safety checks
diff --git a/docs/PLAYER_UI.md b/docs/PLAYER_UI.md
index 47426ae..d819057 100644
--- a/docs/PLAYER_UI.md
+++ b/docs/PLAYER_UI.md
@@ -66,6 +66,12 @@ closing this presentation-only menu never sends a command or changes server
state. Escape and outside activation dismiss it; player actions also close it
before opening their existing destination.
+The header response indicator takes at most one heartbeat sample every five
+seconds, holds one in-flight request, and uses a five-sample median. Good is
+at most 150 ms, delayed is 151--500 ms, and slow is above 500 ms. Hysteresis
+holds good until above 175 ms and slow until at most 450 ms; a sample is stale
+after 15 seconds. Hidden documents and closed sockets do not probe.
+
The browser stores the direction for each outstanding move request. A matching
server `blocked` rejection may include only that player's approved breakthrough
stage, which bends the already rendered bush wall for at most 340 ms without
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md
index c2091d3..7057a34 100644
--- a/docs/PROTOCOL.md
+++ b/docs/PROTOCOL.md
@@ -39,6 +39,9 @@ is only echoed for correlation; it grants no authority.
- `restart`: `{}`. Only connected Player 1 may reset a finished match, and both
players must be connected. Sessions are preserved while a new authoritative
maze and game state are created.
+- `leave`: `{}`. Revokes only the authenticated sending session. A player
+ leaving an unfinished match cancels it for all participants; observer and
+ waiting-lobby departures release only that session.
- `heartbeat`: `{}` after a session has been joined or restored.
## Server messages
@@ -60,7 +63,10 @@ is only echoed for correlation; it grants no authority.
- `match_result`: reports the terminal winner or draw.
- `reset`: reports the authoritative return from a finished match to the
waiting lobby.
-- `ack`: successful heartbeat correlation.
+- `ack`: successful heartbeat correlation. A heartbeat acknowledgement has
+ `payload.command` set to `heartbeat` and a bounded integer
+ `payload.handlingMs` measured only by the ESP monotonic clock while it
+ prepares that acknowledgement.
- `error`: a stable English `code` and Russian player-facing `message`.
An observer session can receive snapshots but cannot issue `start`, `move`, or
diff --git a/docs/UI_STYLE.md b/docs/UI_STYLE.md
index e2010e9..0448df6 100644
--- a/docs/UI_STYLE.md
+++ b/docs/UI_STYLE.md
@@ -106,6 +106,15 @@ are not MVP requirements.
help actions during an active player match. The popover is constrained to
safe areas, closes on outside activation or Escape, and never contains
primary game, lobby, observer, or result actions.
+- The settings popover shows `Выйти из игры` only for an authenticated session.
+ It opens a confirmation modal; an unfinished player match warns that it ends
+ for everyone, while observer and lobby departures explain that only this
+ browser's place is released.
+- A compact response indicator between the connection state and gear shows a
+ rounded total response time or an explicit unknown, stale, or offline state.
+ Its accessible description and settings details expose `Отклик`, `Связь`,
+ and `ESP`; `Связь` is a transport remainder and does not assign blame to
+ Wi-Fi, the ESP, or a player.
- Countdown, pause, and result states appear as modal overlays above the map.
- Textual system notices use one live message rail above the top bar and player
status area. It shows one message at a time: connection loss has highest
diff --git a/test/web/test_player_app.js b/test/web/test_player_app.js
index 1eb52d0..3956684 100644
--- a/test/web/test_player_app.js
+++ b/test/web/test_player_app.js
@@ -103,6 +103,29 @@ test("latency correlation is bounded and only completes matching authoritative v
assert.equal(tracker.pointerSnapshot().length, player.LATENCY_SAMPLE_LIMIT);
});
+test("connection quality uses one correlated heartbeat and bounded local smoothing", () => {
+ let clock = 0;
+ const quality = new player.ConnectionQuality(() => clock);
+ assert.equal(quality.begin("heartbeat-1"), true);
+ assert.equal(quality.begin("heartbeat-2"), false);
+ clock = 85;
+ assert.equal(quality.acknowledge("wrong", 2), false);
+ assert.equal(quality.acknowledge("heartbeat-1", 2), true);
+ assert.deepEqual(quality.latest, {totalMs: 85, communicationMs: 83, handlingMs: 2, received: 85});
+ for (let index = 0; index < player.QUALITY_SAMPLE_LIMIT + 2; index++) {
+ clock += 10;
+ assert.equal(quality.begin(`heartbeat-${index + 2}`), true);
+ clock += 20;
+ assert.equal(quality.acknowledge(`heartbeat-${index + 2}`, 1), true);
+ }
+ assert.equal(quality.samples.length, player.QUALITY_SAMPLE_LIMIT);
+ assert.equal(quality.acknowledge("heartbeat-99", 1001), false);
+ clock += player.HEARTBEAT_STALE_MS + 1;
+ assert.equal(quality.status(), "stale");
+ quality.reset();
+ assert.equal(quality.status(), "unknown");
+});
+
test("player map cells enlarge small projections without exceeding a readable bound", () => {
const sevenBySeven = player.playerCellSize(396, 730, 7, 7);
const fourByFour = player.playerCellSize(396, 730, 4, 4);
@@ -338,8 +361,9 @@ test("refresh contract restores only the token and waits for server projection",
assert.equal("position" in restore.payload, false);
assert.equal("inventory" in restore.payload, false);
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
- assert.equal((script.match(/localStorage\.setItem/g) || []).length, 1);
+ assert.equal((script.match(/localStorage\.setItem/g) || []).length, 2);
assert.match(script, /localStorage\.setItem\(TOKEN_KEY, message\.payload\.token\)/);
+ assert.match(script, /localStorage\.setItem\(NAME_KEY, byId\("player-name"\)\.value\.slice\(0, 32\)\)/);
});
test("player assets are local, Russian, accessible, and responsive", () => {
@@ -392,6 +416,19 @@ test("one compact settings popover replaces standalone secondary controls", () =
assert.match(script, /button\.textContent = reduced \? "Движение: выкл" : "Движение: вкл"/);
});
+test("connection quality stays compact and exposes an honest response split", () => {
+ const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
+ const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
+ const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
+ assert.match(html, /id="quality"[^>]*aria-label="Отклик неизвестен"/);
+ assert.match(html, /id="quality-details"/);
+ assert.match(css, /\.quality-pill \{[^}]*min-width: 48px/);
+ assert.match(script, /send\("heartbeat", \{\}, null\)/);
+ assert.match(script, /communicationMs: Math\.max\(0, smoothedMs - handlingMs\)/);
+ assert.match(script, /message\.payload\.command === "heartbeat"/);
+ assert.match(script, /documentObject\.hidden/);
+});
+
test("reference-derived SVG gameplay symbols preserve local yellow and blue ownership", () => {
const icons = fs.readFileSync(path.join(project, "web/icons.svg"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
diff --git a/web/index.html b/web/index.html
index 5067511..6532120 100644
--- a/web/index.html
+++ b/web/index.html
@@ -13,15 +13,18 @@
MAZE32