fix(session): reset lobby when player leaves finished match
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
@@ -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
@@ -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 `Отклик`, `Связь`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user