feat(runtime): add integration hardening and diagnostics

This commit is contained in:
2026-09-05 21:56:09 +03:00
parent ffdaa299bc
commit c730cb818d
19 changed files with 956 additions and 160 deletions
+57 -20
View File
@@ -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
+6
View File
@@ -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
@@ -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);
@@ -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;
+10
View File
@@ -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) {
@@ -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;
}
@@ -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
}
+82 -80
View File
@@ -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;
@@ -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
}
+346 -33
View File
@@ -3,6 +3,7 @@
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <unistd.h>
@@ -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;
}
+24 -1
View File
@@ -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;
+76
View File
@@ -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.
+15
View File
@@ -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.
+45
View File
@@ -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.
+207 -1
View File
@@ -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)
+19 -1
View File
@@ -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) {
+7
View File
@@ -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):/);
});
+1 -1
View File
@@ -1 +1 @@
0.9.0
0.10.0
+9 -1
View File
@@ -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};
}