feat(observer): add observer mode and disconnect recovery
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user