From a68023e68428094f72edf829b92c2389c29cc5b6 Mon Sep 17 00:00:00 2001 From: sasa Date: Sun, 30 Aug 2026 23:18:07 +0300 Subject: [PATCH] feat: implement safe leave, profile reset, and full game reset semantics --- PLANS.md | 15 +++++++++-- docs/API_CONTRACT.md | 14 +++++++++- docs/MVP.md | 3 +++ docs/openapi.yaml | 42 +++++++++++++++++++++++++++++ include/application.h | 2 ++ include/command_queue.h | 2 +- include/game_lifecycle.h | 7 +++++ include/http_api.h | 3 +++ src/application.c | 18 +++++++++++++ src/game_lifecycle.c | 47 +++++++++++++++++++++++++++++++-- src/http_api.c | 41 +++++++++++++++++++++++++++- src/main.c | 8 +++++- src/sync_service.c | 18 ++++++++++++- test/host/test_game_lifecycle.c | 25 ++++++++++++++++++ test/host/test_http_api.c | 27 +++++++++++++++++++ test/host/test_sync_service.c | 18 +++++++++++++ 16 files changed, 281 insertions(+), 9 deletions(-) diff --git a/PLANS.md b/PLANS.md index c559b80..0a5d1b8 100644 --- a/PLANS.md +++ b/PLANS.md @@ -1557,7 +1557,7 @@ At completion, provide the reference sheet, screenshots of the mobile and tablet ## Milestone 021 — Define and implement safe leave, profile reset, and full game reset semantics -**Status:** `READY` +**Status:** `DONE` **Depends on:** Milestone 020 ### Objective @@ -1646,11 +1646,22 @@ Add host tests covering at least: When all criteria pass, set Milestone 021 to `DONE`, append its execution record, and change Milestone 022 from `BLOCKED` to `READY`. Do not start Milestone 022 in the same task unless explicitly requested. +### Execution record + +- Date: 2026-08-30 +- Board model and revision: ESP32-C6FH4 QFN32, revision v0.2. +- Toolchain and library versions: PlatformIO Core 6.1.19; `espressif32` 7.0.1; ESP-IDF 6.0.1; `esp_littlefs` 1.20.4. +- Result: PASS. +- Evidence: Added bounded `POST /api/session/leave`, `POST /api/session/profile-reset`, and player-authorized `POST /api/game/reset` commands. Leave/profile reset release only the authenticated session; an active player departure safely creates a new lobby game identity while preserving other valid sessions. Full reset cancels bot work, clears all sessions, boards, mode, bot state, rematch approvals, match and cumulative statistics, and command queue, then creates a fresh game identity and recovery generation. Invalidated HTTP state polling receives stable `SESSION_INVALIDATED` recovery metadata. Authenticated WebSocket owners receive a minimal `{type:"reset"}` notification without board or token data and are invalidated before any role-safe state can be serialized. +- Measurements: Added lifecycle coverage for spectator/player leave and full reset, HTTP coverage for idempotent leave/reset and invalidated polling, and WebSocket coverage for minimal reset notification/invalidation. `make -C test/host run` passed all ten suites. `node --check data/app.js` and the 12 browser/unit tests passed. `pio run -e esp32-c6-devkitm-1 -t buildfs`, `pio run -e esp32-c6-devkitm-1`, and `git diff --check` passed. Firmware uses 39,604 / 327,680 B RAM (12.1%) and 1,020,806 / 2,097,152 B flash (48.7%). +- Issues or deviations: No firmware upload or physical multi-client recovery exercise was performed. Milestone 022 owns the browser controls and confirmations; none were added here. +- Next action: Milestone 022 is READY. Do not start it unless explicitly requested. + --- ## Milestone 022 — Add the always-available recovery menu and child-safe confirmations -**Status:** `BLOCKED` +**Status:** `READY` **Depends on:** Milestone 021 ### Objective diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 17badb5..47bab9e 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -38,7 +38,7 @@ Failure (maximum 160 encoded bytes): ``` `code` is one of `MALFORMED_JSON`, `PAYLOAD_TOO_LARGE`, `INVALID_NAME`, -`INVALID_ROLE`, `INVALID_MODE`, `INVALID_COORDINATE`, `UNAUTHORIZED`, +`INVALID_ROLE`, `INVALID_MODE`, `INVALID_COORDINATE`, `UNAUTHORIZED`, `SESSION_INVALIDATED`, `NO_PLAYER_SLOT`, `NO_SPECTATOR_SLOT`, `FORBIDDEN_ROLE`, `WRONG_PHASE`, `NOT_YOUR_TURN`, `CELL_ALREADY_SHOT`, `STALE_GAME`, or `SERVER_BUSY`. `message` is Russian and at most 80 UTF-8 bytes. @@ -51,11 +51,14 @@ Failure (maximum 160 encoded bytes): | `GET /api/health` | 128 B target | diagnostics without secrets, 320 B; includes reset reason | | `POST /api/session/join` | 192 B body | `{name,requestedRole}`; token and role, 192 B | | `POST /api/session/resume` | 96 B body | `{token}`; role and state metadata, 192 B | +| `POST /api/session/leave` | 80 B body | `{token,gameId}`; releases only that session | +| `POST /api/session/profile-reset` | 80 B body | `{token,gameId}`; server release for local profile reset | | `POST /api/game/config` | 96 B body | `{token,gameId,mode}`; common envelope | | `POST /api/game/start` | 80 B body | `{token,gameId}`; common envelope | | `POST /api/game/shot` | 96 B body | `{token,gameId,x,y}`; common envelope | | `POST /api/game/rematch` | 80 B body | `{token,gameId}`; common envelope | | `POST /api/game/abort` | 80 B body | `{token,gameId}`; common envelope | +| `POST /api/game/reset` | 80 B body | `{token,gameId}`; player-only full RAM reset | | `GET /api/state?version=N` | 128 B target | one role-safe state, 768 B | | `GET /api/statistics` | 128 B target | role and bounded match/cumulative counters | @@ -63,6 +66,15 @@ The session token is in each POST body. For `GET /api/state`, it is supplied in `X-Session-Token`; absence creates a spectator-safe view. It is never a URL parameter. +`leave` and `profile-reset` release only the requesting session; profile data is +cleared by the browser in Milestone 022. A player leaving an active match aborts +that match and returns remaining valid players to the lobby. `game/reset` is +available only to an authenticated player and atomically invalidates every +session, clears match and cumulative statistics, and starts a fresh game +generation. Successful recovery responses contain `resetReason` (`session_left`, +`profile_reset`, or `game_reset`) and `generation`. Invalidated-token state +polling returns `SESSION_INVALIDATED` with the same bounded recovery metadata. + ## Role-safe state event The HTTP state response and WebSocket `state` event use this single 768-byte diff --git a/docs/MVP.md b/docs/MVP.md index fcff829..1f1ab1f 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -274,10 +274,13 @@ struct Board { | `GET` | `/api/info` | Состояние устройства и доступность мест без скрытых данных | | `POST` | `/api/session/join` | Вход по имени и желаемой роли | | `POST` | `/api/session/resume` | Восстановление роли по токену | +| `POST` | `/api/session/leave` | Освобождение только текущей сессии | +| `POST` | `/api/session/profile-reset` | Освобождение сессии перед локальной очисткой профиля | | `POST` | `/api/game/config` | Выбор режима игроком 1 | | `POST` | `/api/game/start` | Запуск готовой партии | | `POST` | `/api/game/shot` | Выстрел по координатам | | `POST` | `/api/game/rematch` | Подтверждение повторной игры | +| `POST` | `/api/game/reset` | Аварийный полный сброс игровой памяти игроком | | `GET` | `/api/state?version=N` | Снимок разрешённого состояния и резервный опрос | | `GET` | `/api/health` | Проверка доступности сервера | diff --git a/docs/openapi.yaml b/docs/openapi.yaml index ef83906..0773aa9 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -73,6 +73,24 @@ paths: '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '413': { $ref: '#/components/responses/PayloadTooLarge' } + /api/session/leave: + post: + summary: Release only the authenticated session + requestBody: + required: true + content: { application/json: { schema: { $ref: '#/components/schemas/GameRequest' } } } + responses: &recoveryResponses + '200': { $ref: '#/components/responses/RecoveryAccepted' } + '400': { $ref: '#/components/responses/BadRequest' } + '409': { $ref: '#/components/responses/Conflict' } + '413': { $ref: '#/components/responses/PayloadTooLarge' } + /api/session/profile-reset: + post: + summary: Release the authenticated session for a browser-local profile reset + requestBody: + required: true + content: { application/json: { schema: { $ref: '#/components/schemas/GameRequest' } } } + responses: *recoveryResponses /api/game/config: post: summary: Set the game mode (Player 1, lobby only) @@ -131,6 +149,18 @@ paths: schema: $ref: '#/components/schemas/GameRequest' responses: *commandResponses + /api/game/reset: + post: + summary: Player-only full in-memory game reset + requestBody: + required: true + content: { application/json: { schema: { $ref: '#/components/schemas/GameRequest' } } } + responses: + '200': { $ref: '#/components/responses/RecoveryAccepted' } + '400': { $ref: '#/components/responses/BadRequest' } + '403': { $ref: '#/components/responses/Forbidden' } + '409': { $ref: '#/components/responses/Conflict' } + '413': { $ref: '#/components/responses/PayloadTooLarge' } /api/state: get: summary: Get a complete role-safe state snapshot @@ -175,6 +205,11 @@ components: schema: { $ref: '#/components/schemas/Token' } description: Omit only when a spectator-safe public view is intended. responses: + RecoveryAccepted: + description: Recovery completed or an idempotent retry observed the same completed recovery. + content: + application/json: + schema: { $ref: '#/components/schemas/RecoveryAccepted' } CommandAccepted: description: Command accepted by the authoritative application layer. content: @@ -269,6 +304,13 @@ components: - type: object required: [role] properties: { role: { $ref: '#/components/schemas/Role' } } + RecoveryAccepted: + type: object + required: [ok, resetReason, generation] + properties: + ok: { type: boolean, enum: [true] } + resetReason: { type: string, enum: [session_left, profile_reset, game_reset] } + generation: { $ref: '#/components/schemas/GameId' } Info: type: object required: [ok, phase, gameId, version, player1Available, player2Available, player1Name, player2Name, spectatorsAvailable] diff --git a/include/application.h b/include/application.h index 42a1178..b7c0b6f 100644 --- a/include/application.h +++ b/include/application.h @@ -18,5 +18,7 @@ lifecycle_result_t application_resume(application_t *application, const uint8_t token[kSessionTokenBytes], uint8_t *session_index); bool application_session_for_token(const application_t *application, const uint8_t token[kSessionTokenBytes], uint8_t *session_index); +lifecycle_result_t application_leave(application_t *application, uint8_t session_index, uint32_t game_id, + recovery_reason_t reason); lifecycle_result_t application_submit(application_t *application, const app_command_t *command); bool application_bot_take_turn(application_t *application); diff --git a/include/command_queue.h b/include/command_queue.h index 5614f91..386ff35 100644 --- a/include/command_queue.h +++ b/include/command_queue.h @@ -7,7 +7,7 @@ #include "game_types.h" typedef uint8_t command_type_t; -enum { COMMAND_CONFIG, COMMAND_START, COMMAND_SHOT, COMMAND_REMATCH, COMMAND_ABORT }; +enum { COMMAND_CONFIG, COMMAND_START, COMMAND_SHOT, COMMAND_REMATCH, COMMAND_ABORT, COMMAND_LEAVE, COMMAND_PROFILE_RESET, COMMAND_RESET }; typedef struct { command_type_t type; uint8_t session_index; uint32_t game_id; uint32_t version; coordinate_t coordinate; game_mode_t mode; } app_command_t; typedef struct { app_command_t entries[kCommandQueueCapacity]; uint8_t head; uint8_t tail; uint8_t count; } command_queue_t; diff --git a/include/game_lifecycle.h b/include/game_lifecycle.h index 9338384..a541616 100644 --- a/include/game_lifecycle.h +++ b/include/game_lifecycle.h @@ -24,6 +24,9 @@ enum { LIFECYCLE_RESULT_GENERATION_FAILED, }; +typedef uint8_t recovery_reason_t; +enum { RECOVERY_REASON_NONE, RECOVERY_REASON_SESSION_LEFT, RECOVERY_REASON_PROFILE_RESET, RECOVERY_REASON_GAME_RESET }; + typedef struct { game_engine_t game; session_manager_t sessions; @@ -34,6 +37,8 @@ typedef struct { bool bot_reserved; bool rematch_confirmed[kPlayerCapacity]; uint32_t next_game_id; + uint32_t recovery_generation; + recovery_reason_t recovery_reason; } game_lifecycle_t; void game_lifecycle_init(game_lifecycle_t *lifecycle, random_source_t random); @@ -44,6 +49,8 @@ lifecycle_result_t game_lifecycle_resume(game_lifecycle_t *lifecycle, const uint8_t token[kSessionTokenBytes], uint8_t *session_index); lifecycle_result_t game_lifecycle_disconnect(game_lifecycle_t *lifecycle, uint8_t session_index); lifecycle_result_t game_lifecycle_leave(game_lifecycle_t *lifecycle, uint8_t session_index); +lifecycle_result_t game_lifecycle_reset(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id); +void game_lifecycle_set_recovery_reason(game_lifecycle_t *lifecycle, recovery_reason_t reason); lifecycle_result_t game_lifecycle_configure(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id, game_mode_t mode); lifecycle_result_t game_lifecycle_start(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id); diff --git a/include/http_api.h b/include/http_api.h index 9837bbd..fa136be 100644 --- a/include/http_api.h +++ b/include/http_api.h @@ -13,11 +13,14 @@ typedef enum { HTTP_API_ROUTE_HEALTH, HTTP_API_ROUTE_JOIN, HTTP_API_ROUTE_RESUME, + HTTP_API_ROUTE_LEAVE, + HTTP_API_ROUTE_PROFILE_RESET, HTTP_API_ROUTE_CONFIG, HTTP_API_ROUTE_START, HTTP_API_ROUTE_SHOT, HTTP_API_ROUTE_REMATCH, HTTP_API_ROUTE_ABORT, + HTTP_API_ROUTE_RESET, HTTP_API_ROUTE_STATE, HTTP_API_ROUTE_STATISTICS, } http_api_route_t; diff --git a/src/application.c b/src/application.c index 962efbb..2aa846e 100644 --- a/src/application.c +++ b/src/application.c @@ -37,6 +37,14 @@ bool application_session_for_token(const application_t *application, return application != NULL && session_manager_find(&application->lifecycle.sessions, token, session_index); } +lifecycle_result_t application_leave(application_t *application, uint8_t session_index, uint32_t game_id, + recovery_reason_t reason) { + if (application == NULL || application->lifecycle.game.state.game_id != game_id) return LIFECYCLE_RESULT_STALE_GAME; + const lifecycle_result_t result = game_lifecycle_leave(&application->lifecycle, session_index); + if (result == LIFECYCLE_RESULT_OK) game_lifecycle_set_recovery_reason(&application->lifecycle, reason); + return result; +} + lifecycle_result_t application_submit(application_t *application, const app_command_t *command) { if (!application_enqueue(application, command)) return LIFECYCLE_RESULT_GENERATION_FAILED; app_command_t next = {0}; @@ -52,6 +60,16 @@ lifecycle_result_t application_submit(application_t *application, const app_comm return game_lifecycle_rematch(&application->lifecycle, next.session_index, next.game_id); case COMMAND_ABORT: return game_lifecycle_abort(&application->lifecycle, next.session_index, next.game_id); + case COMMAND_LEAVE: + return application_leave(application, next.session_index, next.game_id, RECOVERY_REASON_SESSION_LEFT); + case COMMAND_PROFILE_RESET: + return application_leave(application, next.session_index, next.game_id, RECOVERY_REASON_PROFILE_RESET); + case COMMAND_RESET: + { + const lifecycle_result_t result = game_lifecycle_reset(&application->lifecycle, next.session_index, next.game_id); + if (result == LIFECYCLE_RESULT_OK) application->command_queue = (command_queue_t){0}; + return result; + } default: return LIFECYCLE_RESULT_GENERATION_FAILED; } diff --git a/src/game_lifecycle.c b/src/game_lifecycle.c index 3411188..8d7fa76 100644 --- a/src/game_lifecycle.c +++ b/src/game_lifecycle.c @@ -68,11 +68,24 @@ static lifecycle_result_t start_current_game(game_lifecycle_t *lifecycle) { return LIFECYCLE_RESULT_OK; } +static void return_to_lobby(game_lifecycle_t *lifecycle) { + const uint32_t version = lifecycle->game.state.version + 1U; + const uint32_t game_id = lifecycle->next_game_id++; + game_engine_init(&lifecycle->game); + lifecycle->game.state.game_id = game_id; + lifecycle->game.state.version = version; + lifecycle->bot_reserved = false; + lifecycle->rematch_confirmed[0] = false; + lifecycle->rematch_confirmed[1] = false; + bot_player_cancel_turn(&lifecycle->bot); +} + void game_lifecycle_init(game_lifecycle_t *lifecycle, random_source_t random) { if (lifecycle == NULL) return; memset(lifecycle, 0, sizeof(*lifecycle)); lifecycle->random = random; lifecycle->next_game_id = 2U; + lifecycle->recovery_generation = 1U; game_engine_init(&lifecycle->game); lifecycle->game.state.game_id = 1U; session_manager_init(&lifecycle->sessions); @@ -110,8 +123,38 @@ lifecycle_result_t game_lifecycle_disconnect(game_lifecycle_t *lifecycle, uint8_ } lifecycle_result_t game_lifecycle_leave(game_lifecycle_t *lifecycle, uint8_t session_index) { - return lifecycle == NULL ? LIFECYCLE_RESULT_UNAUTHORIZED : - session_result(session_manager_leave(&lifecycle->sessions, session_index, lifecycle->game.state.phase)); + if (lifecycle == NULL || session_index >= kSessionCapacity || !lifecycle->sessions.entries[session_index].occupied) return LIFECYCLE_RESULT_UNAUTHORIZED; + const bool player = session_index < kPlayerCapacity && lifecycle->sessions.entries[session_index].role == (role_t)session_index; + if (player && lifecycle->game.state.phase != PHASE_LOBBY) return_to_lobby(lifecycle); + memset(&lifecycle->sessions.entries[session_index], 0, sizeof(session_t)); + ++lifecycle->recovery_generation; + lifecycle->recovery_reason = RECOVERY_REASON_SESSION_LEFT; + return LIFECYCLE_RESULT_OK; +} + +lifecycle_result_t game_lifecycle_reset(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id) { + uint8_t player = 0; + if (!is_player(lifecycle, session_index, &player)) return LIFECYCLE_RESULT_FORBIDDEN_ROLE; + if (!game_id_matches(lifecycle, game_id)) return LIFECYCLE_RESULT_STALE_GAME; + const uint32_t version = lifecycle->game.state.version + 1U; + const uint32_t next_game_id = lifecycle->next_game_id++; + const random_source_t random = lifecycle->random; + const scheduler_t scheduler = lifecycle->bot_scheduler; + bot_player_cancel_turn(&lifecycle->bot); + memset(lifecycle, 0, sizeof(*lifecycle)); + lifecycle->random = random; + lifecycle->bot_scheduler = scheduler; + lifecycle->next_game_id = next_game_id + 1U; + lifecycle->recovery_generation = next_game_id; + lifecycle->recovery_reason = RECOVERY_REASON_GAME_RESET; + game_engine_init(&lifecycle->game); + lifecycle->game.state.game_id = next_game_id; + lifecycle->game.state.version = version; + return LIFECYCLE_RESULT_OK; +} + +void game_lifecycle_set_recovery_reason(game_lifecycle_t *lifecycle, recovery_reason_t reason) { + if (lifecycle != NULL) lifecycle->recovery_reason = reason; } lifecycle_result_t game_lifecycle_configure(game_lifecycle_t *lifecycle, uint8_t session_index, diff --git a/src/http_api.c b/src/http_api.c index b829d17..eb6a781 100644 --- a/src/http_api.c +++ b/src/http_api.c @@ -118,6 +118,15 @@ static const char *phase_text(phase_t phase) { return phase <= PHASE_REMATCH_WAIT ? values[phase] : "lobby"; } +static const char *recovery_reason_text(recovery_reason_t reason) { + switch (reason) { + case RECOVERY_REASON_SESSION_LEFT: return "session_left"; + case RECOVERY_REASON_PROFILE_RESET: return "profile_reset"; + case RECOVERY_REASON_GAME_RESET: return "game_reset"; + default: return "session_invalidated"; + } +} + static void response_write(http_api_response_t *response, uint16_t status, const char *format, ...) { va_list arguments; va_start(arguments, format); @@ -153,6 +162,16 @@ static void response_lifecycle_error(http_api_response_t *response, lifecycle_re } } +static void response_recovery(http_api_response_t *response, const game_lifecycle_t *lifecycle) { + response_write(response, 200U, "{\"ok\":true,\"resetReason\":\"%s\",\"generation\":%" PRIu32 "}", + recovery_reason_text(lifecycle->recovery_reason), lifecycle->recovery_generation); +} + +static void response_invalidated(http_api_response_t *response, const game_lifecycle_t *lifecycle) { + response_write(response, 401U, "{\"ok\":false,\"code\":\"SESSION_INVALIDATED\",\"resetReason\":\"%s\",\"generation\":%" PRIu32 "}", + recovery_reason_text(lifecycle->recovery_reason), lifecycle->recovery_generation); +} + static bool expect_post_body(const http_api_request_t *request, size_t maximum, http_api_response_t *response, uint32_t version) { if (request->method != HTTP_API_POST || !request->content_type_json) { @@ -289,10 +308,14 @@ bool http_api_handle(http_api_t *api, const http_api_request_t *request, http_ap uint8_t token[kSessionTokenBytes]; uint8_t session_index = 0; if (request->session_token != NULL && request->session_token[0] != '\0') { - if (!parse_token(request->session_token, token) || !application_session_for_token(api->application, token, &session_index)) { + if (!parse_token(request->session_token, token)) { response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version); return true; } + if (!application_session_for_token(api->application, token, &session_index)) { + response_invalidated(response, lifecycle); + return true; + } viewer = lifecycle->sessions.entries[session_index].role; } if (!state_presenter_write_lifecycle(lifecycle, viewer, response->body, sizeof(response->body), &response->body_length)) { @@ -374,6 +397,18 @@ bool http_api_handle(http_api_t *api, const http_api_request_t *request, http_ap return true; } if (!application_session_for_token(api->application, token, &command.session_index)) { + if (request->route == HTTP_API_ROUTE_LEAVE || request->route == HTTP_API_ROUTE_PROFILE_RESET) { + response_recovery(response, lifecycle); + return true; + } + if (request->route == HTTP_API_ROUTE_RESET && lifecycle->recovery_reason == RECOVERY_REASON_GAME_RESET) { + response_recovery(response, lifecycle); + return true; + } + if (request->route == HTTP_API_ROUTE_RESET) { + response_error(response, 403U, "FORBIDDEN_ROLE", "Роль не может выполнить действие", version); + return true; + } response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version); return true; } @@ -383,11 +418,15 @@ bool http_api_handle(http_api_t *api, const http_api_request_t *request, http_ap case HTTP_API_ROUTE_SHOT: command.type = COMMAND_SHOT; break; case HTTP_API_ROUTE_REMATCH: command.type = COMMAND_REMATCH; break; case HTTP_API_ROUTE_ABORT: command.type = COMMAND_ABORT; break; + case HTTP_API_ROUTE_LEAVE: command.type = COMMAND_LEAVE; break; + case HTTP_API_ROUTE_PROFILE_RESET: command.type = COMMAND_PROFILE_RESET; break; + case HTTP_API_ROUTE_RESET: command.type = COMMAND_RESET; break; default: response_error(response, 404U, "MALFORMED_JSON", "Маршрут не найден", version); return true; } command.version = version; const lifecycle_result_t result = application_submit(api->application, &command); if (result != LIFECYCLE_RESULT_OK) response_lifecycle_error(response, result, lifecycle->game.state.version); + else if (request->route == HTTP_API_ROUTE_LEAVE || request->route == HTTP_API_ROUTE_PROFILE_RESET || request->route == HTTP_API_ROUTE_RESET) response_recovery(response, lifecycle); else response_write(response, 200U, "{\"ok\":true,\"version\":%" PRIu32 ",\"gameId\":%" PRIu32 "}", lifecycle->game.state.version, lifecycle->game.state.game_id); return true; diff --git a/src/main.c b/src/main.c index 26c5cfc..b4fccab 100644 --- a/src/main.c +++ b/src/main.c @@ -167,11 +167,14 @@ static size_t route_body_limit(http_api_route_t route) { switch (route) { case HTTP_API_ROUTE_JOIN: return 192U; case HTTP_API_ROUTE_RESUME: + case HTTP_API_ROUTE_LEAVE: + case HTTP_API_ROUTE_PROFILE_RESET: case HTTP_API_ROUTE_CONFIG: case HTTP_API_ROUTE_SHOT: return 96U; case HTTP_API_ROUTE_START: case HTTP_API_ROUTE_REMATCH: case HTTP_API_ROUTE_ABORT: return 80U; + case HTTP_API_ROUTE_RESET: return 80U; default: return 0U; } } @@ -363,7 +366,7 @@ static esp_err_t static_file_handler(httpd_req_t *request) { static esp_err_t start_http_server(void) { httpd_config_t config = HTTPD_DEFAULT_CONFIG(); - config.max_uri_handlers = 16U; config.max_open_sockets = kHttpMaxOpenSockets; config.uri_match_fn = httpd_uri_match_wildcard; config.lru_purge_enable = true; + config.max_uri_handlers = 18U; config.max_open_sockets = kHttpMaxOpenSockets; config.uri_match_fn = httpd_uri_match_wildcard; config.lru_purge_enable = true; ESP_RETURN_ON_ERROR(httpd_start(&s_server, &config), kLogTag, "http server start failed"); const httpd_uri_t routes[] = { {.uri = "/", .method = HTTP_GET, .handler = root_handler}, @@ -371,11 +374,14 @@ static esp_err_t start_http_server(void) { {.uri = "/api/health", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_HEALTH}, {.uri = "/api/session/join", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_JOIN}, {.uri = "/api/session/resume", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_RESUME}, + {.uri = "/api/session/leave", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_LEAVE}, + {.uri = "/api/session/profile-reset", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_PROFILE_RESET}, {.uri = "/api/game/config", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_CONFIG}, {.uri = "/api/game/start", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_START}, {.uri = "/api/game/shot", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_SHOT}, {.uri = "/api/game/rematch", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_REMATCH}, {.uri = "/api/game/abort", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_ABORT}, + {.uri = "/api/game/reset", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_RESET}, {.uri = "/api/state", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_STATE}, {.uri = "/api/statistics", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_STATISTICS}, {.uri = "/ws", .method = HTTP_GET, .handler = websocket_handler, .is_websocket = true}, diff --git a/src/sync_service.c b/src/sync_service.c index af1ccdc..a617fe2 100644 --- a/src/sync_service.c +++ b/src/sync_service.c @@ -21,6 +21,11 @@ static void response_error(char output[kStateMessageCapacity], size_t *output_le code, message, version); } +static const char *recovery_reason_text(recovery_reason_t reason) { + return reason == RECOVERY_REASON_GAME_RESET ? "game_reset" : + reason == RECOVERY_REASON_PROFILE_RESET ? "profile_reset" : "session_left"; +} + static bool token_bytes(const char *text, uint8_t token[kSessionTokenBytes]) { if (text == NULL || strlen(text) != kSessionTokenBytes * 2U) return false; for (uint8_t index = 0; index < kSessionTokenBytes; ++index) { @@ -211,9 +216,20 @@ void sync_service_broadcast(sync_service_t *service, sync_send_fn send, void *co for (uint8_t index = 0; index < kSessionCapacity; ++index) { sync_connection_t *connection = &service->connections[index]; if (!connection->active || !connection->authenticated) continue; + const session_t *session = &service->application->lifecycle.sessions.entries[connection->session_index]; + if (!session->occupied) { + char reset[kStateMessageCapacity] = {0}; + size_t reset_length = 0U; + response_write(reset, &reset_length, "{\"type\":\"reset\",\"reason\":\"%s\",\"generation\":%" PRIu32 "}", + recovery_reason_text(service->application->lifecycle.recovery_reason), + service->application->lifecycle.recovery_generation); + (void)send(context, connection->client_id, reset, reset_length); + *connection = (sync_connection_t){0}; + continue; + } char payload[kStateMessageCapacity]; size_t length = 0U; - const role_t role = service->application->lifecycle.sessions.entries[connection->session_index].role; + const role_t role = session->role; if (!state_presenter_write_lifecycle(&service->application->lifecycle, role, payload, sizeof(payload), &length) || !send(context, connection->client_id, payload, length)) *connection = (sync_connection_t){0}; } diff --git a/test/host/test_game_lifecycle.c b/test/host/test_game_lifecycle.c index 1053867..7ba78af 100644 --- a/test/host/test_game_lifecycle.c +++ b/test/host/test_game_lifecycle.c @@ -104,10 +104,35 @@ static void test_bot_lifecycle_and_stale_actions(void) { assert(memcmp(&before, &lifecycle, sizeof(lifecycle)) == 0); } +static void test_leave_and_full_reset(void) { + test_random_t random = {.value = 23U}; + game_lifecycle_t lifecycle = new_lifecycle(&random); + uint8_t player_1 = 0; + uint8_t player_2 = 0; + uint8_t spectator = 0; + join_players(&lifecycle, &player_1, &player_2); + assert(game_lifecycle_join(&lifecycle, ROLE_SPECTATOR, "Watch", &spectator) == LIFECYCLE_RESULT_OK); + const uint32_t lobby_game_id = lifecycle.game.state.game_id; + assert(game_lifecycle_leave(&lifecycle, spectator) == LIFECYCLE_RESULT_OK); + assert(!lifecycle.sessions.entries[spectator].occupied && lifecycle.game.state.game_id == lobby_game_id); + assert(game_lifecycle_start(&lifecycle, player_1, lobby_game_id) == LIFECYCLE_RESULT_OK); + assert(game_lifecycle_leave(&lifecycle, player_2) == LIFECYCLE_RESULT_OK); + assert(!lifecycle.sessions.entries[player_2].occupied && lifecycle.sessions.entries[player_1].occupied); + assert(lifecycle.game.state.phase == PHASE_LOBBY && lifecycle.game.state.game_id != lobby_game_id); + const uint32_t reset_game_id = lifecycle.game.state.game_id; + lifecycle.cumulative[0].games = 3U; + assert(game_lifecycle_reset(&lifecycle, player_1, reset_game_id) == LIFECYCLE_RESULT_OK); + assert(lifecycle.game.state.phase == PHASE_LOBBY && lifecycle.game.state.game_id != reset_game_id); + assert(!lifecycle.sessions.entries[0].occupied && !lifecycle.sessions.entries[1].occupied); + assert(lifecycle.cumulative[0].games == 0U && lifecycle.recovery_reason == RECOVERY_REASON_GAME_RESET); + assert(game_lifecycle_reset(&lifecycle, player_1, reset_game_id) == LIFECYCLE_RESULT_FORBIDDEN_ROLE); +} + int main(void) { test_capacity_sanitize_and_resume(); test_human_lifecycle_and_guards(); test_bot_lifecycle_and_stale_actions(); + test_leave_and_full_reset(); puts("game lifecycle tests passed"); return 0; } diff --git a/test/host/test_http_api.c b/test/host/test_http_api.c index 1144be4..400ebb7 100644 --- a/test/host/test_http_api.c +++ b/test/host/test_http_api.c @@ -186,9 +186,36 @@ static void test_sessions_commands_and_state(void) { expect_code(&response, 503U, "SERVER_BUSY"); } +static void test_recovery_routes(void) { + application_t application; + test_random_t random = {.value = 31U}; + http_api_t api = new_api(&application, &random); + http_api_response_t response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST, + "{\"name\":\"Alice\",\"requestedRole\":\"player1\"}", NULL); + char player_token[33]; token_from_response(&response, player_token); + char command[96]; + snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1}", player_token); + response = call(&api, HTTP_API_ROUTE_LEAVE, HTTP_API_POST, command, NULL); + assert(response.status == 200U && strstr(response.body, "session_left") != NULL); + response = call(&api, HTTP_API_ROUTE_LEAVE, HTTP_API_POST, command, NULL); + assert(response.status == 200U && strstr(response.body, "session_left") != NULL); + response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST, + "{\"name\":\"Alice\",\"requestedRole\":\"player1\"}", NULL); + token_from_response(&response, player_token); + snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1}", player_token); + response = call(&api, HTTP_API_ROUTE_RESET, HTTP_API_POST, command, NULL); + assert(response.status == 200U && strstr(response.body, "game_reset") != NULL); + response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, player_token); + assert(response.status == 401U && strstr(response.body, "SESSION_INVALIDATED") != NULL && strstr(response.body, "game_reset") != NULL); + response = call(&api, HTTP_API_ROUTE_RESET, HTTP_API_POST, + "{\"token\":\"00000000000000000000000000000000\",\"gameId\":1}", NULL); + assert(response.status == 200U && strstr(response.body, "game_reset") != NULL); +} + int main(void) { test_public_routes_and_parse_limits(); test_sessions_commands_and_state(); + test_recovery_routes(); puts("http api tests passed"); return 0; } diff --git a/test/host/test_sync_service.c b/test/host/test_sync_service.c index d89bc20..415fcc6 100644 --- a/test/host/test_sync_service.c +++ b/test/host/test_sync_service.c @@ -124,9 +124,27 @@ static void test_timeout_ping_and_command(void) { assert(changed && !close && output_length == 0U && application.lifecycle.game.state.mode == MODE_BOT); } +static void test_reset_notification_invalidates_authenticated_client(void) { + test_random_t random = {.value = 17U}; + application_t application; + application_init(&application, (random_source_t){.next_u32 = next_random, .context = &random}); + uint8_t player = 0; + assert(application_join(&application, ROLE_PLAYER_1, "Alice", &player) == LIFECYCLE_RESULT_OK); + sync_service_t service; + sync_service_init(&service, &application); + assert(sync_service_open(&service, 0, 0U)); + hello(&service, 0, application.lifecycle.sessions.entries[player].token, 0U); + assert(game_lifecycle_reset(&application.lifecycle, player, 1U) == LIFECYCLE_RESULT_OK); + capture_t capture = {.fail_client = -1}; + sync_service_broadcast(&service, capture_send, &capture); + assert(capture.sends[0] == 1U && strstr(capture.payloads[0], "\"type\":\"reset\"") != NULL); + assert(strstr(capture.payloads[0], "game_reset") != NULL && !service.connections[0].active); +} + int main(void) { test_authentication_visibility_and_backpressure(); test_timeout_ping_and_command(); + test_reset_notification_invalidates_authenticated_client(); puts("sync service tests passed"); return 0; }