From ffdaa299bc38cb8066ea1eb0fcce07ebfd1019be Mon Sep 17 00:00:00 2001 From: sasa Date: Sat, 5 Sep 2026 21:19:52 +0300 Subject: [PATCH] feat(observer): add observer mode and disconnect recovery --- PLANS.md | 75 ++++- components/maze32_game/include/maze32_game.h | 10 + components/maze32_game/maze32_game.c | 120 +++++++- .../maze32_lobby/include/maze32_protocol.h | 3 + components/maze32_lobby/maze32_protocol.c | 18 ++ components/maze32_web/CMakeLists.txt | 2 +- .../maze32_web/include/maze32_gateway.h | 7 + components/maze32_web/maze32_gateway.c | 274 +++++++++++++++++- data/index.html.gz | Bin 2565 -> 2962 bytes docs/GAME_CORE.md | 24 +- docs/OBSERVER_UI.md | 37 +++ docs/PLAYER_UI.md | 3 + docs/PROTOCOL.md | 25 +- scripts/run_host_tests.sh | 10 + scripts/ws_protocol_client.py | 131 ++++++++- test/host/test_disconnect_recovery.c | 96 ++++++ test/host/test_game_core.c | 3 + test/host/test_lobby_protocol.c | 19 +- test/web/test_player_app.js | 30 ++ version.txt | 2 +- web/index.html | 12 + web/player.css | 33 +++ web/player.js | 97 ++++++- 23 files changed, 988 insertions(+), 43 deletions(-) create mode 100644 docs/OBSERVER_UI.md create mode 100644 test/host/test_disconnect_recovery.c diff --git a/PLANS.md b/PLANS.md index 8afb58d..4f16894 100644 --- a/PLANS.md +++ b/PLANS.md @@ -657,7 +657,7 @@ Complete the authoritative gameplay mechanics and terminal match outcomes. ## Milestone 008 — Player browser experience -**Status:** `IN_PROGRESS` +**Status:** `COMPLETE` ### Objective @@ -705,8 +705,7 @@ into the browser. ### Evidence -Implementation is complete, but the milestone remains `IN_PROGRESS` until the -required physical-device and current-stable-browser checks can be run. +Implementation and required verification are complete. - `bash scripts/run_host_tests.sh` completed successfully. The five focused browser tests reported keyboard/swipe direction equivalence, intention-only @@ -750,16 +749,26 @@ required physical-device and current-stable-browser checks can be run. `606b98e11cbc479bf11bffa9a38d62ddfebce0689f0e3ebf148521190a032fd5`; `cmp` returned zero, proving byte-for-byte delivery without interrupting the host's internet connection. -- Live browser validation is blocked. The browser-control runtime returned `No - browser is available`, and its browser inventory was empty. Therefore the - required visual and interaction passes at `412 x 916`, representative tablet - and desktop viewports, and current stable Chrome, Safari, Firefox, and Edge - cannot yet be recorded. Milestone 008 must not be marked `COMPLETE` until - those browser checks pass. Milestone 009 remains `NOT_STARTED`. +- Automated live browser validation was unavailable because the browser-control + runtime returned `No browser is available` and its browser inventory was + empty. The project owner subsequently supplied the required manual browser + evidence recorded below. +- On 2026-09-04, a fresh browser-control inventory check again returned an + empty list. Milestone 008 was therefore changed from `IN_PROGRESS` to + `BLOCKED`; no Milestone 009 implementation work was started. +- On 2026-09-04, the project owner completed the required manual player-UI + checks in Chrome 152, Firefox 155, Safari 26.6, and Edge 152.0.4191.53. The + checks covered the `412 x 916` phone layout without page scrolling, + representative tablet and desktop layouts, keyboard and swipe movement, + the accessible drop-key control, and refresh/session restoration. Together + with the automated browser-contract, projection-security, localization, + offline-asset, payload-size, build, and physical-device evidence above, this + satisfies every Milestone 008 acceptance criterion. Milestone 008 is + `COMPLETE`; Milestone 009 remains `NOT_STARTED`. ## Milestone 009 — Observer mode and disconnect recovery -**Status:** `NOT_STARTED` +**Status:** `IN_PROGRESS` ### Objective @@ -794,9 +803,49 @@ and reconnect behavior. ### Evidence -Completion evidence is not yet available because this milestone has not -started. Required evidence includes role-authorization tests, four-observer -browser evidence, and timed reconnect/timeout scenarios. +Implementation is complete, but the milestone remains `IN_PROGRESS` until the +required physical-device and observer-browser checks can be recorded. + +- On 2026-09-05, `bash scripts/run_host_tests.sh` completed successfully. The + new deterministic recovery suite proved immediate authoritative pause, + rejection of commands without state or effect progression while paused, + restoration before the monotonic 60,000 ms deadline, timeout draw at the + deadline, late-reconnect rejection, and preservation of the remaining 200 ms + finish window across a pause. Observer-role command enqueue returned + `read_only`, and observer connection changes left lobby phase unchanged. +- The same host run reported `game default observer serialization: PASS (688 + chunks, maximum 494 bytes)`. The projection contains all 8,192 cells, both + player records and connection states, and remains below the 512-byte message + bound. Seven browser-contract tests passed, covering authoritative chunk + assembly, observer role rendering, absence of observer mutation controls, + bounded fit/zoom/pan behavior, and the Russian sub-`1024 x 768` notice. Web + asset reproducibility passed with SHA-256 + `e8d097c7aa1a56cf84748078176b50cc8e2718082edfda35625b6d4436a1fd67` + and 19,133 of 1,572,864 allowed compressed bytes. +- Separate AddressSanitizer and UndefinedBehaviorSanitizer runs of the changed + game-core and disconnect-recovery suites passed without findings; leak + detection was disabled because the sandbox executes under `ptrace`. + `node --check web/player.js`, compilation of + `scripts/ws_protocol_client.py`, and `git diff --check` also passed. +- `/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1` completed + with `[SUCCESS]`, reporting 124,148 of 327,680 RAM bytes and 1,023,742 of + 2,097,152 application bytes. The independent partition check reported + `firmware.bin uses 1024368/2097152 bytes in partition factory`. The + corresponding `buildfs` command completed with `[SUCCESS]` and reported + `littlefs.bin uses 2031616/2031616 bytes in partition littlefs`, with all five + compressed assets present. +- Physical verification remains outstanding. The prepared dependency-free + command `python3 scripts/ws_protocol_client.py 192.168.1.50 --verify-m009` + covers simultaneous full-state delivery to four observers, observer mutation + rejection, observer-disconnect continuity, same-state player restoration, + and the real 60-second timeout draw. At the end of this pass, + `/dev/ttyACM0` was absent and `192.168.1.50` was unreachable, so the final + `0.9.0` images could not be uploaded and this scenario could not be run. +- Automated live observer validation also remains outstanding. The + browser-control runtime returned `No browser is available`, and its browser + inventory was empty. A live check at `1024 x 768` or larger plus a smaller + viewport must still confirm full-map fit, updates, zoom, pan, read-only UI, + player connection state, and the required Russian large-screen notice. ## Milestone 010 — On-device integration and resource hardening diff --git a/components/maze32_game/include/maze32_game.h b/components/maze32_game/include/maze32_game.h index 4395656..f804692 100644 --- a/components/maze32_game/include/maze32_game.h +++ b/components/maze32_game/include/maze32_game.h @@ -15,10 +15,12 @@ extern "C" { #define MAZE32_GAME_NO_KEY UINT8_MAX #define MAZE32_GAME_VISION_MOVES 10U #define MAZE32_GAME_DRAW_WINDOW_MS 200U +#define MAZE32_GAME_RECONNECT_TIMEOUT_MS 60000U #define MAZE32_GAME_MAX_ARTIFACTS (2U * MAZE32_MAZE_MAX_ARTIFACT_PAIRS) #define MAZE32_GAME_MAX_OBJECTS \ (6U + 2U * MAZE32_MAZE_MAX_LANDMARK_PAIRS + 2U * MAZE32_MAZE_MAX_ARTIFACT_PAIRS) #define MAZE32_GAME_VIEW_CELLS_PER_CHUNK 12U +#define MAZE32_GAME_OBSERVER_FIRST_CHUNK_CELLS 8U #define MAZE32_GAME_VIEW_OBJECTS_PER_CHUNK 6U #define MAZE32_GAME_VIEW_CHUNK_BYTES 512U #define MAZE32_GAME_RAM_BUDGET_BYTES (40U * 1024U) @@ -28,6 +30,7 @@ typedef enum { MAZE32_GAME_PHASE_COUNTDOWN = 0, MAZE32_GAME_PHASE_ACTIVE, MAZE32_GAME_PHASE_FINISH_PENDING, + MAZE32_GAME_PHASE_PAUSED, MAZE32_GAME_PHASE_FINISHED, } maze32_game_phase_t; @@ -96,6 +99,8 @@ typedef struct { maze32_game_compass_direction_t compass_hint; uint8_t has_moved; uint8_t exited; + uint8_t connected; + uint64_t reconnect_deadline_ms; } maze32_game_player_t; typedef enum { @@ -159,6 +164,8 @@ typedef struct { uint8_t first_exited_player; uint8_t has_exit_time[MAZE32_GAME_PLAYER_COUNT]; uint64_t exit_times_ms[MAZE32_GAME_PLAYER_COUNT]; + maze32_game_phase_t phase_before_pause; + uint64_t paused_started_ms; } maze32_game_t; typedef enum { @@ -203,6 +210,7 @@ typedef struct { uint8_t observer_vision_moves_remaining[MAZE32_GAME_PLAYER_COUNT]; maze32_game_compass_direction_t observer_compass_hints[MAZE32_GAME_PLAYER_COUNT]; uint8_t observer_exited[MAZE32_GAME_PLAYER_COUNT]; + uint8_t observer_connected[MAZE32_GAME_PLAYER_COUNT]; uint8_t observer_exit_open[MAZE32_GAME_PLAYER_COUNT]; uint8_t observer_has_exit_time[MAZE32_GAME_PLAYER_COUNT]; uint64_t observer_exit_times_ms[MAZE32_GAME_PLAYER_COUNT]; @@ -224,6 +232,8 @@ maze32_game_result_t maze32_game_enqueue_drop_key(maze32_game_t* game, maze32_ga uint32_t* command_sequence); maze32_game_command_result_t maze32_game_process_next(maze32_game_t* game); int maze32_game_advance_time(maze32_game_t* game, uint64_t now_ms); +maze32_game_result_t maze32_game_set_player_connected(maze32_game_t* game, uint8_t player_index, + int connected, uint64_t now_ms); uint8_t maze32_game_player_view_radius(const maze32_game_t* game, uint8_t player_index); maze32_game_result_t maze32_game_project_view(const maze32_game_t* game, maze32_game_role_t role, maze32_view_t* view); diff --git a/components/maze32_game/maze32_game.c b/components/maze32_game/maze32_game.c index 66510e0..7c37414 100644 --- a/components/maze32_game/maze32_game.c +++ b/components/maze32_game/maze32_game.c @@ -78,11 +78,13 @@ maze32_game_result_t maze32_game_init(maze32_game_t* game, const maze32_maze_t* game->maze = maze; game->config = *config; game->phase = MAZE32_GAME_PHASE_COUNTDOWN; + game->phase_before_pause = MAZE32_GAME_PHASE_ACTIVE; game->next_command_sequence = 1U; game->first_exited_player = MAZE32_GAME_NO_OWNER; for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) { game->players[player].position = maze->starts[player]; game->players[player].carried_key = MAZE32_GAME_NO_KEY; + game->players[player].connected = 1U; game->keys[player].location = MAZE32_GAME_KEY_GROUND; game->keys[player].ground_position = maze->keys[player]; game->keys[player].carrier = MAZE32_GAME_NO_OWNER; @@ -93,7 +95,13 @@ maze32_game_result_t maze32_game_init(maze32_game_t* game, const maze32_maze_t* void maze32_game_set_phase(maze32_game_t* game, maze32_game_phase_t phase) { if (game != NULL && phase >= MAZE32_GAME_PHASE_COUNTDOWN && phase <= MAZE32_GAME_PHASE_FINISHED) { - game->phase = phase; + if (phase == MAZE32_GAME_PHASE_ACTIVE && + (!game->players[0].connected || !game->players[1].connected)) { + game->phase_before_pause = MAZE32_GAME_PHASE_ACTIVE; + game->phase = MAZE32_GAME_PHASE_PAUSED; + } else { + game->phase = phase; + } } } @@ -139,7 +147,25 @@ maze32_game_result_t maze32_game_enqueue_drop_key(maze32_game_t* game, maze32_ga } int maze32_game_advance_time(maze32_game_t* game, uint64_t now_ms) { - if (game == NULL || game->phase != MAZE32_GAME_PHASE_FINISH_PENDING || + if (game == NULL) { + return 0; + } + if (game->phase == MAZE32_GAME_PHASE_PAUSED) { + for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) { + if (!game->players[player].connected && + now_ms >= game->players[player].reconnect_deadline_ms) { + game->phase = MAZE32_GAME_PHASE_FINISHED; + game->final_result = MAZE32_GAME_FINAL_DRAW; + game->paused_started_ms = 0U; + game->command_head = 0U; + game->command_count = 0U; + game->state_sequence++; + return 1; + } + } + return 0; + } + if (game->phase != MAZE32_GAME_PHASE_FINISH_PENDING || game->first_exited_player >= MAZE32_GAME_PLAYER_COUNT) { return 0; } @@ -154,6 +180,60 @@ int maze32_game_advance_time(maze32_game_t* game, uint64_t now_ms) { return 1; } +maze32_game_result_t maze32_game_set_player_connected(maze32_game_t* game, uint8_t player_index, + int connected, uint64_t now_ms) { + if (game == NULL || player_index >= MAZE32_GAME_PLAYER_COUNT) { + return MAZE32_GAME_RESULT_INVALID_ARGUMENT; + } + maze32_game_player_t* player = &game->players[player_index]; + uint8_t normalized = connected ? 1U : 0U; + if (player->connected == normalized) { + return MAZE32_GAME_RESULT_ACCEPTED; + } + if (game->phase == MAZE32_GAME_PHASE_FINISHED) { + return MAZE32_GAME_RESULT_MATCH_FINISHED; + } + if (normalized) { + if (game->phase == MAZE32_GAME_PHASE_PAUSED && now_ms >= player->reconnect_deadline_ms) { + maze32_game_advance_time(game, now_ms); + return MAZE32_GAME_RESULT_MATCH_FINISHED; + } + player->connected = 1U; + player->reconnect_deadline_ms = 0U; + if (game->phase == MAZE32_GAME_PHASE_PAUSED && game->players[0].connected && + game->players[1].connected) { + if (game->phase_before_pause == MAZE32_GAME_PHASE_FINISH_PENDING && + game->first_exited_player < MAZE32_GAME_PLAYER_COUNT && + now_ms > game->paused_started_ms) { + uint64_t pause_duration_ms = now_ms - game->paused_started_ms; + uint64_t* exit_time_ms = &game->exit_times_ms[game->first_exited_player]; + *exit_time_ms = *exit_time_ms > UINT64_MAX - pause_duration_ms + ? UINT64_MAX + : *exit_time_ms + pause_duration_ms; + } + game->phase = game->phase_before_pause; + game->paused_started_ms = 0U; + } + game->state_sequence++; + return MAZE32_GAME_RESULT_ACCEPTED; + } + + player->connected = 0U; + player->reconnect_deadline_ms = now_ms > UINT64_MAX - MAZE32_GAME_RECONNECT_TIMEOUT_MS + ? UINT64_MAX + : now_ms + MAZE32_GAME_RECONNECT_TIMEOUT_MS; + if (game->phase == MAZE32_GAME_PHASE_COUNTDOWN || game->phase == MAZE32_GAME_PHASE_ACTIVE || + game->phase == MAZE32_GAME_PHASE_FINISH_PENDING) { + game->phase_before_pause = game->phase; + game->phase = MAZE32_GAME_PHASE_PAUSED; + game->paused_started_ms = now_ms; + game->command_head = 0U; + game->command_count = 0U; + } + game->state_sequence++; + return MAZE32_GAME_RESULT_ACCEPTED; +} + static void accept_player_move(maze32_game_player_t* player, uint64_t submitted_ms) { player->last_move_ms = submitted_ms; player->accepted_moves++; @@ -496,6 +576,7 @@ maze32_game_result_t maze32_game_project_view(const maze32_game_t* game, maze32_ game->players[player].vision_moves_remaining; view->observer_compass_hints[player] = game->players[player].compass_hint; view->observer_exited[player] = game->players[player].exited; + view->observer_connected[player] = game->players[player].connected; view->observer_exit_open[player] = game->exits[player].open; view->observer_has_exit_time[player] = game->has_exit_time[player]; view->observer_exit_times_ms[player] = game->exit_times_ms[player]; @@ -517,12 +598,24 @@ maze32_game_result_t maze32_game_project_view(const maze32_game_t* game, maze32_ return MAZE32_GAME_RESULT_ACCEPTED; } +static size_t cell_chunk_count(const maze32_view_t* view) { + if (view->role != MAZE32_GAME_ROLE_OBSERVER || view->cell_count == 0U) { + return (view->cell_count + MAZE32_GAME_VIEW_CELLS_PER_CHUNK - 1U) / + MAZE32_GAME_VIEW_CELLS_PER_CHUNK; + } + if (view->cell_count <= MAZE32_GAME_OBSERVER_FIRST_CHUNK_CELLS) { + return 1U; + } + size_t remaining = view->cell_count - MAZE32_GAME_OBSERVER_FIRST_CHUNK_CELLS; + return 1U + + (remaining + MAZE32_GAME_VIEW_CELLS_PER_CHUNK - 1U) / MAZE32_GAME_VIEW_CELLS_PER_CHUNK; +} + size_t maze32_game_view_chunk_count(const maze32_view_t* view) { if (view == NULL) { return 0U; } - size_t cell_chunks = (view->cell_count + MAZE32_GAME_VIEW_CELLS_PER_CHUNK - 1U) / - MAZE32_GAME_VIEW_CELLS_PER_CHUNK; + size_t cell_chunks = cell_chunk_count(view); size_t object_chunks = (view->object_count + MAZE32_GAME_VIEW_OBJECTS_PER_CHUNK - 1U) / MAZE32_GAME_VIEW_OBJECTS_PER_CHUNK; return cell_chunks + object_chunks; @@ -556,7 +649,8 @@ static const char* object_name(maze32_view_object_kind_t kind) { } static const char* phase_name(maze32_game_phase_t phase) { - static const char* const NAMES[] = {"countdown", "active", "finish_pending", "finished"}; + static const char* const NAMES[] = {"countdown", "active", "finish_pending", "paused", + "finished"}; return NAMES[phase]; } @@ -605,11 +699,12 @@ static int append_observer_status(const maze32_view_t* view, char* output, size_ } for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) { if (!append_text(output, output_size, offset, - "%s{\"cell\":%u,\"visionMoves\":%u,\"hint\":\"%s\",\"exited\":%u", + "%s{\"cell\":%u,\"visionMoves\":%u,\"hint\":\"%s\",\"exited\":%u," + "\"connected\":%u", player == 0U ? "" : ",", view->observer_player_cell_indices[player], view->observer_vision_moves_remaining[player], compass_name(view->observer_compass_hints[player]), - view->observer_exited[player]) || + view->observer_exited[player], view->observer_connected[player]) || !append_nullable_key(output, output_size, offset, "key", view->observer_carried_keys[player]) || !append_text(output, output_size, offset, "}")) { @@ -669,11 +764,18 @@ maze32_game_result_t maze32_game_serialize_view_chunk(const maze32_view_t* view, return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL; } - size_t cell_chunks = (view->cell_count + MAZE32_GAME_VIEW_CELLS_PER_CHUNK - 1U) / - MAZE32_GAME_VIEW_CELLS_PER_CHUNK; + size_t cell_chunks = cell_chunk_count(view); if (chunk_index < cell_chunks) { size_t first = chunk_index * MAZE32_GAME_VIEW_CELLS_PER_CHUNK; + if (view->role == MAZE32_GAME_ROLE_OBSERVER) { + first = chunk_index == 0U ? 0U + : MAZE32_GAME_OBSERVER_FIRST_CHUNK_CELLS + + (chunk_index - 1U) * MAZE32_GAME_VIEW_CELLS_PER_CHUNK; + } size_t end = first + MAZE32_GAME_VIEW_CELLS_PER_CHUNK; + if (view->role == MAZE32_GAME_ROLE_OBSERVER && chunk_index == 0U) { + end = MAZE32_GAME_OBSERVER_FIRST_CHUNK_CELLS; + } if (end > view->cell_count) { end = view->cell_count; } diff --git a/components/maze32_lobby/include/maze32_protocol.h b/components/maze32_lobby/include/maze32_protocol.h index 2cb9059..dc04512 100644 --- a/components/maze32_lobby/include/maze32_protocol.h +++ b/components/maze32_lobby/include/maze32_protocol.h @@ -19,6 +19,8 @@ typedef enum { MAZE32_MESSAGE_JOIN, MAZE32_MESSAGE_RESTORE, MAZE32_MESSAGE_START, + MAZE32_MESSAGE_MOVE, + MAZE32_MESSAGE_DROP_KEY, MAZE32_MESSAGE_HEARTBEAT, MAZE32_MESSAGE_SUGGEST_NAME, } maze32_message_type_t; @@ -39,6 +41,7 @@ typedef struct { maze32_role_t role; char name[MAZE32_PLAYER_NAME_MAX_BYTES + 1U]; char token[MAZE32_SESSION_TOKEN_HEX_LENGTH + 1U]; + char direction[6U]; } maze32_client_message_t; maze32_protocol_result_t maze32_protocol_parse(const char* data, size_t length, diff --git a/components/maze32_lobby/maze32_protocol.c b/components/maze32_lobby/maze32_protocol.c index b2c90a8..d565088 100644 --- a/components/maze32_lobby/maze32_protocol.c +++ b/components/maze32_lobby/maze32_protocol.c @@ -123,6 +123,15 @@ static int payload_field_allowed(maze32_message_type_t type, const char* key, ch *fields |= 1U; return 1; } + if (type == MAZE32_MESSAGE_MOVE && strcmp(key, "direction") == 0 && !(*fields & 1U)) { + if (strcmp(value, "north") != 0 && strcmp(value, "east") != 0 && + strcmp(value, "south") != 0 && strcmp(value, "west") != 0) { + return 0; + } + memcpy(message->direction, value, strlen(value) + 1U); + *fields |= 1U; + return 1; + } return 0; } @@ -159,6 +168,9 @@ static int parse_payload(json_cursor_t* cursor, maze32_client_message_t* message if (message->type == MAZE32_MESSAGE_RESTORE) { return fields == 1U; } + if (message->type == MAZE32_MESSAGE_MOVE) { + return fields == 1U; + } return fields == 0U; } @@ -172,6 +184,12 @@ static maze32_message_type_t message_type(const char* value) { if (strcmp(value, "start") == 0) { return MAZE32_MESSAGE_START; } + if (strcmp(value, "move") == 0) { + return MAZE32_MESSAGE_MOVE; + } + if (strcmp(value, "drop_key") == 0) { + return MAZE32_MESSAGE_DROP_KEY; + } if (strcmp(value, "heartbeat") == 0) { return MAZE32_MESSAGE_HEARTBEAT; } diff --git a/components/maze32_web/CMakeLists.txt b/components/maze32_web/CMakeLists.txt index e0e71b8..86bf146 100644 --- a/components/maze32_web/CMakeLists.txt +++ b/components/maze32_web/CMakeLists.txt @@ -1,5 +1,5 @@ idf_component_register( SRCS "maze32_web.c" "maze32_gateway.c" INCLUDE_DIRS "include" - REQUIRES esp_http_server esp_timer heap maze32_lobby maze32_platform + REQUIRES esp_http_server esp_timer heap maze32_game maze32_lobby maze32_maze maze32_platform ) diff --git a/components/maze32_web/include/maze32_gateway.h b/components/maze32_web/include/maze32_gateway.h index afd4b56..61c100d 100644 --- a/components/maze32_web/include/maze32_gateway.h +++ b/components/maze32_web/include/maze32_gateway.h @@ -7,7 +7,9 @@ #include "esp_err.h" #include "esp_http_server.h" #include "esp_timer.h" +#include "maze32_game.h" #include "maze32_lobby.h" +#include "maze32_maze.h" #ifdef __cplusplus extern "C" { @@ -42,6 +44,11 @@ typedef struct { uint32_t server_sequence; unsigned int suggestion_index; esp_timer_handle_t countdown_timer; + esp_timer_handle_t reconnect_timer; + maze32_maze_t maze; + maze32_maze_workspace_t maze_workspace; + maze32_game_t game; + maze32_view_t view; } maze32_gateway_t; esp_err_t maze32_gateway_init(maze32_gateway_t* gateway); diff --git a/components/maze32_web/maze32_gateway.c b/components/maze32_web/maze32_gateway.c index 37d4b61..c918d21 100644 --- a/components/maze32_web/maze32_gateway.c +++ b/components/maze32_web/maze32_gateway.c @@ -148,6 +148,64 @@ static void drain_queue(maze32_gateway_t* gateway) { } } +static maze32_game_role_t game_role_for_session(const maze32_session_t* session) { + if (session->role == MAZE32_ROLE_OBSERVER) { + return MAZE32_GAME_ROLE_OBSERVER; + } + return session->player_slot == 1U ? MAZE32_GAME_ROLE_PLAYER_ONE : MAZE32_GAME_ROLE_PLAYER_TWO; +} + +static void send_view(maze32_gateway_t* gateway, const maze32_session_t* session, + int full_observer_view) { + if (session == NULL || !session->connected || + maze32_game_project_view(&gateway->game, game_role_for_session(session), &gateway->view) != + MAZE32_GAME_RESULT_ACCEPTED) { + return; + } + if (session->role == MAZE32_ROLE_OBSERVER && !full_observer_view) { + gateway->view.cell_count = 0U; + } + size_t chunks = maze32_game_view_chunk_count(&gateway->view); + for (size_t chunk = 0U; chunk < chunks; ++chunk) { + char payload[MAZE32_GAME_VIEW_CHUNK_BYTES]; + size_t written = 0U; + if (maze32_game_serialize_view_chunk(&gateway->view, chunk, payload, sizeof(payload), + &written) != MAZE32_GAME_RESULT_ACCEPTED) { + ESP_LOGE(TAG, "View serialization failed for fd=%d, chunk=%u", session->connection_id, + (unsigned int)chunk); + return; + } + (void)written; + if (!enqueue(gateway, session->connection_id, payload)) { + return; + } + drain_queue(gateway); + } +} + +static void broadcast_views(maze32_gateway_t* gateway, int full_observer_view) { + for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) { + send_view(gateway, &gateway->lobby.sessions[index], full_observer_view); + } +} + +static void broadcast_match_event(maze32_gateway_t* gateway, const char* type, + const char* payload) { + char response[MAZE32_GATEWAY_OUTGOING_LIMIT]; + int written = snprintf(response, sizeof(response), + "{\"v\":1,\"type\":\"%s\",\"seq\":%" PRIu32 ",\"payload\":%s}", type, + next_server_sequence(gateway), payload); + if (written < 0 || (size_t)written >= sizeof(response)) { + return; + } + for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) { + const maze32_session_t* session = &gateway->lobby.sessions[index]; + if (session->role != MAZE32_ROLE_NONE && session->connected) { + enqueue(gateway, session->connection_id, response); + } + } +} + static void send_error(maze32_gateway_t* gateway, int socket_fd, const char* request_id, const char* code, const char* message) { char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = ""; @@ -399,6 +457,7 @@ static void countdown_finished_work(void* context) { if (!maze32_lobby_activate_if_due(&gateway->lobby, now_ms())) { return; } + maze32_game_set_phase(&gateway->game, MAZE32_GAME_PHASE_ACTIVE); char response[MAZE32_GATEWAY_OUTGOING_LIMIT]; int written = snprintf(response, sizeof(response), "{\"v\":1,\"type\":\"active\",\"seq\":%" PRIu32 @@ -413,6 +472,7 @@ static void countdown_finished_work(void* context) { } } broadcast_snapshot(gateway); + broadcast_views(gateway, 1); drain_queue(gateway); ESP_LOGI(TAG, "Lobby became active at server time %" PRIu64 " ms", gateway->lobby.activation_ms); @@ -428,6 +488,53 @@ static void countdown_timer_callback(void* context) { } } +static void schedule_reconnect_deadline(maze32_gateway_t* gateway); + +static void reconnect_deadline_work(void* context) { + maze32_gateway_t* gateway = context; + if (maze32_game_advance_time(&gateway->game, now_ms())) { + broadcast_match_event(gateway, "match_result", "{\"result\":\"draw\"}"); + broadcast_views(gateway, 0); + drain_queue(gateway); + ESP_LOGI(TAG, "Player reconnect deadline expired; match ended in a draw"); + return; + } + schedule_reconnect_deadline(gateway); +} + +static void reconnect_timer_callback(void* context) { + maze32_gateway_t* gateway = context; + if (gateway->server != NULL) { + esp_err_t result = httpd_queue_work(gateway->server, reconnect_deadline_work, gateway); + if (result != ESP_OK) { + ESP_LOGE(TAG, "Could not queue reconnect deadline: %s", esp_err_to_name(result)); + } + } +} + +static void schedule_reconnect_deadline(maze32_gateway_t* gateway) { + esp_timer_stop(gateway->reconnect_timer); + if (gateway->game.phase != MAZE32_GAME_PHASE_PAUSED) { + return; + } + uint64_t deadline = UINT64_MAX; + for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) { + if (!gateway->game.players[player].connected && + gateway->game.players[player].reconnect_deadline_ms < deadline) { + deadline = gateway->game.players[player].reconnect_deadline_ms; + } + } + if (deadline == UINT64_MAX) { + return; + } + uint64_t current_ms = now_ms(); + uint64_t delay_ms = deadline > current_ms ? deadline - current_ms : 1U; + esp_err_t result = esp_timer_start_once(gateway->reconnect_timer, delay_ms * 1000U); + if (result != ESP_OK) { + ESP_LOGE(TAG, "Reconnect timer could not start: %s", esp_err_to_name(result)); + } +} + static int rate_limit_allows(maze32_gateway_connection_t* connection, uint64_t current_ms) { if (current_ms - connection->rate_window_started_ms >= 1000U) { connection->rate_window_started_ms = current_ms; @@ -437,6 +544,87 @@ static int rate_limit_allows(maze32_gateway_connection_t* connection, uint64_t c return connection->messages_in_window <= MAZE32_GATEWAY_RATE_LIMIT_PER_SECOND; } +static maze32_game_direction_t parse_direction(const char* direction) { + if (strcmp(direction, "north") == 0) { + return MAZE32_GAME_DIRECTION_NORTH; + } + if (strcmp(direction, "east") == 0) { + return MAZE32_GAME_DIRECTION_EAST; + } + if (strcmp(direction, "south") == 0) { + return MAZE32_GAME_DIRECTION_SOUTH; + } + return MAZE32_GAME_DIRECTION_WEST; +} + +static void send_game_error(maze32_gateway_t* gateway, int socket_fd, const char* request_id, + maze32_game_result_t result) { + const char* message = "Команда игры отклонена сервером"; + if (result == MAZE32_GAME_RESULT_READ_ONLY) { + message = "Наблюдатели смотрят, но не двигают героев"; + } else if (result == MAZE32_GAME_RESULT_PREMATURE) { + message = "Матч сейчас на паузе или ещё не начался"; + } else if (result == MAZE32_GAME_RESULT_RATE_LIMITED) { + message = "Слишком быстро: лабиринт просит соблюдать интервал"; + } else if (result == MAZE32_GAME_RESULT_BLOCKED) { + message = "Там стена. Очень убедительная"; + } else if (result == MAZE32_GAME_RESULT_NO_KEY) { + message = "Карман пуст — бросать нечего"; + } else if (result == MAZE32_GAME_RESULT_MATCH_FINISHED) { + message = "Гонка уже закончилась"; + } + send_error(gateway, socket_fd, request_id, maze32_game_result_name(result), message); +} + +static void handle_game_command(maze32_gateway_t* gateway, const maze32_session_t* session, + const maze32_client_message_t* message) { + if (session->role == MAZE32_ROLE_OBSERVER) { + ESP_LOGW(TAG, "Observer mutation rejected: fd=%d, type=%u", session->connection_id, + (unsigned int)message->type); + send_game_error(gateway, session->connection_id, message->request_id, + MAZE32_GAME_RESULT_READ_ONLY); + return; + } + if (gateway->lobby.phase != MAZE32_LOBBY_ACTIVE) { + send_game_error(gateway, session->connection_id, message->request_id, + MAZE32_GAME_RESULT_PREMATURE); + return; + } + uint32_t command_sequence = 0U; + maze32_game_role_t role = game_role_for_session(session); + maze32_game_result_t result = + message->type == MAZE32_MESSAGE_MOVE + ? maze32_game_enqueue_move(&gateway->game, role, parse_direction(message->direction), + now_ms(), &command_sequence) + : maze32_game_enqueue_drop_key(&gateway->game, role, now_ms(), &command_sequence); + if (result == MAZE32_GAME_RESULT_ACCEPTED) { + maze32_game_command_result_t processed = maze32_game_process_next(&gateway->game); + result = processed.result; + } + if (result != MAZE32_GAME_RESULT_ACCEPTED) { + send_game_error(gateway, session->connection_id, message->request_id, result); + return; + } + char acknowledgement[160] = ""; + char prefix[96]; + size_t length = 0U; + int written = snprintf(prefix, sizeof(prefix), + "{\"v\":1,\"type\":\"ack\",\"seq\":%" PRIu32 ",\"requestId\":", + next_server_sequence(gateway)); + if (written >= 0 && (size_t)written < sizeof(prefix) && + append_text(acknowledgement, sizeof(acknowledgement), &length, prefix) && + append_json_string(acknowledgement, sizeof(acknowledgement), &length, + message->request_id) && + (written = snprintf(prefix, sizeof(prefix), + ",\"payload\":{\"commandSequence\":%" PRIu32 "}}", command_sequence)) >= + 0 && + (size_t)written < sizeof(prefix) && + append_text(acknowledgement, sizeof(acknowledgement), &length, prefix)) { + enqueue(gateway, session->connection_id, acknowledgement); + } + broadcast_views(gateway, 0); +} + static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_t* connection, const maze32_client_message_t* message) { int socket_fd = connection->socket_fd; @@ -464,6 +652,9 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_ } send_session(gateway, socket_fd, session, message->request_id); broadcast_snapshot(gateway); + if (gateway->lobby.phase == MAZE32_LOBBY_ACTIVE) { + send_view(gateway, session, 1); + } return; } if (message->type == MAZE32_MESSAGE_RESTORE) { @@ -480,7 +671,27 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_ return; } send_session(gateway, socket_fd, session, message->request_id); + if (session->role == MAZE32_ROLE_PLAYER && gateway->lobby.phase != MAZE32_LOBBY_WAITING) { + maze32_game_phase_t paused_phase = gateway->game.phase; + maze32_game_result_t reconnect_result = maze32_game_set_player_connected( + &gateway->game, (uint8_t)(session->player_slot - 1U), 1, now_ms()); + if (reconnect_result == MAZE32_GAME_RESULT_ACCEPTED) { + schedule_reconnect_deadline(gateway); + if (paused_phase == MAZE32_GAME_PHASE_PAUSED && + gateway->game.phase != MAZE32_GAME_PHASE_PAUSED) { + broadcast_match_event(gateway, "resume", "{}"); + ESP_LOGI(TAG, "Player %u restored; match resumed", session->player_slot); + } + } + } broadcast_snapshot(gateway); + if (gateway->lobby.phase == MAZE32_LOBBY_ACTIVE) { + if (session->role == MAZE32_ROLE_OBSERVER) { + send_view(gateway, session, 1); + } else { + broadcast_views(gateway, 0); + } + } return; } @@ -492,6 +703,9 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_ return; } if (message->type == MAZE32_MESSAGE_START) { + if (session->role == MAZE32_ROLE_OBSERVER) { + ESP_LOGW(TAG, "Observer mutation rejected: fd=%d, type=start", socket_fd); + } maze32_lobby_result_t result = maze32_lobby_start(&gateway->lobby, socket_fd, now_ms()); if (result != MAZE32_LOBBY_OK) { send_lobby_error(gateway, socket_fd, message->request_id, result); @@ -506,6 +720,10 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_ } return; } + if (message->type == MAZE32_MESSAGE_MOVE || message->type == MAZE32_MESSAGE_DROP_KEY) { + handle_game_command(gateway, session, message); + return; + } if (message->type == MAZE32_MESSAGE_HEARTBEAT) { char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = ""; size_t length = 0U; @@ -604,6 +822,19 @@ esp_err_t maze32_gateway_init(maze32_gateway_t* gateway) { } memset(gateway, 0, sizeof(*gateway)); maze32_lobby_init(&gateway->lobby); + maze32_maze_config_t maze_config = maze32_maze_default_config(); + maze32_maze_report_t maze_report; + if (maze32_maze_generate(&gateway->maze, &gateway->maze_workspace, &maze_config, esp_random(), + &maze_report) != MAZE32_MAZE_OK) { + ESP_LOGE(TAG, "Initial maze generation failed: %s", + maze32_maze_result_name(maze_report.result)); + return ESP_FAIL; + } + maze32_game_config_t game_config = maze32_game_default_config(); + if (maze32_game_init(&gateway->game, &gateway->maze, &game_config) != + MAZE32_GAME_RESULT_ACCEPTED) { + return ESP_FAIL; + } const esp_timer_create_args_t timer_arguments = { .callback = countdown_timer_callback, .arg = gateway, @@ -611,7 +842,22 @@ esp_err_t maze32_gateway_init(maze32_gateway_t* gateway) { .name = "lobby_countdown", .skip_unhandled_events = true, }; - return esp_timer_create(&timer_arguments, &gateway->countdown_timer); + esp_err_t result = esp_timer_create(&timer_arguments, &gateway->countdown_timer); + if (result != ESP_OK) { + return result; + } + const esp_timer_create_args_t reconnect_timer_arguments = { + .callback = reconnect_timer_callback, + .arg = gateway, + .dispatch_method = ESP_TIMER_TASK, + .name = "reconnect_deadline", + .skip_unhandled_events = true, + }; + result = esp_timer_create(&reconnect_timer_arguments, &gateway->reconnect_timer); + if (result != ESP_OK) { + esp_timer_delete(gateway->countdown_timer); + } + return result; } void maze32_gateway_set_server(maze32_gateway_t* gateway, httpd_handle_t server) { @@ -648,9 +894,35 @@ void maze32_gateway_close_socket(httpd_handle_t server, int socket_fd) { if (gateway != NULL) { maze32_gateway_connection_t* connection = find_connection(gateway, socket_fd); if (connection != NULL) { + const maze32_session_t* session = + maze32_lobby_session_for_connection(&gateway->lobby, socket_fd); + unsigned int disconnected_slot = 0U; + if (session != NULL && session->role == MAZE32_ROLE_PLAYER && + gateway->lobby.phase != MAZE32_LOBBY_WAITING) { + disconnected_slot = session->player_slot; + uint64_t disconnected_at_ms = now_ms(); + if (maze32_game_set_player_connected( + &gateway->game, (uint8_t)(disconnected_slot - 1U), 0, disconnected_at_ms) == + MAZE32_GAME_RESULT_ACCEPTED) { + char payload[96]; + int written = snprintf( + payload, sizeof(payload), "{\"slot\":%u,\"deadlineMs\":%" PRIu64 "}", + disconnected_slot, + gateway->game.players[disconnected_slot - 1U].reconnect_deadline_ms); + if (written >= 0 && (size_t)written < sizeof(payload)) { + broadcast_match_event(gateway, "pause", payload); + } + schedule_reconnect_deadline(gateway); + ESP_LOGI(TAG, "Player %u disconnected; match paused for %u ms", + disconnected_slot, (unsigned int)MAZE32_GAME_RECONNECT_TIMEOUT_MS); + } + } maze32_lobby_disconnect(&gateway->lobby, socket_fd); memset(connection, 0, sizeof(*connection)); broadcast_snapshot(gateway); + if (disconnected_slot != 0U) { + broadcast_views(gateway, 0); + } drain_queue(gateway); ESP_LOGI(TAG, "WebSocket disconnected: fd=%d", socket_fd); } diff --git a/data/index.html.gz b/data/index.html.gz index d929a8f2ab84188dddca4772da07e85ec705965c..d07cf3a29933e38f503d8a532729e438c6e32739 100644 GIT binary patch literal 2962 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