feat: implement safe leave, profile reset, and full game reset semantics

This commit is contained in:
2026-08-30 23:18:07 +03:00
parent c0c3c73c7a
commit a68023e684
16 changed files with 281 additions and 9 deletions
+18
View File
@@ -37,6 +37,14 @@ bool application_session_for_token(const application_t *application,
return application != NULL && session_manager_find(&application->lifecycle.sessions, token, session_index);
}
lifecycle_result_t application_leave(application_t *application, uint8_t session_index, uint32_t game_id,
recovery_reason_t reason) {
if (application == NULL || application->lifecycle.game.state.game_id != game_id) return LIFECYCLE_RESULT_STALE_GAME;
const lifecycle_result_t result = game_lifecycle_leave(&application->lifecycle, session_index);
if (result == LIFECYCLE_RESULT_OK) game_lifecycle_set_recovery_reason(&application->lifecycle, reason);
return result;
}
lifecycle_result_t application_submit(application_t *application, const app_command_t *command) {
if (!application_enqueue(application, command)) return LIFECYCLE_RESULT_GENERATION_FAILED;
app_command_t next = {0};
@@ -52,6 +60,16 @@ lifecycle_result_t application_submit(application_t *application, const app_comm
return game_lifecycle_rematch(&application->lifecycle, next.session_index, next.game_id);
case COMMAND_ABORT:
return game_lifecycle_abort(&application->lifecycle, next.session_index, next.game_id);
case COMMAND_LEAVE:
return application_leave(application, next.session_index, next.game_id, RECOVERY_REASON_SESSION_LEFT);
case COMMAND_PROFILE_RESET:
return application_leave(application, next.session_index, next.game_id, RECOVERY_REASON_PROFILE_RESET);
case COMMAND_RESET:
{
const lifecycle_result_t result = game_lifecycle_reset(&application->lifecycle, next.session_index, next.game_id);
if (result == LIFECYCLE_RESULT_OK) application->command_queue = (command_queue_t){0};
return result;
}
default:
return LIFECYCLE_RESULT_GENERATION_FAILED;
}
+45 -2
View File
@@ -68,11 +68,24 @@ static lifecycle_result_t start_current_game(game_lifecycle_t *lifecycle) {
return LIFECYCLE_RESULT_OK;
}
static void return_to_lobby(game_lifecycle_t *lifecycle) {
const uint32_t version = lifecycle->game.state.version + 1U;
const uint32_t game_id = lifecycle->next_game_id++;
game_engine_init(&lifecycle->game);
lifecycle->game.state.game_id = game_id;
lifecycle->game.state.version = version;
lifecycle->bot_reserved = false;
lifecycle->rematch_confirmed[0] = false;
lifecycle->rematch_confirmed[1] = false;
bot_player_cancel_turn(&lifecycle->bot);
}
void game_lifecycle_init(game_lifecycle_t *lifecycle, random_source_t random) {
if (lifecycle == NULL) return;
memset(lifecycle, 0, sizeof(*lifecycle));
lifecycle->random = random;
lifecycle->next_game_id = 2U;
lifecycle->recovery_generation = 1U;
game_engine_init(&lifecycle->game);
lifecycle->game.state.game_id = 1U;
session_manager_init(&lifecycle->sessions);
@@ -110,8 +123,38 @@ lifecycle_result_t game_lifecycle_disconnect(game_lifecycle_t *lifecycle, uint8_
}
lifecycle_result_t game_lifecycle_leave(game_lifecycle_t *lifecycle, uint8_t session_index) {
return lifecycle == NULL ? LIFECYCLE_RESULT_UNAUTHORIZED :
session_result(session_manager_leave(&lifecycle->sessions, session_index, lifecycle->game.state.phase));
if (lifecycle == NULL || session_index >= kSessionCapacity || !lifecycle->sessions.entries[session_index].occupied) return LIFECYCLE_RESULT_UNAUTHORIZED;
const bool player = session_index < kPlayerCapacity && lifecycle->sessions.entries[session_index].role == (role_t)session_index;
if (player && lifecycle->game.state.phase != PHASE_LOBBY) return_to_lobby(lifecycle);
memset(&lifecycle->sessions.entries[session_index], 0, sizeof(session_t));
++lifecycle->recovery_generation;
lifecycle->recovery_reason = RECOVERY_REASON_SESSION_LEFT;
return LIFECYCLE_RESULT_OK;
}
lifecycle_result_t game_lifecycle_reset(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id) {
uint8_t player = 0;
if (!is_player(lifecycle, session_index, &player)) return LIFECYCLE_RESULT_FORBIDDEN_ROLE;
if (!game_id_matches(lifecycle, game_id)) return LIFECYCLE_RESULT_STALE_GAME;
const uint32_t version = lifecycle->game.state.version + 1U;
const uint32_t next_game_id = lifecycle->next_game_id++;
const random_source_t random = lifecycle->random;
const scheduler_t scheduler = lifecycle->bot_scheduler;
bot_player_cancel_turn(&lifecycle->bot);
memset(lifecycle, 0, sizeof(*lifecycle));
lifecycle->random = random;
lifecycle->bot_scheduler = scheduler;
lifecycle->next_game_id = next_game_id + 1U;
lifecycle->recovery_generation = next_game_id;
lifecycle->recovery_reason = RECOVERY_REASON_GAME_RESET;
game_engine_init(&lifecycle->game);
lifecycle->game.state.game_id = next_game_id;
lifecycle->game.state.version = version;
return LIFECYCLE_RESULT_OK;
}
void game_lifecycle_set_recovery_reason(game_lifecycle_t *lifecycle, recovery_reason_t reason) {
if (lifecycle != NULL) lifecycle->recovery_reason = reason;
}
lifecycle_result_t game_lifecycle_configure(game_lifecycle_t *lifecycle, uint8_t session_index,
+40 -1
View File
@@ -118,6 +118,15 @@ static const char *phase_text(phase_t phase) {
return phase <= PHASE_REMATCH_WAIT ? values[phase] : "lobby";
}
static const char *recovery_reason_text(recovery_reason_t reason) {
switch (reason) {
case RECOVERY_REASON_SESSION_LEFT: return "session_left";
case RECOVERY_REASON_PROFILE_RESET: return "profile_reset";
case RECOVERY_REASON_GAME_RESET: return "game_reset";
default: return "session_invalidated";
}
}
static void response_write(http_api_response_t *response, uint16_t status, const char *format, ...) {
va_list arguments;
va_start(arguments, format);
@@ -153,6 +162,16 @@ static void response_lifecycle_error(http_api_response_t *response, lifecycle_re
}
}
static void response_recovery(http_api_response_t *response, const game_lifecycle_t *lifecycle) {
response_write(response, 200U, "{\"ok\":true,\"resetReason\":\"%s\",\"generation\":%" PRIu32 "}",
recovery_reason_text(lifecycle->recovery_reason), lifecycle->recovery_generation);
}
static void response_invalidated(http_api_response_t *response, const game_lifecycle_t *lifecycle) {
response_write(response, 401U, "{\"ok\":false,\"code\":\"SESSION_INVALIDATED\",\"resetReason\":\"%s\",\"generation\":%" PRIu32 "}",
recovery_reason_text(lifecycle->recovery_reason), lifecycle->recovery_generation);
}
static bool expect_post_body(const http_api_request_t *request, size_t maximum, http_api_response_t *response,
uint32_t version) {
if (request->method != HTTP_API_POST || !request->content_type_json) {
@@ -289,10 +308,14 @@ bool http_api_handle(http_api_t *api, const http_api_request_t *request, http_ap
uint8_t token[kSessionTokenBytes];
uint8_t session_index = 0;
if (request->session_token != NULL && request->session_token[0] != '\0') {
if (!parse_token(request->session_token, token) || !application_session_for_token(api->application, token, &session_index)) {
if (!parse_token(request->session_token, token)) {
response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version);
return true;
}
if (!application_session_for_token(api->application, token, &session_index)) {
response_invalidated(response, lifecycle);
return true;
}
viewer = lifecycle->sessions.entries[session_index].role;
}
if (!state_presenter_write_lifecycle(lifecycle, viewer, response->body, sizeof(response->body), &response->body_length)) {
@@ -374,6 +397,18 @@ bool http_api_handle(http_api_t *api, const http_api_request_t *request, http_ap
return true;
}
if (!application_session_for_token(api->application, token, &command.session_index)) {
if (request->route == HTTP_API_ROUTE_LEAVE || request->route == HTTP_API_ROUTE_PROFILE_RESET) {
response_recovery(response, lifecycle);
return true;
}
if (request->route == HTTP_API_ROUTE_RESET && lifecycle->recovery_reason == RECOVERY_REASON_GAME_RESET) {
response_recovery(response, lifecycle);
return true;
}
if (request->route == HTTP_API_ROUTE_RESET) {
response_error(response, 403U, "FORBIDDEN_ROLE", "Роль не может выполнить действие", version);
return true;
}
response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version);
return true;
}
@@ -383,11 +418,15 @@ bool http_api_handle(http_api_t *api, const http_api_request_t *request, http_ap
case HTTP_API_ROUTE_SHOT: command.type = COMMAND_SHOT; break;
case HTTP_API_ROUTE_REMATCH: command.type = COMMAND_REMATCH; break;
case HTTP_API_ROUTE_ABORT: command.type = COMMAND_ABORT; break;
case HTTP_API_ROUTE_LEAVE: command.type = COMMAND_LEAVE; break;
case HTTP_API_ROUTE_PROFILE_RESET: command.type = COMMAND_PROFILE_RESET; break;
case HTTP_API_ROUTE_RESET: command.type = COMMAND_RESET; break;
default: response_error(response, 404U, "MALFORMED_JSON", "Маршрут не найден", version); return true;
}
command.version = version;
const lifecycle_result_t result = application_submit(api->application, &command);
if (result != LIFECYCLE_RESULT_OK) response_lifecycle_error(response, result, lifecycle->game.state.version);
else if (request->route == HTTP_API_ROUTE_LEAVE || request->route == HTTP_API_ROUTE_PROFILE_RESET || request->route == HTTP_API_ROUTE_RESET) response_recovery(response, lifecycle);
else response_write(response, 200U, "{\"ok\":true,\"version\":%" PRIu32 ",\"gameId\":%" PRIu32 "}",
lifecycle->game.state.version, lifecycle->game.state.game_id);
return true;
+7 -1
View File
@@ -167,11 +167,14 @@ static size_t route_body_limit(http_api_route_t route) {
switch (route) {
case HTTP_API_ROUTE_JOIN: return 192U;
case HTTP_API_ROUTE_RESUME:
case HTTP_API_ROUTE_LEAVE:
case HTTP_API_ROUTE_PROFILE_RESET:
case HTTP_API_ROUTE_CONFIG:
case HTTP_API_ROUTE_SHOT: return 96U;
case HTTP_API_ROUTE_START:
case HTTP_API_ROUTE_REMATCH:
case HTTP_API_ROUTE_ABORT: return 80U;
case HTTP_API_ROUTE_RESET: return 80U;
default: return 0U;
}
}
@@ -363,7 +366,7 @@ static esp_err_t static_file_handler(httpd_req_t *request) {
static esp_err_t start_http_server(void) {
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.max_uri_handlers = 16U; config.max_open_sockets = kHttpMaxOpenSockets; config.uri_match_fn = httpd_uri_match_wildcard; config.lru_purge_enable = true;
config.max_uri_handlers = 18U; config.max_open_sockets = kHttpMaxOpenSockets; config.uri_match_fn = httpd_uri_match_wildcard; config.lru_purge_enable = true;
ESP_RETURN_ON_ERROR(httpd_start(&s_server, &config), kLogTag, "http server start failed");
const httpd_uri_t routes[] = {
{.uri = "/", .method = HTTP_GET, .handler = root_handler},
@@ -371,11 +374,14 @@ static esp_err_t start_http_server(void) {
{.uri = "/api/health", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_HEALTH},
{.uri = "/api/session/join", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_JOIN},
{.uri = "/api/session/resume", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_RESUME},
{.uri = "/api/session/leave", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_LEAVE},
{.uri = "/api/session/profile-reset", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_PROFILE_RESET},
{.uri = "/api/game/config", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_CONFIG},
{.uri = "/api/game/start", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_START},
{.uri = "/api/game/shot", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_SHOT},
{.uri = "/api/game/rematch", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_REMATCH},
{.uri = "/api/game/abort", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_ABORT},
{.uri = "/api/game/reset", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_RESET},
{.uri = "/api/state", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_STATE},
{.uri = "/api/statistics", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_STATISTICS},
{.uri = "/ws", .method = HTTP_GET, .handler = websocket_handler, .is_websocket = true},
+17 -1
View File
@@ -21,6 +21,11 @@ static void response_error(char output[kStateMessageCapacity], size_t *output_le
code, message, version);
}
static const char *recovery_reason_text(recovery_reason_t reason) {
return reason == RECOVERY_REASON_GAME_RESET ? "game_reset" :
reason == RECOVERY_REASON_PROFILE_RESET ? "profile_reset" : "session_left";
}
static bool token_bytes(const char *text, uint8_t token[kSessionTokenBytes]) {
if (text == NULL || strlen(text) != kSessionTokenBytes * 2U) return false;
for (uint8_t index = 0; index < kSessionTokenBytes; ++index) {
@@ -211,9 +216,20 @@ void sync_service_broadcast(sync_service_t *service, sync_send_fn send, void *co
for (uint8_t index = 0; index < kSessionCapacity; ++index) {
sync_connection_t *connection = &service->connections[index];
if (!connection->active || !connection->authenticated) continue;
const session_t *session = &service->application->lifecycle.sessions.entries[connection->session_index];
if (!session->occupied) {
char reset[kStateMessageCapacity] = {0};
size_t reset_length = 0U;
response_write(reset, &reset_length, "{\"type\":\"reset\",\"reason\":\"%s\",\"generation\":%" PRIu32 "}",
recovery_reason_text(service->application->lifecycle.recovery_reason),
service->application->lifecycle.recovery_generation);
(void)send(context, connection->client_id, reset, reset_length);
*connection = (sync_connection_t){0};
continue;
}
char payload[kStateMessageCapacity];
size_t length = 0U;
const role_t role = service->application->lifecycle.sessions.entries[connection->session_index].role;
const role_t role = session->role;
if (!state_presenter_write_lifecycle(&service->application->lifecycle, role, payload, sizeof(payload), &length) ||
!send(context, connection->client_id, payload, length)) *connection = (sync_connection_t){0};
}