1249 lines
53 KiB
C
1249 lines
53 KiB
C
#include "maze32_gateway.h"
|
|
|
|
#include <inttypes.h>
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
#include <sys/select.h>
|
|
#include <sys/socket.h>
|
|
#include <unistd.h>
|
|
|
|
#include "esp_heap_caps.h"
|
|
#include "esp_log.h"
|
|
#include "esp_random.h"
|
|
#include "maze32_protocol.h"
|
|
|
|
static const char* TAG = "maze32_gateway";
|
|
static const char* NAME_SUGGESTIONS[] = {
|
|
"Байт-Беглец", "Кот-Навигатор", "Сэр Тупичок", "Пиксельный Ёж", "Лорд Лабиринт",
|
|
};
|
|
|
|
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 &&
|
|
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;
|
|
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;
|
|
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) {
|
|
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 send_suggestion(maze32_gateway_t* gateway, int socket_fd, const char* request_id) {
|
|
const char* suggestion = NULL;
|
|
size_t suggestion_count = sizeof(NAME_SUGGESTIONS) / sizeof(NAME_SUGGESTIONS[0]);
|
|
for (size_t attempt = 0U; attempt < suggestion_count; ++attempt) {
|
|
size_t index = gateway->suggestion_index++ % suggestion_count;
|
|
int occupied = 0;
|
|
for (size_t session_index = 0U; session_index < MAZE32_SESSION_LIMIT; ++session_index) {
|
|
const maze32_session_t* session = &gateway->lobby.sessions[session_index];
|
|
if (session->role == MAZE32_ROLE_PLAYER &&
|
|
strcmp(session->name, NAME_SUGGESTIONS[index]) == 0) {
|
|
occupied = 1;
|
|
break;
|
|
}
|
|
}
|
|
if (!occupied) {
|
|
suggestion = NAME_SUGGESTIONS[index];
|
|
break;
|
|
}
|
|
}
|
|
if (suggestion == NULL) {
|
|
send_error(gateway, socket_fd, request_id, "no_suggestion",
|
|
"Все смешные имена уже разобрали");
|
|
return;
|
|
}
|
|
char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = "";
|
|
size_t length = 0U;
|
|
char prefix[128];
|
|
int written =
|
|
snprintf(prefix, sizeof(prefix), "{\"v\":1,\"type\":\"name_suggestion\",\"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;
|
|
}
|
|
if (append_text(response, sizeof(response), &length, ",\"payload\":{\"name\":") &&
|
|
append_json_string(response, sizeof(response), &length, suggestion) &&
|
|
append_text(response, sizeof(response), &length, "}}")) {
|
|
enqueue(gateway, socket_fd, response);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
broadcast_snapshot(gateway);
|
|
broadcast_views(gateway, 1);
|
|
drain_queue(gateway);
|
|
ESP_LOGI(TAG, "Lobby became active at server time %" PRIu64 " ms",
|
|
gateway->lobby.activation_ms);
|
|
}
|
|
|
|
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 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);
|
|
}
|
|
send_game_error(gateway, session->connection_id, message->request_id, result);
|
|
return;
|
|
}
|
|
char acknowledgement[160] = "";
|
|
char prefix[96];
|
|
size_t length = 0U;
|
|
int written = snprintf(prefix, sizeof(prefix),
|
|
"{\"v\":1,\"type\":\"ack\",\"seq\":%" PRIu32 ",\"requestId\":",
|
|
next_server_sequence(gateway));
|
|
if (written >= 0 && (size_t)written < sizeof(prefix) &&
|
|
append_text(acknowledgement, sizeof(acknowledgement), &length, prefix) &&
|
|
append_json_string(acknowledgement, sizeof(acknowledgement), &length,
|
|
message->request_id) &&
|
|
(written = snprintf(prefix, sizeof(prefix),
|
|
",\"payload\":{\"commandSequence\":%" PRIu32 "}}", command_sequence)) >=
|
|
0 &&
|
|
(size_t)written < sizeof(prefix) &&
|
|
append_text(acknowledgement, sizeof(acknowledgement), &length, prefix)) {
|
|
enqueue(gateway, session->connection_id, acknowledgement);
|
|
}
|
|
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_SUGGEST_NAME) {
|
|
send_suggestion(gateway, socket_fd, message->request_id);
|
|
return;
|
|
}
|
|
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_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) {
|
|
char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = "";
|
|
size_t length = 0U;
|
|
char prefix[128];
|
|
int written = snprintf(prefix, sizeof(prefix), "{\"v\":1,\"type\":\"ack\",\"seq\":%" PRIu32,
|
|
next_server_sequence(gateway));
|
|
if (written >= 0 && (size_t)written < sizeof(prefix) &&
|
|
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\"}}")) {
|
|
enqueue(gateway, socket_fd, response);
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|