feat(observer): add observer mode and disconnect recovery

This commit is contained in:
2026-09-05 21:19:52 +03:00
parent d042c3ce90
commit ffdaa299bc
23 changed files with 988 additions and 43 deletions
@@ -15,10 +15,12 @@ extern "C" {
#define MAZE32_GAME_NO_KEY UINT8_MAX
#define MAZE32_GAME_VISION_MOVES 10U
#define MAZE32_GAME_DRAW_WINDOW_MS 200U
#define MAZE32_GAME_RECONNECT_TIMEOUT_MS 60000U
#define MAZE32_GAME_MAX_ARTIFACTS (2U * MAZE32_MAZE_MAX_ARTIFACT_PAIRS)
#define MAZE32_GAME_MAX_OBJECTS \
(6U + 2U * MAZE32_MAZE_MAX_LANDMARK_PAIRS + 2U * MAZE32_MAZE_MAX_ARTIFACT_PAIRS)
#define MAZE32_GAME_VIEW_CELLS_PER_CHUNK 12U
#define MAZE32_GAME_OBSERVER_FIRST_CHUNK_CELLS 8U
#define MAZE32_GAME_VIEW_OBJECTS_PER_CHUNK 6U
#define MAZE32_GAME_VIEW_CHUNK_BYTES 512U
#define MAZE32_GAME_RAM_BUDGET_BYTES (40U * 1024U)
@@ -28,6 +30,7 @@ typedef enum {
MAZE32_GAME_PHASE_COUNTDOWN = 0,
MAZE32_GAME_PHASE_ACTIVE,
MAZE32_GAME_PHASE_FINISH_PENDING,
MAZE32_GAME_PHASE_PAUSED,
MAZE32_GAME_PHASE_FINISHED,
} maze32_game_phase_t;
@@ -96,6 +99,8 @@ typedef struct {
maze32_game_compass_direction_t compass_hint;
uint8_t has_moved;
uint8_t exited;
uint8_t connected;
uint64_t reconnect_deadline_ms;
} maze32_game_player_t;
typedef enum {
@@ -159,6 +164,8 @@ typedef struct {
uint8_t first_exited_player;
uint8_t has_exit_time[MAZE32_GAME_PLAYER_COUNT];
uint64_t exit_times_ms[MAZE32_GAME_PLAYER_COUNT];
maze32_game_phase_t phase_before_pause;
uint64_t paused_started_ms;
} maze32_game_t;
typedef enum {
@@ -203,6 +210,7 @@ typedef struct {
uint8_t observer_vision_moves_remaining[MAZE32_GAME_PLAYER_COUNT];
maze32_game_compass_direction_t observer_compass_hints[MAZE32_GAME_PLAYER_COUNT];
uint8_t observer_exited[MAZE32_GAME_PLAYER_COUNT];
uint8_t observer_connected[MAZE32_GAME_PLAYER_COUNT];
uint8_t observer_exit_open[MAZE32_GAME_PLAYER_COUNT];
uint8_t observer_has_exit_time[MAZE32_GAME_PLAYER_COUNT];
uint64_t observer_exit_times_ms[MAZE32_GAME_PLAYER_COUNT];
@@ -224,6 +232,8 @@ maze32_game_result_t maze32_game_enqueue_drop_key(maze32_game_t* game, maze32_ga
uint32_t* command_sequence);
maze32_game_command_result_t maze32_game_process_next(maze32_game_t* game);
int maze32_game_advance_time(maze32_game_t* game, uint64_t now_ms);
maze32_game_result_t maze32_game_set_player_connected(maze32_game_t* game, uint8_t player_index,
int connected, uint64_t now_ms);
uint8_t maze32_game_player_view_radius(const maze32_game_t* game, uint8_t player_index);
maze32_game_result_t maze32_game_project_view(const maze32_game_t* game, maze32_game_role_t role,
maze32_view_t* view);
+111 -9
View File
@@ -78,11 +78,13 @@ maze32_game_result_t maze32_game_init(maze32_game_t* game, const maze32_maze_t*
game->maze = maze;
game->config = *config;
game->phase = MAZE32_GAME_PHASE_COUNTDOWN;
game->phase_before_pause = MAZE32_GAME_PHASE_ACTIVE;
game->next_command_sequence = 1U;
game->first_exited_player = MAZE32_GAME_NO_OWNER;
for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) {
game->players[player].position = maze->starts[player];
game->players[player].carried_key = MAZE32_GAME_NO_KEY;
game->players[player].connected = 1U;
game->keys[player].location = MAZE32_GAME_KEY_GROUND;
game->keys[player].ground_position = maze->keys[player];
game->keys[player].carrier = MAZE32_GAME_NO_OWNER;
@@ -93,7 +95,13 @@ maze32_game_result_t maze32_game_init(maze32_game_t* game, const maze32_maze_t*
void maze32_game_set_phase(maze32_game_t* game, maze32_game_phase_t phase) {
if (game != NULL && phase >= MAZE32_GAME_PHASE_COUNTDOWN &&
phase <= MAZE32_GAME_PHASE_FINISHED) {
game->phase = phase;
if (phase == MAZE32_GAME_PHASE_ACTIVE &&
(!game->players[0].connected || !game->players[1].connected)) {
game->phase_before_pause = MAZE32_GAME_PHASE_ACTIVE;
game->phase = MAZE32_GAME_PHASE_PAUSED;
} else {
game->phase = phase;
}
}
}
@@ -139,7 +147,25 @@ maze32_game_result_t maze32_game_enqueue_drop_key(maze32_game_t* game, maze32_ga
}
int maze32_game_advance_time(maze32_game_t* game, uint64_t now_ms) {
if (game == NULL || game->phase != MAZE32_GAME_PHASE_FINISH_PENDING ||
if (game == NULL) {
return 0;
}
if (game->phase == MAZE32_GAME_PHASE_PAUSED) {
for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) {
if (!game->players[player].connected &&
now_ms >= game->players[player].reconnect_deadline_ms) {
game->phase = MAZE32_GAME_PHASE_FINISHED;
game->final_result = MAZE32_GAME_FINAL_DRAW;
game->paused_started_ms = 0U;
game->command_head = 0U;
game->command_count = 0U;
game->state_sequence++;
return 1;
}
}
return 0;
}
if (game->phase != MAZE32_GAME_PHASE_FINISH_PENDING ||
game->first_exited_player >= MAZE32_GAME_PLAYER_COUNT) {
return 0;
}
@@ -154,6 +180,60 @@ int maze32_game_advance_time(maze32_game_t* game, uint64_t now_ms) {
return 1;
}
maze32_game_result_t maze32_game_set_player_connected(maze32_game_t* game, uint8_t player_index,
int connected, uint64_t now_ms) {
if (game == NULL || player_index >= MAZE32_GAME_PLAYER_COUNT) {
return MAZE32_GAME_RESULT_INVALID_ARGUMENT;
}
maze32_game_player_t* player = &game->players[player_index];
uint8_t normalized = connected ? 1U : 0U;
if (player->connected == normalized) {
return MAZE32_GAME_RESULT_ACCEPTED;
}
if (game->phase == MAZE32_GAME_PHASE_FINISHED) {
return MAZE32_GAME_RESULT_MATCH_FINISHED;
}
if (normalized) {
if (game->phase == MAZE32_GAME_PHASE_PAUSED && now_ms >= player->reconnect_deadline_ms) {
maze32_game_advance_time(game, now_ms);
return MAZE32_GAME_RESULT_MATCH_FINISHED;
}
player->connected = 1U;
player->reconnect_deadline_ms = 0U;
if (game->phase == MAZE32_GAME_PHASE_PAUSED && game->players[0].connected &&
game->players[1].connected) {
if (game->phase_before_pause == MAZE32_GAME_PHASE_FINISH_PENDING &&
game->first_exited_player < MAZE32_GAME_PLAYER_COUNT &&
now_ms > game->paused_started_ms) {
uint64_t pause_duration_ms = now_ms - game->paused_started_ms;
uint64_t* exit_time_ms = &game->exit_times_ms[game->first_exited_player];
*exit_time_ms = *exit_time_ms > UINT64_MAX - pause_duration_ms
? UINT64_MAX
: *exit_time_ms + pause_duration_ms;
}
game->phase = game->phase_before_pause;
game->paused_started_ms = 0U;
}
game->state_sequence++;
return MAZE32_GAME_RESULT_ACCEPTED;
}
player->connected = 0U;
player->reconnect_deadline_ms = now_ms > UINT64_MAX - MAZE32_GAME_RECONNECT_TIMEOUT_MS
? UINT64_MAX
: now_ms + MAZE32_GAME_RECONNECT_TIMEOUT_MS;
if (game->phase == MAZE32_GAME_PHASE_COUNTDOWN || game->phase == MAZE32_GAME_PHASE_ACTIVE ||
game->phase == MAZE32_GAME_PHASE_FINISH_PENDING) {
game->phase_before_pause = game->phase;
game->phase = MAZE32_GAME_PHASE_PAUSED;
game->paused_started_ms = now_ms;
game->command_head = 0U;
game->command_count = 0U;
}
game->state_sequence++;
return MAZE32_GAME_RESULT_ACCEPTED;
}
static void accept_player_move(maze32_game_player_t* player, uint64_t submitted_ms) {
player->last_move_ms = submitted_ms;
player->accepted_moves++;
@@ -496,6 +576,7 @@ maze32_game_result_t maze32_game_project_view(const maze32_game_t* game, maze32_
game->players[player].vision_moves_remaining;
view->observer_compass_hints[player] = game->players[player].compass_hint;
view->observer_exited[player] = game->players[player].exited;
view->observer_connected[player] = game->players[player].connected;
view->observer_exit_open[player] = game->exits[player].open;
view->observer_has_exit_time[player] = game->has_exit_time[player];
view->observer_exit_times_ms[player] = game->exit_times_ms[player];
@@ -517,12 +598,24 @@ maze32_game_result_t maze32_game_project_view(const maze32_game_t* game, maze32_
return MAZE32_GAME_RESULT_ACCEPTED;
}
static size_t cell_chunk_count(const maze32_view_t* view) {
if (view->role != MAZE32_GAME_ROLE_OBSERVER || view->cell_count == 0U) {
return (view->cell_count + MAZE32_GAME_VIEW_CELLS_PER_CHUNK - 1U) /
MAZE32_GAME_VIEW_CELLS_PER_CHUNK;
}
if (view->cell_count <= MAZE32_GAME_OBSERVER_FIRST_CHUNK_CELLS) {
return 1U;
}
size_t remaining = view->cell_count - MAZE32_GAME_OBSERVER_FIRST_CHUNK_CELLS;
return 1U +
(remaining + MAZE32_GAME_VIEW_CELLS_PER_CHUNK - 1U) / MAZE32_GAME_VIEW_CELLS_PER_CHUNK;
}
size_t maze32_game_view_chunk_count(const maze32_view_t* view) {
if (view == NULL) {
return 0U;
}
size_t cell_chunks = (view->cell_count + MAZE32_GAME_VIEW_CELLS_PER_CHUNK - 1U) /
MAZE32_GAME_VIEW_CELLS_PER_CHUNK;
size_t cell_chunks = cell_chunk_count(view);
size_t object_chunks = (view->object_count + MAZE32_GAME_VIEW_OBJECTS_PER_CHUNK - 1U) /
MAZE32_GAME_VIEW_OBJECTS_PER_CHUNK;
return cell_chunks + object_chunks;
@@ -556,7 +649,8 @@ static const char* object_name(maze32_view_object_kind_t kind) {
}
static const char* phase_name(maze32_game_phase_t phase) {
static const char* const NAMES[] = {"countdown", "active", "finish_pending", "finished"};
static const char* const NAMES[] = {"countdown", "active", "finish_pending", "paused",
"finished"};
return NAMES[phase];
}
@@ -605,11 +699,12 @@ static int append_observer_status(const maze32_view_t* view, char* output, size_
}
for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) {
if (!append_text(output, output_size, offset,
"%s{\"cell\":%u,\"visionMoves\":%u,\"hint\":\"%s\",\"exited\":%u",
"%s{\"cell\":%u,\"visionMoves\":%u,\"hint\":\"%s\",\"exited\":%u,"
"\"connected\":%u",
player == 0U ? "" : ",", view->observer_player_cell_indices[player],
view->observer_vision_moves_remaining[player],
compass_name(view->observer_compass_hints[player]),
view->observer_exited[player]) ||
view->observer_exited[player], view->observer_connected[player]) ||
!append_nullable_key(output, output_size, offset, "key",
view->observer_carried_keys[player]) ||
!append_text(output, output_size, offset, "}")) {
@@ -669,11 +764,18 @@ maze32_game_result_t maze32_game_serialize_view_chunk(const maze32_view_t* view,
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
}
size_t cell_chunks = (view->cell_count + MAZE32_GAME_VIEW_CELLS_PER_CHUNK - 1U) /
MAZE32_GAME_VIEW_CELLS_PER_CHUNK;
size_t cell_chunks = cell_chunk_count(view);
if (chunk_index < cell_chunks) {
size_t first = chunk_index * MAZE32_GAME_VIEW_CELLS_PER_CHUNK;
if (view->role == MAZE32_GAME_ROLE_OBSERVER) {
first = chunk_index == 0U ? 0U
: MAZE32_GAME_OBSERVER_FIRST_CHUNK_CELLS +
(chunk_index - 1U) * MAZE32_GAME_VIEW_CELLS_PER_CHUNK;
}
size_t end = first + MAZE32_GAME_VIEW_CELLS_PER_CHUNK;
if (view->role == MAZE32_GAME_ROLE_OBSERVER && chunk_index == 0U) {
end = MAZE32_GAME_OBSERVER_FIRST_CHUNK_CELLS;
}
if (end > view->cell_count) {
end = view->cell_count;
}
@@ -19,6 +19,8 @@ typedef enum {
MAZE32_MESSAGE_JOIN,
MAZE32_MESSAGE_RESTORE,
MAZE32_MESSAGE_START,
MAZE32_MESSAGE_MOVE,
MAZE32_MESSAGE_DROP_KEY,
MAZE32_MESSAGE_HEARTBEAT,
MAZE32_MESSAGE_SUGGEST_NAME,
} maze32_message_type_t;
@@ -39,6 +41,7 @@ typedef struct {
maze32_role_t role;
char name[MAZE32_PLAYER_NAME_MAX_BYTES + 1U];
char token[MAZE32_SESSION_TOKEN_HEX_LENGTH + 1U];
char direction[6U];
} maze32_client_message_t;
maze32_protocol_result_t maze32_protocol_parse(const char* data, size_t length,
+18
View File
@@ -123,6 +123,15 @@ static int payload_field_allowed(maze32_message_type_t type, const char* key, ch
*fields |= 1U;
return 1;
}
if (type == MAZE32_MESSAGE_MOVE && strcmp(key, "direction") == 0 && !(*fields & 1U)) {
if (strcmp(value, "north") != 0 && strcmp(value, "east") != 0 &&
strcmp(value, "south") != 0 && strcmp(value, "west") != 0) {
return 0;
}
memcpy(message->direction, value, strlen(value) + 1U);
*fields |= 1U;
return 1;
}
return 0;
}
@@ -159,6 +168,9 @@ static int parse_payload(json_cursor_t* cursor, maze32_client_message_t* message
if (message->type == MAZE32_MESSAGE_RESTORE) {
return fields == 1U;
}
if (message->type == MAZE32_MESSAGE_MOVE) {
return fields == 1U;
}
return fields == 0U;
}
@@ -172,6 +184,12 @@ static maze32_message_type_t message_type(const char* value) {
if (strcmp(value, "start") == 0) {
return MAZE32_MESSAGE_START;
}
if (strcmp(value, "move") == 0) {
return MAZE32_MESSAGE_MOVE;
}
if (strcmp(value, "drop_key") == 0) {
return MAZE32_MESSAGE_DROP_KEY;
}
if (strcmp(value, "heartbeat") == 0) {
return MAZE32_MESSAGE_HEARTBEAT;
}
+1 -1
View File
@@ -1,5 +1,5 @@
idf_component_register(
SRCS "maze32_web.c" "maze32_gateway.c"
INCLUDE_DIRS "include"
REQUIRES esp_http_server esp_timer heap maze32_lobby maze32_platform
REQUIRES esp_http_server esp_timer heap maze32_game maze32_lobby maze32_maze maze32_platform
)
@@ -7,7 +7,9 @@
#include "esp_err.h"
#include "esp_http_server.h"
#include "esp_timer.h"
#include "maze32_game.h"
#include "maze32_lobby.h"
#include "maze32_maze.h"
#ifdef __cplusplus
extern "C" {
@@ -42,6 +44,11 @@ typedef struct {
uint32_t server_sequence;
unsigned int suggestion_index;
esp_timer_handle_t countdown_timer;
esp_timer_handle_t reconnect_timer;
maze32_maze_t maze;
maze32_maze_workspace_t maze_workspace;
maze32_game_t game;
maze32_view_t view;
} maze32_gateway_t;
esp_err_t maze32_gateway_init(maze32_gateway_t* gateway);
+273 -1
View File
@@ -148,6 +148,64 @@ static void drain_queue(maze32_gateway_t* gateway) {
}
}
static maze32_game_role_t game_role_for_session(const maze32_session_t* session) {
if (session->role == MAZE32_ROLE_OBSERVER) {
return MAZE32_GAME_ROLE_OBSERVER;
}
return session->player_slot == 1U ? MAZE32_GAME_ROLE_PLAYER_ONE : MAZE32_GAME_ROLE_PLAYER_TWO;
}
static void send_view(maze32_gateway_t* gateway, const maze32_session_t* session,
int full_observer_view) {
if (session == NULL || !session->connected ||
maze32_game_project_view(&gateway->game, game_role_for_session(session), &gateway->view) !=
MAZE32_GAME_RESULT_ACCEPTED) {
return;
}
if (session->role == MAZE32_ROLE_OBSERVER && !full_observer_view) {
gateway->view.cell_count = 0U;
}
size_t chunks = maze32_game_view_chunk_count(&gateway->view);
for (size_t chunk = 0U; chunk < chunks; ++chunk) {
char payload[MAZE32_GAME_VIEW_CHUNK_BYTES];
size_t written = 0U;
if (maze32_game_serialize_view_chunk(&gateway->view, chunk, payload, sizeof(payload),
&written) != MAZE32_GAME_RESULT_ACCEPTED) {
ESP_LOGE(TAG, "View serialization failed for fd=%d, chunk=%u", session->connection_id,
(unsigned int)chunk);
return;
}
(void)written;
if (!enqueue(gateway, session->connection_id, payload)) {
return;
}
drain_queue(gateway);
}
}
static void broadcast_views(maze32_gateway_t* gateway, int full_observer_view) {
for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) {
send_view(gateway, &gateway->lobby.sessions[index], full_observer_view);
}
}
static void broadcast_match_event(maze32_gateway_t* gateway, const char* type,
const char* payload) {
char response[MAZE32_GATEWAY_OUTGOING_LIMIT];
int written = snprintf(response, sizeof(response),
"{\"v\":1,\"type\":\"%s\",\"seq\":%" PRIu32 ",\"payload\":%s}", type,
next_server_sequence(gateway), payload);
if (written < 0 || (size_t)written >= sizeof(response)) {
return;
}
for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) {
const maze32_session_t* session = &gateway->lobby.sessions[index];
if (session->role != MAZE32_ROLE_NONE && session->connected) {
enqueue(gateway, session->connection_id, response);
}
}
}
static void send_error(maze32_gateway_t* gateway, int socket_fd, const char* request_id,
const char* code, const char* message) {
char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = "";
@@ -399,6 +457,7 @@ static void countdown_finished_work(void* context) {
if (!maze32_lobby_activate_if_due(&gateway->lobby, now_ms())) {
return;
}
maze32_game_set_phase(&gateway->game, MAZE32_GAME_PHASE_ACTIVE);
char response[MAZE32_GATEWAY_OUTGOING_LIMIT];
int written = snprintf(response, sizeof(response),
"{\"v\":1,\"type\":\"active\",\"seq\":%" PRIu32
@@ -413,6 +472,7 @@ static void countdown_finished_work(void* context) {
}
}
broadcast_snapshot(gateway);
broadcast_views(gateway, 1);
drain_queue(gateway);
ESP_LOGI(TAG, "Lobby became active at server time %" PRIu64 " ms",
gateway->lobby.activation_ms);
@@ -428,6 +488,53 @@ static void countdown_timer_callback(void* context) {
}
}
static void schedule_reconnect_deadline(maze32_gateway_t* gateway);
static void reconnect_deadline_work(void* context) {
maze32_gateway_t* gateway = context;
if (maze32_game_advance_time(&gateway->game, now_ms())) {
broadcast_match_event(gateway, "match_result", "{\"result\":\"draw\"}");
broadcast_views(gateway, 0);
drain_queue(gateway);
ESP_LOGI(TAG, "Player reconnect deadline expired; match ended in a draw");
return;
}
schedule_reconnect_deadline(gateway);
}
static void reconnect_timer_callback(void* context) {
maze32_gateway_t* gateway = context;
if (gateway->server != NULL) {
esp_err_t result = httpd_queue_work(gateway->server, reconnect_deadline_work, gateway);
if (result != ESP_OK) {
ESP_LOGE(TAG, "Could not queue reconnect deadline: %s", esp_err_to_name(result));
}
}
}
static void schedule_reconnect_deadline(maze32_gateway_t* gateway) {
esp_timer_stop(gateway->reconnect_timer);
if (gateway->game.phase != MAZE32_GAME_PHASE_PAUSED) {
return;
}
uint64_t deadline = UINT64_MAX;
for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) {
if (!gateway->game.players[player].connected &&
gateway->game.players[player].reconnect_deadline_ms < deadline) {
deadline = gateway->game.players[player].reconnect_deadline_ms;
}
}
if (deadline == UINT64_MAX) {
return;
}
uint64_t current_ms = now_ms();
uint64_t delay_ms = deadline > current_ms ? deadline - current_ms : 1U;
esp_err_t result = esp_timer_start_once(gateway->reconnect_timer, delay_ms * 1000U);
if (result != ESP_OK) {
ESP_LOGE(TAG, "Reconnect timer could not start: %s", esp_err_to_name(result));
}
}
static int rate_limit_allows(maze32_gateway_connection_t* connection, uint64_t current_ms) {
if (current_ms - connection->rate_window_started_ms >= 1000U) {
connection->rate_window_started_ms = current_ms;
@@ -437,6 +544,87 @@ static int rate_limit_allows(maze32_gateway_connection_t* connection, uint64_t c
return connection->messages_in_window <= MAZE32_GATEWAY_RATE_LIMIT_PER_SECOND;
}
static maze32_game_direction_t parse_direction(const char* direction) {
if (strcmp(direction, "north") == 0) {
return MAZE32_GAME_DIRECTION_NORTH;
}
if (strcmp(direction, "east") == 0) {
return MAZE32_GAME_DIRECTION_EAST;
}
if (strcmp(direction, "south") == 0) {
return MAZE32_GAME_DIRECTION_SOUTH;
}
return MAZE32_GAME_DIRECTION_WEST;
}
static void send_game_error(maze32_gateway_t* gateway, int socket_fd, const char* request_id,
maze32_game_result_t result) {
const char* message = "Команда игры отклонена сервером";
if (result == MAZE32_GAME_RESULT_READ_ONLY) {
message = "Наблюдатели смотрят, но не двигают героев";
} else if (result == MAZE32_GAME_RESULT_PREMATURE) {
message = "Матч сейчас на паузе или ещё не начался";
} else if (result == MAZE32_GAME_RESULT_RATE_LIMITED) {
message = "Слишком быстро: лабиринт просит соблюдать интервал";
} else if (result == MAZE32_GAME_RESULT_BLOCKED) {
message = "Там стена. Очень убедительная";
} else if (result == MAZE32_GAME_RESULT_NO_KEY) {
message = "Карман пуст — бросать нечего";
} else if (result == MAZE32_GAME_RESULT_MATCH_FINISHED) {
message = "Гонка уже закончилась";
}
send_error(gateway, socket_fd, request_id, maze32_game_result_name(result), message);
}
static void handle_game_command(maze32_gateway_t* gateway, const maze32_session_t* session,
const maze32_client_message_t* message) {
if (session->role == MAZE32_ROLE_OBSERVER) {
ESP_LOGW(TAG, "Observer mutation rejected: fd=%d, type=%u", session->connection_id,
(unsigned int)message->type);
send_game_error(gateway, session->connection_id, message->request_id,
MAZE32_GAME_RESULT_READ_ONLY);
return;
}
if (gateway->lobby.phase != MAZE32_LOBBY_ACTIVE) {
send_game_error(gateway, session->connection_id, message->request_id,
MAZE32_GAME_RESULT_PREMATURE);
return;
}
uint32_t command_sequence = 0U;
maze32_game_role_t role = game_role_for_session(session);
maze32_game_result_t result =
message->type == MAZE32_MESSAGE_MOVE
? maze32_game_enqueue_move(&gateway->game, role, parse_direction(message->direction),
now_ms(), &command_sequence)
: maze32_game_enqueue_drop_key(&gateway->game, role, now_ms(), &command_sequence);
if (result == MAZE32_GAME_RESULT_ACCEPTED) {
maze32_game_command_result_t processed = maze32_game_process_next(&gateway->game);
result = processed.result;
}
if (result != MAZE32_GAME_RESULT_ACCEPTED) {
send_game_error(gateway, session->connection_id, message->request_id, result);
return;
}
char acknowledgement[160] = "";
char prefix[96];
size_t length = 0U;
int written = snprintf(prefix, sizeof(prefix),
"{\"v\":1,\"type\":\"ack\",\"seq\":%" PRIu32 ",\"requestId\":",
next_server_sequence(gateway));
if (written >= 0 && (size_t)written < sizeof(prefix) &&
append_text(acknowledgement, sizeof(acknowledgement), &length, prefix) &&
append_json_string(acknowledgement, sizeof(acknowledgement), &length,
message->request_id) &&
(written = snprintf(prefix, sizeof(prefix),
",\"payload\":{\"commandSequence\":%" PRIu32 "}}", command_sequence)) >=
0 &&
(size_t)written < sizeof(prefix) &&
append_text(acknowledgement, sizeof(acknowledgement), &length, prefix)) {
enqueue(gateway, session->connection_id, acknowledgement);
}
broadcast_views(gateway, 0);
}
static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_t* connection,
const maze32_client_message_t* message) {
int socket_fd = connection->socket_fd;
@@ -464,6 +652,9 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_
}
send_session(gateway, socket_fd, session, message->request_id);
broadcast_snapshot(gateway);
if (gateway->lobby.phase == MAZE32_LOBBY_ACTIVE) {
send_view(gateway, session, 1);
}
return;
}
if (message->type == MAZE32_MESSAGE_RESTORE) {
@@ -480,7 +671,27 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_
return;
}
send_session(gateway, socket_fd, session, message->request_id);
if (session->role == MAZE32_ROLE_PLAYER && gateway->lobby.phase != MAZE32_LOBBY_WAITING) {
maze32_game_phase_t paused_phase = gateway->game.phase;
maze32_game_result_t reconnect_result = maze32_game_set_player_connected(
&gateway->game, (uint8_t)(session->player_slot - 1U), 1, now_ms());
if (reconnect_result == MAZE32_GAME_RESULT_ACCEPTED) {
schedule_reconnect_deadline(gateway);
if (paused_phase == MAZE32_GAME_PHASE_PAUSED &&
gateway->game.phase != MAZE32_GAME_PHASE_PAUSED) {
broadcast_match_event(gateway, "resume", "{}");
ESP_LOGI(TAG, "Player %u restored; match resumed", session->player_slot);
}
}
}
broadcast_snapshot(gateway);
if (gateway->lobby.phase == MAZE32_LOBBY_ACTIVE) {
if (session->role == MAZE32_ROLE_OBSERVER) {
send_view(gateway, session, 1);
} else {
broadcast_views(gateway, 0);
}
}
return;
}
@@ -492,6 +703,9 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_
return;
}
if (message->type == MAZE32_MESSAGE_START) {
if (session->role == MAZE32_ROLE_OBSERVER) {
ESP_LOGW(TAG, "Observer mutation rejected: fd=%d, type=start", socket_fd);
}
maze32_lobby_result_t result = maze32_lobby_start(&gateway->lobby, socket_fd, now_ms());
if (result != MAZE32_LOBBY_OK) {
send_lobby_error(gateway, socket_fd, message->request_id, result);
@@ -506,6 +720,10 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_
}
return;
}
if (message->type == MAZE32_MESSAGE_MOVE || message->type == MAZE32_MESSAGE_DROP_KEY) {
handle_game_command(gateway, session, message);
return;
}
if (message->type == MAZE32_MESSAGE_HEARTBEAT) {
char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = "";
size_t length = 0U;
@@ -604,6 +822,19 @@ esp_err_t maze32_gateway_init(maze32_gateway_t* gateway) {
}
memset(gateway, 0, sizeof(*gateway));
maze32_lobby_init(&gateway->lobby);
maze32_maze_config_t maze_config = maze32_maze_default_config();
maze32_maze_report_t maze_report;
if (maze32_maze_generate(&gateway->maze, &gateway->maze_workspace, &maze_config, esp_random(),
&maze_report) != MAZE32_MAZE_OK) {
ESP_LOGE(TAG, "Initial maze generation failed: %s",
maze32_maze_result_name(maze_report.result));
return ESP_FAIL;
}
maze32_game_config_t game_config = maze32_game_default_config();
if (maze32_game_init(&gateway->game, &gateway->maze, &game_config) !=
MAZE32_GAME_RESULT_ACCEPTED) {
return ESP_FAIL;
}
const esp_timer_create_args_t timer_arguments = {
.callback = countdown_timer_callback,
.arg = gateway,
@@ -611,7 +842,22 @@ esp_err_t maze32_gateway_init(maze32_gateway_t* gateway) {
.name = "lobby_countdown",
.skip_unhandled_events = true,
};
return esp_timer_create(&timer_arguments, &gateway->countdown_timer);
esp_err_t result = esp_timer_create(&timer_arguments, &gateway->countdown_timer);
if (result != ESP_OK) {
return result;
}
const esp_timer_create_args_t reconnect_timer_arguments = {
.callback = reconnect_timer_callback,
.arg = gateway,
.dispatch_method = ESP_TIMER_TASK,
.name = "reconnect_deadline",
.skip_unhandled_events = true,
};
result = esp_timer_create(&reconnect_timer_arguments, &gateway->reconnect_timer);
if (result != ESP_OK) {
esp_timer_delete(gateway->countdown_timer);
}
return result;
}
void maze32_gateway_set_server(maze32_gateway_t* gateway, httpd_handle_t server) {
@@ -648,9 +894,35 @@ void maze32_gateway_close_socket(httpd_handle_t server, int socket_fd) {
if (gateway != NULL) {
maze32_gateway_connection_t* connection = find_connection(gateway, socket_fd);
if (connection != NULL) {
const maze32_session_t* session =
maze32_lobby_session_for_connection(&gateway->lobby, socket_fd);
unsigned int disconnected_slot = 0U;
if (session != NULL && session->role == MAZE32_ROLE_PLAYER &&
gateway->lobby.phase != MAZE32_LOBBY_WAITING) {
disconnected_slot = session->player_slot;
uint64_t disconnected_at_ms = now_ms();
if (maze32_game_set_player_connected(
&gateway->game, (uint8_t)(disconnected_slot - 1U), 0, disconnected_at_ms) ==
MAZE32_GAME_RESULT_ACCEPTED) {
char payload[96];
int written = snprintf(
payload, sizeof(payload), "{\"slot\":%u,\"deadlineMs\":%" PRIu64 "}",
disconnected_slot,
gateway->game.players[disconnected_slot - 1U].reconnect_deadline_ms);
if (written >= 0 && (size_t)written < sizeof(payload)) {
broadcast_match_event(gateway, "pause", payload);
}
schedule_reconnect_deadline(gateway);
ESP_LOGI(TAG, "Player %u disconnected; match paused for %u ms",
disconnected_slot, (unsigned int)MAZE32_GAME_RECONNECT_TIMEOUT_MS);
}
}
maze32_lobby_disconnect(&gateway->lobby, socket_fd);
memset(connection, 0, sizeof(*connection));
broadcast_snapshot(gateway);
if (disconnected_slot != 0U) {
broadcast_views(gateway, 0);
}
drain_queue(gateway);
ESP_LOGI(TAG, "WebSocket disconnected: fd=%d", socket_fd);
}