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
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
+4
View File
@@ -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
+58 -28
View File
@@ -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.
+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) {
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];
+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.
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
+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
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.
+3 -3
View File
@@ -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 `Отклик`, `Связь`,
+47 -1
View File
@@ -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;
}
+17
View File
@@ -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/);
});
+2 -2
View File
@@ -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();