#include "maze32_gateway.h" #include #include #include #include #include #include #include #include #include "esp_heap_caps.h" #include "esp_log.h" #include "esp_random.h" #include "maze32_protocol.h" static const char* TAG = "maze32_gateway"; 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 int gateway_send_all(httpd_handle_t server, int socket_fd, const char* buffer, size_t length, int flags) { (void)server; if (buffer == NULL) { return HTTPD_SOCK_ERR_INVALID; } size_t offset = 0U; while (offset < length) { int sent = send(socket_fd, buffer + offset, length - offset, flags); if (sent > 0) { offset += (size_t)sent; continue; } if (sent < 0 && errno == EINTR) { continue; } if (sent < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { return HTTPD_SOCK_ERR_TIMEOUT; } return HTTPD_SOCK_ERR_FAIL; } return (int)offset; } 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 && gateway->connections[index].socket_fd == socket_fd) { return &gateway->connections[index]; } } return NULL; } static maze32_gateway_connection_t* open_connection(maze32_gateway_t* gateway, int socket_fd) { maze32_gateway_connection_t* existing = find_connection(gateway, socket_fd); if (existing != NULL) { return existing; } for (size_t index = 0U; index < MAZE32_GATEWAY_CONNECTION_LIMIT; ++index) { maze32_gateway_connection_t* connection = &gateway->connections[index]; if (!connection->active) { memset(connection, 0, sizeof(*connection)); connection->active = 1; connection->socket_fd = socket_fd; if (httpd_sess_set_send_override(gateway->server, socket_fd, gateway_send_all) != ESP_OK) { ESP_LOGW(TAG, "Could not install bounded send callback for fd=%d", socket_fd); } connection->rate_window_started_ms = now_ms(); 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) { sample_resources(gateway, "six_clients"); } return connection; } } return NULL; } static int append_text(char* output, size_t capacity, size_t* length, const char* text) { size_t text_length = strlen(text); if (*length + text_length >= capacity) { return 0; } memcpy(output + *length, text, text_length); *length += text_length; output[*length] = '\0'; return 1; } static int append_json_string(char* output, size_t capacity, size_t* length, const char* value) { if (!append_text(output, capacity, length, "\"")) { return 0; } for (const unsigned char* cursor = (const unsigned char*)value; *cursor != '\0'; ++cursor) { const char* escape = NULL; if (*cursor == '"') { escape = "\\\""; } else if (*cursor == '\\') { escape = "\\\\"; } else if (*cursor == '\n') { escape = "\\n"; } else if (*cursor == '\r') { escape = "\\r"; } else if (*cursor == '\t') { escape = "\\t"; } if (escape != NULL) { if (!append_text(output, capacity, length, escape)) { return 0; } } else { char byte[2] = {(char)*cursor, '\0'}; if (!append_text(output, capacity, length, byte)) { return 0; } } } return append_text(output, capacity, length, "\""); } static uint32_t next_server_sequence(maze32_gateway_t* gateway) { gateway->server_sequence++; if (gateway->server_sequence == 0U) { gateway->server_sequence = 1U; } return gateway->server_sequence; } 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; } size_t index = (gateway->outgoing_head + gateway->outgoing_count) % MAZE32_GATEWAY_QUEUE_LIMIT; 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 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 = { .final = true, .fragmented = false, .type = HTTPD_WS_TYPE_TEXT, .payload = (uint8_t*)entry->payload, .len = entry->length, }; int ready = 1; int original_flags = -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) { original_flags = fcntl(entry->socket_fd, F_GETFL, 0); if (original_flags >= 0) { (void)fcntl(entry->socket_fd, F_SETFL, original_flags | O_NONBLOCK); } } } 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); } } if (original_flags >= 0) { (void)fcntl(entry->socket_fd, F_SETFL, original_flags); } 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) { if (session->role == MAZE32_ROLE_OBSERVER) { return MAZE32_GAME_ROLE_OBSERVER; } return session->player_slot == 1U ? MAZE32_GAME_ROLE_PLAYER_ONE : MAZE32_GAME_ROLE_PLAYER_TWO; } static void send_view(maze32_gateway_t* gateway, const maze32_session_t* session, int full_observer_view) { if (session == NULL || !session->connected || maze32_game_project_view(&gateway->game, game_role_for_session(session), &gateway->view) != MAZE32_GAME_RESULT_ACCEPTED) { return; } if (session->role == MAZE32_ROLE_OBSERVER && !full_observer_view) { gateway->view.cell_count = 0U; } size_t chunks = maze32_game_view_chunk_count(&gateway->view); for (size_t chunk = 0U; chunk < chunks; ++chunk) { char payload[MAZE32_GAME_VIEW_CHUNK_BYTES]; size_t written = 0U; if (maze32_game_serialize_view_chunk(&gateway->view, chunk, payload, sizeof(payload), &written) != MAZE32_GAME_RESULT_ACCEPTED) { ESP_LOGE(TAG, "View serialization failed for fd=%d, chunk=%u", session->connection_id, (unsigned int)chunk); return; } (void)written; 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; } } } static void broadcast_views(maze32_gateway_t* gateway, int full_observer_view) { for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) { send_view(gateway, &gateway->lobby.sessions[index], full_observer_view); } } static void broadcast_match_event(maze32_gateway_t* gateway, const char* type, const char* payload) { char response[MAZE32_GATEWAY_OUTGOING_LIMIT]; int written = snprintf(response, sizeof(response), "{\"v\":1,\"type\":\"%s\",\"seq\":%" PRIu32 ",\"payload\":%s}", type, next_server_sequence(gateway), payload); if (written < 0 || (size_t)written >= sizeof(response)) { return; } for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) { const maze32_session_t* session = &gateway->lobby.sessions[index]; if (session->role != MAZE32_ROLE_NONE && session->connected) { enqueue(gateway, session->connection_id, response); } } } static void send_error(maze32_gateway_t* gateway, int socket_fd, const char* request_id, const char* code, const char* message) { char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = ""; size_t length = 0U; char prefix[128]; int written = snprintf(prefix, sizeof(prefix), "{\"v\":1,\"type\":\"error\",\"seq\":%" PRIu32, next_server_sequence(gateway)); if (written < 0 || (size_t)written >= sizeof(prefix) || !append_text(response, sizeof(response), &length, prefix)) { return; } if (request_id != NULL && request_id[0] != '\0') { if (!append_text(response, sizeof(response), &length, ",\"requestId\":") || !append_json_string(response, sizeof(response), &length, request_id)) { return; } } if (!append_text(response, sizeof(response), &length, ",\"payload\":{\"code\":") || !append_json_string(response, sizeof(response), &length, code) || !append_text(response, sizeof(response), &length, ",\"message\":") || !append_json_string(response, sizeof(response), &length, message) || !append_text(response, sizeof(response), &length, "}}")) { return; } enqueue(gateway, socket_fd, response); } static const maze32_session_t* player_for_slot(const maze32_lobby_t* lobby, unsigned int slot) { for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) { const maze32_session_t* session = &lobby->sessions[index]; if (session->role == MAZE32_ROLE_PLAYER && session->player_slot == slot) { return session; } } return NULL; } static int build_snapshot(maze32_gateway_t* gateway, char* response, size_t capacity) { uint32_t sequence = next_server_sequence(gateway); int written = snprintf(response, capacity, "{\"v\":1,\"type\":\"lobby\",\"seq\":%" PRIu32 ",\"payload\":{\"revision\":%" PRIu32 ",\"phase\":\"%s\"," "\"ready\":%s,\"ownerSlot\":1,\"players\":[", sequence, gateway->lobby.revision, maze32_lobby_phase_name(gateway->lobby.phase), maze32_lobby_ready(&gateway->lobby) ? "true" : "false"); if (written < 0 || (size_t)written >= capacity) { return 0; } size_t length = (size_t)written; for (unsigned int slot = 1U; slot <= MAZE32_PLAYER_LIMIT; ++slot) { const maze32_session_t* player = player_for_slot(&gateway->lobby, slot); char prefix[96]; written = snprintf(prefix, sizeof(prefix), "%s{\"slot\":%u,\"name\":", slot == 1U ? "" : ",", slot); if (written < 0 || (size_t)written >= sizeof(prefix) || !append_text(response, capacity, &length, prefix) || !append_json_string(response, capacity, &length, player == NULL ? "" : player->name)) { return 0; } written = snprintf(prefix, sizeof(prefix), ",\"occupied\":%s,\"connected\":%s}", player == NULL ? "false" : "true", player != NULL && player->connected ? "true" : "false"); if (written < 0 || (size_t)written >= sizeof(prefix) || !append_text(response, capacity, &length, prefix)) { return 0; } } char suffix[128]; written = snprintf(suffix, sizeof(suffix), "],\"observers\":%u,\"observerLimit\":%u}}", maze32_lobby_connected_observers(&gateway->lobby), (unsigned int)MAZE32_OBSERVER_LIMIT); return written >= 0 && (size_t)written < sizeof(suffix) && append_text(response, capacity, &length, suffix); } static void broadcast_snapshot(maze32_gateway_t* gateway) { char response[MAZE32_GATEWAY_OUTGOING_LIMIT]; if (!build_snapshot(gateway, response, sizeof(response))) { ESP_LOGE(TAG, "Lobby snapshot exceeded its fixed buffer"); return; } for (size_t index = 0U; index < MAZE32_GATEWAY_CONNECTION_LIMIT; ++index) { maze32_gateway_connection_t* connection = &gateway->connections[index]; if (connection->active && maze32_lobby_session_for_connection(&gateway->lobby, connection->socket_fd) != NULL) { enqueue(gateway, connection->socket_fd, response); } } } static void send_session(maze32_gateway_t* gateway, int socket_fd, const maze32_session_t* session, const char* request_id) { char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = ""; size_t length = 0U; char prefix[160]; int written = snprintf(prefix, sizeof(prefix), "{\"v\":1,\"type\":\"session\",\"seq\":%" PRIu32, next_server_sequence(gateway)); if (written < 0 || (size_t)written >= sizeof(prefix) || !append_text(response, sizeof(response), &length, prefix)) { return; } if (request_id[0] != '\0' && (!append_text(response, sizeof(response), &length, ",\"requestId\":") || !append_json_string(response, sizeof(response), &length, request_id))) { return; } written = snprintf(prefix, sizeof(prefix), ",\"payload\":{\"role\":\"%s\",\"slot\":%u,\"token\":", session->role == MAZE32_ROLE_PLAYER ? "player" : "observer", session->player_slot); if (written < 0 || (size_t)written >= sizeof(prefix) || !append_text(response, sizeof(response), &length, prefix) || !append_json_string(response, sizeof(response), &length, session->token) || !append_text(response, sizeof(response), &length, "}}")) { return; } enqueue(gateway, socket_fd, response); } static void generate_token(maze32_gateway_t* gateway, char* token) { static const char HEX[] = "0123456789abcdef"; uint8_t bytes[MAZE32_SESSION_TOKEN_BYTES]; for (;;) { esp_fill_random(bytes, sizeof(bytes)); for (size_t index = 0U; index < sizeof(bytes); ++index) { token[index * 2U] = HEX[bytes[index] >> 4U]; token[index * 2U + 1U] = HEX[bytes[index] & 0x0fU]; } token[MAZE32_SESSION_TOKEN_HEX_LENGTH] = '\0'; int collision = 0; for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) { if (gateway->lobby.sessions[index].role != MAZE32_ROLE_NONE && strcmp(gateway->lobby.sessions[index].token, token) == 0) { collision = 1; break; } } if (!collision) { return; } } } static void send_lobby_error(maze32_gateway_t* gateway, int socket_fd, const char* request_id, maze32_lobby_result_t result) { switch (result) { case MAZE32_LOBBY_INVALID_NAME: send_error(gateway, socket_fd, request_id, "invalid_name", "Имя должно быть длиной от 1 до 32 байт без пробелов по краям"); break; case MAZE32_LOBBY_DUPLICATE_NAME: send_error(gateway, socket_fd, request_id, "duplicate_name", "Это имя уже занято"); break; case MAZE32_LOBBY_ROLE_FULL: send_error(gateway, socket_fd, request_id, "role_full", "Свободных мест больше нет"); break; case MAZE32_LOBBY_INVALID_TOKEN: send_error(gateway, socket_fd, request_id, "invalid_token", "Сессия не найдена"); break; case MAZE32_LOBBY_SESSION_CONNECTED: send_error(gateway, socket_fd, request_id, "session_connected", "Эта сессия уже подключена"); break; case MAZE32_LOBBY_NOT_OWNER: send_error(gateway, socket_fd, request_id, "not_owner", "Запустить игру может только Игрок 1"); break; case MAZE32_LOBBY_NOT_READY: send_error(gateway, socket_fd, request_id, "not_ready", "Для старта нужны оба игрока онлайн"); break; case MAZE32_LOBBY_INVALID_PHASE: send_error(gateway, socket_fd, request_id, "invalid_phase", "Лобби уже запустило обратный отсчёт"); break; default: send_error(gateway, socket_fd, request_id, "request_rejected", "Команда отклонена"); break; } } static void broadcast_countdown(maze32_gateway_t* gateway) { char response[MAZE32_GATEWAY_OUTGOING_LIMIT]; int written = snprintf(response, sizeof(response), "{\"v\":1,\"type\":\"countdown\",\"seq\":%" PRIu32 ",\"payload\":{\"steps\":[3,2,1],\"startedAtMs\":%" PRIu64 ",\"activationAtMs\":%" PRIu64 "}}", next_server_sequence(gateway), gateway->lobby.countdown_started_ms, gateway->lobby.activation_ms); if (written < 0 || (size_t)written >= sizeof(response)) { return; } for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) { const maze32_session_t* session = &gateway->lobby.sessions[index]; if (session->role == MAZE32_ROLE_PLAYER && session->connected) { enqueue(gateway, session->connection_id, response); } } } static void countdown_finished_work(void* context) { maze32_gateway_t* gateway = context; if (!maze32_lobby_activate_if_due(&gateway->lobby, now_ms())) { return; } maze32_game_set_phase(&gateway->game, MAZE32_GAME_PHASE_ACTIVE); char response[MAZE32_GATEWAY_OUTGOING_LIMIT]; int written = snprintf(response, sizeof(response), "{\"v\":1,\"type\":\"active\",\"seq\":%" PRIu32 ",\"payload\":{\"activatedAtMs\":%" PRIu64 "}}", next_server_sequence(gateway), gateway->lobby.activation_ms); if (written >= 0 && (size_t)written < sizeof(response)) { for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) { const maze32_session_t* session = &gateway->lobby.sessions[index]; if (session->role != MAZE32_ROLE_NONE && session->connected) { enqueue(gateway, session->connection_id, response); } } } drain_queue(gateway); broadcast_snapshot(gateway); drain_queue(gateway); broadcast_views(gateway, 1); drain_queue(gateway); ESP_LOGI(TAG, "Lobby became active at server time %" PRIu64 " ms", gateway->lobby.activation_ms); } static void countdown_timer_callback(void* context) { maze32_gateway_t* gateway = context; if (gateway->server != NULL) { esp_err_t result = httpd_queue_work(gateway->server, countdown_finished_work, gateway); if (result != ESP_OK) { ESP_LOGE(TAG, "Could not queue countdown activation: %s", esp_err_to_name(result)); } } } 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())) { 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) { esp_err_t result = httpd_queue_work(gateway->server, reconnect_deadline_work, gateway); if (result != ESP_OK) { ESP_LOGE(TAG, "Could not queue reconnect deadline: %s", esp_err_to_name(result)); } } } static void schedule_reconnect_deadline(maze32_gateway_t* gateway) { esp_timer_stop(gateway->reconnect_timer); if (gateway->game.phase != MAZE32_GAME_PHASE_PAUSED) { return; } uint64_t deadline = UINT64_MAX; for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) { if (!gateway->game.players[player].connected && gateway->game.players[player].reconnect_deadline_ms < deadline) { deadline = gateway->game.players[player].reconnect_deadline_ms; } } if (deadline == UINT64_MAX) { return; } uint64_t current_ms = now_ms(); uint64_t delay_ms = deadline > current_ms ? deadline - current_ms : 1U; esp_err_t result = esp_timer_start_once(gateway->reconnect_timer, delay_ms * 1000U); if (result != ESP_OK) { ESP_LOGE(TAG, "Reconnect timer could not start: %s", esp_err_to_name(result)); } } static int rate_limit_allows(maze32_gateway_connection_t* connection, uint64_t current_ms) { if (current_ms - connection->rate_window_started_ms >= 1000U) { connection->rate_window_started_ms = current_ms; connection->messages_in_window = 0U; } connection->messages_in_window++; return connection->messages_in_window <= MAZE32_GATEWAY_RATE_LIMIT_PER_SECOND; } static maze32_game_direction_t parse_direction(const char* direction) { if (strcmp(direction, "north") == 0) { return MAZE32_GAME_DIRECTION_NORTH; } if (strcmp(direction, "east") == 0) { return MAZE32_GAME_DIRECTION_EAST; } if (strcmp(direction, "south") == 0) { return MAZE32_GAME_DIRECTION_SOUTH; } return MAZE32_GAME_DIRECTION_WEST; } static void send_game_error(maze32_gateway_t* gateway, int socket_fd, const char* request_id, maze32_game_result_t result) { const char* message = "Команда игры отклонена сервером"; if (result == MAZE32_GAME_RESULT_READ_ONLY) { message = "Наблюдатели смотрят, но не двигают героев"; } else if (result == MAZE32_GAME_RESULT_PREMATURE) { message = "Матч сейчас на паузе или ещё не начался"; } else if (result == MAZE32_GAME_RESULT_RATE_LIMITED) { message = "Слишком быстро: лабиринт просит соблюдать интервал"; } else if (result == MAZE32_GAME_RESULT_BLOCKED) { message = "Там стена. Очень убедительная"; } else if (result == MAZE32_GAME_RESULT_NO_KEY) { message = "Карман пуст — бросать нечего"; } else if (result == MAZE32_GAME_RESULT_MATCH_FINISHED) { message = "Гонка уже закончилась"; } send_error(gateway, socket_fd, request_id, maze32_game_result_name(result), message); } static void send_breakthrough_impact(maze32_gateway_t* gateway, int socket_fd, const char* request_id, uint8_t hits) { char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = ""; int written = snprintf(response, sizeof(response), "{\"v\":1,\"type\":\"error\",\"seq\":%" PRIu32 ",\"requestId\":\"%s\",\"payload\":{\"code\":\"blocked\"," "\"message\":\"Там стена. Очень убедительная\"," "\"breakthroughHits\":%u}}", next_server_sequence(gateway), request_id, (unsigned int)hits); if (written >= 0 && (size_t)written < sizeof(response)) { enqueue(gateway, socket_fd, response); } } static void handle_game_command(maze32_gateway_t* gateway, const maze32_session_t* session, const maze32_client_message_t* message) { if (session->role == MAZE32_ROLE_OBSERVER) { ESP_LOGW(TAG, "Observer mutation rejected: fd=%d, type=%u", session->connection_id, (unsigned int)message->type); send_game_error(gateway, session->connection_id, message->request_id, MAZE32_GAME_RESULT_READ_ONLY); return; } if (gateway->lobby.phase != MAZE32_LOBBY_ACTIVE) { send_game_error(gateway, session->connection_id, message->request_id, MAZE32_GAME_RESULT_PREMATURE); return; } uint32_t command_sequence = 0U; maze32_game_role_t role = game_role_for_session(session); maze32_game_result_t result = message->type == MAZE32_MESSAGE_MOVE ? maze32_game_enqueue_move(&gateway->game, role, parse_direction(message->direction), now_ms(), &command_sequence) : maze32_game_enqueue_drop_key(&gateway->game, role, now_ms(), &command_sequence); maze32_game_command_result_t processed = {.result = result}; if (result == MAZE32_GAME_RESULT_ACCEPTED) { 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); } if (result == MAZE32_GAME_RESULT_BLOCKED && processed.breakthrough_hits > 0U) { send_breakthrough_impact(gateway, session->connection_id, message->request_id, processed.breakthrough_hits); } else { send_game_error(gateway, session->connection_id, message->request_id, result); } return; } char acknowledgement[160] = ""; char prefix[96]; size_t length = 0U; int written = snprintf(prefix, sizeof(prefix), "{\"v\":1,\"type\":\"ack\",\"seq\":%" PRIu32 ",\"requestId\":", next_server_sequence(gateway)); if (written >= 0 && (size_t)written < sizeof(prefix) && append_text(acknowledgement, sizeof(acknowledgement), &length, prefix) && append_json_string(acknowledgement, sizeof(acknowledgement), &length, message->request_id) && (written = snprintf(prefix, sizeof(prefix), ",\"payload\":{\"commandSequence\":%" PRIu32 ",\"stateSequence\":%" PRIu32 "}}", command_sequence, gateway->game.state_sequence)) >= 0 && (size_t)written < sizeof(prefix) && 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; if (message->type == MAZE32_MESSAGE_JOIN) { if (maze32_lobby_session_for_connection(&gateway->lobby, socket_fd) != NULL) { send_error(gateway, socket_fd, message->request_id, "already_joined", "Сессия уже выбрала роль"); return; } char token[MAZE32_SESSION_TOKEN_HEX_LENGTH + 1U]; generate_token(gateway, token); maze32_session_t* session = NULL; maze32_lobby_result_t result = message->role == MAZE32_ROLE_PLAYER ? maze32_lobby_join_player(&gateway->lobby, socket_fd, message->name, token, &session) : maze32_lobby_join_observer(&gateway->lobby, socket_fd, token, &session); if (result != MAZE32_LOBBY_OK) { send_lobby_error(gateway, socket_fd, message->request_id, result); return; } send_session(gateway, socket_fd, session, message->request_id); broadcast_snapshot(gateway); if (gateway->lobby.phase == MAZE32_LOBBY_ACTIVE) { send_view(gateway, session, 1); } return; } if (message->type == MAZE32_MESSAGE_RESTORE) { if (maze32_lobby_session_for_connection(&gateway->lobby, socket_fd) != NULL) { send_error(gateway, socket_fd, message->request_id, "already_joined", "Сессия уже восстановлена"); return; } maze32_session_t* session = NULL; maze32_lobby_result_t result = maze32_lobby_restore(&gateway->lobby, socket_fd, message->token, &session); if (result != MAZE32_LOBBY_OK) { send_lobby_error(gateway, socket_fd, message->request_id, result); return; } send_session(gateway, socket_fd, session, message->request_id); if (session->role == MAZE32_ROLE_PLAYER && gateway->lobby.phase != MAZE32_LOBBY_WAITING) { maze32_game_phase_t paused_phase = gateway->game.phase; maze32_game_result_t reconnect_result = maze32_game_set_player_connected( &gateway->game, (uint8_t)(session->player_slot - 1U), 1, now_ms()); if (reconnect_result == MAZE32_GAME_RESULT_ACCEPTED) { schedule_reconnect_deadline(gateway); if (paused_phase == MAZE32_GAME_PHASE_PAUSED && gateway->game.phase != MAZE32_GAME_PHASE_PAUSED) { broadcast_match_event(gateway, "resume", "{}"); ESP_LOGI(TAG, "Player %u restored; match resumed", session->player_slot); } if (gateway->game.phase == MAZE32_GAME_PHASE_FINISH_PENDING) { schedule_finish_deadline(gateway); } } } broadcast_snapshot(gateway); if (gateway->lobby.phase == MAZE32_LOBBY_ACTIVE) { if (session->role == MAZE32_ROLE_OBSERVER) { send_view(gateway, session, 1); } else { broadcast_views(gateway, 0); } } return; } const maze32_session_t* session = maze32_lobby_session_for_connection(&gateway->lobby, socket_fd); if (session == NULL) { send_error(gateway, socket_fd, message->request_id, "unauthorized", "Сначала выберите роль или восстановите сессию"); return; } if (message->type == MAZE32_MESSAGE_LEAVE) { maze32_role_t leaving_role = session->role; int cancel_match = leaving_role == MAZE32_ROLE_PLAYER && gateway->lobby.phase != MAZE32_LOBBY_WAITING && gateway->game.phase != MAZE32_GAME_PHASE_FINISHED; char acknowledgement[192] = ""; size_t length = 0U; char prefix[96]; 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\":\"leave\"}}")) { enqueue(gateway, socket_fd, acknowledgement); } if (cancel_match) { esp_timer_stop(gateway->countdown_timer); esp_timer_stop(gateway->reconnect_timer); esp_timer_stop(gateway->finish_timer); initialize_game(gateway); maze32_lobby_reset_match(&gateway->lobby); } maze32_lobby_leave(&gateway->lobby, socket_fd, NULL, NULL); if (cancel_match) { broadcast_match_event(gateway, "reset", "{}"); } broadcast_snapshot(gateway); drain_queue(gateway); return; } if (message->type == MAZE32_MESSAGE_START) { if (session->role == MAZE32_ROLE_OBSERVER) { ESP_LOGW(TAG, "Observer mutation rejected: fd=%d, type=start", socket_fd); } maze32_lobby_result_t result = maze32_lobby_start(&gateway->lobby, socket_fd, now_ms()); if (result != MAZE32_LOBBY_OK) { send_lobby_error(gateway, socket_fd, message->request_id, result); return; } gateway->metrics.matches_started++; sample_resources(gateway, "match_start"); broadcast_snapshot(gateway); broadcast_countdown(gateway); esp_err_t timer_result = esp_timer_start_once(gateway->countdown_timer, MAZE32_COUNTDOWN_DURATION_MS * 1000U); if (timer_result != ESP_OK) { ESP_LOGE(TAG, "Countdown timer could not start: %s", esp_err_to_name(timer_result)); } return; } if (message->type == MAZE32_MESSAGE_MOVE || message->type == MAZE32_MESSAGE_DROP_KEY) { handle_game_command(gateway, session, message); return; } if (message->type == MAZE32_MESSAGE_RESTART) { handle_restart(gateway, session, message); return; } if (message->type == MAZE32_MESSAGE_HEARTBEAT) { int64_t heartbeat_started_us = esp_timer_get_time(); char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = ""; size_t length = 0U; char prefix[128]; char handling_text[32]; int written = snprintf(prefix, sizeof(prefix), "{\"v\":1,\"type\":\"ack\",\"seq\":%" PRIu32, next_server_sequence(gateway)); uint64_t handling_ms = (uint64_t)(esp_timer_get_time() - heartbeat_started_us) / 1000U; if (handling_ms > 1000U) { handling_ms = 1000U; } int handling_written = snprintf(handling_text, sizeof(handling_text), "%" PRIu64, handling_ms); if (written >= 0 && (size_t)written < sizeof(prefix) && handling_written >= 0 && (size_t)handling_written < sizeof(handling_text) && append_text(response, sizeof(response), &length, prefix) && append_text(response, sizeof(response), &length, ",\"requestId\":") && append_json_string(response, sizeof(response), &length, message->request_id) && append_text(response, sizeof(response), &length, ",\"payload\":{\"command\":\"heartbeat\",\"handlingMs\":") && append_text(response, sizeof(response), &length, handling_text) && append_text(response, sizeof(response), &length, "}}")) { enqueue_with_policy(gateway, socket_fd, response, 1); } } } 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); if (connection == NULL) { connection = open_connection(gateway, socket_fd); if (connection == NULL) { ESP_LOGW(TAG, "WebSocket connection limit reached; rejecting fd=%d", socket_fd); shutdown(socket_fd, SHUT_RDWR); return ESP_OK; } } httpd_ws_frame_t frame = {0}; esp_err_t result = httpd_ws_recv_frame(request, &frame, 0U); 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); return ESP_OK; } uint8_t payload[MAZE32_PROTOCOL_MESSAGE_LIMIT + 1U]; frame.payload = payload; result = httpd_ws_recv_frame(request, &frame, MAZE32_PROTOCOL_MESSAGE_LIMIT); if (result != ESP_OK) { return result; } if (frame.type == HTTPD_WS_TYPE_CLOSE) { return ESP_OK; } if (frame.type != HTTPD_WS_TYPE_TEXT) { send_error(gateway, socket_fd, "", "text_required", "Нужен текстовый JSON-пакет"); drain_queue(gateway); return ESP_OK; } 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; } maze32_client_message_t message; 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); return ESP_OK; } if (message.sequence <= connection->last_client_sequence) { send_error(gateway, socket_fd, message.request_id, "stale_sequence", "Команда устарела или уже обработана"); drain_queue(gateway); return ESP_OK; } connection->last_client_sequence = message.sequence; handle_message(gateway, connection, &message); drain_queue(gateway); 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); if (open_connection(gateway, socket_fd) == NULL) { ESP_LOGW(TAG, "WebSocket connection limit reached; rejecting fd=%d", socket_fd); return ESP_ERR_NO_MEM; } return ESP_OK; } esp_err_t maze32_gateway_init(maze32_gateway_t* gateway) { if (gateway == NULL) { return ESP_ERR_INVALID_ARG; } memset(gateway, 0, sizeof(*gateway)); maze32_lobby_init(&gateway->lobby); if (initialize_game(gateway) != ESP_OK) { return ESP_FAIL; } const esp_timer_create_args_t timer_arguments = { .callback = countdown_timer_callback, .arg = gateway, .dispatch_method = ESP_TIMER_TASK, .name = "lobby_countdown", .skip_unhandled_events = true, }; esp_err_t result = esp_timer_create(&timer_arguments, &gateway->countdown_timer); if (result != ESP_OK) { return result; } const esp_timer_create_args_t reconnect_timer_arguments = { .callback = reconnect_timer_callback, .arg = gateway, .dispatch_method = ESP_TIMER_TASK, .name = "reconnect_deadline", .skip_unhandled_events = true, }; result = esp_timer_create(&reconnect_timer_arguments, &gateway->reconnect_timer); if (result != ESP_OK) { esp_timer_delete(gateway->countdown_timer); return result; } 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; } void maze32_gateway_set_server(maze32_gateway_t* gateway, httpd_handle_t server) { if (gateway != NULL) { gateway->server = server; } } esp_err_t maze32_gateway_register(httpd_handle_t server, maze32_gateway_t* gateway) { if (server == NULL || gateway == NULL) { return ESP_ERR_INVALID_ARG; } maze32_gateway_set_server(gateway, server); const httpd_uri_t websocket = { .uri = "/ws", .method = HTTP_GET, .handler = websocket_handler, .user_ctx = gateway, .is_websocket = true, .ws_pre_handshake_cb = websocket_pre_handshake, }; esp_err_t result = httpd_register_uri_handler(server, &websocket); if (result == ESP_OK) { ESP_LOGI(TAG, "WebSocket gateway ready: connections=%u, message=%u, queue=%u", (unsigned int)MAZE32_GATEWAY_CONNECTION_LIMIT, (unsigned int)MAZE32_PROTOCOL_MESSAGE_LIMIT, (unsigned int)MAZE32_GATEWAY_QUEUE_LIMIT); } return result; } void maze32_gateway_close_socket(httpd_handle_t server, int socket_fd) { maze32_gateway_t* gateway = httpd_get_global_user_ctx(server); if (gateway != NULL) { maze32_gateway_connection_t* connection = find_connection(gateway, socket_fd); if (connection != NULL) { const maze32_session_t* session = maze32_lobby_session_for_connection(&gateway->lobby, socket_fd); unsigned int disconnected_slot = 0U; if (session != NULL && session->role == MAZE32_ROLE_PLAYER && gateway->lobby.phase != MAZE32_LOBBY_WAITING) { disconnected_slot = session->player_slot; uint64_t disconnected_at_ms = now_ms(); if (maze32_game_set_player_connected( &gateway->game, (uint8_t)(disconnected_slot - 1U), 0, disconnected_at_ms) == MAZE32_GAME_RESULT_ACCEPTED) { char payload[96]; int written = snprintf( payload, sizeof(payload), "{\"slot\":%u,\"deadlineMs\":%" PRIu64 "}", disconnected_slot, gateway->game.players[disconnected_slot - 1U].reconnect_deadline_ms); if (written >= 0 && (size_t)written < sizeof(payload)) { broadcast_match_event(gateway, "pause", payload); } 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); } } maze32_lobby_disconnect(&gateway->lobby, socket_fd); memset(connection, 0, sizeof(*connection)); broadcast_snapshot(gateway); if (disconnected_slot != 0U) { broadcast_views(gateway, 0); } drain_queue(gateway); sample_resources(gateway, NULL); ESP_LOGI(TAG, "WebSocket disconnected: fd=%d", socket_fd); } } close(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; }