feat(session): add explicit leave and immediate slot release

This commit is contained in:
2026-09-13 22:53:18 +03:00
parent 44433aee9c
commit b6d484dbc9
16 changed files with 526 additions and 13 deletions
+7
View File
@@ -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;
+203 -3
View File
@@ -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.
@@ -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);
@@ -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;
+24
View File
@@ -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) {
@@ -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;
}
+48 -3
View File
@@ -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);
}
}
}
Binary file not shown.
+5
View File
@@ -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
+6
View File
@@ -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
+7 -1
View File
@@ -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
+9
View File
@@ -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
+38 -1
View File
@@ -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");
+4
View File
@@ -13,15 +13,18 @@
<header class="topbar">
<a class="brand" href="/" aria-label="maze32, на главную">MAZE<span>32</span></a>
<div class="connection-pill" id="connection" data-state="connecting" role="status"><span class="signal" aria-hidden="true"></span><span id="connection-text">Ловим сигнал ESP…</span></div>
<span class="quality-pill" id="quality" data-state="unknown" role="status" aria-live="polite" aria-label="Отклик неизвестен"><svg aria-hidden="true"><use href="/icons.svg#signal"></use></svg><span id="quality-text">—</span></span>
<button class="icon-button" id="settings-toggle" type="button" aria-controls="settings-menu" aria-expanded="false" aria-label="Открыть настройки"><svg aria-hidden="true"><use href="/icons.svg#gear"></use></svg></button>
</header>
<aside class="settings-menu" id="settings-menu" hidden aria-label="Настройки">
<strong>Настройки</strong>
<span class="settings-details" id="details-text">Подключение ещё не готово.</span>
<span class="settings-quality" id="quality-details">Отклик: неизвестен</span>
<button class="settings-item" id="settings-mute" type="button" aria-pressed="false" hidden>Звук: вкл</button>
<button class="settings-item" id="settings-motion" type="button" aria-pressed="false" hidden>Движение: вкл</button>
<button class="settings-item" id="settings-help" type="button" hidden>Показать обучение</button>
<a class="settings-item" id="settings-wifi" href="/provision">Настроить Wi‑Fi</a>
<button class="settings-item settings-exit" id="settings-leave" type="button" hidden>Выйти из игры</button>
</aside>
<section class="screen welcome-screen" id="welcome-screen" aria-labelledby="welcome-title">
@@ -86,6 +89,7 @@
</main>
<div class="modal" id="modal" hidden role="dialog" aria-modal="true" aria-labelledby="modal-title"><div class="modal-card"><svg id="modal-icon" aria-hidden="true"><use href="/icons.svg#hourglass"></use></svg><p class="eyebrow" id="modal-kicker">Серверное объявление</p><h2 id="modal-title">Приготовились!</h2><div class="countdown" id="countdown" hidden>3</div><p id="modal-text">Один сервер — один честный момент.</p><button class="button primary" id="modal-action" type="button" hidden>Вернуться в лобби</button></div></div>
<section class="modal" id="leave-confirmation" hidden role="dialog" aria-modal="true" aria-labelledby="leave-title"><div class="modal-card"><p class="eyebrow">Подтверждение</p><h2 id="leave-title">Выйти из игры?</h2><p id="leave-text"></p><div class="button-row"><button class="button primary" id="leave-accept" type="button">Выйти</button><button class="button" id="leave-cancel" type="button">Остаться</button></div></div></section>
<script src="/player.js" defer></script>
</body>
</html>
+8
View File
@@ -36,6 +36,11 @@ button { color: inherit; }
.brand { color: var(--ink); text-decoration: none; font-weight: 1000; font-size: clamp(1.1rem, 3vw, 1.45rem); letter-spacing: .14em; text-shadow: 3px 3px 0 #000; }
.brand span { color: var(--aqua); }
.connection-pill { margin-left: auto; display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; }
.quality-pill { display: flex; align-items: center; gap: 4px; min-width: 48px; color: var(--muted); font-size: .68rem; font-weight: 800; white-space: nowrap; }
.quality-pill svg { width: 17px; height: 17px; }
.quality-pill[data-state="good"] { color: var(--success); }
.quality-pill[data-state="delayed"] { color: var(--warning); }
.quality-pill[data-state="slow"], .quality-pill[data-state="offline"] { color: var(--error); }
.signal { width: 9px; height: 9px; border-radius: 50%; background: var(--warning); box-shadow: 0 0 10px var(--warning); }
.connection-pill[data-state="online"] .signal { background: var(--success); box-shadow: 0 0 10px var(--success); }
.connection-pill[data-state="offline"] .signal { background: var(--disconnect); box-shadow: 0 0 10px var(--disconnect); }
@@ -43,9 +48,11 @@ button { color: inherit; }
.icon-button svg { width: 100%; height: 100%; color: var(--aqua); }
.settings-menu { position: fixed; z-index: 40; top: max(58px, calc(env(safe-area-inset-top) + 58px)); right: max(12px, env(safe-area-inset-right)); display: grid; gap: 8px; width: min(300px, calc(100% - 24px)); max-height: calc(100dvh - max(70px, env(safe-area-inset-top)) - max(12px, env(safe-area-inset-bottom))); overflow-y: auto; padding: 14px; border: 2px solid var(--edge); background: #120b21f5; box-shadow: 6px 6px 0 var(--shadow); font-size: .8rem; }
.settings-details { color: var(--muted); line-height: 1.45; }
.settings-quality { color: var(--muted); line-height: 1.45; }
.settings-item { min-height: 42px; display: flex; align-items: center; justify-content: space-between; border: 1px solid var(--edge); background: #0c0815; padding: 8px 10px; color: var(--ink); font: inherit; text-align: left; text-decoration: none; cursor: pointer; }
.settings-item:hover, .settings-item:focus-visible { border-color: var(--aqua); background: #164049; }
.settings-item[aria-pressed="true"] { border-color: var(--warning); color: var(--warning); }
.settings-exit { border-color: var(--error); color: var(--error); }
.screen { min-height: calc(100dvh - 90px); animation: screen-in .28s steps(5, end); }
.welcome-screen { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(320px, 500px); align-items: center; gap: clamp(24px, 6vw, 90px); padding: 5vh 4vw; }
@@ -290,6 +297,7 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.dpad { right: max(12px, env(safe-area-inset-right)); bottom: max(12px, env(safe-area-inset-bottom)); grid-template-columns: repeat(3, 62px); grid-template-rows: repeat(3, 62px); gap: 5px; }
.dpad button { min-width: 62px; min-height: 62px; font-size: 1.3rem; }
.connection-pill { font-size: .58rem; }
.quality-pill { min-width: 40px; font-size: .6rem; }
.connection-pill #connection-text { max-width: 145px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}
+161 -5
View File
@@ -2,6 +2,7 @@
"use strict";
const TOKEN_KEY = "maze32.session.v1";
const NAME_KEY = "maze32.player-name.v1";
const MAX_VIEW_CHUNKS = 90;
const MAX_VIEW_WIDTH = 32;
const MAX_VIEW_HEIGHT = 32;
@@ -16,6 +17,9 @@
const AVATAR_FAST_REPEAT_MS = 170;
const DIRECTIONAL_PAD_REPEAT_MS = 170;
const LATENCY_SAMPLE_LIMIT = 256;
const QUALITY_SAMPLE_LIMIT = 5;
const HEARTBEAT_INTERVAL_MS = 5000;
const HEARTBEAT_STALE_MS = 15000;
const ONBOARDING_STEPS = Object.freeze([
Object.freeze({icon: "hand", title: "Потяни героя", text: "Потяни героя — лабиринт поедет под ним."}),
Object.freeze({icon: "motion", title: "Отпусти — герой ждёт", text: "Рука остановилась — шаги тоже."}),
@@ -249,6 +253,50 @@
pointerSnapshot() { return this.pointerSamples.slice(); }
}
class ConnectionQuality {
constructor(now) {
this.now = typeof now === "function" ? now : () => root.performance.now();
this.pending = null;
this.samples = [];
this.latest = null;
this.level = "unknown";
}
begin(requestId) {
if (this.pending || !requestId) return false;
this.pending = {requestId, sent: this.now()};
return true;
}
acknowledge(requestId, handlingMs) {
if (!this.pending || this.now() - this.pending.sent > HEARTBEAT_STALE_MS || this.pending.requestId !== requestId || !Number.isInteger(handlingMs) || handlingMs < 0 || handlingMs > 1000) return false;
const totalMs = Math.max(0, this.now() - this.pending.sent);
this.pending = null;
this.samples.push(totalMs);
if (this.samples.length > QUALITY_SAMPLE_LIMIT) this.samples.shift();
const ordered = this.samples.slice().sort((left, right) => left - right);
const smoothedMs = ordered[Math.floor(ordered.length / 2)];
this.latest = {totalMs: smoothedMs, communicationMs: Math.max(0, smoothedMs - handlingMs), handlingMs, received: this.now()};
if (this.level === "good") this.level = smoothedMs > 175 ? "delayed" : "good";
else if (this.level === "slow") this.level = smoothedMs <= 450 ? "delayed" : "slow";
else if (smoothedMs <= 150) this.level = "good";
else this.level = smoothedMs <= 500 ? "delayed" : "slow";
return true;
}
reset() { this.pending = null; this.samples = []; this.latest = null; this.level = "unknown"; }
expirePending() {
if (this.pending && this.now() - this.pending.sent > HEARTBEAT_STALE_MS) this.pending = null;
}
status() {
if (!this.latest) return "unknown";
if (this.now() - this.latest.received > HEARTBEAT_STALE_MS) return "stale";
return this.level;
}
}
function createEnvelope(type, sequence, requestId, payload) {
return {v: 1, type, seq: sequence, requestId, payload: payload || {}};
}
@@ -355,7 +403,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, rail: {priority: 0, key: null, persistent: false}, effects: {impactTimer: null, rateTimer: null, breakthroughTimer: null, travelTimer: null}, hedge: {openedKeys: new Set()}, moveDirections: new Map(), 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}, dpad: {pointerId: null, direction: null, repeatTimer: 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, heartbeatTimer: null, leaveRequest: null, noticeTimer: null, rail: {priority: 0, key: null, persistent: false}, effects: {impactTimer: null, rateTimer: null, breakthroughTimer: null, travelTimer: null}, hedge: {openedKeys: new Set()}, moveDirections: new Map(), touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), latency: new LatencyTracker(() => windowObject.performance.now()), quality: new ConnectionQuality(() => 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}, dpad: {pointerId: null, direction: null, repeatTimer: null}, observer: {scale: 1, x: 0, y: 0, dragging: null}};
state.moveFlow = new LatestIntentFlow((direction, element) => sendMoveNow(direction, element));
function showNotice(text, kind, priority, persistent, key) {
@@ -423,6 +471,7 @@
byId("observer-screen").hidden = name !== "observer";
documentObject.body.classList.toggle("match-active", name === "match");
["settings-mute", "settings-motion", "settings-help"].forEach(id => { byId(id).hidden = name !== "match"; });
byId("settings-leave").hidden = state.session == null;
if (name !== "match") stopBreakthroughTravel();
if (name !== "match") closeSettings(false);
}
@@ -444,6 +493,40 @@
if (firstAction) firstAction.focus();
}
function openLeaveConfirmation() {
closeSettings(false);
const phase = state.snapshot ? state.snapshot.phase : state.lobby && state.lobby.phase;
const endsMatch = state.session && state.session.role === "player" &&
["countdown", "active", "finish_pending", "paused"].includes(phase);
byId("leave-text").textContent = endsMatch
? "Текущая гонка закончится для всех, а ваш слот сразу освободится."
: "Освободим только ваше место в игре.";
byId("leave-confirmation").hidden = false;
byId("leave-accept").focus();
}
function closeLeaveConfirmation() {
byId("leave-confirmation").hidden = true;
byId("settings-toggle").focus();
}
function finishLeave() {
windowObject.localStorage.removeItem(TOKEN_KEY);
state.session = null;
state.lobby = null;
state.snapshot = null;
state.leaveRequest = null;
state.pending.clear();
resetPlayerControls();
clearEffects();
stopHeartbeat();
byId("maze").replaceChildren();
byId("map-placeholder").hidden = false;
byId("player-name").value = windowObject.localStorage.getItem(NAME_KEY) || "";
closeLeaveConfirmation();
setScreen("welcome");
}
function renderWelcomeHeadline(headline) {
const title = byId("welcome-title");
const highlight = documentObject.createElement("span");
@@ -457,9 +540,51 @@
byId("details-text").textContent = connectionState === "online" ? "WebSocket подключён к локальному ESP." : "Команды временно не отправляются. Повторяем подключение автоматически.";
if (connectionState === "offline") { resetPlayerControls(); state.moveDirections.clear(); showNotice("Связь ушла гулять. Ждём её здесь.", "error", 3, true, "offline"); }
else if (state.rail.persistent) showNotice("Связь вернулась! Кусты снова в игре.", "success", 3, false, "online");
renderQuality();
}
function renderQuality() {
const quality = state.quality.latest;
const status = !state.socket || state.socket.readyState !== windowObject.WebSocket.OPEN ? "offline" : state.quality.status();
const indicator = byId("quality");
indicator.dataset.state = status;
if (!quality || status === "unknown" || status === "stale" || status === "offline") {
const label = status === "offline" ? "нет связи" : status === "stale" ? "устарело" : "неизвестно";
byId("quality-text").textContent = status === "offline" ? "нет" : "—";
indicator.setAttribute("aria-label", `Отклик: ${label}`);
byId("quality-details").textContent = `Отклик: ${label}`;
return;
}
const total = Math.round(quality.totalMs);
const communication = Math.round(quality.communicationMs);
const handling = Math.round(quality.handlingMs);
byId("quality-text").textContent = `${total} мс`;
indicator.setAttribute("aria-label", `Отклик ${total} мс. Связь ${communication} мс. ESP ${handling} мс.`);
byId("quality-details").textContent = `Отклик: ${total} мс · Связь: ${communication} мс · ESP: ${handling} мс. Связь — остаток транспорта, не только Wi‑Fi.`;
}
function stopHeartbeat() {
windowObject.clearTimeout(state.heartbeatTimer);
state.heartbeatTimer = null;
state.quality.reset();
renderQuality();
}
function scheduleHeartbeat(delay) {
windowObject.clearTimeout(state.heartbeatTimer);
state.heartbeatTimer = windowObject.setTimeout(() => {
state.quality.expirePending();
if (documentObject.hidden || !state.session || state.quality.pending || !state.socket || state.socket.readyState !== windowObject.WebSocket.OPEN) {
scheduleHeartbeat(HEARTBEAT_INTERVAL_MS);
return;
}
send("heartbeat", {}, null);
scheduleHeartbeat(HEARTBEAT_INTERVAL_MS);
}, delay);
}
renderWelcomeHeadline(randomLoginHeadline());
byId("player-name").value = windowObject.localStorage.getItem(NAME_KEY) || "";
function beginFeedback() {
if (state.feedbackReady) return;
@@ -505,6 +630,7 @@
}
const sequence = ++state.clientSequence;
const requestId = `${type}-${sequence}`;
if (type === "heartbeat" && !state.quality.begin(requestId)) return null;
state.socket.send(JSON.stringify(createEnvelope(type, sequence, requestId, payload)));
setPending(requestId, element);
if (type === "move") { state.latency.sent(requestId); state.moveDirections.set(requestId, payload.direction); }
@@ -852,7 +978,9 @@
clearPending(message.requestId);
if (message.type === "session") {
state.session = message.payload; windowObject.localStorage.setItem(TOKEN_KEY, message.payload.token);
if (message.payload.role === "player") windowObject.localStorage.setItem(NAME_KEY, byId("player-name").value.slice(0, 32));
showNotice(message.payload.role === "player" ? `Слот P${message.payload.slot} закреплён за этим браузером.` : "Вы в зрительном зале. Руками не трогаем!", "success");
scheduleHeartbeat(0);
if (state.lobby) renderLobby();
} else if (message.type === "lobby") { state.lobby = message.payload; renderLobby();
} else if (message.type === "countdown") { showCountdown(message.payload);
@@ -861,7 +989,16 @@
} else if (message.type === "resume") { hideModal(); showNotice("Оба героя снова на связи. Гонка продолжается.", "success");
} else if (message.type === "match_result") { renderResult(message.payload.result);
} else if (message.type === "reset") { state.snapshot = null; state.viewSequence = -1; state.assembler = new SnapshotAssembler(); resetPlayerControls(); hideModal(); setScreen("lobby"); showNotice("Лабиринт пересажен. Можно начинать новую гонку.", "success");
} else if (message.type === "ack") { state.latency.acknowledged(message.requestId, message.payload && message.payload.stateSequence); state.moveDirections.delete(message.requestId); state.moveFlow.complete(message.requestId); feedback(true);
} else if (message.type === "ack") {
if (message.payload && message.payload.command === "leave" && message.requestId === state.leaveRequest) {
finishLeave();
return;
}
if (message.payload && message.payload.command === "heartbeat") {
if (state.quality.acknowledge(message.requestId, message.payload.handlingMs)) renderQuality();
return;
}
state.latency.acknowledged(message.requestId, message.payload && message.payload.stateSequence); state.moveDirections.delete(message.requestId); state.moveFlow.complete(message.requestId); feedback(true);
} else if (message.type === "error") {
const direction = state.moveDirections.get(message.requestId);
state.moveDirections.delete(message.requestId);
@@ -879,7 +1016,7 @@
state.socket = new windowObject.WebSocket(`${scheme}://${windowObject.location.host}/ws`);
state.socket.onopen = () => { setConnection("online", "ESP на связи"); const token = windowObject.localStorage.getItem(TOKEN_KEY); if (token) send("restore", {token}, null); };
state.socket.onmessage = event => { let message; try { message = JSON.parse(event.data); } catch (_) { showNotice("Лабиринт прислал каракули. Ждём понятный знак.", "error"); return; } handleMessage(message); };
state.socket.onclose = () => { state.moveFlow.disconnect(); setConnection("offline", "Связь потеряна"); state.reconnectTimer = windowObject.setTimeout(connect, 1000); };
state.socket.onclose = () => { state.moveFlow.disconnect(); stopHeartbeat(); setConnection("offline", "Связь потеряна"); state.reconnectTimer = windowObject.setTimeout(connect, 1000); };
state.socket.onerror = () => showNotice("Связь чихнула. Пробуем снова…", "error");
}
@@ -986,6 +1123,11 @@
documentObject.addEventListener("pointerdown", beginFeedback, {once: true});
documentObject.addEventListener("keydown", event => {
if (event.key === "Escape" && !byId("leave-confirmation").hidden) {
event.preventDefault();
closeLeaveConfirmation();
return;
}
if (event.key === "Escape" && !byId("settings-menu").hidden) {
event.preventDefault();
closeSettings(true);
@@ -1022,7 +1164,14 @@
};
windowObject.addEventListener("resize", refreshPlayerFocalPoint);
windowObject.addEventListener("orientationchange", refreshPlayerFocalPoint);
documentObject.addEventListener("visibilitychange", () => { if (documentObject.hidden) resetPlayerControls(); });
documentObject.addEventListener("visibilitychange", () => {
if (documentObject.hidden) {
resetPlayerControls();
stopHeartbeat();
} else if (state.session && state.socket && state.socket.readyState === windowObject.WebSocket.OPEN) {
scheduleHeartbeat(0);
}
});
documentObject.querySelectorAll("[data-direction]").forEach(button => {
button.addEventListener("pointerdown", event => {
if (button.disabled) return;
@@ -1056,6 +1205,13 @@
byId("settings-motion").addEventListener("click", () => setReducedMotion(!state.reducedMotion));
byId("settings-help").addEventListener("click", () => { closeSettings(false); showOnboarding(0); });
byId("settings-wifi").addEventListener("click", () => closeSettings(false));
byId("settings-leave").addEventListener("click", openLeaveConfirmation);
byId("leave-cancel").addEventListener("click", closeLeaveConfirmation);
byId("leave-accept").addEventListener("click", () => {
const requestId = send("leave", {}, byId("leave-accept"));
if (requestId) state.leaveRequest = requestId;
else showNotice("Не удалось отправить выход. Связь продолжается.", "error");
});
byId("onboarding-next").addEventListener("click", () => {
const next = nextOnboardingStep(state.onboardingStep);
if (next == null) hideOnboarding();
@@ -1096,7 +1252,7 @@
return {state, handleMessage, move, resetAvatarControl, latencySamples: () => state.latency.snapshot(), pointerFeedbackSamples: () => state.latency.pointerSnapshot()};
}
const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, NAME_ADJECTIVES, NAME_NOUNS, 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, DIRECTIONAL_PAD_REPEAT_MS, LatestIntentFlow, LatencyTracker, directionFromKey, directionFromSwipe, avatarDirection, avatarRepeatMs, avatarVector, playerCellSize, playerMapOffset, playerFocalPoint, playerFocalMapOffset, nextOnboardingStep, randomIndex, randomSuggestedName, randomLoginHeadline, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, hedgeEdgeKey, hedgeKeyForMove, hedgeVariant, projectedHedgeEdges, clampObserverZoom, observerFitScale, boot});
const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, NAME_ADJECTIVES, NAME_NOUNS, NAME_COMBINATION_COUNT, LOGIN_HEADLINES, ONBOARDING_STEPS, LATENCY_SAMPLE_LIMIT, QUALITY_SAMPLE_LIMIT, HEARTBEAT_INTERVAL_MS, HEARTBEAT_STALE_MS, AVATAR_TOUCH_TARGET_SIZE, AVATAR_MAX_DEFLECTION, AVATAR_DEAD_ZONE, AVATAR_DIRECTION_HYSTERESIS, AVATAR_SLOW_REPEAT_MS, AVATAR_FAST_REPEAT_MS, DIRECTIONAL_PAD_REPEAT_MS, LatestIntentFlow, LatencyTracker, ConnectionQuality, directionFromKey, directionFromSwipe, avatarDirection, avatarRepeatMs, avatarVector, playerCellSize, playerMapOffset, playerFocalPoint, playerFocalMapOffset, nextOnboardingStep, randomIndex, randomSuggestedName, randomLoginHeadline, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, hedgeEdgeKey, hedgeKeyForMove, hedgeVariant, projectedHedgeEdges, 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); });