From 8ad856b931880e9a302897b1ff83c1f3e30b7a25 Mon Sep 17 00:00:00 2001 From: sasa Date: Sun, 13 Sep 2026 23:14:09 +0300 Subject: [PATCH] fix(session): reset lobby when player leaves finished match --- ARCHITECTURE.md | 5 ++ MVP.md | 4 ++ PLANS.md | 86 +++++++++++++++++--------- components/maze32_web/maze32_gateway.c | 5 +- docs/PLAYER_UI.md | 6 ++ docs/PROTOCOL.md | 3 +- docs/UI_STYLE.md | 6 +- test/host/test_lobby_protocol.c | 48 +++++++++++++- test/web/test_player_app.js | 17 +++++ web/player.js | 4 +- 10 files changed, 146 insertions(+), 38 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3924481..a96773e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -195,6 +195,11 @@ stateDiagram-v2 During `Paused`, movement commands are rejected and move-counted effects do not advance. During `FinishPending`, the first exit is retained while the server waits long enough to determine whether the second exit creates a draw. +An authenticated explicit player leave from any non-waiting phase, including +`Finished`, stops the current lifecycle and returns remaining sessions to the +waiting lobby. This is distinct from an unexpected disconnect, which preserves +the player's reconnect reservation for 60 seconds; observer leave and player +leave from `Lobby` revoke only the departing session. ## 6. Authoritative command processing diff --git a/MVP.md b/MVP.md index dbbd206..6505ecd 100644 --- a/MVP.md +++ b/MVP.md @@ -216,6 +216,10 @@ Browser controls must support: state. - If the player does not reconnect within 60 seconds, the match ends in a draw. - Reloading a page is treated as a reconnect, not as leaving the match. +- A confirmed explicit player exit during any non-waiting phase, including a + finished result, revokes only that session and authoritatively returns all + remaining participants to a fresh waiting lobby. Observer exits and player + exits from an already waiting lobby affect only the departing session. ## 11. Browser presentation diff --git a/PLANS.md b/PLANS.md index 1481215..950a69e 100644 --- a/PLANS.md +++ b/PLANS.md @@ -3443,8 +3443,9 @@ resources. 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 +prefilled. A player leaving any non-waiting match phase, including a finished +result, 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 @@ -3457,10 +3458,10 @@ observer departure or a lobby departure must not reset gameplay. 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. + countdown, active, finish-pending, paused, or finished state, clearly warn + that the current match will return everyone to the waiting lobby 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, @@ -3469,15 +3470,14 @@ observer departure or a lobby departure must not reset gameplay. 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 player leave during countdown, active, finish-pending, paused, + or finished 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, revoke only + that player's session and free its slot. - 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. @@ -3536,18 +3536,20 @@ observer departure or a lobby departure must not reset gameplay. 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. +3. A player confirmation during countdown, active, finish-pending, paused, or + finished state explicitly says that the match will return everyone to the + waiting lobby 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. +5. A player leaving the waiting lobby releases only that player's session and + slot. A player leaving a completed result performs the same authoritative + return-to-lobby transition as another non-waiting player exit, without + disconnecting any remaining participant. 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 @@ -3586,11 +3588,12 @@ observer departure or a lobby departure must not reset gameplay. 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. +16. Physical-device testing confirms that a player can cancel a non-waiting + match, including a finished result, 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 @@ -3614,3 +3617,30 @@ observer departure or a lobby departure must not reset gameplay. immediate server-side session revocation, player-match cancellation, and a browser confirmation flow that clears the temporary token only after the correlated acknowledgement. +- On 2026-09-13, a corrective lifecycle inspection found that the explicit + player-leave handler excluded `MAZE32_GAME_PHASE_FINISHED` from its match + reset condition. It therefore revoked the departing result-screen session + but did not broadcast `reset`, which could leave remaining clients behind an + unusable result modal. The handler now resets for a player leaving every + non-waiting lobby phase, including a completed result; it keeps waiting-lobby + player exits and observer exits session-scoped. The reset is broadcast before + the fresh lobby snapshot, and browser reset handling hides the modal and + renders the waiting lobby. +- On 2026-09-13, `node --test test/web/test_player_app.js` passed 31 tests, + including the result-phase leave regression that verifies the confirmation + warning, server reset condition, correlated token cleanup, modal dismissal, + and lobby rendering. `bash scripts/run_host_tests.sh` passed all suites; + its lobby/protocol suite reported explicit leave session revocation and slot + reuse as passing, including invalid-token restoration after either Player 1 + or Player 2 leaves, retained player and observer sessions, observer-only + leave, duplicate leave idempotence, and immediate replacement of both player + slots. `python3 + scripts/verify_web_assets.py` passed with deterministic payload + `3fb28e1ae55deba0ac592bcd99d81b7e95271604a5b35d7f293c4b8bffef6143` at + 33,183 / 1,572,864 bytes. `/home/sasa/.platformio/penv/bin/pio run` passed + at 1,030,256 / 2,097,152 factory bytes, and `/home/sasa/.platformio/penv/bin/pio + run --target buildfs` passed at 2,031,616 / 2,031,616 LittleFS bytes. +- On 2026-09-13, the project owner checked the corrected result-screen exit + flow in the browser and confirmed that the issue was fixed: a remaining + participant is returned from the result modal to a usable waiting lobby when + the other player explicitly leaves. diff --git a/components/maze32_web/maze32_gateway.c b/components/maze32_web/maze32_gateway.c index f31848f..0db54d9 100644 --- a/components/maze32_web/maze32_gateway.c +++ b/components/maze32_web/maze32_gateway.c @@ -950,9 +950,8 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_ } 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; + int cancel_match = + leaving_role == MAZE32_ROLE_PLAYER && gateway->lobby.phase != MAZE32_LOBBY_WAITING; char acknowledgement[192] = ""; size_t length = 0U; char prefix[96]; diff --git a/docs/PLAYER_UI.md b/docs/PLAYER_UI.md index d819057..4335bab 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. +Authenticated sessions also see `Выйти из игры`. Its confirmation states that +a player exit from any non-waiting phase, including the result screen, returns +the remaining participants to the waiting lobby; observer exits and waiting +lobby player exits release only the departing browser's place. The browser +removes its token only after the correlated server acknowledgement. + 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 diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 7057a34..38430e6 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -40,7 +40,8 @@ is only echoed for correlation; it grants no authority. 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 + leaving any non-waiting match phase, including a finished result, cancels + that match and returns remaining sessions to the waiting lobby; observer and waiting-lobby departures release only that session. - `heartbeat`: `{}` after a session has been joined or restored. diff --git a/docs/UI_STYLE.md b/docs/UI_STYLE.md index 0448df6..cd38a9f 100644 --- a/docs/UI_STYLE.md +++ b/docs/UI_STYLE.md @@ -107,9 +107,9 @@ are not MVP requirements. 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. + It opens a confirmation modal; a player match, including a finished result, + warns that it returns everyone to the waiting lobby, while observer and + waiting-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 `Отклик`, `Связь`, diff --git a/test/host/test_lobby_protocol.c b/test/host/test_lobby_protocol.c index 57b3569..ce7a94a 100644 --- a/test/host/test_lobby_protocol.c +++ b/test/host/test_lobby_protocol.c @@ -39,8 +39,11 @@ static void test_protocol_schema(void) { const char* restart = "{\"v\":1,\"type\":\"restart\",\"seq\":5,\"payload\":{}}"; assert(maze32_protocol_parse(restart, strlen(restart), &message) == MAZE32_PROTOCOL_OK); assert(message.type == MAZE32_MESSAGE_RESTART); + const char* leave = "{\"v\":1,\"type\":\"leave\",\"seq\":6,\"payload\":{}}"; + assert(maze32_protocol_parse(leave, strlen(leave), &message) == MAZE32_PROTOCOL_OK); + assert(message.type == MAZE32_MESSAGE_LEAVE); const char* diagonal = - "{\"v\":1,\"type\":\"move\",\"seq\":6,\"payload\":{\"direction\":\"northwest\"}}"; + "{\"v\":1,\"type\":\"move\",\"seq\":7,\"payload\":{\"direction\":\"northwest\"}}"; assert(maze32_protocol_parse(diagonal, strlen(diagonal), &message) == MAZE32_PROTOCOL_INVALID_PAYLOAD); @@ -129,9 +132,52 @@ static void test_lobby_limits_restoration_and_authority(void) { puts("lobby repeated-match reset: PASS (128 cycles)"); } +static void test_explicit_leave_releases_only_departing_session(void) { + maze32_lobby_t lobby; + maze32_lobby_init(&lobby); + maze32_session_t* player_1 = NULL; + maze32_session_t* player_2 = NULL; + maze32_session_t* observer = NULL; + assert(maze32_lobby_join_player(&lobby, 10, "Первый", TOKEN_1, &player_1) == + MAZE32_LOBBY_OK); + assert(maze32_lobby_join_player(&lobby, 11, "Второй", TOKEN_2, &player_2) == + MAZE32_LOBBY_OK); + assert(maze32_lobby_join_observer(&lobby, 20, OBSERVER_TOKENS[0], &observer) == + MAZE32_LOBBY_OK); + + maze32_role_t role = MAZE32_ROLE_NONE; + unsigned int player_slot = 0U; + assert(maze32_lobby_leave(&lobby, 10, &role, &player_slot)); + assert(role == MAZE32_ROLE_PLAYER && player_slot == 1U); + assert(maze32_lobby_session_for_connection(&lobby, 10) == NULL); + assert(maze32_lobby_session_for_connection(&lobby, 11) == player_2); + assert(maze32_lobby_session_for_connection(&lobby, 20) == observer); + assert(maze32_lobby_restore(&lobby, 30, TOKEN_1, &player_1) == MAZE32_LOBBY_INVALID_TOKEN); + assert(maze32_lobby_join_player(&lobby, 30, "Новый первый", OBSERVER_TOKENS[1], &player_1) == + MAZE32_LOBBY_OK); + assert(player_1->player_slot == 1U); + assert(!maze32_lobby_leave(&lobby, 10, NULL, NULL)); + + assert(maze32_lobby_leave(&lobby, 11, &role, &player_slot)); + assert(role == MAZE32_ROLE_PLAYER && player_slot == 2U); + assert(maze32_lobby_session_for_connection(&lobby, 30) == player_1); + assert(maze32_lobby_session_for_connection(&lobby, 20) == observer); + assert(maze32_lobby_restore(&lobby, 31, TOKEN_2, &player_2) == MAZE32_LOBBY_INVALID_TOKEN); + assert(maze32_lobby_join_player(&lobby, 31, "Новый второй", OBSERVER_TOKENS[2], &player_2) == + MAZE32_LOBBY_OK); + assert(player_2->player_slot == 2U); + + assert(maze32_lobby_leave(&lobby, 20, &role, &player_slot)); + assert(role == MAZE32_ROLE_OBSERVER && player_slot == 0U); + assert(maze32_lobby_session_for_connection(&lobby, 30) == player_1); + assert(maze32_lobby_session_for_connection(&lobby, 31) == player_2); + puts("explicit leave session revocation and slot reuse: PASS"); +} + int main(void) { test_protocol_schema(); test_lobby_limits_restoration_and_authority(); + test_explicit_leave_releases_only_departing_session(); puts("lobby and protocol tests: PASS"); return 0; } diff --git a/test/web/test_player_app.js b/test/web/test_player_app.js index 3956684..facd571 100644 --- a/test/web/test_player_app.js +++ b/test/web/test_player_app.js @@ -490,3 +490,20 @@ test("finished matches expose only an owner restart intention", () => { assert.match(script, /message\.type === "reset"/); assert.doesNotMatch(script, /restart[^\n]*(position|inventory|result):/); }); + +test("finished-player exit returns remaining browsers to the waiting lobby", () => { + const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8"); + const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8"); + const gateway = fs.readFileSync(path.join(project, "components/maze32_web/maze32_gateway.c"), "utf8"); + const leaveHandler = gateway.match(/if \(message->type == MAZE32_MESSAGE_LEAVE\) \{[\s\S]*?if \(message->type == MAZE32_MESSAGE_START\)/)[0]; + + assert.match(html, /id="settings-leave"/); + assert.match(html, /id="leave-confirmation"/); + assert.match(script, /\["countdown", "active", "finish_pending", "paused", "finished"\]\.includes\(phase\)/); + assert.match(script, /message\.type === "reset"[\s\S]*?hideModal\(\); setScreen\("lobby"\)/); + assert.match(script, /message\.payload\.command === "leave" && message\.requestId === state\.leaveRequest/); + assert.match(script, /windowObject\.localStorage\.removeItem\(TOKEN_KEY\)/); + assert.match(script, /setScreen\("welcome"\)/); + assert.match(leaveHandler, /leaving_role == MAZE32_ROLE_PLAYER &&\s*gateway->lobby\.phase != MAZE32_LOBBY_WAITING;/); + assert.doesNotMatch(leaveHandler, /gateway->game\.phase != MAZE32_GAME_PHASE_FINISHED/); +}); diff --git a/web/player.js b/web/player.js index 2e2b60d..1393a0e 100644 --- a/web/player.js +++ b/web/player.js @@ -497,9 +497,9 @@ 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); + ["countdown", "active", "finish_pending", "paused", "finished"].includes(phase); byId("leave-text").textContent = endsMatch - ? "Текущая гонка закончится для всех, а ваш слот сразу освободится." + ? "Все вернутся в лобби, а ваш слот сразу освободится." : "Освободим только ваше место в игре."; byId("leave-confirmation").hidden = false; byId("leave-accept").focus();