feat: complete human-vs-ESP32 gameplay and cumulative statistics

This commit is contained in:
2026-08-29 23:33:27 +03:00
parent 650ade2726
commit c8e0c9168b
14 changed files with 269 additions and 19 deletions
+8
View File
@@ -8,6 +8,10 @@ void application_init(application_t *application, random_source_t random) {
game_lifecycle_init(&application->lifecycle, random);
}
void application_set_bot_scheduler(application_t *application, scheduler_t scheduler) {
if (application != NULL) game_lifecycle_set_bot_scheduler(&application->lifecycle, scheduler);
}
bool application_enqueue(application_t *application, const app_command_t *command) {
return application != NULL && command_queue_push(&application->command_queue, command);
}
@@ -52,3 +56,7 @@ lifecycle_result_t application_submit(application_t *application, const app_comm
return LIFECYCLE_RESULT_GENERATION_FAILED;
}
}
bool application_bot_take_turn(application_t *application) {
return application != NULL && game_lifecycle_bot_take_turn(&application->lifecycle);
}
+31 -2
View File
@@ -44,6 +44,8 @@ static void record_cumulative(game_lifecycle_t *lifecycle) {
++total->games;
total->shots += match->shots;
total->hits += match->hits;
total->misses += match->misses;
total->ships_sunk += match->ships_sunk;
if (lifecycle->game.state.winner == player) ++total->wins;
else ++total->losses;
}
@@ -56,8 +58,14 @@ static lifecycle_result_t start_current_game(game_lifecycle_t *lifecycle) {
game_engine_init(&lifecycle->game);
lifecycle->game.state.game_id = game_id;
lifecycle->game.state.version = version;
return game_result(game_engine_start(&lifecycle->game, game_id, mode,
&(fleet_generator_t){.random = lifecycle->random}));
const lifecycle_result_t result = game_result(game_engine_start(&lifecycle->game, game_id, mode,
&(fleet_generator_t){.random = lifecycle->random}));
if (result != LIFECYCLE_RESULT_OK || mode != MODE_BOT) return result;
bot_player_init(&lifecycle->bot, lifecycle->random, lifecycle->bot_scheduler);
if (lifecycle->game.state.current_player == 1U && lifecycle->bot_scheduler.schedule_after_ms != NULL) {
(void)bot_player_schedule_turn(&lifecycle->bot);
}
return LIFECYCLE_RESULT_OK;
}
void game_lifecycle_init(game_lifecycle_t *lifecycle, random_source_t random) {
@@ -70,6 +78,11 @@ void game_lifecycle_init(game_lifecycle_t *lifecycle, random_source_t random) {
session_manager_init(&lifecycle->sessions);
}
void game_lifecycle_set_bot_scheduler(game_lifecycle_t *lifecycle, scheduler_t scheduler) {
if (lifecycle == NULL) return;
lifecycle->bot_scheduler = scheduler;
}
lifecycle_result_t game_lifecycle_join(game_lifecycle_t *lifecycle, role_t requested_role, const char *name,
uint8_t *session_index) {
if (lifecycle == NULL) return LIFECYCLE_RESULT_UNAUTHORIZED;
@@ -134,6 +147,8 @@ lifecycle_result_t game_lifecycle_shot(game_lifecycle_t *lifecycle, uint8_t sess
if (!game_id_matches(lifecycle, game_id)) return LIFECYCLE_RESULT_STALE_GAME;
const lifecycle_result_t outcome = game_result(game_engine_shot(&lifecycle->game, player, coordinate, result));
if (outcome == LIFECYCLE_RESULT_OK && lifecycle->game.state.phase == PHASE_FINISHED) record_cumulative(lifecycle);
else if (outcome == LIFECYCLE_RESULT_OK && lifecycle->game.state.mode == MODE_BOT && lifecycle->game.state.current_player == 1U &&
lifecycle->bot_scheduler.schedule_after_ms != NULL) (void)bot_player_schedule_turn(&lifecycle->bot);
return outcome;
}
@@ -170,5 +185,19 @@ lifecycle_result_t game_lifecycle_abort(game_lifecycle_t *lifecycle, uint8_t ses
lifecycle->game.state.game_id = next_game_id;
lifecycle->game.state.version = version;
lifecycle->bot_reserved = false;
bot_player_cancel_turn(&lifecycle->bot);
return LIFECYCLE_RESULT_OK;
}
bool game_lifecycle_bot_take_turn(game_lifecycle_t *lifecycle) {
if (lifecycle == NULL || lifecycle->game.state.phase != PHASE_IN_PROGRESS || lifecycle->game.state.mode != MODE_BOT ||
lifecycle->game.state.current_player != 1U) return false;
coordinate_t coordinate = {0};
shot_result_t result = {0};
if (!bot_player_next_shot(&lifecycle->bot, &coordinate) ||
game_engine_shot(&lifecycle->game, 1U, coordinate, &result) != GAME_RESULT_OK) return false;
bot_player_record_result(&lifecycle->bot, coordinate, &(bot_shot_result_t){.hit = result.hit, .sunk = result.sunk});
if (lifecycle->game.state.phase == PHASE_FINISHED) record_cumulative(lifecycle);
else if (lifecycle->game.state.current_player == 1U) (void)bot_player_schedule_turn(&lifecycle->bot);
return true;
}
+22
View File
@@ -299,6 +299,28 @@ bool http_api_handle(http_api_t *api, const http_api_request_t *request, http_ap
} else response->status = 200U;
return true;
}
if (request->route == HTTP_API_ROUTE_STATISTICS && request->method == HTTP_API_GET) {
role_t viewer = ROLE_SPECTATOR;
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)) {
response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version);
return true;
}
viewer = lifecycle->sessions.entries[session_index].role;
}
const match_statistics_t *match = lifecycle->game.state.statistics;
const cumulative_statistics_t *total = lifecycle->cumulative;
response_write(response, 200U, "{\"ok\":true,\"viewer\":\"%s\",\"gameId\":%" PRIu32
",\"match\":[[%u,%u,%u,%u],[%u,%u,%u,%u]],\"cumulative\":[[%u,%u,%u,%u,%" PRIu32 ",%" PRIu32 ",%" PRIu32
"],[%u,%u,%u,%u,%" PRIu32 ",%" PRIu32 ",%" PRIu32 "]]}", role_text(viewer), lifecycle->game.state.game_id,
match[0].shots, match[0].hits, match[0].misses, match[0].ships_sunk,
match[1].shots, match[1].hits, match[1].misses, match[1].ships_sunk,
total[0].games, total[0].wins, total[0].losses, total[0].ships_sunk, total[0].shots, total[0].hits, total[0].misses,
total[1].games, total[1].wins, total[1].losses, total[1].ships_sunk, total[1].shots, total[1].hits, total[1].misses);
return response->body_length < sizeof(response->body);
}
if (request->route == HTTP_API_ROUTE_JOIN) {
char name[kDisplayNameBytes + 1U] = {0};
role_t role = ROLE_SPECTATOR;
+23 -2
View File
@@ -44,6 +44,7 @@ static http_api_t s_api;
static sync_service_t s_sync;
static uint16_t s_rejected_input;
static esp_timer_handle_t s_sync_timer;
static esp_timer_handle_t s_bot_timer;
#if WIFI_CONFIG_AVAILABLE
static esp_timer_handle_t s_reconnect_timer;
#endif
@@ -110,6 +111,22 @@ static void queue_state_broadcast(void) {
if (s_server != NULL && httpd_queue_work(s_server, sync_broadcast_work, NULL) != ESP_OK) ++s_rejected_input;
}
static bool schedule_bot_turn(void *unused, uint32_t delay_ms) {
(void)unused;
return s_bot_timer != NULL && esp_timer_start_once(s_bot_timer, (uint64_t)delay_ms * 1000U) == ESP_OK;
}
static void bot_turn_work(void *unused) {
(void)unused;
const uint32_t version_before = s_application.lifecycle.game.state.version;
if (application_bot_take_turn(&s_application) && s_application.lifecycle.game.state.version != version_before) queue_state_broadcast();
}
static void bot_timer_callback(void *unused) {
(void)unused;
if (s_server != NULL && httpd_queue_work(s_server, bot_turn_work, NULL) != ESP_OK) ++s_rejected_input;
}
static void sync_expire_work(void *unused) {
(void)unused;
int closed[kSessionCapacity] = {0};
@@ -162,7 +179,7 @@ static esp_err_t api_handler(httpd_req_t *request) {
}
const bool target_too_large = request->method == HTTP_GET && httpd_req_get_url_query_len(request) > 128U;
if (target_too_large) ++s_rejected_input;
if (route == HTTP_API_ROUTE_STATE) {
if (route == HTTP_API_ROUTE_STATE || route == HTTP_API_ROUTE_STATISTICS) {
const size_t token_length = httpd_req_get_hdr_value_len(request, "X-Session-Token");
if (token_length > 0U && token_length < sizeof(token) && httpd_req_get_hdr_value_str(request, "X-Session-Token", token, sizeof(token)) != ESP_OK) token[0] = '\0';
else if (token_length >= sizeof(token)) snprintf(token, sizeof(token), "%s", "invalid");
@@ -328,7 +345,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 = 15U; config.max_open_sockets = kHttpMaxOpenSockets; config.uri_match_fn = httpd_uri_match_wildcard; config.lru_purge_enable = true;
config.max_uri_handlers = 16U; 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},
@@ -342,6 +359,7 @@ static esp_err_t start_http_server(void) {
{.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/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},
{.uri = "/*", .method = HTTP_GET, .handler = static_file_handler},
};
@@ -360,6 +378,9 @@ void app_main(void) {
if (result == ESP_ERR_NVS_NO_FREE_PAGES || result == ESP_ERR_NVS_NEW_VERSION_FOUND) { ESP_ERROR_CHECK(nvs_flash_erase()); result = nvs_flash_init(); }
ESP_ERROR_CHECK(result); ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_event_loop_create_default());
application_init(&s_application, (random_source_t){.next_u32 = platform_random, .context = NULL});
const esp_timer_create_args_t bot_timer_args = {.callback = bot_timer_callback, .name = "bot_turn"};
ESP_ERROR_CHECK(esp_timer_create(&bot_timer_args, &s_bot_timer));
application_set_bot_scheduler(&s_application, (scheduler_t){.schedule_after_ms = schedule_bot_turn, .context = NULL});
http_api_init(&s_api, &s_application);
sync_service_init(&s_sync, &s_application);
mount_littlefs();