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 d929a8f..d07cf3a 100644 Binary files a/data/index.html.gz and b/data/index.html.gz differ diff --git a/docs/GAME_CORE.md b/docs/GAME_CORE.md index 741983d..030a207 100644 --- a/docs/GAME_CORE.md +++ b/docs/GAME_CORE.md @@ -1,9 +1,8 @@ # Authoritative game core -Milestones 006 and 007 provide the portable movement engine, gameplay object -systems, terminal results, and view projector. The ESP remains the only owner -of mutable game state. Reconnect behavior and browser controls remain out of -scope until their later milestones. +Milestones 006, 007, and 009 provide the portable movement engine, gameplay +object systems, terminal results, view projector, and disconnect recovery. The +ESP remains the only owner of mutable game state. ## Command processing @@ -87,3 +86,20 @@ compass hint, and own-exit status. An opponent's carried key is included only while that opponent is currently visible. Observer snapshots contain both inventories and effect counters, both exit states and authoritative exit timestamps, every remaining ground object, and the public match result. + +## Disconnect recovery + +Both players start connected. Disconnecting either player during `active` or +`finish_pending` stores that exact phase, clears queued commands, enters +`paused`, and assigns the disconnected player a monotonic deadline 60,000 ms +after detection. Commands processed during the pause are rejected before any +position, inventory, effect counter, or result can change. +If the disconnect occurs during `finish_pending`, the remaining 200 ms result +window is preserved and continues only after both players reconnect. + +Restoring the reserved player session before the deadline reconnects the same +player record. The stored phase resumes only when both player records are +connected, so a second disconnected player cannot be bypassed. Reaching a +disconnected player's deadline ends the match with the authoritative `draw` +result and clears queued commands. Observer connection state never enters this +state machine. diff --git a/docs/OBSERVER_UI.md b/docs/OBSERVER_UI.md new file mode 100644 index 0000000..c21b291 --- /dev/null +++ b/docs/OBSERVER_UI.md @@ -0,0 +1,37 @@ +# Observer browser mode + +Milestone 009 adds a password-free observer role to the same local browser +application. The role is still authenticated by its temporary session token, +but joining it does not request a player name or consume either player slot. +At most four observer sessions are connected at once. + +## Projection and authorization + +An observer renders only the server's `observer` view projection. That +projection contains all maze cells, remaining objects, both player positions, +inventories, effects, exit state, phase, and result. The browser does not +simulate movements or infer updates. Each player status also reflects the +server-owned WebSocket connection state. A complete projection establishes the +server-approved static geometry on join, restoration, and match start. +Subsequent bounded projections replace all dynamic player and object state +while reusing only that authoritative geometry, avoiding an 8,192-cell +retransmission after every move. + +The observer screen contains no movement, start, or drop-key control. This is +only presentation-level defense: the gateway also rejects and logs observer +`start`, `move`, and `drop_key` attempts before they reach authoritative +mutation. Disconnecting an observer changes only lobby presence and never +pauses the game. + +## Viewport and navigation + +At `1024 x 768` CSS pixels and above, the complete `128 x 64` default map is +initially scaled to fit the available stage. The observer can zoom with the +labelled buttons or mouse wheel and pan with pointer dragging. Zoom is bounded +between 0.25 and 6 times the eight-pixel base cell size; the **Вся карта** +control restores the calculated fit and centered position. + +When either viewport dimension is below the supported minimum, the map and +controls are hidden and the Russian message **Нужен экран побольше** explains +the `1024 x 768` requirement. All assets remain local and use the shared +retro-arcade visual language. diff --git a/docs/PLAYER_UI.md b/docs/PLAYER_UI.md index 710c5ab..d947d66 100644 --- a/docs/PLAYER_UI.md +++ b/docs/PLAYER_UI.md @@ -57,3 +57,6 @@ Russian; protocol identifiers remain English. Sound and supported vibration are enabled after the first pointer or keyboard interaction. Both are progressive enhancements: unsupported devices continue without an error. + +Observer presentation and its read-only interaction boundary are documented in +`OBSERVER_UI.md`. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 1e13684..8a9e42a 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -33,10 +33,12 @@ is only echoed for correlation; it grants no authority. - `suggest_name`: `{}`. - `start`: `{}`. Only connected Player 1 may use it, and both player slots must be connected. +- `move`: `{"direction":"north|east|south|west"}`. Only a player in an active, + unpaused match may submit movement intent. +- `drop_key`: `{}`. Only a player in an active, unpaused match may submit the + key-drop intent. - `heartbeat`: `{}` after a session has been joined or restored. -No Milestone 005 maze or movement messages are implemented by this milestone. - ## Server messages - `session`: the private role result containing `role`, `slot`, and temporary @@ -47,10 +49,21 @@ No Milestone 005 maze or movement messages are implemented by this milestone. - `countdown`: the single authoritative `[3,2,1]` event with common monotonic `startedAtMs` and `activationAtMs` values. It is sent to both players. - `active`: the exact authoritative activation instant. +- `view`: a bounded chunk of a role-specific authoritative game projection. + Observer chunks contain the complete maze and both player records, including + their connection state; player chunks retain the fog-of-war boundary. +- `pause`: identifies the disconnected player slot and its monotonic reconnect + deadline. +- `resume`: reports that both players are attached and authoritative gameplay + has resumed. +- `match_result`: reports the terminal winner or draw. - `ack`: successful heartbeat correlation. - `error`: a stable English `code` and Russian player-facing `message`. -An observer session can receive snapshots but cannot issue `start`. Player -tokens reserve their original slot after disconnection so a refresh can restore -it. A disconnected observer token may be restored until its bounded slot is -reused by a new observer. +An observer session can receive snapshots but cannot issue `start`, `move`, or +`drop_key`; attempts are rejected with `read_only` where applicable and logged +by the gateway. Player tokens reserve their original slot after disconnection +so a refresh can restore it. A player disconnect pauses the game for at most 60 +seconds; restoration reattaches the same state, while expiry produces a draw. +A disconnected observer token may be restored until its bounded slot is reused +by a new observer, and observer disconnects never pause the match. diff --git a/scripts/run_host_tests.sh b/scripts/run_host_tests.sh index 4d19691..c7b312f 100755 --- a/scripts/run_host_tests.sh +++ b/scripts/run_host_tests.sh @@ -78,6 +78,16 @@ cc -std=c11 -Wall -Wextra -Werror -pedantic \ -o "$test_directory/test_game_mechanics" "$test_directory/test_game_mechanics" + +cc -std=c11 -Wall -Wextra -Werror -pedantic \ + -I"$project_directory/components/maze32_maze/include" \ + -I"$project_directory/components/maze32_game/include" \ + "$project_directory/components/maze32_maze/maze32_maze.c" \ + "$project_directory/components/maze32_game/maze32_game.c" \ + "$project_directory/test/host/test_disconnect_recovery.c" \ + -o "$test_directory/test_disconnect_recovery" + +"$test_directory/test_disconnect_recovery" node "$project_directory/test/web/test_player_app.js" python3 "$project_directory/scripts/generate_web_assets.py" \ --source "$project_directory/web" \ diff --git a/scripts/ws_protocol_client.py b/scripts/ws_protocol_client.py index 94ef0ec..0716066 100644 --- a/scripts/ws_protocol_client.py +++ b/scripts/ws_protocol_client.py @@ -10,6 +10,7 @@ import json import os import socket import struct +import threading import time from typing import Any @@ -120,6 +121,131 @@ def join(client: WebSocket, role: str, name: str | None = None) -> dict[str, Any return client.receive_type("session")["payload"] +def receive_view(client: WebSocket, maximum_messages: int = 800) -> dict[str, Any]: + parts: dict[int, dict[str, Any]] = {} + sequence: int | None = None + expected = 0 + for _ in range(maximum_messages): + message = client.receive() + if message.get("type") != "view": + continue + payload = message["payload"] + if sequence != message["seq"]: + sequence = message["seq"] + expected = payload["chunks"] + parts = {} + parts[payload["chunk"]] = payload + if len(parts) == expected: + first = dict(parts[0]) + first["cells"] = [cell for index in range(expected) for cell in parts[index]["cells"]] + first["objects"] = [ + item for index in range(expected) for item in parts[index]["objects"] + ] + return first + raise RuntimeError("did not receive a complete view") + + +def open_direction(view: dict[str, Any]) -> str: + passages = next(cell[1] for cell in view["cells"] if cell[0] == view["self"]) + for bit, direction in ((1, "north"), (2, "east"), (4, "south"), (8, "west")): + if passages & bit: + return direction + raise AssertionError("player cell has no open passage") + + +def verify_m009(host: str) -> None: + clients: list[WebSocket] = [] + try: + player_1 = WebSocket(host) + player_2 = WebSocket(host) + clients.extend((player_1, player_2)) + join(player_1, "player", "Наблюдаемый Один") + session_2 = join(player_2, "player", "Наблюдаемый Два") + observers = [WebSocket(host) for _ in range(4)] + clients.extend(observers) + for observer in observers: + join(observer, "observer") + observer.socket.settimeout(30.0) + + observer_views: list[dict[str, Any] | None] = [None] * 4 + failures: list[BaseException] = [] + + def read_observer(index: int) -> None: + try: + observers[index].receive_type("active", attempts=40) + observer_views[index] = receive_view(observers[index]) + except BaseException as error: # Preserve worker failure for the main thread. + failures.append(error) + + readers = [threading.Thread(target=read_observer, args=(index,)) for index in range(4)] + for reader in readers: + reader.start() + player_1.send("start", {}, "m009-start") + player_1.receive_type("countdown") + player_2.receive_type("countdown") + player_1.receive_type("active") + player_2.receive_type("active") + player_1_view = receive_view(player_1) + player_2_view = receive_view(player_2) + for reader in readers: + reader.join(timeout=35.0) + assert not failures and all(not reader.is_alive() for reader in readers) + assert all(view is not None and len(view["cells"]) == 8192 for view in observer_views) + print("four-observer full-map delivery: PASS (8192 cells each)") + + observers[0].send("move", {"direction": "north"}, "observer-move") + error = observers[0].receive_type("error") + assert error["payload"]["code"] == "read_only" + print("observer mutation authorization: PASS (read_only)") + + direction = open_direction(player_1_view) + player_1.send("move", {"direction": direction}, "before-observer-close") + player_1.receive_type("ack") + player_1_view = receive_view(player_1) + observers[-1].close() + clients.remove(observers[-1]) + observers.pop() + time.sleep(0.2) + player_1.send("move", {"direction": open_direction(player_1_view)}, + "after-observer-close") + player_1.receive_type("ack") + print("observer disconnect continuity: PASS") + + preserved_cell = player_2_view["self"] + preserved_inventory = player_2_view.get("inventory") + token_2 = session_2["token"] + player_2.close() + clients.remove(player_2) + pause = player_1.receive_type("pause", attempts=30)["payload"] + assert pause["slot"] == 2 + player_1.send("move", {"direction": "north"}, "paused-move") + error = player_1.receive_type("error", attempts=30) + assert error["payload"]["code"] == "premature" + + restored = WebSocket(host) + clients.append(restored) + restored.send("restore", {"token": token_2}, "restore-player-2") + restored_session = restored.receive_type("session")["payload"] + assert restored_session["slot"] == 2 and restored_session["token"] == token_2 + player_1.receive_type("resume", attempts=30) + restored_view = receive_view(restored) + assert restored_view["self"] == preserved_cell + assert restored_view.get("inventory") == preserved_inventory + print("player pause and reconnect: PASS (same position and inventory)") + + restored.close() + clients.remove(restored) + player_1.socket.settimeout(70.0) + started = time.monotonic() + result = player_1.receive_type("match_result", attempts=60)["payload"] + elapsed = time.monotonic() - started + assert result["result"] == "draw" and elapsed >= 59.0 + print(f"reconnect timeout draw: PASS ({elapsed:.1f} seconds)") + finally: + for client in clients: + client.close() + + def verify(host: str) -> None: clients: list[WebSocket] = [] try: @@ -223,8 +349,11 @@ def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("host", help="maze32 IP address or hostname") parser.add_argument("--verify", action="store_true", help="run the Milestone 004 scenario") + parser.add_argument("--verify-m009", action="store_true", help="run the Milestone 009 scenario") arguments = parser.parse_args() - if arguments.verify: + if arguments.verify_m009: + verify_m009(arguments.host) + elif arguments.verify: verify(arguments.host) else: client = WebSocket(arguments.host) diff --git a/test/host/test_disconnect_recovery.c b/test/host/test_disconnect_recovery.c new file mode 100644 index 0000000..22db8a9 --- /dev/null +++ b/test/host/test_disconnect_recovery.c @@ -0,0 +1,96 @@ +#include +#include + +#include "maze32_game.h" + +static void initialize_game(maze32_maze_t* maze, maze32_maze_workspace_t* workspace, + maze32_game_t* game) { + maze32_maze_config_t maze_config = maze32_maze_default_config(); + maze32_maze_report_t report; + assert(maze32_maze_generate(maze, workspace, &maze_config, UINT32_C(0x5a320009), &report) == + MAZE32_MAZE_OK); + maze32_game_config_t game_config = maze32_game_default_config(); + game_config.movement_cooldown_ms = 0U; + assert(maze32_game_init(game, maze, &game_config) == MAZE32_GAME_RESULT_ACCEPTED); + maze32_game_set_phase(game, MAZE32_GAME_PHASE_ACTIVE); +} + +static void test_player_pause_and_reconnect(void) { + static maze32_maze_t maze; + static maze32_maze_workspace_t workspace; + static maze32_game_t game; + initialize_game(&maze, &workspace, &game); + game.players[0].vision_moves_remaining = 7U; + maze32_cell_t position = game.players[0].position; + + assert(maze32_game_set_player_connected(&game, 1U, 0, 1000U) == MAZE32_GAME_RESULT_ACCEPTED); + assert(game.phase == MAZE32_GAME_PHASE_PAUSED); + assert(game.players[1].reconnect_deadline_ms == 61000U); + uint32_t sequence = 0U; + assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_NORTH, + 2000U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED); + assert(maze32_game_process_next(&game).result == MAZE32_GAME_RESULT_PREMATURE); + assert(game.players[0].position.x == position.x && game.players[0].position.y == position.y); + assert(game.players[0].vision_moves_remaining == 7U); + + assert(maze32_game_set_player_connected(&game, 1U, 1, 60999U) == MAZE32_GAME_RESULT_ACCEPTED); + assert(game.phase == MAZE32_GAME_PHASE_ACTIVE); + assert(game.players[1].reconnect_deadline_ms == 0U); + assert(game.players[0].vision_moves_remaining == 7U); +} + +static void test_timeout_draw_and_observer_read_only(void) { + static maze32_maze_t maze; + static maze32_maze_workspace_t workspace; + static maze32_game_t game; + static maze32_view_t view; + initialize_game(&maze, &workspace, &game); + + uint32_t sequence = 0U; + assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_OBSERVER, MAZE32_GAME_DIRECTION_EAST, + 1U, &sequence) == MAZE32_GAME_RESULT_READ_ONLY); + assert(game.state_sequence == 0U); + assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, &view) == + MAZE32_GAME_RESULT_ACCEPTED); + assert(view.cell_count == maze.config.width * maze.config.height); + assert(view.observer_connected[0] && view.observer_connected[1]); + + assert(maze32_game_set_player_connected(&game, 0U, 0, 5000U) == MAZE32_GAME_RESULT_ACCEPTED); + assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, &view) == + MAZE32_GAME_RESULT_ACCEPTED); + assert(!view.observer_connected[0] && view.observer_connected[1]); + assert(!maze32_game_advance_time(&game, 64999U)); + assert(maze32_game_advance_time(&game, 65000U)); + assert(game.phase == MAZE32_GAME_PHASE_FINISHED); + assert(game.final_result == MAZE32_GAME_FINAL_DRAW); + assert(maze32_game_set_player_connected(&game, 0U, 1, 65000U) == + MAZE32_GAME_RESULT_MATCH_FINISHED); +} + +static void test_finish_window_is_frozen_while_paused(void) { + static maze32_maze_t maze; + static maze32_maze_workspace_t workspace; + static maze32_game_t game; + initialize_game(&maze, &workspace, &game); + game.phase = MAZE32_GAME_PHASE_FINISH_PENDING; + game.first_exited_player = 0U; + game.has_exit_time[0] = 1U; + game.exit_times_ms[0] = 1000U; + + assert(maze32_game_set_player_connected(&game, 1U, 0, 1100U) == MAZE32_GAME_RESULT_ACCEPTED); + assert(game.phase == MAZE32_GAME_PHASE_PAUSED); + assert(maze32_game_set_player_connected(&game, 1U, 1, 60100U) == MAZE32_GAME_RESULT_ACCEPTED); + assert(game.phase == MAZE32_GAME_PHASE_FINISH_PENDING); + assert(game.exit_times_ms[0] == 60000U); + assert(!maze32_game_advance_time(&game, 60200U)); + assert(maze32_game_advance_time(&game, 60201U)); + assert(game.final_result == MAZE32_GAME_FINAL_PLAYER_ONE); +} + +int main(void) { + test_player_pause_and_reconnect(); + test_timeout_draw_and_observer_read_only(); + test_finish_window_is_frozen_while_paused(); + puts("disconnect recovery tests: PASS"); + return 0; +} diff --git a/test/host/test_game_core.c b/test/host/test_game_core.c index 6370287..efc2fdf 100644 --- a/test/host/test_game_core.c +++ b/test/host/test_game_core.c @@ -312,6 +312,9 @@ static void test_default_map_chunk_bounds(void) { size_t written = 0U; assert(maze32_game_serialize_view_chunk(&view, chunk, payload, sizeof(payload), &written) == MAZE32_GAME_RESULT_ACCEPTED); + if (chunk == 0U) { + assert(strstr(payload, "\"connected\":1") != NULL); + } if (written > largest_chunk) { largest_chunk = written; } diff --git a/test/host/test_lobby_protocol.c b/test/host/test_lobby_protocol.c index f2055d5..981673d 100644 --- a/test/host/test_lobby_protocol.c +++ b/test/host/test_lobby_protocol.c @@ -29,6 +29,18 @@ static void test_protocol_schema(void) { assert(message.type == MAZE32_MESSAGE_RESTORE); assert(strcmp(message.token, TOKEN_1) == 0); + const char* move = "{\"v\":1,\"type\":\"move\",\"seq\":3,\"payload\":{\"direction\":\"west\"}}"; + assert(maze32_protocol_parse(move, strlen(move), &message) == MAZE32_PROTOCOL_OK); + assert(message.type == MAZE32_MESSAGE_MOVE); + assert(strcmp(message.direction, "west") == 0); + const char* drop_key = "{\"v\":1,\"type\":\"drop_key\",\"seq\":4,\"payload\":{}}"; + assert(maze32_protocol_parse(drop_key, strlen(drop_key), &message) == MAZE32_PROTOCOL_OK); + assert(message.type == MAZE32_MESSAGE_DROP_KEY); + const char* diagonal = + "{\"v\":1,\"type\":\"move\",\"seq\":5,\"payload\":{\"direction\":\"northwest\"}}"; + assert(maze32_protocol_parse(diagonal, strlen(diagonal), &message) == + MAZE32_PROTOCOL_INVALID_PAYLOAD); + const char* bad_version = "{\"v\":2,\"type\":\"start\",\"seq\":1,\"payload\":{}}"; assert(maze32_protocol_parse(bad_version, strlen(bad_version), &message) == MAZE32_PROTOCOL_UNSUPPORTED_VERSION); @@ -52,6 +64,7 @@ static void test_lobby_limits_restoration_and_authority(void) { maze32_lobby_init(&lobby); maze32_session_t* player_1 = NULL; maze32_session_t* player_2 = NULL; + maze32_session_t* extra = NULL; assert(maze32_lobby_join_player(&lobby, 10, "Капитан Клавиша", TOKEN_1, &player_1) == MAZE32_LOBBY_OK); assert(player_1->player_slot == 1U); @@ -71,7 +84,11 @@ static void test_lobby_limits_restoration_and_authority(void) { assert(observer->role == MAZE32_ROLE_OBSERVER); } assert(maze32_lobby_connected_observers(&lobby) == MAZE32_OBSERVER_LIMIT); - maze32_session_t* extra = NULL; + maze32_lobby_phase_t phase_before_observer_disconnect = lobby.phase; + maze32_lobby_disconnect(&lobby, 20); + assert(lobby.phase == phase_before_observer_disconnect); + assert(maze32_lobby_connected_observers(&lobby) == MAZE32_OBSERVER_LIMIT - 1U); + assert(maze32_lobby_restore(&lobby, 20, OBSERVER_TOKENS[0], &extra) == MAZE32_LOBBY_OK); revision = lobby.revision; assert(maze32_lobby_join_observer(&lobby, 30, OBSERVER_TOKENS[4], &extra) == MAZE32_LOBBY_ROLE_FULL); diff --git a/test/web/test_player_app.js b/test/web/test_player_app.js index 03e528f..3c96c9b 100644 --- a/test/web/test_player_app.js +++ b/test/web/test_player_app.js @@ -74,3 +74,33 @@ test("player assets are local, Russian, accessible, and responsive", () => { assert.match(icons, /id="key"/); assert.match(icons, /id="maze"/); }); + +test("observer view fits large screens and bounds zoom", () => { + assert.equal(player.observerFitScale(1024, 600, 128, 64), .96); + assert.equal(player.clampObserverZoom(.01), .25); + assert.equal(player.clampObserverZoom(20), 6); + const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8"); + const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8"); + const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8"); + assert.match(html, /Режим наблюдателя · только чтение/); + assert.match(html, /Нужен экран побольше/); + assert.match(html, /Зрительный зал открывается от 1024 × 768/); + assert.match(css, /@media \(max-width: 1023px\), \(max-height: 767px\)/); + assert.match(css, /\.observer-stage \{[^}]*overflow: hidden/); + assert.match(script, /snapshot\.role === "observer"/); + assert.match(script, /state\.session\.role === "observer" \? "observer" : "match"/); + assert.match(script, /!player\.connected \? "нет связи"/); + assert.match(script, /addEventListener\("wheel"/); + assert.match(script, /addEventListener\("pointermove"/); +}); + +test("observer protocol exposes no mutation control path", () => { + const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8"); + const observerSection = html.match(/
/)[0]; + assert.doesNotMatch(observerSection, /data-direction|drop-key|start-match/); + const observer = {v: 1, type: "view", seq: 30, payload: {role: "observer", phase: "active", result: "none", width: 2, height: 2, players: [{cell: 0, key: null, exited: 0, connected: 1}, {cell: 3, key: 1, exited: 0, connected: 1}], chunk: 0, chunks: 1, cells: [[0, 6], [1, 12], [2, 3], [3, 9]], objects: []}}; + const snapshot = new player.SnapshotAssembler().accept(observer); + assert.equal(snapshot.role, "observer"); + assert.deepEqual(snapshot.players.map(item => item.cell), [0, 3]); + assert.equal("self" in snapshot, false); +}); diff --git a/version.txt b/version.txt index a3df0a6..ac39a10 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.8.0 +0.9.0 diff --git a/web/index.html b/web/index.html index 3ca253a..12ab9a5 100644 --- a/web/index.html +++ b/web/index.html @@ -57,6 +57,18 @@
+ +
Сервер решает всё. Мы отвечаем за драматичное мерцание.
diff --git a/web/player.css b/web/player.css index 5f00bb3..a248cd4 100644 --- a/web/player.css +++ b/web/player.css @@ -158,6 +158,39 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; } .modal-card h2 { font-size: clamp(1.5rem, 7vw, 2.7rem); } .countdown { color: var(--warning); font-size: clamp(5rem, 24vw, 10rem); font-weight: 1000; line-height: 1; text-shadow: 7px 7px 0 #000; animation: count .65s steps(2, end) infinite; } +.observer-screen { height: calc(100dvh - 78px); min-height: 678px; overflow: hidden; padding-top: 92px; } +.observer-toolbar { position: absolute; z-index: 8; inset: 8px 0 auto; min-height: 76px; display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 18px; padding: 9px 12px; border: 1px solid var(--edge); background: #171027eb; } +.observer-toolbar h1 { font-size: clamp(1.15rem, 2.8vw, 2.1rem); } +.observer-toolbar .eyebrow { margin-bottom: 3px; } +.observer-players { display: flex; align-items: center; gap: 12px; color: var(--muted); font-size: .72rem; } +.observer-players span { padding: 7px; border: 1px solid #51386e; } +.observer-players .player-one { border-color: var(--p1); } +.observer-players .player-two { border-color: var(--p2); } +.zoom-controls { display: flex; gap: 5px; } +.zoom-controls button { min-width: 42px; min-height: 38px; border: 1px solid var(--aqua); background: #102a30; color: var(--aqua); cursor: pointer; } +.observer-stage { position: absolute; inset: 92px 0 0; overflow: hidden; border: 2px solid #2f2041; background: #050408; cursor: grab; touch-action: none; user-select: none; } +.observer-stage.dragging { cursor: grabbing; } +.observer-maze { position: absolute; left: 50%; top: 50%; display: grid; transform-origin: center; will-change: transform; } +.observer-maze .cell { width: 8px; height: 8px; border-width: 1px; } +.observer-maze .cell::after { inset: 2px; } +.observer-maze .object { inset: 5%; } +.observer-maze .object-key::after { display: none; } +.observer-maze .object-landmark { font-size: 6px; } +.observer-maze .pawn { inset: -30%; z-index: 5; font-size: 5px; border-width: 1px; box-shadow: 1px 1px 0 #000; } +.observer-maze .pawn-player-one { color: var(--p1); } +.observer-maze .pawn-player-two { color: var(--p2); transform: rotate(45deg); } +.observer-maze .pawn-player-two span { transform: rotate(-45deg); } +.observer-requirement { display: none; position: absolute; inset: 92px 0 0; place-content: center; justify-items: center; gap: 12px; padding: 24px; color: var(--muted); text-align: center; border: 2px solid var(--edge); background: var(--panel-deep); } +.observer-requirement svg { width: 58px; height: 58px; color: var(--p2); } +.observer-requirement strong { color: var(--ink); font-size: 1.2rem; text-transform: uppercase; } + +@media (max-width: 1023px), (max-height: 767px) { + .observer-toolbar { grid-template-columns: 1fr; } + .observer-toolbar > :not(:first-child) { display: none; } + .observer-stage { display: none; } + .observer-requirement { display: grid; } +} + @media (max-width: 720px) { :root { --cell: 20px; } #app { padding-left: max(8px, env(safe-area-inset-left)); padding-right: max(8px, env(safe-area-inset-right)); } diff --git a/web/player.js b/web/player.js index 3208316..9be83a4 100644 --- a/web/player.js +++ b/web/player.js @@ -5,6 +5,7 @@ const MAX_VIEW_CHUNKS = 700; const MAX_VIEW_CELLS = 8192; const MAX_VIEW_OBJECTS = 30; + const OBSERVER_CELL_SIZE = 8; const KEY_DIRECTIONS = Object.freeze({ ArrowUp: "north", KeyW: "north", ArrowRight: "east", KeyD: "east", @@ -85,9 +86,18 @@ return snapshot && Array.isArray(snapshot.cells) ? snapshot.cells.map(cell => cell[0]) : []; } + function clampObserverZoom(value) { + return Math.min(6, Math.max(.25, value)); + } + + function observerFitScale(stageWidth, stageHeight, columns, rows) { + if (![stageWidth, stageHeight, columns, rows].every(value => Number.isFinite(value) && value > 0)) return 1; + return clampObserverZoom(Math.min(stageWidth / (columns * OBSERVER_CELL_SIZE), stageHeight / (rows * OBSERVER_CELL_SIZE)) * .96); + } + function boot(documentObject, windowObject) { const byId = id => documentObject.getElementById(id); - const state = {socket: null, clientSequence: 0, serverSequence: 0, viewSequence: -1, session: null, lobby: null, snapshot: null, reconnectTimer: null, noticeTimer: null, touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), audio: null, feedbackReady: false}; + const state = {socket: null, clientSequence: 0, serverSequence: 0, viewSequence: -1, session: null, lobby: null, snapshot: null, reconnectTimer: null, noticeTimer: null, touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), audio: null, feedbackReady: false, observer: {scale: 1, x: 0, y: 0, dragging: null}}; function showNotice(text, kind) { const element = byId("notice"); @@ -101,6 +111,7 @@ byId("welcome-screen").hidden = name !== "welcome"; byId("lobby-screen").hidden = name !== "lobby"; byId("match-screen").hidden = name !== "match"; + byId("observer-screen").hidden = name !== "observer"; } function setConnection(connectionState, text) { @@ -245,6 +256,10 @@ } function renderSnapshot(snapshot) { + if (snapshot.role === "observer") { + renderObserverSnapshot(snapshot); + return; + } state.snapshot = snapshot; setScreen("match"); const slot = state.session && state.session.slot ? state.session.slot : 1; @@ -267,6 +282,66 @@ else hideModal(); } + function applyObserverTransform() { + const transform = state.observer; + byId("observer-maze").style.transform = `translate(-50%, -50%) translate(${transform.x}px, ${transform.y}px) scale(${transform.scale})`; + } + + function resetObserverView(snapshot) { + const stage = byId("observer-stage"); + state.observer.scale = observerFitScale(stage.clientWidth, stage.clientHeight, snapshot.width, snapshot.height); + state.observer.x = 0; + state.observer.y = 0; + applyObserverTransform(); + } + + function renderObserverSnapshot(snapshot) { + if (!snapshot.cells.length && state.snapshot && state.snapshot.role === "observer" && state.snapshot.width === snapshot.width && state.snapshot.height === snapshot.height) { + snapshot.cells = state.snapshot.cells; + } + state.snapshot = snapshot; + setScreen("observer"); + const maze = byId("observer-maze"); + maze.replaceChildren(); + maze.style.gridTemplateColumns = `repeat(${snapshot.width}, ${OBSERVER_CELL_SIZE}px)`; + maze.style.gridTemplateRows = `repeat(${snapshot.height}, ${OBSERVER_CELL_SIZE}px)`; + const objects = new Map(); + snapshot.objects.forEach(object => { const list = objects.get(object[3]) || []; list.push(object); objects.set(object[3], list); }); + const players = Array.isArray(snapshot.players) ? snapshot.players : []; + snapshot.cells.forEach(data => { + const index = data[0]; const passages = data[1]; + const cell = documentObject.createElement("span"); + cell.className = "cell"; + cell.style.gridColumn = String(index % snapshot.width + 1); + cell.style.gridRow = String(Math.floor(index / snapshot.width) + 1); + if (!(passages & 1)) cell.classList.add("wall-n"); + if (!(passages & 2)) cell.classList.add("wall-e"); + if (!(passages & 4)) cell.classList.add("wall-s"); + if (!(passages & 8)) cell.classList.add("wall-w"); + (objects.get(index) || []).forEach(object => addObject(cell, object)); + players.forEach((player, playerIndex) => { + if (player.cell === index && !player.exited) { + const pawn = documentObject.createElement("span"); + pawn.className = `pawn pawn-player-${playerIndex === 0 ? "one" : "two"}`; + const label = documentObject.createElement("span"); label.textContent = `P${playerIndex + 1}`; + pawn.appendChild(label); cell.appendChild(pawn); + } + }); + maze.appendChild(cell); + }); + players.forEach((player, index) => { + const key = player.key == null ? "без ключа" : `ключ P${player.key + 1}`; + byId(`observer-p${index + 1}`).textContent = !player.connected ? "нет связи" : player.exited ? "снаружи" : key; + }); + byId("observer-phase").textContent = PHASES[snapshot.phase] || "Сервер размышляет"; + const dimensions = `${snapshot.width}x${snapshot.height}`; + if (!state.observer.initialized || state.observer.dimensions !== dimensions) { + resetObserverView(snapshot); state.observer.initialized = true; state.observer.dimensions = dimensions; + } else applyObserverTransform(); + if (snapshot.phase === "finished" || snapshot.result && snapshot.result !== "none") renderResult(snapshot.result); + else if (snapshot.phase !== "paused") hideModal(); + } + function showModal(title, text, kicker, countdown) { byId("modal-title").textContent = title; byId("modal-text").textContent = text; byId("modal-kicker").textContent = kicker; byId("countdown").hidden = !countdown; byId("modal").hidden = false; @@ -279,8 +354,8 @@ let title = "Ничья!"; let text = "Фотофиниш пожал плечами: оба выбрались почти одновременно."; if (result === "player1" || result === "player2") { const winner = result === "player1" ? 1 : 2; - title = winner === slot ? "Победа!" : "Победил соперник"; - text = winner === slot ? "Вы официально быстрее очень большого куста." : "Соперник нашёл выход. Лабиринт требует реванш."; + title = slot ? (winner === slot ? "Победа!" : "Победил соперник") : `Победил Игрок ${winner}`; + text = slot ? (winner === slot ? "Вы официально быстрее очень большого куста." : "Соперник нашёл выход. Лабиринт требует реванш.") : "Зрительный зал фиксирует героический побег без вмешательства в результат."; } showModal(title, text, "ГОНКА ЗАВЕРШЕНА", false); byId("modal-action").hidden = false; @@ -314,8 +389,9 @@ } else if (message.type === "lobby") { state.lobby = message.payload; renderLobby(); } else if (message.type === "name_suggestion") { byId("player-name").value = message.payload.name; } else if (message.type === "countdown") { showCountdown(message.payload); - } else if (message.type === "active") { setScreen("match"); byId("countdown").textContent = "ВПЕРЁД"; windowObject.setTimeout(hideModal, 750); - } else if (message.type === "pause") { showModal("Пауза", "Второй герой переподключается. Ноги пока не считаются.", "ЖДЁМ СВЯЗЬ", false); + } else if (message.type === "active") { setScreen(state.session && state.session.role === "observer" ? "observer" : "match"); byId("countdown").textContent = "ВПЕРЁД"; windowObject.setTimeout(hideModal, 750); + } else if (message.type === "pause") { showModal("Пауза", `Игрок ${message.payload.slot || "?"} переподключается. Ноги пока не считаются.`, "ЖДЁМ СВЯЗЬ", false); + } else if (message.type === "resume") { hideModal(); showNotice("Оба героя снова на связи. Гонка продолжается.", "success"); } else if (message.type === "match_result") { renderResult(message.payload.result); } else if (message.type === "ack") { feedback(true); } else if (message.type === "error") { @@ -352,13 +428,22 @@ byId("join-player").addEventListener("click", () => send("join", {role: "player", name: byId("player-name").value}, byId("join-player"))); byId("join-observer").addEventListener("click", () => send("join", {role: "observer"}, byId("join-observer"))); byId("start-match").addEventListener("click", () => send("start", {}, byId("start-match"))); + byId("zoom-in").addEventListener("click", () => { state.observer.scale = clampObserverZoom(state.observer.scale * 1.25); applyObserverTransform(); }); + byId("zoom-out").addEventListener("click", () => { state.observer.scale = clampObserverZoom(state.observer.scale / 1.25); applyObserverTransform(); }); + byId("zoom-reset").addEventListener("click", () => { if (state.snapshot) resetObserverView(state.snapshot); }); + byId("observer-stage").addEventListener("wheel", event => { event.preventDefault(); state.observer.scale = clampObserverZoom(state.observer.scale * (event.deltaY < 0 ? 1.12 : .89)); applyObserverTransform(); }, {passive: false}); + byId("observer-stage").addEventListener("pointerdown", event => { state.observer.dragging = {id: event.pointerId, x: event.clientX, y: event.clientY, originX: state.observer.x, originY: state.observer.y}; byId("observer-stage").classList.add("dragging"); if (byId("observer-stage").setPointerCapture) byId("observer-stage").setPointerCapture(event.pointerId); }); + byId("observer-stage").addEventListener("pointermove", event => { const drag = state.observer.dragging; if (!drag || drag.id !== event.pointerId) return; state.observer.x = drag.originX + event.clientX - drag.x; state.observer.y = drag.originY + event.clientY - drag.y; applyObserverTransform(); }); + const stopObserverDrag = event => { if (state.observer.dragging && state.observer.dragging.id === event.pointerId) { state.observer.dragging = null; byId("observer-stage").classList.remove("dragging"); } }; + byId("observer-stage").addEventListener("pointerup", stopObserverDrag); + byId("observer-stage").addEventListener("pointercancel", stopObserverDrag); byId("details-toggle").addEventListener("click", () => { const panel = byId("details-panel"); panel.hidden = !panel.hidden; byId("details-toggle").setAttribute("aria-expanded", String(!panel.hidden)); }); byId("modal-action").addEventListener("click", () => { hideModal(); setScreen("lobby"); }); connect(); return {state, handleMessage, move}; } - const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, directionFromKey, directionFromSwipe, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, boot}); + const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, directionFromKey, directionFromSwipe, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, clampObserverZoom, observerFitScale, boot}); if (typeof module !== "undefined" && module.exports) module.exports = api; root.Maze32Player = api; if (root.document) root.document.addEventListener("DOMContentLoaded", () => { root.maze32PlayerApp = boot(root.document, root); });