From c730cb818d33a7f7e4d611e2156cb03f8e2a61ec Mon Sep 17 00:00:00 2001 From: sasa Date: Sat, 5 Sep 2026 21:56:09 +0300 Subject: [PATCH] feat(runtime): add integration hardening and diagnostics --- PLANS.md | 77 +++- README.md | 6 + .../maze32_lobby/include/maze32_lobby.h | 1 + .../maze32_lobby/include/maze32_protocol.h | 1 + components/maze32_lobby/maze32_lobby.c | 10 + components/maze32_lobby/maze32_protocol.c | 3 + .../maze32_network/include/maze32_network.h | 41 +- components/maze32_network/maze32_network.c | 162 ++++---- .../maze32_web/include/maze32_gateway.h | 28 ++ components/maze32_web/maze32_gateway.c | 379 ++++++++++++++++-- components/maze32_web/maze32_web.c | 25 +- docs/INTEGRATION_TESTING.md | 76 ++++ docs/PROTOCOL.md | 15 + docs/RESOURCE_BUDGET.md | 45 +++ scripts/ws_protocol_client.py | 208 +++++++++- test/host/test_lobby_protocol.c | 20 +- test/web/test_player_app.js | 7 + version.txt | 2 +- web/player.js | 10 +- 19 files changed, 956 insertions(+), 160 deletions(-) create mode 100644 docs/INTEGRATION_TESTING.md create mode 100644 docs/RESOURCE_BUDGET.md diff --git a/PLANS.md b/PLANS.md index 4f16894..fa6778d 100644 --- a/PLANS.md +++ b/PLANS.md @@ -768,7 +768,7 @@ Implementation and required verification are complete. ## Milestone 009 — Observer mode and disconnect recovery -**Status:** `IN_PROGRESS` +**Status:** `COMPLETE` ### Objective @@ -803,8 +803,7 @@ and reconnect behavior. ### Evidence -Implementation is complete, but the milestone remains `IN_PROGRESS` until the -required physical-device and observer-browser checks can be recorded. +Implementation and required verification are complete. - On 2026-09-05, `bash scripts/run_host_tests.sh` completed successfully. The new deterministic recovery suite proved immediate authoritative pause, @@ -834,22 +833,27 @@ required physical-device and observer-browser checks can be recorded. 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. +- Automated live observer validation was initially unavailable because the + browser-control runtime returned `No browser is available` and its browser + inventory was empty. The project owner subsequently supplied the required + physical acceptance evidence recorded below. +- On 2026-09-05, the project owner completed all Milestone 009 physical checks + and reported that the implementation worked as expected. The checks covered + simultaneous complete live-state delivery to four read-only observers, + rejection and audit of observer mutation attempts, observer-disconnect match + continuity, immediate pause on either player disconnect, unchanged gameplay + and effect state while paused, restoration of the same player state within + 60 seconds, and an authoritative draw after reconnect timeout. Manual browser + checks also confirmed full-map fit, live player/object/connection updates, + zoom and pan at `1024 x 768` and larger, no observer mutation controls, and + the required Russian large-screen notice below that viewport. Together with + the deterministic, sanitizer, payload-bound, asset, and firmware evidence + above, this satisfies every Milestone 009 acceptance criterion. Milestone + 009 is `COMPLETE`; Milestone 010 remains `NOT_STARTED`. ## Milestone 010 — On-device integration and resource hardening -**Status:** `NOT_STARTED` +**Status:** `IN_PROGRESS` ### Objective @@ -883,10 +887,43 @@ the ESP32-C6 Flash, RAM, socket, and network limits. ### Evidence -Completion evidence is not yet available because this milestone has not -started. Required evidence includes production build reports, physical-device -logs, six-client load results, memory graphs or samples, and repeated-match -stress results. +- `/home/sasa/.platformio/penv/bin/pio run` completed with `[SUCCESS]` on + 2026-09-05. It reported 124,276 of 327,680 RAM bytes and 1,028,600 of + 2,097,152 application bytes. `firmware.bin` measured 1,029,232 bytes in the + 2,097,152-byte factory partition, leaving 1,067,920 bytes. The ELF contains + application version `0.10.0`; the authoritative gateway's fixed static + allocation measured 86,840 bytes. +- `/home/sasa/.platformio/penv/bin/pio run -t buildfs` completed with + `[SUCCESS]`. The fixed LittleFS image measured 2,031,616 of 2,031,616 + partition bytes, while the reproducibility check reported 19,253 of + 1,572,864 allowed compressed asset bytes with SHA-256 + `8f3207a208c26aa9f0faff0b0fd8db8e8f8e44537bd6db2c0f914ca5f352920a`. +- `bash scripts/run_host_tests.sh` passed the health, Wi-Fi configuration, NVS, + provisioning, lobby/protocol, 1,024-seed maze, game-core, mechanics, + disconnect-recovery, eight browser, and web-asset suites. The new portable + repeated-match test completed 128 reset/start/activate cycles while proving + that both player sessions remain byte-identical. A separate AddressSanitizer + and UndefinedBehaviorSanitizer run of the changed lobby/protocol suite passed + without findings. +- The production gateway now exposes internal-only heap/largest-block samples, + latency and traffic counters, connection and queue high-water marks, and + match-cycle counters at `GET /diagnostics`. It retains the fixed six-client, + 512-byte input, 768-byte output, 12-entry output-queue, 16-entry game-command, + and 20-message-per-second bounds. Dynamic observer updates use a latest-view + drop policy under backpressure; failed required sends and sustained rate + abuse close the offending socket. No added code or test depends on PSRAM or + a GPIO. +- `python3 scripts/ws_protocol_client.py 192.168.1.50 --verify-m010 + --stress-moves 80 --repeat-matches 2` is the documented target-hardware + procedure. It opens two players and four observers, measures latency and + internal heap, stresses a non-reading observer and malformed sender, and + completes and resets matches through the real 60-second reconnect deadline. + Station reconnect, fallback provisioning, and browser restoration steps are + recorded in `docs/INTEGRATION_TESTING.md` without requiring the development + host to leave Mazda7. +- Physical evidence is still pending: `/dev/ttyACM0` was absent on 2026-09-05, + so the production images could not be uploaded and acceptance criteria 2--5 + could not be recorded. Milestone 010 therefore remains `IN_PROGRESS`. ## Milestone 011 — Gameplay tuning and MVP acceptance diff --git a/README.md b/README.md index 48dac0c..33c55b2 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,12 @@ $HOME/.platformio/penv/bin/pio device monitor A successful boot reports the firmware version, LittleFS capacity and use, network state, HTTP service startup, and health state `serving`. +The production integration/load procedure and its fixed resource thresholds +are documented in `docs/INTEGRATION_TESTING.md` and +`docs/RESOURCE_BUDGET.md`. Runtime counters and internal-memory samples are +available from `GET /diagnostics`; the endpoint contains no credentials, +session tokens, or game projection. + ## Wi-Fi provisioning and recovery With no saved credentials, or after a failed station connection, the device diff --git a/components/maze32_lobby/include/maze32_lobby.h b/components/maze32_lobby/include/maze32_lobby.h index 2202350..633047f 100644 --- a/components/maze32_lobby/include/maze32_lobby.h +++ b/components/maze32_lobby/include/maze32_lobby.h @@ -68,6 +68,7 @@ maze32_lobby_result_t maze32_lobby_restore(maze32_lobby_t* lobby, int connection void maze32_lobby_disconnect(maze32_lobby_t* lobby, int connection_id); maze32_lobby_result_t maze32_lobby_start(maze32_lobby_t* lobby, int connection_id, uint64_t now_ms); int maze32_lobby_activate_if_due(maze32_lobby_t* lobby, uint64_t now_ms); +void maze32_lobby_reset_match(maze32_lobby_t* lobby); const maze32_session_t* maze32_lobby_session_for_connection(const maze32_lobby_t* lobby, int connection_id); unsigned int maze32_lobby_connected_observers(const maze32_lobby_t* lobby); diff --git a/components/maze32_lobby/include/maze32_protocol.h b/components/maze32_lobby/include/maze32_protocol.h index dc04512..a48a971 100644 --- a/components/maze32_lobby/include/maze32_protocol.h +++ b/components/maze32_lobby/include/maze32_protocol.h @@ -21,6 +21,7 @@ typedef enum { MAZE32_MESSAGE_START, MAZE32_MESSAGE_MOVE, MAZE32_MESSAGE_DROP_KEY, + MAZE32_MESSAGE_RESTART, MAZE32_MESSAGE_HEARTBEAT, MAZE32_MESSAGE_SUGGEST_NAME, } maze32_message_type_t; diff --git a/components/maze32_lobby/maze32_lobby.c b/components/maze32_lobby/maze32_lobby.c index 936b8f1..e39ffe5 100644 --- a/components/maze32_lobby/maze32_lobby.c +++ b/components/maze32_lobby/maze32_lobby.c @@ -195,6 +195,16 @@ int maze32_lobby_activate_if_due(maze32_lobby_t* lobby, uint64_t now_ms) { return 1; } +void maze32_lobby_reset_match(maze32_lobby_t* lobby) { + if (lobby == NULL) { + return; + } + lobby->phase = MAZE32_LOBBY_WAITING; + lobby->countdown_started_ms = 0U; + lobby->activation_ms = 0U; + lobby->revision++; +} + const maze32_session_t* maze32_lobby_session_for_connection(const maze32_lobby_t* lobby, int connection_id) { if (lobby == NULL) { diff --git a/components/maze32_lobby/maze32_protocol.c b/components/maze32_lobby/maze32_protocol.c index d565088..348ad89 100644 --- a/components/maze32_lobby/maze32_protocol.c +++ b/components/maze32_lobby/maze32_protocol.c @@ -190,6 +190,9 @@ static maze32_message_type_t message_type(const char* value) { if (strcmp(value, "drop_key") == 0) { return MAZE32_MESSAGE_DROP_KEY; } + if (strcmp(value, "restart") == 0) { + return MAZE32_MESSAGE_RESTART; + } if (strcmp(value, "heartbeat") == 0) { return MAZE32_MESSAGE_HEARTBEAT; } diff --git a/components/maze32_network/include/maze32_network.h b/components/maze32_network/include/maze32_network.h index 5d26bb7..be7396f 100644 --- a/components/maze32_network/include/maze32_network.h +++ b/components/maze32_network/include/maze32_network.h @@ -35,36 +35,33 @@ typedef struct { typedef struct { EventGroupHandle_t events; SemaphoreHandle_t operation_mutex; - esp_netif_t *station_netif; - esp_netif_t *access_point_netif; + esp_netif_t* station_netif; + esp_netif_t* access_point_netif; volatile maze32_network_state_t state; + volatile int station_wait_active; unsigned int retry_count; char access_point_ssid[24]; maze32_reconfiguration_guard_fn reconfiguration_guard; - void *guard_context; + void* guard_context; httpd_handle_t server; } maze32_network_t; -esp_err_t maze32_network_init(maze32_network_t *network, - maze32_reconfiguration_guard_fn guard, - void *guard_context); -esp_err_t maze32_network_start(maze32_network_t *network); -esp_err_t maze32_network_apply_credentials(maze32_network_t *network, - const maze32_wifi_credentials_t *credentials); -esp_err_t maze32_network_reset_credentials(maze32_network_t *network); -void maze32_network_retry_saved_credentials(void *context); -void maze32_network_return_to_provisioning(void *context); -esp_err_t maze32_network_scan(maze32_network_t *network, - maze32_wifi_scan_result_t *results, - size_t capacity, - size_t *count); -maze32_network_state_t maze32_network_state(const maze32_network_t *network); -const char *maze32_network_state_name(maze32_network_state_t state); -const char *maze32_network_access_point_ssid(const maze32_network_t *network); -esp_err_t maze32_network_ip_address(const maze32_network_t *network, - char *output, +esp_err_t maze32_network_init(maze32_network_t* network, maze32_reconfiguration_guard_fn guard, + void* guard_context); +esp_err_t maze32_network_start(maze32_network_t* network); +esp_err_t maze32_network_apply_credentials(maze32_network_t* network, + const maze32_wifi_credentials_t* credentials); +esp_err_t maze32_network_reset_credentials(maze32_network_t* network); +void maze32_network_retry_saved_credentials(void* context); +void maze32_network_return_to_provisioning(void* context); +esp_err_t maze32_network_scan(maze32_network_t* network, maze32_wifi_scan_result_t* results, + size_t capacity, size_t* count); +maze32_network_state_t maze32_network_state(const maze32_network_t* network); +const char* maze32_network_state_name(maze32_network_state_t state); +const char* maze32_network_access_point_ssid(const maze32_network_t* network); +esp_err_t maze32_network_ip_address(const maze32_network_t* network, char* output, size_t output_size); -esp_err_t maze32_provisioning_register(httpd_handle_t server, maze32_network_t *network); +esp_err_t maze32_provisioning_register(httpd_handle_t server, maze32_network_t* network); #ifdef __cplusplus } diff --git a/components/maze32_network/maze32_network.c b/components/maze32_network/maze32_network.c index 7bdc52f..4dbcfa4 100644 --- a/components/maze32_network/maze32_network.c +++ b/components/maze32_network/maze32_network.c @@ -13,42 +13,61 @@ #define MAZE32_STATION_RETRY_LIMIT 3U #define MAZE32_STATION_TIMEOUT_MS 12000U -static const char *TAG = "maze32_network"; -static maze32_network_t *active_network; +static const char* TAG = "maze32_network"; +static maze32_network_t* active_network; -static void set_state(maze32_network_t *network, maze32_network_state_t state) -{ +static void set_state(maze32_network_t* network, maze32_network_state_t state) { network->state = state; ESP_LOGI(TAG, "Network state: %s", maze32_network_state_name(state)); } -static void network_event(void *argument, - esp_event_base_t event_base, - int32_t event_id, - void *event_data) -{ +static void network_event(void* argument, esp_event_base_t event_base, int32_t event_id, + void* event_data) { (void)event_data; - maze32_network_t *network = argument; + maze32_network_t* network = argument; if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START && network->state == MAZE32_NETWORK_STATION_CONNECTING) { esp_wifi_connect(); } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED && - network->state == MAZE32_NETWORK_STATION_CONNECTING) { + (network->state == MAZE32_NETWORK_STATION_CONNECTING || + network->state == MAZE32_NETWORK_STATION_CONNECTED)) { + if (network->state == MAZE32_NETWORK_STATION_CONNECTED) { + set_state(network, MAZE32_NETWORK_STATION_CONNECTING); + network->retry_count = 0U; + } if (network->retry_count < MAZE32_STATION_RETRY_LIMIT) { ++network->retry_count; - ESP_LOGW(TAG, "Station connection failed; retry %u/%u", - network->retry_count, MAZE32_STATION_RETRY_LIMIT); + ESP_LOGW(TAG, "Station connection failed; retry %u/%u", network->retry_count, + MAZE32_STATION_RETRY_LIMIT); esp_wifi_connect(); - } else { + } else if (network->station_wait_active) { xEventGroupSetBits(network->events, MAZE32_FAILED_BIT); + } else if (network->server != NULL) { + set_state(network, MAZE32_NETWORK_PROVISIONING); + esp_err_t result = + httpd_queue_work(network->server, maze32_network_return_to_provisioning, network); + if (result != ESP_OK) { + ESP_LOGE(TAG, "Could not schedule runtime fallback: %s", esp_err_to_name(result)); + set_state(network, MAZE32_NETWORK_ERROR); + } + } else { + set_state(network, MAZE32_NETWORK_ERROR); } } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) { - xEventGroupSetBits(network->events, MAZE32_CONNECTED_BIT); + if (network->station_wait_active) { + xEventGroupSetBits(network->events, MAZE32_CONNECTED_BIT); + } else if (network->state == MAZE32_NETWORK_STATION_CONNECTING) { + network->retry_count = 0U; + set_state(network, MAZE32_NETWORK_STATION_CONNECTED); + char address[16]; + if (maze32_network_ip_address(network, address, sizeof(address)) == ESP_OK) { + ESP_LOGI(TAG, "Runtime station reconnect succeeded: IP=%s", address); + } + } } } -static esp_err_t stop_wifi(void) -{ +static esp_err_t stop_wifi(void) { esp_err_t result = esp_wifi_stop(); if (result == ESP_ERR_WIFI_NOT_STARTED || result == ESP_ERR_WIFI_NOT_INIT) { return ESP_OK; @@ -56,8 +75,7 @@ static esp_err_t stop_wifi(void) return result; } -static esp_err_t start_provisioning(maze32_network_t *network) -{ +static esp_err_t start_provisioning(maze32_network_t* network) { set_state(network, MAZE32_NETWORK_PROVISIONING); esp_err_t result = stop_wifi(); if (result != ESP_OK) { @@ -89,9 +107,8 @@ static esp_err_t start_provisioning(maze32_network_t *network) return result; } -static esp_err_t connect_station(maze32_network_t *network, - const maze32_wifi_credentials_t *credentials) -{ +static esp_err_t connect_station(maze32_network_t* network, + const maze32_wifi_credentials_t* credentials) { esp_err_t result = stop_wifi(); if (result != ESP_OK) { return result; @@ -113,19 +130,19 @@ static esp_err_t connect_station(maze32_network_t *network, result = esp_wifi_set_config(WIFI_IF_STA, &station); } if (result == ESP_OK) { + network->station_wait_active = 1; result = esp_wifi_start(); } if (result != ESP_OK) { + network->station_wait_active = 0; ESP_LOGE(TAG, "Station startup failed: %s", esp_err_to_name(result)); return start_provisioning(network); } - EventBits_t bits = xEventGroupWaitBits( - network->events, - MAZE32_CONNECTED_BIT | MAZE32_FAILED_BIT, - pdTRUE, - pdFALSE, - pdMS_TO_TICKS(MAZE32_STATION_TIMEOUT_MS)); + EventBits_t bits = + xEventGroupWaitBits(network->events, MAZE32_CONNECTED_BIT | MAZE32_FAILED_BIT, pdTRUE, + pdFALSE, pdMS_TO_TICKS(MAZE32_STATION_TIMEOUT_MS)); + network->station_wait_active = 0; if ((bits & MAZE32_CONNECTED_BIT) != 0U) { set_state(network, MAZE32_NETWORK_STATION_CONNECTED); char address[16]; @@ -139,22 +156,18 @@ static esp_err_t connect_station(maze32_network_t *network, return start_provisioning(network); } -static int save_credentials(const maze32_wifi_credentials_t *credentials, void *context) -{ +static int save_credentials(const maze32_wifi_credentials_t* credentials, void* context) { (void)context; return maze32_config_store_save(credentials) == ESP_OK ? 0 : -1; } -static int clear_credentials(void *context) -{ +static int clear_credentials(void* context) { (void)context; return maze32_config_store_clear() == ESP_OK ? 0 : -1; } -esp_err_t maze32_network_init(maze32_network_t *network, - maze32_reconfiguration_guard_fn guard, - void *guard_context) -{ +esp_err_t maze32_network_init(maze32_network_t* network, maze32_reconfiguration_guard_fn guard, + void* guard_context) { if (network == NULL || guard == NULL || active_network != NULL) { return ESP_ERR_INVALID_ARG; } @@ -173,8 +186,8 @@ esp_err_t maze32_network_init(maze32_network_t *network, if (result != ESP_OK) { return result; } - snprintf(network->access_point_ssid, sizeof(network->access_point_ssid), - "maze32-%02X%02X", mac[4], mac[5]); + snprintf(network->access_point_ssid, sizeof(network->access_point_ssid), "maze32-%02X%02X", + mac[4], mac[5]); wifi_init_config_t wifi_configuration = WIFI_INIT_CONFIG_DEFAULT(); result = esp_wifi_init(&wifi_configuration); @@ -196,8 +209,7 @@ esp_err_t maze32_network_init(maze32_network_t *network, return result; } -esp_err_t maze32_network_start(maze32_network_t *network) -{ +esp_err_t maze32_network_start(maze32_network_t* network) { if (network == NULL || xSemaphoreTake(network->operation_mutex, portMAX_DELAY) != pdTRUE) { return ESP_ERR_INVALID_ARG; } @@ -214,16 +226,13 @@ esp_err_t maze32_network_start(maze32_network_t *network) return result; } -esp_err_t maze32_network_apply_credentials(maze32_network_t *network, - const maze32_wifi_credentials_t *credentials) -{ +esp_err_t maze32_network_apply_credentials(maze32_network_t* network, + const maze32_wifi_credentials_t* credentials) { if (network == NULL) { return ESP_ERR_INVALID_ARG; } - int result = maze32_wifi_config_apply(credentials, - network->reconfiguration_guard, - save_credentials, - network->guard_context); + int result = maze32_wifi_config_apply(credentials, network->reconfiguration_guard, + save_credentials, network->guard_context); if (result == -1) { return ESP_ERR_INVALID_ARG; } @@ -233,13 +242,11 @@ esp_err_t maze32_network_apply_credentials(maze32_network_t *network, return result == 0 ? ESP_OK : ESP_FAIL; } -esp_err_t maze32_network_reset_credentials(maze32_network_t *network) -{ +esp_err_t maze32_network_reset_credentials(maze32_network_t* network) { if (network == NULL) { return ESP_ERR_INVALID_ARG; } - int result = maze32_wifi_config_reset(network->reconfiguration_guard, - clear_credentials, + int result = maze32_wifi_config_reset(network->reconfiguration_guard, clear_credentials, network->guard_context); if (result == -2) { return ESP_ERR_INVALID_STATE; @@ -247,9 +254,8 @@ esp_err_t maze32_network_reset_credentials(maze32_network_t *network) return result == 0 ? ESP_OK : ESP_FAIL; } -void maze32_network_retry_saved_credentials(void *context) -{ - maze32_network_t *network = context; +void maze32_network_retry_saved_credentials(void* context) { + maze32_network_t* network = context; if (network == NULL || xSemaphoreTake(network->operation_mutex, portMAX_DELAY) != pdTRUE) { return; } @@ -265,9 +271,8 @@ void maze32_network_retry_saved_credentials(void *context) xSemaphoreGive(network->operation_mutex); } -void maze32_network_return_to_provisioning(void *context) -{ - maze32_network_t *network = context; +void maze32_network_return_to_provisioning(void* context) { + maze32_network_t* network = context; if (network == NULL || xSemaphoreTake(network->operation_mutex, portMAX_DELAY) != pdTRUE) { return; } @@ -278,11 +283,8 @@ void maze32_network_return_to_provisioning(void *context) xSemaphoreGive(network->operation_mutex); } -esp_err_t maze32_network_scan(maze32_network_t *network, - maze32_wifi_scan_result_t *results, - size_t capacity, - size_t *count) -{ +esp_err_t maze32_network_scan(maze32_network_t* network, maze32_wifi_scan_result_t* results, + size_t capacity, size_t* count) { if (network == NULL || results == NULL || count == NULL || capacity == 0U || capacity > MAZE32_WIFI_SCAN_LIMIT || xSemaphoreTake(network->operation_mutex, pdMS_TO_TICKS(1000U)) != pdTRUE) { @@ -313,34 +315,34 @@ esp_err_t maze32_network_scan(maze32_network_t *network, return result; } -maze32_network_state_t maze32_network_state(const maze32_network_t *network) -{ +maze32_network_state_t maze32_network_state(const maze32_network_t* network) { return network == NULL ? MAZE32_NETWORK_ERROR : network->state; } -const char *maze32_network_state_name(maze32_network_state_t state) -{ +const char* maze32_network_state_name(maze32_network_state_t state) { switch (state) { - case MAZE32_NETWORK_BOOTING: return "booting"; - case MAZE32_NETWORK_STATION_CONNECTING: return "station_connecting"; - case MAZE32_NETWORK_STATION_CONNECTED: return "station_connected"; - case MAZE32_NETWORK_PROVISIONING: return "provisioning"; - case MAZE32_NETWORK_ERROR: return "error"; - default: return "unknown"; + case MAZE32_NETWORK_BOOTING: + return "booting"; + case MAZE32_NETWORK_STATION_CONNECTING: + return "station_connecting"; + case MAZE32_NETWORK_STATION_CONNECTED: + return "station_connected"; + case MAZE32_NETWORK_PROVISIONING: + return "provisioning"; + case MAZE32_NETWORK_ERROR: + return "error"; + default: + return "unknown"; } } -const char *maze32_network_access_point_ssid(const maze32_network_t *network) -{ +const char* maze32_network_access_point_ssid(const maze32_network_t* network) { return network == NULL ? "" : network->access_point_ssid; } -esp_err_t maze32_network_ip_address(const maze32_network_t *network, - char *output, - size_t output_size) -{ - if (network == NULL || output == NULL || output_size < 16U || - network->station_netif == NULL) { +esp_err_t maze32_network_ip_address(const maze32_network_t* network, char* output, + size_t output_size) { + if (network == NULL || output == NULL || output_size < 16U || network->station_netif == NULL) { return ESP_ERR_INVALID_ARG; } esp_netif_ip_info_t information; diff --git a/components/maze32_web/include/maze32_gateway.h b/components/maze32_web/include/maze32_gateway.h index 61c100d..d11e2e8 100644 --- a/components/maze32_web/include/maze32_gateway.h +++ b/components/maze32_web/include/maze32_gateway.h @@ -19,6 +19,8 @@ extern "C" { #define MAZE32_GATEWAY_QUEUE_LIMIT 12U #define MAZE32_GATEWAY_OUTGOING_LIMIT 768U #define MAZE32_GATEWAY_RATE_LIMIT_PER_SECOND 20U +#define MAZE32_GATEWAY_RATE_LIMIT_CLOSE_THRESHOLD 40U +#define MAZE32_GATEWAY_PLAYER_LATENCY_BUDGET_MS 250U typedef struct { int active; @@ -26,14 +28,36 @@ typedef struct { uint32_t last_client_sequence; uint64_t rate_window_started_ms; unsigned int messages_in_window; + int closing; } maze32_gateway_connection_t; typedef struct { int socket_fd; size_t length; + int drop_if_busy; char payload[MAZE32_GATEWAY_OUTGOING_LIMIT]; } maze32_gateway_outgoing_t; +typedef struct { + uint32_t connection_high_water; + uint32_t queue_high_water; + uint32_t messages_received; + uint32_t messages_sent; + uint32_t messages_dropped; + uint32_t send_failures; + uint32_t malformed_messages; + uint32_t oversized_messages; + uint32_t rate_limited_messages; + uint32_t accepted_game_commands; + uint32_t matches_started; + uint32_t matches_completed; + uint32_t matches_reset; + uint64_t maximum_handler_us; + uint64_t maximum_send_us; + size_t minimum_internal_free_bytes; + size_t minimum_internal_largest_block_bytes; +} maze32_gateway_metrics_t; + typedef struct { httpd_handle_t server; maze32_lobby_t lobby; @@ -45,6 +69,8 @@ typedef struct { unsigned int suggestion_index; esp_timer_handle_t countdown_timer; esp_timer_handle_t reconnect_timer; + esp_timer_handle_t finish_timer; + maze32_gateway_metrics_t metrics; maze32_maze_t maze; maze32_maze_workspace_t maze_workspace; maze32_game_t game; @@ -56,6 +82,8 @@ void maze32_gateway_set_server(maze32_gateway_t* gateway, httpd_handle_t server) esp_err_t maze32_gateway_register(httpd_handle_t server, maze32_gateway_t* gateway); void maze32_gateway_close_socket(httpd_handle_t server, int socket_fd); int maze32_gateway_can_reconfigure(const maze32_gateway_t* gateway); +size_t maze32_gateway_format_diagnostics(maze32_gateway_t* gateway, char* output, + size_t output_size); #ifdef __cplusplus } diff --git a/components/maze32_web/maze32_gateway.c b/components/maze32_web/maze32_gateway.c index c918d21..6dd394e 100644 --- a/components/maze32_web/maze32_gateway.c +++ b/components/maze32_web/maze32_gateway.c @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -20,6 +21,46 @@ static uint64_t now_ms(void) { return (uint64_t)(esp_timer_get_time() / 1000); } +static size_t internal_free_bytes(void) { + return heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); +} + +static size_t internal_largest_block_bytes(void) { + return heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); +} + +static unsigned int active_connection_count(const maze32_gateway_t* gateway) { + unsigned int count = 0U; + for (size_t index = 0U; index < MAZE32_GATEWAY_CONNECTION_LIMIT; ++index) { + if (gateway->connections[index].active) { + count++; + } + } + return count; +} + +static void sample_resources(maze32_gateway_t* gateway, const char* label) { + size_t free_bytes = internal_free_bytes(); + size_t largest_block = internal_largest_block_bytes(); + if (gateway->metrics.minimum_internal_free_bytes == 0U || + free_bytes < gateway->metrics.minimum_internal_free_bytes) { + gateway->metrics.minimum_internal_free_bytes = free_bytes; + } + if (gateway->metrics.minimum_internal_largest_block_bytes == 0U || + largest_block < gateway->metrics.minimum_internal_largest_block_bytes) { + gateway->metrics.minimum_internal_largest_block_bytes = largest_block; + } + if (label != NULL) { + ESP_LOGI(TAG, + "M010 resources: label=%s active=%u queue=%u free=%u largest=%u min_free=%u " + "min_largest=%u", + label, active_connection_count(gateway), (unsigned int)gateway->outgoing_count, + (unsigned int)free_bytes, (unsigned int)largest_block, + (unsigned int)gateway->metrics.minimum_internal_free_bytes, + (unsigned int)gateway->metrics.minimum_internal_largest_block_bytes); + } +} + static maze32_gateway_connection_t* find_connection(maze32_gateway_t* gateway, int socket_fd) { for (size_t index = 0U; index < MAZE32_GATEWAY_CONNECTION_LIMIT; ++index) { if (gateway->connections[index].active && @@ -42,19 +83,14 @@ static maze32_gateway_connection_t* open_connection(maze32_gateway_t* gateway, i connection->active = 1; connection->socket_fd = socket_fd; connection->rate_window_started_ms = now_ms(); - unsigned int active_count = 0U; - for (size_t count_index = 0U; count_index < MAZE32_GATEWAY_CONNECTION_LIMIT; - ++count_index) { - if (gateway->connections[count_index].active) { - active_count++; - } + unsigned int active_count = active_connection_count(gateway); + if (active_count > gateway->metrics.connection_high_water) { + gateway->metrics.connection_high_water = active_count; } ESP_LOGI(TAG, "WebSocket connected: fd=%d, active=%u/%u", socket_fd, active_count, (unsigned int)MAZE32_GATEWAY_CONNECTION_LIMIT); if (active_count == MAZE32_GATEWAY_CONNECTION_LIMIT) { - ESP_LOGI(TAG, "Six-client heap: free=%u bytes, largest=%u bytes", - (unsigned int)heap_caps_get_free_size(MALLOC_CAP_8BIT), - (unsigned int)heap_caps_get_largest_free_block(MALLOC_CAP_8BIT)); + sample_resources(gateway, "six_clients"); } return connection; } @@ -112,10 +148,16 @@ static uint32_t next_server_sequence(maze32_gateway_t* gateway) { return gateway->server_sequence; } -static int enqueue(maze32_gateway_t* gateway, int socket_fd, const char* payload) { +static int enqueue_with_policy(maze32_gateway_t* gateway, int socket_fd, const char* payload, + int drop_if_busy) { size_t length = strlen(payload); if (gateway->outgoing_count >= MAZE32_GATEWAY_QUEUE_LIMIT || length >= MAZE32_GATEWAY_OUTGOING_LIMIT) { + if (drop_if_busy) { + gateway->metrics.messages_dropped++; + } else { + gateway->metrics.send_failures++; + } ESP_LOGW(TAG, "Bounded outgoing queue rejected a message for fd=%d", socket_fd); return 0; } @@ -123,12 +165,33 @@ static int enqueue(maze32_gateway_t* gateway, int socket_fd, const char* payload maze32_gateway_outgoing_t* entry = &gateway->outgoing[index]; entry->socket_fd = socket_fd; entry->length = length; + entry->drop_if_busy = drop_if_busy; memcpy(entry->payload, payload, length + 1U); gateway->outgoing_count++; + if (gateway->outgoing_count > gateway->metrics.queue_high_water) { + gateway->metrics.queue_high_water = (uint32_t)gateway->outgoing_count; + } return 1; } -static void drain_queue(maze32_gateway_t* gateway) { +static int enqueue(maze32_gateway_t* gateway, int socket_fd, const char* payload) { + return enqueue_with_policy(gateway, socket_fd, payload, 0); +} + +static void close_slow_connection(maze32_gateway_t* gateway, int socket_fd) { + maze32_gateway_connection_t* connection = find_connection(gateway, socket_fd); + if (connection == NULL || connection->closing) { + return; + } + connection->closing = 1; + ESP_LOGW(TAG, "Closing an unresponsive WebSocket: fd=%d", socket_fd); + if (httpd_sess_trigger_close(gateway->server, socket_fd) != ESP_OK) { + ESP_LOGW(TAG, "Could not schedule close for fd=%d", socket_fd); + } +} + +static int drain_queue(maze32_gateway_t* gateway) { + int success = 1; while (gateway->outgoing_count > 0U) { maze32_gateway_outgoing_t* entry = &gateway->outgoing[gateway->outgoing_head]; httpd_ws_frame_t frame = { @@ -138,14 +201,38 @@ static void drain_queue(maze32_gateway_t* gateway) { .payload = (uint8_t*)entry->payload, .len = entry->length, }; - esp_err_t result = httpd_ws_send_frame_async(gateway->server, entry->socket_fd, &frame); - if (result != ESP_OK) { - ESP_LOGW(TAG, "WebSocket send failed for fd=%d: %s", entry->socket_fd, - esp_err_to_name(result)); + int ready = 1; + if (entry->drop_if_busy) { + fd_set write_set; + FD_ZERO(&write_set); + FD_SET(entry->socket_fd, &write_set); + struct timeval timeout = {0}; + ready = select(entry->socket_fd + 1, NULL, &write_set, NULL, &timeout) > 0 && + FD_ISSET(entry->socket_fd, &write_set); + } + if (!ready) { + gateway->metrics.messages_dropped++; + } else { + int64_t started_us = esp_timer_get_time(); + esp_err_t result = httpd_ws_send_frame_async(gateway->server, entry->socket_fd, &frame); + uint64_t elapsed_us = (uint64_t)(esp_timer_get_time() - started_us); + if (elapsed_us > gateway->metrics.maximum_send_us) { + gateway->metrics.maximum_send_us = elapsed_us; + } + if (result == ESP_OK) { + gateway->metrics.messages_sent++; + } else { + gateway->metrics.send_failures++; + success = 0; + ESP_LOGW(TAG, "WebSocket send failed for fd=%d: %s", entry->socket_fd, + esp_err_to_name(result)); + close_slow_connection(gateway, entry->socket_fd); + } } gateway->outgoing_head = (gateway->outgoing_head + 1U) % MAZE32_GATEWAY_QUEUE_LIMIT; gateway->outgoing_count--; } + return success; } static maze32_game_role_t game_role_for_session(const maze32_session_t* session) { @@ -176,10 +263,13 @@ static void send_view(maze32_gateway_t* gateway, const maze32_session_t* session return; } (void)written; - if (!enqueue(gateway, session->connection_id, payload)) { + int drop_if_busy = session->role == MAZE32_ROLE_OBSERVER && !full_observer_view; + if (!enqueue_with_policy(gateway, session->connection_id, payload, drop_if_busy)) { + return; + } + if (!drain_queue(gateway)) { return; } - drain_queue(gateway); } } @@ -489,19 +579,82 @@ static void countdown_timer_callback(void* context) { } static void schedule_reconnect_deadline(maze32_gateway_t* gateway); +static void schedule_finish_deadline(maze32_gateway_t* gateway); + +static const char* final_result_name(maze32_game_final_result_t result) { + switch (result) { + case MAZE32_GAME_FINAL_PLAYER_ONE: + return "player1"; + case MAZE32_GAME_FINAL_PLAYER_TWO: + return "player2"; + case MAZE32_GAME_FINAL_DRAW: + return "draw"; + default: + return "none"; + } +} + +static void complete_match(maze32_gateway_t* gateway) { + char payload[48]; + int written = snprintf(payload, sizeof(payload), "{\"result\":\"%s\"}", + final_result_name(gateway->game.final_result)); + esp_timer_stop(gateway->finish_timer); + if (written >= 0 && (size_t)written < sizeof(payload)) { + broadcast_match_event(gateway, "match_result", payload); + } + broadcast_views(gateway, 0); + drain_queue(gateway); + gateway->metrics.matches_completed++; + sample_resources(gateway, "match_complete"); +} 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); + complete_match(gateway); ESP_LOGI(TAG, "Player reconnect deadline expired; match ended in a draw"); return; } schedule_reconnect_deadline(gateway); } +static void finish_deadline_work(void* context) { + maze32_gateway_t* gateway = context; + if (maze32_game_advance_time(&gateway->game, now_ms())) { + complete_match(gateway); + ESP_LOGI(TAG, "Authoritative finish window closed"); + return; + } + schedule_finish_deadline(gateway); +} + +static void finish_timer_callback(void* context) { + maze32_gateway_t* gateway = context; + if (gateway->server != NULL) { + esp_err_t result = httpd_queue_work(gateway->server, finish_deadline_work, gateway); + if (result != ESP_OK) { + ESP_LOGE(TAG, "Could not queue finish deadline: %s", esp_err_to_name(result)); + } + } +} + +static void schedule_finish_deadline(maze32_gateway_t* gateway) { + esp_timer_stop(gateway->finish_timer); + if (gateway->game.phase != MAZE32_GAME_PHASE_FINISH_PENDING || + gateway->game.first_exited_player >= MAZE32_GAME_PLAYER_COUNT) { + return; + } + uint64_t exit_ms = gateway->game.exit_times_ms[gateway->game.first_exited_player]; + uint64_t window_ms = MAZE32_GAME_DRAW_WINDOW_MS + 1U; + uint64_t deadline = exit_ms > UINT64_MAX - window_ms ? UINT64_MAX : exit_ms + window_ms; + 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->finish_timer, delay_ms * 1000U); + if (result != ESP_OK) { + ESP_LOGE(TAG, "Finish timer could not start: %s", esp_err_to_name(result)); + } +} + static void reconnect_timer_callback(void* context) { maze32_gateway_t* gateway = context; if (gateway->server != NULL) { @@ -597,11 +750,16 @@ static void handle_game_command(maze32_gateway_t* gateway, const maze32_session_ ? 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); + maze32_game_command_result_t processed = {.result = result}; if (result == MAZE32_GAME_RESULT_ACCEPTED) { - maze32_game_command_result_t processed = maze32_game_process_next(&gateway->game); + processed = maze32_game_process_next(&gateway->game); result = processed.result; } + int finished_now = processed.finished && gateway->game.phase == MAZE32_GAME_PHASE_FINISHED; if (result != MAZE32_GAME_RESULT_ACCEPTED) { + if (finished_now) { + complete_match(gateway); + } send_game_error(gateway, session->connection_id, message->request_id, result); return; } @@ -622,9 +780,85 @@ static void handle_game_command(maze32_gateway_t* gateway, const maze32_session_ append_text(acknowledgement, sizeof(acknowledgement), &length, prefix)) { enqueue(gateway, session->connection_id, acknowledgement); } + gateway->metrics.accepted_game_commands++; + if (finished_now) { + complete_match(gateway); + return; + } + if (gateway->game.phase == MAZE32_GAME_PHASE_FINISH_PENDING) { + schedule_finish_deadline(gateway); + } broadcast_views(gateway, 0); } +static esp_err_t initialize_game(maze32_gateway_t* gateway) { + 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, "Maze generation failed: %s", maze32_maze_result_name(maze_report.result)); + return ESP_FAIL; + } + maze32_game_config_t game_config = maze32_game_default_config(); + return maze32_game_init(&gateway->game, &gateway->maze, &game_config) == + MAZE32_GAME_RESULT_ACCEPTED + ? ESP_OK + : ESP_FAIL; +} + +static void handle_restart(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=restart", session->connection_id); + send_game_error(gateway, session->connection_id, message->request_id, + MAZE32_GAME_RESULT_READ_ONLY); + return; + } + if (session->player_slot != 1U) { + send_lobby_error(gateway, session->connection_id, message->request_id, + MAZE32_LOBBY_NOT_OWNER); + return; + } + if (gateway->game.phase != MAZE32_GAME_PHASE_FINISHED) { + send_error(gateway, session->connection_id, message->request_id, "invalid_phase", + "Новая гонка доступна после финиша текущей"); + return; + } + if (!maze32_lobby_ready(&gateway->lobby)) { + send_lobby_error(gateway, session->connection_id, message->request_id, + MAZE32_LOBBY_NOT_READY); + return; + } + if (initialize_game(gateway) != ESP_OK) { + send_error(gateway, session->connection_id, message->request_id, "maze_generation_failed", + "Лабиринт отказался пересаживаться. Попробуйте ещё раз"); + return; + } + esp_timer_stop(gateway->countdown_timer); + esp_timer_stop(gateway->reconnect_timer); + esp_timer_stop(gateway->finish_timer); + maze32_lobby_reset_match(&gateway->lobby); + gateway->metrics.matches_reset++; + char acknowledgement[192] = ""; + 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) && + append_text(acknowledgement, sizeof(acknowledgement), &length, + ",\"payload\":{\"command\":\"restart\"}}")) { + enqueue(gateway, session->connection_id, acknowledgement); + } + broadcast_match_event(gateway, "reset", "{}"); + broadcast_snapshot(gateway); + drain_queue(gateway); + sample_resources(gateway, "match_reset"); +} + 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; @@ -682,6 +916,9 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_ broadcast_match_event(gateway, "resume", "{}"); ESP_LOGI(TAG, "Player %u restored; match resumed", session->player_slot); } + if (gateway->game.phase == MAZE32_GAME_PHASE_FINISH_PENDING) { + schedule_finish_deadline(gateway); + } } } broadcast_snapshot(gateway); @@ -711,6 +948,8 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_ send_lobby_error(gateway, socket_fd, message->request_id, result); return; } + gateway->metrics.matches_started++; + sample_resources(gateway, "match_start"); broadcast_snapshot(gateway); broadcast_countdown(gateway); esp_err_t timer_result = @@ -724,6 +963,10 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_ handle_game_command(gateway, session, message); return; } + if (message->type == MAZE32_MESSAGE_RESTART) { + handle_restart(gateway, session, message); + return; + } if (message->type == MAZE32_MESSAGE_HEARTBEAT) { char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = ""; size_t length = 0U; @@ -741,7 +984,7 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_ } } -static esp_err_t websocket_handler(httpd_req_t* request) { +static esp_err_t websocket_handler_impl(httpd_req_t* request) { maze32_gateway_t* gateway = request->user_ctx; int socket_fd = httpd_req_to_sockfd(request); maze32_gateway_connection_t* connection = find_connection(gateway, socket_fd); @@ -758,7 +1001,9 @@ static esp_err_t websocket_handler(httpd_req_t* request) { if (result != ESP_OK) { return result; } + gateway->metrics.messages_received++; if (frame.len > MAZE32_PROTOCOL_MESSAGE_LIMIT) { + gateway->metrics.oversized_messages++; ESP_LOGW(TAG, "Oversized WebSocket frame rejected: fd=%d, length=%u", socket_fd, (unsigned int)frame.len); shutdown(socket_fd, SHUT_RDWR); @@ -780,8 +1025,13 @@ static esp_err_t websocket_handler(httpd_req_t* request) { } payload[frame.len] = '\0'; if (!rate_limit_allows(connection, now_ms())) { + gateway->metrics.rate_limited_messages++; send_error(gateway, socket_fd, "", "rate_limited", "Слишком много команд"); drain_queue(gateway); + if (connection->messages_in_window > MAZE32_GATEWAY_RATE_LIMIT_CLOSE_THRESHOLD) { + ESP_LOGW(TAG, "Sustained rate-limit abuse; closing fd=%d", socket_fd); + close_slow_connection(gateway, socket_fd); + } return ESP_OK; } @@ -789,6 +1039,7 @@ static esp_err_t websocket_handler(httpd_req_t* request) { maze32_protocol_result_t parse_result = maze32_protocol_parse((const char*)payload, frame.len, &message); if (parse_result != MAZE32_PROTOCOL_OK) { + gateway->metrics.malformed_messages++; send_error(gateway, socket_fd, "", maze32_protocol_result_name(parse_result), "Пакет не прошёл проверку протокола"); drain_queue(gateway); @@ -806,6 +1057,20 @@ static esp_err_t websocket_handler(httpd_req_t* request) { return ESP_OK; } +static esp_err_t websocket_handler(httpd_req_t* request) { + maze32_gateway_t* gateway = request->user_ctx; + int64_t started_us = esp_timer_get_time(); + esp_err_t result = websocket_handler_impl(request); + uint64_t elapsed_us = (uint64_t)(esp_timer_get_time() - started_us); + if (gateway != NULL) { + if (elapsed_us > gateway->metrics.maximum_handler_us) { + gateway->metrics.maximum_handler_us = elapsed_us; + } + sample_resources(gateway, NULL); + } + return result; +} + static esp_err_t websocket_pre_handshake(httpd_req_t* request) { maze32_gateway_t* gateway = request->user_ctx; int socket_fd = httpd_req_to_sockfd(request); @@ -822,17 +1087,7 @@ 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) { + if (initialize_game(gateway) != ESP_OK) { return ESP_FAIL; } const esp_timer_create_args_t timer_arguments = { @@ -856,7 +1111,22 @@ esp_err_t maze32_gateway_init(maze32_gateway_t* gateway) { result = esp_timer_create(&reconnect_timer_arguments, &gateway->reconnect_timer); if (result != ESP_OK) { esp_timer_delete(gateway->countdown_timer); + return result; } + const esp_timer_create_args_t finish_timer_arguments = { + .callback = finish_timer_callback, + .arg = gateway, + .dispatch_method = ESP_TIMER_TASK, + .name = "finish_deadline", + .skip_unhandled_events = true, + }; + result = esp_timer_create(&finish_timer_arguments, &gateway->finish_timer); + if (result != ESP_OK) { + esp_timer_delete(gateway->reconnect_timer); + esp_timer_delete(gateway->countdown_timer); + return result; + } + sample_resources(gateway, "startup"); return result; } @@ -912,6 +1182,9 @@ void maze32_gateway_close_socket(httpd_handle_t server, int socket_fd) { if (written >= 0 && (size_t)written < sizeof(payload)) { broadcast_match_event(gateway, "pause", payload); } + if (gateway->game.phase_before_pause == MAZE32_GAME_PHASE_FINISH_PENDING) { + esp_timer_stop(gateway->finish_timer); + } schedule_reconnect_deadline(gateway); ESP_LOGI(TAG, "Player %u disconnected; match paused for %u ms", disconnected_slot, (unsigned int)MAZE32_GAME_RECONNECT_TIMEOUT_MS); @@ -924,6 +1197,7 @@ void maze32_gateway_close_socket(httpd_handle_t server, int socket_fd) { broadcast_views(gateway, 0); } drain_queue(gateway); + sample_resources(gateway, NULL); ESP_LOGI(TAG, "WebSocket disconnected: fd=%d", socket_fd); } } @@ -933,3 +1207,42 @@ void maze32_gateway_close_socket(httpd_handle_t server, int socket_fd) { int maze32_gateway_can_reconfigure(const maze32_gateway_t* gateway) { return gateway != NULL && maze32_lobby_can_reconfigure(&gateway->lobby); } + +size_t maze32_gateway_format_diagnostics(maze32_gateway_t* gateway, char* output, + size_t output_size) { + if (gateway == NULL || output == NULL || output_size == 0U) { + return 0U; + } + sample_resources(gateway, NULL); + int written = snprintf( + output, output_size, + "{\"activeConnections\":%u,\"connectionHighWater\":%" PRIu32 + ",\"queueDepth\":%u,\"queueHighWater\":%" PRIu32 ",\"messagesReceived\":%" PRIu32 + ",\"messagesSent\":%" PRIu32 ",\"messagesDropped\":%" PRIu32 ",\"sendFailures\":%" PRIu32 + ",\"malformedMessages\":%" PRIu32 ",\"oversizedMessages\":%" PRIu32 + ",\"rateLimitedMessages\":%" PRIu32 ",\"acceptedGameCommands\":%" PRIu32 + ",\"matchesStarted\":%" PRIu32 ",\"matchesCompleted\":%" PRIu32 ",\"matchesReset\":%" PRIu32 + ",\"maximumHandlerUs\":%" PRIu64 ",\"maximumSendUs\":%" PRIu64 + ",\"internalFreeBytes\":%u,\"internalLargestBlockBytes\":%u," + "\"minimumInternalFreeBytes\":%u,\"minimumInternalLargestBlockBytes\":%u," + "\"limits\":{\"connections\":%u,\"messageBytes\":%u,\"outgoingBytes\":%u," + "\"queueEntries\":%u,\"gameCommands\":%u,\"lwipSockets\":%u," + "\"playerLatencyBudgetMs\":%u,\"psramRequired\":false}}", + active_connection_count(gateway), gateway->metrics.connection_high_water, + (unsigned int)gateway->outgoing_count, gateway->metrics.queue_high_water, + gateway->metrics.messages_received, gateway->metrics.messages_sent, + gateway->metrics.messages_dropped, gateway->metrics.send_failures, + gateway->metrics.malformed_messages, gateway->metrics.oversized_messages, + gateway->metrics.rate_limited_messages, gateway->metrics.accepted_game_commands, + gateway->metrics.matches_started, gateway->metrics.matches_completed, + gateway->metrics.matches_reset, gateway->metrics.maximum_handler_us, + gateway->metrics.maximum_send_us, (unsigned int)internal_free_bytes(), + (unsigned int)internal_largest_block_bytes(), + (unsigned int)gateway->metrics.minimum_internal_free_bytes, + (unsigned int)gateway->metrics.minimum_internal_largest_block_bytes, + (unsigned int)MAZE32_GATEWAY_CONNECTION_LIMIT, (unsigned int)MAZE32_PROTOCOL_MESSAGE_LIMIT, + (unsigned int)MAZE32_GATEWAY_OUTGOING_LIMIT, (unsigned int)MAZE32_GATEWAY_QUEUE_LIMIT, + (unsigned int)MAZE32_GAME_COMMAND_CAPACITY, (unsigned int)CONFIG_LWIP_MAX_SOCKETS, + (unsigned int)MAZE32_GATEWAY_PLAYER_LATENCY_BUDGET_MS); + return written < 0 || (size_t)written >= output_size ? 0U : (size_t)written; +} diff --git a/components/maze32_web/maze32_web.c b/components/maze32_web/maze32_web.c index 254a7ac..6f765da 100644 --- a/components/maze32_web/maze32_web.c +++ b/components/maze32_web/maze32_web.c @@ -82,6 +82,20 @@ static esp_err_t send_health(httpd_req_t* request) { return httpd_resp_send(request, response, length); } +static esp_err_t send_diagnostics(httpd_req_t* request) { + maze32_gateway_t* gateway = request->user_ctx; + char response[1024]; + size_t length = maze32_gateway_format_diagnostics(gateway, response, sizeof(response)); + if (length == 0U) { + httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, + "Диагностика временно недоступна"); + return ESP_FAIL; + } + httpd_resp_set_type(request, "application/json"); + httpd_resp_set_hdr(request, "Cache-Control", "no-store"); + return httpd_resp_send(request, response, (ssize_t)length); +} + esp_err_t maze32_web_start(httpd_handle_t* server, maze32_health_t* health, maze32_gateway_t* gateway) { if (server == NULL || health == NULL || gateway == NULL) { @@ -89,7 +103,7 @@ esp_err_t maze32_web_start(httpd_handle_t* server, maze32_health_t* health, } httpd_config_t configuration = HTTPD_DEFAULT_CONFIG(); - configuration.max_uri_handlers = 12; + configuration.max_uri_handlers = 13; configuration.max_open_sockets = 9; configuration.lru_purge_enable = false; configuration.recv_wait_timeout = 5; @@ -139,6 +153,12 @@ esp_err_t maze32_web_start(httpd_handle_t* server, maze32_health_t* health, .handler = send_health, .user_ctx = health, }; + const httpd_uri_t diagnostics_uri = { + .uri = "/diagnostics", + .method = HTTP_GET, + .handler = send_diagnostics, + .user_ctx = gateway, + }; result = httpd_register_uri_handler(*server, &root); if (result == ESP_OK) { @@ -156,6 +176,9 @@ esp_err_t maze32_web_start(httpd_handle_t* server, maze32_health_t* health, if (result == ESP_OK) { result = httpd_register_uri_handler(*server, &health_uri); } + if (result == ESP_OK) { + result = httpd_register_uri_handler(*server, &diagnostics_uri); + } if (result != ESP_OK) { httpd_stop(*server); *server = NULL; diff --git a/docs/INTEGRATION_TESTING.md b/docs/INTEGRATION_TESTING.md new file mode 100644 index 0000000..b6f9777 --- /dev/null +++ b/docs/INTEGRATION_TESTING.md @@ -0,0 +1,76 @@ +# On-device integration testing + +This procedure verifies Milestone 010 on the ESP32-C6FH4 without PSRAM or any +assumed GPIO. Run it against production firmware and the production LittleFS +image. Start from a freshly booted device with no occupied lobby sessions. + +## Build and delivery + +```sh +/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1 +/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1 --target buildfs +/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1 --target upload --upload-port /dev/ttyACM0 +/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1 --target uploadfs --upload-port /dev/ttyACM0 +``` + +Monitor the USB serial log at 115200 baud. Confirm application version +`0.10.0`, the 4 MB partition table, successful LittleFS mount, station or +fallback network startup, registration of all HTTP/WebSocket routes, and +health state `serving`. + +## Resource and load scenario + +Keep the development host connected to `Mazda7`; do not switch it to the +device fallback access point. When the board has station address +`192.168.1.50`, run: + +```sh +python3 scripts/ws_protocol_client.py 192.168.1.50 \ + --verify-m010 --stress-moves 80 --repeat-matches 2 +``` + +The scenario intentionally takes more than two minutes because both repeated +matches use the real 60-second reconnect deadline. It performs these checks: + +1. Connect two players and four observers and assemble all four complete + 8,192-cell observer projections. +2. Verify heartbeat round-trip latency for all six sessions. +3. Stop consuming one observer stream while Player 1 performs accepted moves; + intermediate observer updates may be dropped, but player acknowledgements + must remain within the reported 250 ms budget. +4. Flood a second observer with malformed messages. The fixed rate limiter + closes sustained abuse, and the players remain responsive. +5. Complete and authoritatively reset two matches through the real disconnect + timeout. Each reset generates a new server-owned maze while preserving the + connected sessions. +6. Fetch `/diagnostics` before, during, and after the run. Assert the connection + and queue high-water marks remain within six and twelve, internal free heap + stays above 32 KiB, the largest internal block stays above 16 KiB, and the + post-reset free-heap samples remain within a 32 KiB range. + +Capture the command output, the final diagnostics JSON, and serial lines that +begin with `M010 resources:`. The JSON counters are monotonic since boot and +include connections, queue use, dropped messages, failures, malformed/rate- +limited input, commands, match cycles, maximum handler/send time, and current +and minimum internal heap samples. + +## Station, fallback, and browser recovery + +These checks involve network state and must be performed from a separate phone +or computer so the development host retains its Mazda7 internet connection. + +1. Reboot with the saved Mazda7 configuration and confirm + `/api/wifi/status` reports `station_connected`. Reload both player pages + during an active match and verify their tokens restore the same slots and + authoritative state. +2. From the separate client, exercise the established provisioning procedure + with an intentionally unavailable test network. Confirm the bounded station + attempts return the device to `maze32-XXXX` at + `http://192.168.4.1/provision` and that the Russian page remains usable. +3. Restore Mazda7 through the provisioning page without recording its + password. Reboot and confirm station connection, browser recovery, `/health`, + and `/diagnostics` again. + +Do not record an acceptance result unless the physical logs and samples are +available. Do not use an LED, a board pin, or a PSRAM capability as a test +oracle. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 8a9e42a..7566bb6 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -37,6 +37,9 @@ is only echoed for correlation; it grants no authority. unpaused match may submit movement intent. - `drop_key`: `{}`. Only a player in an active, unpaused match may submit the key-drop intent. +- `restart`: `{}`. Only connected Player 1 may reset a finished match, and both + players must be connected. Sessions are preserved while a new authoritative + maze and game state are created. - `heartbeat`: `{}` after a session has been joined or restored. ## Server messages @@ -57,6 +60,8 @@ is only echoed for correlation; it grants no authority. - `resume`: reports that both players are attached and authoritative gameplay has resumed. - `match_result`: reports the terminal winner or draw. +- `reset`: reports the authoritative return from a finished match to the + waiting lobby. - `ack`: successful heartbeat correlation. - `error`: a stable English `code` and Russian player-facing `message`. @@ -67,3 +72,13 @@ 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. + +## Runtime diagnostics + +`GET /diagnostics` returns bounded operational counters and internal-memory +samples without exposing session tokens, Wi-Fi credentials, or hidden maze +state. Counters cover connection and queue high-water marks, sent/dropped/ +failed messages, malformed and rate-limited input, accepted game commands, +match cycles, maximum WebSocket handler/send duration, and current/minimum +internal free heap and largest block. The response also publishes the fixed +limits used by the Milestone 010 physical stress client. diff --git a/docs/RESOURCE_BUDGET.md b/docs/RESOURCE_BUDGET.md new file mode 100644 index 0000000..433dcf4 --- /dev/null +++ b/docs/RESOURCE_BUDGET.md @@ -0,0 +1,45 @@ +# Runtime resource budget + +Milestone 010 fixes and exposes the following production limits. They are +compile-time bounds, not targets that may grow with match duration. + +| Resource | Limit | Defensive behavior | +| --- | ---: | --- | +| WebSocket connections | 6 | A seventh WebSocket handshake is rejected. | +| LwIP sockets | 16 | ESP-IDF remains configured by `sdkconfig.defaults`. | +| Client message | 512 bytes | An oversized frame is counted and disconnected. | +| Outgoing message | 768 bytes | An over-limit message cannot enter the queue. | +| Outgoing queue | 12 entries | Queue overflow is counted; memory cannot grow. | +| Game command queue | 16 entries | Excess commands receive `queue_full`. | +| Input rate | 20 messages/second | Excess messages are rejected; sustained abuse above 40 in the same window is disconnected. | +| Player response budget | 250 ms | The physical stress client fails above this round-trip threshold. | +| Observer update policy | Latest bounded projection | A busy observer may lose intermediate dynamic chunks and recover on a newer projection. Failed or stalled required sends close the socket. | +| Reconnect deadline | 60,000 ms | Gameplay pauses, then ends in a draw at expiry. | + +## Build-time measurements + +The Milestone 010 production build on 2026-09-05 reported 124,276 of 327,680 +static RAM bytes and 1,028,600 of 2,097,152 application bytes. The independent +binary check measured 1,029,232 bytes in the 2 MiB factory partition, leaving +1,067,920 bytes of application margin. The compressed web +payload totals 19,253 bytes against its 1.5 MiB budget, leaving 1,553,611 bytes +of payload margin. LittleFS remains the fixed 1,984 KiB partition and its image +is checked against `partitions.csv` on every build. + +The global authoritative gateway, including its one maze, one projection +buffer, fixed queues, sessions, timers, and metrics, occupies 86,840 bytes of +static storage in the production ELF. It does not allocate a maze or +projection per client. No production source or test requires PSRAM or a GPIO. + +## Runtime measurements + +`GET /diagnostics` returns current and minimum internal 8-bit heap values and +the largest internal free block. It deliberately samples +`MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT`, so external RAM cannot make a failing +internal-memory result appear healthy. Serial samples use the same capability +mask. + +Final six-client, slow-client, repeated-match, and network-recovery samples +belong in the active milestone evidence in `PLANS.md`. Build-time numbers in +this document must be refreshed if the production image changes before that +evidence is recorded. diff --git a/scripts/ws_protocol_client.py b/scripts/ws_protocol_client.py index 0716066..b2a462c 100644 --- a/scripts/ws_protocol_client.py +++ b/scripts/ws_protocol_client.py @@ -6,6 +6,7 @@ from __future__ import annotations import argparse import base64 import hashlib +import http.client import json import os import socket @@ -14,6 +15,8 @@ import threading import time from typing import Any +MALFORMED_STRESS_MESSAGES = 80 + class WebSocket: def __init__(self, host: str, port: int = 80, timeout: float = 8.0): @@ -153,6 +156,200 @@ def open_direction(view: dict[str, Any]) -> str: raise AssertionError("player cell has no open passage") +def diagnostics(host: str) -> dict[str, Any]: + connection = http.client.HTTPConnection(host, 80, timeout=8.0) + try: + connection.request("GET", "/diagnostics") + response = connection.getresponse() + payload = response.read() + if response.status != 200: + raise RuntimeError(f"diagnostics returned HTTP {response.status}") + return json.loads(payload.decode("utf-8")) + finally: + connection.close() + + +def verify_m010(host: str, stress_moves: int, repeat_matches: int) -> None: + clients: list[WebSocket] = [] + try: + baseline = diagnostics(host) + player_1 = WebSocket(host, timeout=30.0) + player_2 = WebSocket(host, timeout=30.0) + clients.extend((player_1, player_2)) + join(player_1, "player", "Нагрузочный Один") + session_2 = join(player_2, "player", "Нагрузочный Два") + observers = [WebSocket(host, timeout=30.0) for _ in range(4)] + clients.extend(observers) + for observer in observers: + join(observer, "observer") + + observer_views: list[dict[str, Any] | None] = [None] * len(observers) + failures: list[BaseException] = [] + + def read_observer(index: int) -> None: + try: + observers[index].receive_type("active", attempts=50) + 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(len(observers))] + for reader in readers: + reader.start() + player_1.send("start", {}, "m010-start") + player_1.receive_type("active", attempts=50) + player_2.receive_type("active", attempts=50) + player_1_view = receive_view(player_1) + receive_view(player_2) + for reader in readers: + reader.join(timeout=40.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) + + six_client_sample = diagnostics(host) + limits = six_client_sample["limits"] + assert six_client_sample["activeConnections"] == 6 + assert six_client_sample["connectionHighWater"] == 6 + assert six_client_sample["queueHighWater"] <= limits["queueEntries"] + print("M010 six-client load: PASS (2 players + 4 complete observers)") + + observers[0].send("restart", {}, "m010-observer-restart") + observer_error = observers[0].receive_type("error", attempts=30) + assert observer_error["payload"]["code"] == "read_only" + + heartbeat_latencies_ms: list[float] = [] + for index, client in enumerate(clients): + started = time.monotonic() + client.send("heartbeat", {}, f"m010-heartbeat-{index}") + client.receive_type("ack", attempts=30) + heartbeat_latencies_ms.append((time.monotonic() - started) * 1000.0) + assert max(heartbeat_latencies_ms) <= limits["playerLatencyBudgetMs"] + print(f"M010 six-client responsiveness: PASS (max {max(heartbeat_latencies_ms):.1f} ms)") + + slow_observer = observers[0] + slow_observer.socket.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1024) + drain_stop = threading.Event() + drain_failures: list[BaseException] = [] + + def drain_fast_observer(observer: WebSocket) -> None: + observer.socket.settimeout(1.0) + while not drain_stop.is_set(): + try: + observer.receive() + except socket.timeout: + continue + except BaseException as error: # Preserve an unexpected disconnect. + drain_failures.append(error) + return + + drainers = [threading.Thread(target=drain_fast_observer, args=(observer,)) + for observer in observers[1:]] + for drainer in drainers: + drainer.start() + opposite = {"north": "south", "east": "west", "south": "north", "west": "east"} + direction = open_direction(player_1_view) + command_latencies_ms: list[float] = [] + for move_index in range(stress_moves): + time.sleep(0.16) + started = time.monotonic() + player_1.send("move", {"direction": direction}, f"m010-move-{move_index}") + player_1.receive_type("ack", attempts=50) + command_latencies_ms.append((time.monotonic() - started) * 1000.0) + player_1_view = receive_view(player_1) + receive_view(player_2) + direction = opposite[direction] + + drain_stop.set() + for drainer in drainers: + drainer.join(timeout=3.0) + assert not drain_failures and all(not drainer.is_alive() for drainer in drainers) + for observer in observers[1:]: + observer.socket.settimeout(30.0) + + abusive_observer = observers[1] + + def malformed_flood() -> None: + try: + for _ in range(MALFORMED_STRESS_MESSAGES): + abusive_observer.send_raw(b"{") + except OSError: + pass + + flood = threading.Thread(target=malformed_flood) + flood.start() + stress_heartbeat_latencies_ms: list[float] = [] + for index in range(10): + started = time.monotonic() + player_1.send("heartbeat", {}, f"m010-stress-heartbeat-{index}") + player_1.receive_type("ack", attempts=50) + stress_heartbeat_latencies_ms.append((time.monotonic() - started) * 1000.0) + time.sleep(0.02) + flood.join(timeout=10.0) + assert not flood.is_alive() + stressed = diagnostics(host) + maximum_latency_ms = max(command_latencies_ms + stress_heartbeat_latencies_ms) + assert maximum_latency_ms <= limits["playerLatencyBudgetMs"] + assert stressed["malformedMessages"] > baseline["malformedMessages"] + assert stressed["rateLimitedMessages"] > baseline["rateLimitedMessages"] + assert stressed["queueHighWater"] <= limits["queueEntries"] + assert stressed["minimumInternalFreeBytes"] > 32768 + assert stressed["minimumInternalLargestBlockBytes"] > 16384 + print( + "M010 slow/malformed stress: PASS " + f"(max player latency {maximum_latency_ms:.1f} ms, " + f"dropped {stressed['messagesDropped'] - baseline['messagesDropped']})" + ) + + for observer in observers: + observer.close() + if observer in clients: + clients.remove(observer) + observers.clear() + time.sleep(1.0) + + reset_heap_samples: list[int] = [] + for match_index in range(repeat_matches): + token_2 = session_2["token"] + player_2.close() + clients.remove(player_2) + player_1.receive_type("pause", attempts=100) + player_1.socket.settimeout(70.0) + result = player_1.receive_type("match_result", attempts=200)["payload"] + assert result["result"] == "draw" + + player_2 = WebSocket(host, timeout=30.0) + clients.append(player_2) + player_2.send("restore", {"token": token_2}, f"m010-restore-{match_index}") + session_2 = player_2.receive_type("session", attempts=50)["payload"] + player_1.send("restart", {}, f"m010-restart-{match_index}") + player_1.receive_type("ack", attempts=100) + time.sleep(0.2) + sample = diagnostics(host) + reset_heap_samples.append(sample["internalFreeBytes"]) + if match_index + 1 < repeat_matches: + player_1.send("start", {}, f"m010-start-{match_index + 1}") + player_1.receive_type("active", attempts=100) + player_2.receive_type("active", attempts=100) + receive_view(player_1) + receive_view(player_2) + + final_sample = diagnostics(host) + assert final_sample["matchesCompleted"] - baseline["matchesCompleted"] >= repeat_matches + assert final_sample["matchesReset"] - baseline["matchesReset"] >= repeat_matches + if len(reset_heap_samples) > 1: + assert max(reset_heap_samples) - min(reset_heap_samples) <= 32768 + print( + f"M010 repeated-match stability: PASS ({repeat_matches} matches, " + f"heap range {max(reset_heap_samples) - min(reset_heap_samples)} bytes)" + ) + print(json.dumps(final_sample, ensure_ascii=False, sort_keys=True)) + finally: + for client in clients: + client.close() + + def verify_m009(host: str) -> None: clients: list[WebSocket] = [] try: @@ -350,8 +547,17 @@ def main() -> None: 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") + parser.add_argument("--verify-m010", action="store_true", help="run the Milestone 010 load and stability scenario") + parser.add_argument("--stress-moves", type=int, default=80, + help="accepted moves sent while one observer is not reading") + parser.add_argument("--repeat-matches", type=int, default=2, + help="real 60-second timeout matches completed and reset") arguments = parser.parse_args() - if arguments.verify_m009: + if arguments.stress_moves < 1 or arguments.repeat_matches < 1: + parser.error("--stress-moves and --repeat-matches must be positive") + if arguments.verify_m010: + verify_m010(arguments.host, arguments.stress_moves, arguments.repeat_matches) + elif arguments.verify_m009: verify_m009(arguments.host) elif arguments.verify: verify(arguments.host) diff --git a/test/host/test_lobby_protocol.c b/test/host/test_lobby_protocol.c index 981673d..57b3569 100644 --- a/test/host/test_lobby_protocol.c +++ b/test/host/test_lobby_protocol.c @@ -36,8 +36,11 @@ static void test_protocol_schema(void) { 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* restart = "{\"v\":1,\"type\":\"restart\",\"seq\":5,\"payload\":{}}"; + assert(maze32_protocol_parse(restart, strlen(restart), &message) == MAZE32_PROTOCOL_OK); + assert(message.type == MAZE32_MESSAGE_RESTART); const char* diagonal = - "{\"v\":1,\"type\":\"move\",\"seq\":5,\"payload\":{\"direction\":\"northwest\"}}"; + "{\"v\":1,\"type\":\"move\",\"seq\":6,\"payload\":{\"direction\":\"northwest\"}}"; assert(maze32_protocol_parse(diagonal, strlen(diagonal), &message) == MAZE32_PROTOCOL_INVALID_PAYLOAD); @@ -109,6 +112,21 @@ static void test_lobby_limits_restoration_and_authority(void) { assert(!maze32_lobby_activate_if_due(&lobby, 4999U)); assert(maze32_lobby_activate_if_due(&lobby, 5000U)); assert(lobby.phase == MAZE32_LOBBY_ACTIVE); + maze32_session_t sessions_before_reset[MAZE32_SESSION_LIMIT]; + memcpy(sessions_before_reset, lobby.sessions, sizeof(sessions_before_reset)); + for (uint64_t cycle = 0U; cycle < 128U; ++cycle) { + revision = lobby.revision; + maze32_lobby_reset_match(&lobby); + assert(lobby.phase == MAZE32_LOBBY_WAITING); + assert(lobby.countdown_started_ms == 0U && lobby.activation_ms == 0U); + assert(lobby.revision == revision + 1U); + assert(maze32_lobby_ready(&lobby)); + assert(memcmp(lobby.sessions, sessions_before_reset, sizeof(sessions_before_reset)) == 0); + uint64_t started_ms = 10000U + cycle * 4000U; + assert(maze32_lobby_start(&lobby, 10, started_ms) == MAZE32_LOBBY_OK); + assert(maze32_lobby_activate_if_due(&lobby, started_ms + MAZE32_COUNTDOWN_DURATION_MS)); + } + puts("lobby repeated-match reset: PASS (128 cycles)"); } int main(void) { diff --git a/test/web/test_player_app.js b/test/web/test_player_app.js index 3c96c9b..2d9ccbb 100644 --- a/test/web/test_player_app.js +++ b/test/web/test_player_app.js @@ -104,3 +104,10 @@ test("observer protocol exposes no mutation control path", () => { assert.deepEqual(snapshot.players.map(item => item.cell), [0, 3]); assert.equal("self" in snapshot, false); }); + +test("finished matches expose only an owner restart intention", () => { + const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8"); + assert.match(script, /send\("restart", \{\}, byId\("modal-action"\)\)/); + assert.match(script, /message\.type === "reset"/); + assert.doesNotMatch(script, /restart[^\n]*(position|inventory|result):/); +}); diff --git a/version.txt b/version.txt index ac39a10..78bc1ab 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.9.0 +0.10.0 diff --git a/web/player.js b/web/player.js index 9be83a4..dff8c35 100644 --- a/web/player.js +++ b/web/player.js @@ -358,6 +358,7 @@ text = slot ? (winner === slot ? "Вы официально быстрее очень большого куста." : "Соперник нашёл выход. Лабиринт требует реванш.") : "Зрительный зал фиксирует героический побег без вмешательства в результат."; } showModal(title, text, "ГОНКА ЗАВЕРШЕНА", false); + byId("modal-action").textContent = state.session && state.session.role === "player" && state.session.slot === 1 ? "Новая гонка" : "Вернуться в лобби"; byId("modal-action").hidden = false; } @@ -393,6 +394,7 @@ } 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 === "reset") { state.snapshot = null; state.viewSequence = -1; state.assembler = new SnapshotAssembler(); hideModal(); setScreen("lobby"); showNotice("Лабиринт пересажен. Можно начинать новую гонку.", "success"); } else if (message.type === "ack") { feedback(true); } else if (message.type === "error") { if (message.payload.code === "invalid_token") { windowObject.localStorage.removeItem(TOKEN_KEY); state.session = null; setScreen("welcome"); } @@ -438,7 +440,13 @@ 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"); }); + byId("modal-action").addEventListener("click", () => { + if (state.session && state.session.role === "player" && state.session.slot === 1 && state.snapshot && state.snapshot.phase === "finished") { + send("restart", {}, byId("modal-action")); + } else { + hideModal(); setScreen("lobby"); + } + }); connect(); return {state, handleMessage, move}; }