feat: implement safe leave, profile reset, and full game reset semantics
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user