fix(session): reset lobby when player leaves finished match

This commit is contained in:
2026-09-13 23:14:09 +03:00
parent b6d484dbc9
commit 8ad856b931
10 changed files with 146 additions and 38 deletions
+5
View File
@@ -195,6 +195,11 @@ stateDiagram-v2
During `Paused`, movement commands are rejected and move-counted effects do not During `Paused`, movement commands are rejected and move-counted effects do not
advance. During `FinishPending`, the first exit is retained while the server advance. During `FinishPending`, the first exit is retained while the server
waits long enough to determine whether the second exit creates a draw. 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 ## 6. Authoritative command processing
+4
View File
@@ -216,6 +216,10 @@ Browser controls must support:
state. state.
- If the player does not reconnect within 60 seconds, the match ends in a draw. - 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. - 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 ## 11. Browser presentation
+58 -28
View File
@@ -3443,8 +3443,9 @@ resources.
Let an authenticated player or observer deliberately leave through the Let an authenticated player or observer deliberately leave through the
top-right settings menu, revoke only that browser's session, immediately free 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 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 prefilled. A player leaving any non-waiting match phase, including a finished
that match and return every remaining participant to the lobby, while an 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. observer departure or a lobby departure must not reset gameplay.
### Scope ### 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 authenticated player or observer session; do not show it on the unauthenticated
welcome and name-entry screen. welcome and name-entry screen.
- Require a modal confirmation before sending an exit command. For a player in - Require a modal confirmation before sending an exit command. For a player in
countdown, active, finish-pending, or paused state, clearly warn that the countdown, active, finish-pending, paused, or finished state, clearly warn
current match will end for everyone and the player's slot will be freed. For that the current match will return everyone to the waiting lobby and the
a player in the lobby or an observer in any phase, explain that only this player's slot will be freed. For a player in the lobby or an observer in any
browser's place will be released. phase, explain that only this browser's place will be released.
- Treat opening or cancelling the confirmation as browser-local UI only. While - Treat opening or cancelling the confirmation as browser-local UI only. While
it is open, suppress input from that browser without pausing or otherwise it is open, suppress input from that browser without pausing or otherwise
mutating authoritative state; cancelling must restore the previous screen, 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 correlated success response. It must be distinct from an unexpected socket
disconnect, page reload, or transient connection loss, which retain the disconnect, page reload, or transient connection loss, which retain the
existing 60-second player reconnection contract. existing 60-second player reconnection contract.
- On an accepted player leave during countdown, active, finish-pending, or - On an accepted player leave during countdown, active, finish-pending, paused,
paused state, stop all match timers, clear the current authoritative match, or finished state, stop all match timers, clear the current authoritative
return the lobby to its waiting state, retain every other connected session match, return the lobby to its waiting state, retain every other connected
and role, revoke the leaving session, free its player slot immediately, and session and role, revoke the leaving session, free its player slot
broadcast the cancellation and fresh lobby state to the remaining players immediately, and broadcast the cancellation and fresh lobby state to the
and observers. remaining players and observers.
- On an accepted player leave while already in the waiting lobby or after a - On an accepted player leave while already in the waiting lobby, revoke only
completed result, revoke only that player's session and free its slot. Do not that player's session and free its slot.
reset an unfinished match because none is active in those phases.
- On an accepted observer leave in any phase, revoke only that observer's - 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 session and free its observer slot. Never pause, reset, finish, restart, or
otherwise mutate the match because an observer leaves. 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, modal and sends no protocol command until explicit confirmation. Cancelling,
pressing Escape, or activating the modal's cancel action leaves the session pressing Escape, or activating the modal's cancel action leaves the session
and authoritative state unchanged and safely restores focus. and authoritative state unchanged and safely restores focus.
3. A player confirmation during countdown, active, finish-pending, or paused 3. A player confirmation during countdown, active, finish-pending, paused, or
state explicitly says that the match will end for everyone and the slot will finished state explicitly says that the match will return everyone to the
be freed. Confirmation produces exactly one authoritative cancellation, waiting lobby and the slot will be freed. Confirmation produces exactly one
stops every relevant timer, clears the current match, and returns all authoritative cancellation, stops every relevant timer, clears the current
remaining connected participants to a fresh waiting lobby. 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 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 the same accepted server transition. Every other player's and observer's
session, role, and connection remain valid, and the resulting lobby snapshot session, role, and connection remain valid, and the resulting lobby snapshot
consistently shows the newly free slot. consistently shows the newly free slot.
5. A player leaving the waiting lobby or a completed result releases only that 5. A player leaving the waiting lobby releases only that player's session and
player's session and slot. It does not disconnect other participants or slot. A player leaving a completed result performs the same authoritative
perform an additional match reset. 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 6. An observer leaving during any phase releases only that observer session and
observer slot. Match phase, players, timers, maze, objects, inventory, and observer slot. Match phase, players, timers, maze, objects, inventory, and
results remain byte-for-byte unchanged except for the expected lobby 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; 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; the payload remains within the 1.5 MiB web budget and LittleFS partition;
and firmware remains within the factory partition. and firmware remains within the factory partition.
16. Physical-device testing confirms that a player can cancel an unfinished 16. Physical-device testing confirms that a player can cancel a non-waiting
match and immediately release its slot, an observer can leave without match, including a finished result, and immediately release its slot, an
affecting play, the remaining browsers receive the correct lobby state, observer can leave without affecting play, the remaining browsers receive
and the departing browser returns to a prefilled name-entry screen without the correct lobby state, and the departing browser returns to a prefilled
stale controls, delayed input, or reconnect side effects. name-entry screen without stale controls, delayed input, or reconnect side
effects.
17. The project owner confirms on representative mobile and desktop browsers 17. The project owner confirms on representative mobile and desktop browsers
that the exit action is easy to find but difficult to trigger accidentally, that the exit action is easy to find but difficult to trigger accidentally,
its consequences are clear, and all role-specific outcomes match the 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 immediate server-side session revocation, player-match cancellation, and a
browser confirmation flow that clears the temporary token only after the browser confirmation flow that clears the temporary token only after the
correlated acknowledgement. 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.
+2 -3
View File
@@ -950,9 +950,8 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_
} }
if (message->type == MAZE32_MESSAGE_LEAVE) { if (message->type == MAZE32_MESSAGE_LEAVE) {
maze32_role_t leaving_role = session->role; maze32_role_t leaving_role = session->role;
int cancel_match = leaving_role == MAZE32_ROLE_PLAYER && int cancel_match =
gateway->lobby.phase != MAZE32_LOBBY_WAITING && leaving_role == MAZE32_ROLE_PLAYER && gateway->lobby.phase != MAZE32_LOBBY_WAITING;
gateway->game.phase != MAZE32_GAME_PHASE_FINISHED;
char acknowledgement[192] = ""; char acknowledgement[192] = "";
size_t length = 0U; size_t length = 0U;
char prefix[96]; char prefix[96];
+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 state. Escape and outside activation dismiss it; player actions also close it
before opening their existing destination. 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 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 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 at most 150 ms, delayed is 151--500 ms, and slow is above 500 ms. Hysteresis
+2 -1
View File
@@ -40,7 +40,8 @@ is only echoed for correlation; it grants no authority.
players must be connected. Sessions are preserved while a new authoritative players must be connected. Sessions are preserved while a new authoritative
maze and game state are created. maze and game state are created.
- `leave`: `{}`. Revokes only the authenticated sending session. A player - `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. waiting-lobby departures release only that session.
- `heartbeat`: `{}` after a session has been joined or restored. - `heartbeat`: `{}` after a session has been joined or restored.
+3 -3
View File
@@ -107,9 +107,9 @@ are not MVP requirements.
safe areas, closes on outside activation or Escape, and never contains safe areas, closes on outside activation or Escape, and never contains
primary game, lobby, observer, or result actions. primary game, lobby, observer, or result actions.
- The settings popover shows `Выйти из игры` only for an authenticated session. - The settings popover shows `Выйти из игры` only for an authenticated session.
It opens a confirmation modal; an unfinished player match warns that it ends It opens a confirmation modal; a player match, including a finished result,
for everyone, while observer and lobby departures explain that only this warns that it returns everyone to the waiting lobby, while observer and
browser's place is released. waiting-lobby departures explain that only this browser's place is released.
- A compact response indicator between the connection state and gear shows a - A compact response indicator between the connection state and gear shows a
rounded total response time or an explicit unknown, stale, or offline state. rounded total response time or an explicit unknown, stale, or offline state.
Its accessible description and settings details expose `Отклик`, `Связь`, Its accessible description and settings details expose `Отклик`, `Связь`,
+47 -1
View File
@@ -39,8 +39,11 @@ static void test_protocol_schema(void) {
const char* restart = "{\"v\":1,\"type\":\"restart\",\"seq\":5,\"payload\":{}}"; const char* restart = "{\"v\":1,\"type\":\"restart\",\"seq\":5,\"payload\":{}}";
assert(maze32_protocol_parse(restart, strlen(restart), &message) == MAZE32_PROTOCOL_OK); assert(maze32_protocol_parse(restart, strlen(restart), &message) == MAZE32_PROTOCOL_OK);
assert(message.type == MAZE32_MESSAGE_RESTART); 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 = 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) == assert(maze32_protocol_parse(diagonal, strlen(diagonal), &message) ==
MAZE32_PROTOCOL_INVALID_PAYLOAD); 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)"); 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) { int main(void) {
test_protocol_schema(); test_protocol_schema();
test_lobby_limits_restoration_and_authority(); test_lobby_limits_restoration_and_authority();
test_explicit_leave_releases_only_departing_session();
puts("lobby and protocol tests: PASS"); puts("lobby and protocol tests: PASS");
return 0; return 0;
} }
+17
View File
@@ -490,3 +490,20 @@ test("finished matches expose only an owner restart intention", () => {
assert.match(script, /message\.type === "reset"/); assert.match(script, /message\.type === "reset"/);
assert.doesNotMatch(script, /restart[^\n]*(position|inventory|result):/); 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/);
});
+2 -2
View File
@@ -497,9 +497,9 @@
closeSettings(false); closeSettings(false);
const phase = state.snapshot ? state.snapshot.phase : state.lobby && state.lobby.phase; const phase = state.snapshot ? state.snapshot.phase : state.lobby && state.lobby.phase;
const endsMatch = state.session && state.session.role === "player" && 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-text").textContent = endsMatch
? "Текущая гонка закончится для всех, а ваш слот сразу освободится." ? "Все вернутся в лобби, а ваш слот сразу освободится."
: "Освободим только ваше место в игре."; : "Освободим только ваше место в игре.";
byId("leave-confirmation").hidden = false; byId("leave-confirmation").hidden = false;
byId("leave-accept").focus(); byId("leave-accept").focus();