From c8e0c9168b347beb4bf64fbfccf56deb38a52eb0 Mon Sep 17 00:00:00 2001 From: sasa Date: Sat, 29 Aug 2026 23:33:27 +0300 Subject: [PATCH] feat: complete human-vs-ESP32 gameplay and cumulative statistics --- PLANS.md | 15 +++- data/app.js | 28 +++++-- docs/API_CONTRACT.md | 11 +++ include/application.h | 2 + include/game_lifecycle.h | 5 ++ include/http_api.h | 1 + include/statistics.h | 10 ++- src/application.c | 8 ++ src/game_lifecycle.c | 33 +++++++- src/http_api.c | 22 ++++++ src/main.c | 25 +++++- test/host/Makefile | 16 ++-- test/host/test_bot_game_integration.c | 107 ++++++++++++++++++++++++++ test/host/test_http_api.c | 5 ++ 14 files changed, 269 insertions(+), 19 deletions(-) create mode 100644 test/host/test_bot_game_integration.c diff --git a/PLANS.md b/PLANS.md index 107053f..f2ab968 100644 --- a/PLANS.md +++ b/PLANS.md @@ -780,7 +780,7 @@ If all criteria pass, set this milestone to `DONE`, append its execution record, ## Milestone 015 — Complete human-vs-ESP32 gameplay and cumulative statistics -**Status:** `READY` +**Status:** `DONE` **Depends on:** Milestone 014 ### Objective @@ -808,11 +808,22 @@ Integrate and prove the complete bot game, delayed multi-shot turns, rematch, an If all criteria pass, set this milestone to `DONE`, append its execution record, and change Milestone 016 from `BLOCKED` to `READY`. +### Execution record + +- Date: 2026-08-29 +- Board model and revision: ESP32-C6FH4 QFN32, revision v0.2. +- Toolchain and library versions: PlatformIO Core 6.1.19; `espressif32` 7.0.1; ESP-IDF 6.0.1; `esp_littlefs` 1.20.4. +- Result: PASS for automated integration verification; pending physical-device confirmation. +- Evidence: Integrated the existing bounded bot strategy into the game lifecycle and ESP-IDF one-shot timer. Bot work is queued onto the HTTP-server work context after a 500–900 ms delay, never performed in the timer callback, and is rescheduled only after a bot hit. The lifecycle resets bot knowledge for each new bot match, records bot-shot outcomes without exposing an opponent board to the strategy, and preserves cumulative counters across rematches. Added cumulative misses and sunk ships, exposed bounded match/cumulative counters through `GET /api/statistics`, and rendered Russian ESP32 labels and cumulative result statistics in the browser. +- Measurements: `make -C test/host run` passed command queue, domain, bot strategy, lifecycle, presenter, HTTP API, synchronization, human-game integration, and bot-game integration suites. The bot integration verifies both possible first players, the 500–900 ms scheduled turn, non-duplicated bot progression, complete human-versus-bot finish, exact per-match/cumulative counters, rematch reset with a new game ID, and reboot-scoped reset. `node --check data/app.js`, gzip integrity checks, and compressed JavaScript syntax checks passed. Source/compressed web assets total 33,260 B. `pio run -e esp32-c6-devkitm-1 -t buildfs` and `pio run -e esp32-c6-devkitm-1` passed; firmware uses 39,492 / 327,680 B RAM (12.1%) and 1,018,174 / 2,097,152 B flash (48.6%). +- Issues or deviations: No firmware upload or real-time physical-client bot run was performed. Verify the ESP32 timer delay, reconnect during a pending bot turn, and repeated on-device games after upload. The product rule permits abort only for a disconnected human opponent; bot games retain that locked behavior. +- Next action: Milestone 016 is ready. Do not start it unless explicitly requested. + --- ## Milestone 016 — Harden errors, recovery, and resource usage -**Status:** `BLOCKED` +**Status:** `READY` **Depends on:** Milestone 015 ### Objective diff --git a/data/app.js b/data/app.js index 1cc7a4d..069e095 100644 --- a/data/app.js +++ b/data/app.js @@ -16,6 +16,8 @@ let info; let state; let selectedTarget; + let cumulativeStatistics; + let statisticsGameId; let activeBoard = 'own'; let socket; let retryTimer; @@ -106,7 +108,7 @@ function ownIndex() { return role === 'player2' ? 1 : 0; } function opponentIndex() { return ownIndex() ^ 1; } function canShoot() { return state?.phase === 'in_progress' && state.turn === role && isPlayer(); } - function playerLabel(index) { return `Игрок ${index + 1}`; } + function playerLabel(index) { return index === 1 && state?.mode === 'bot' ? 'ESP32' : `Игрок ${index + 1}`; } function cellInfo(value) { if (value === '1') return ['ship', 'Корабль']; @@ -157,7 +159,7 @@ ]; return [ { index: ownIndex(), title: 'Моё поле', key: 'own', targetable: false }, - { index: opponentIndex(), title: 'Поле соперника', key: 'opponent', targetable: canShoot() } + { index: opponentIndex(), title: state.mode === 'bot' ? 'Поле ESP32' : 'Поле соперника', key: 'opponent', targetable: canShoot() } ]; } @@ -198,7 +200,7 @@ function renderGame() { if (!state) return; showScreen('game'); - ui.turn.textContent = state.turn === role ? 'Ваш ход. Выберите клетку соперника.' : `Ходит ${state.turn === 'player1' ? 'игрок 1' : 'игрок 2'}.`; + ui.turn.textContent = state.turn === role ? 'Ваш ход. Выберите клетку соперника.' : `Ходит ${playerLabel(state.turn === 'player2' ? 1 : 0)}.`; ui.wins.textContent = `Победы: ${state.wins[0]} : ${state.wins[1]}`; renderBoards(ui.boards); const available = canShoot(); @@ -211,10 +213,12 @@ function renderResult() { showScreen('result'); + if (statisticsGameId !== state.gameId) refreshStatistics(); const waiting = state.phase === 'rematch_wait'; - const winner = state.winner === 0 || state.winner === 1 ? `Победил игрок ${state.winner + 1}. ` : ''; - const statistics = state.statistics.map((entry, index) => `Игрок ${index + 1}: выстрелы ${entry[0]}, попадания ${entry[1]}, промахи ${entry[2]}, потоплено ${entry[3]}.`).join(' '); - ui.result.textContent = `${winner}${statistics} ${waiting ? 'Ожидается подтверждение повторной игры.' : 'Поля раскрыты. Можно подтвердить повторную игру.'}`; + const winner = state.winner === 0 || state.winner === 1 ? `Победил ${playerLabel(state.winner)}. ` : ''; + const matchSummary = state.statistics.map((entry, index) => `${playerLabel(index)}: выстрелы ${entry[0]}, попадания ${entry[1]}, промахи ${entry[2]}, потоплено ${entry[3]}.`).join(' '); + const cumulative = cumulativeStatistics?.cumulative?.map((entry, index) => `${playerLabel(index)} всего: игр ${entry[0]}, побед ${entry[1]}, поражений ${entry[2]}, выстрелов ${entry[4]}, попаданий ${entry[5]}, промахов ${entry[6]}, потоплено ${entry[3]}.`).join(' ') || ''; + ui.result.textContent = `${winner}${matchSummary} ${cumulative} ${waiting ? 'Ожидается подтверждение повторной игры.' : 'Поля раскрыты. Можно подтвердить повторную игру.'}`; ui.rematch.hidden = !isPlayer(); ui.rematch.disabled = !isPlayer(); ui.rematch.textContent = waiting ? 'Подтвердить повторно' : 'Сыграть ещё'; @@ -242,6 +246,18 @@ } catch (error) { notify(error.message, true); throw error; } } + async function refreshStatistics() { + const requestedGameId = state?.gameId; + try { + const response = await request('/api/statistics', { cache: 'no-store', headers: token ? { 'X-Session-Token': token } : {} }); + if (state?.gameId === requestedGameId && response.gameId === requestedGameId && Array.isArray(response.cumulative)) { + cumulativeStatistics = response; + statisticsGameId = requestedGameId; + renderResult(); + } + } catch (_) { /* The state snapshot still renders the current-match statistics. */ } + } + async function join(requestedRole) { const name = ui.name.value.trim(); if (!name) { notify('Введите имя.', true); ui.name.focus(); return; } diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 3e8bb24..922b38f 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -55,6 +55,7 @@ Failure (maximum 160 encoded bytes): | `POST /api/game/rematch` | 80 B body | `{token,gameId}`; common envelope | | `POST /api/game/abort` | 80 B body | `{token,gameId}`; common envelope | | `GET /api/state?version=N` | 128 B target | one role-safe state, 512 B | +| `GET /api/statistics` | 128 B target | role and bounded match/cumulative counters | The session token is in each POST body. For `GET /api/state`, it is supplied in `X-Session-Token`; absence creates a spectator-safe view. It is never a URL @@ -75,6 +76,16 @@ contain `1`. `winner` is `null` until `finished`, then player index `0` or `1`. Each compact statistics tuple is `[shots,hits,misses,shipsSunk]`. No other event contains a board. +## Statistics response + +`GET /api/statistics` accepts the same optional `X-Session-Token` as state and +returns no board data. `match` uses `[shots,hits,misses,shipsSunk]` per side; +`cumulative` uses `[games,wins,losses,shipsSunk,shots,hits,misses]` per side. + +```json +{"ok":true,"viewer":"player1","gameId":4,"match":[[3,2,1,1],[4,1,3,0]],"cumulative":[[2,1,1,10,30,15,15],[2,1,1,8,28,14,14]]} +``` + ## WebSocket Endpoint: `GET /ws`; all incoming frames are text JSON, at most 192 B. The diff --git a/include/application.h b/include/application.h index 664211a..42a1178 100644 --- a/include/application.h +++ b/include/application.h @@ -9,6 +9,7 @@ typedef struct { command_queue_t command_queue; game_lifecycle_t lifecycle; } application_t; void application_init(application_t *application, random_source_t random); +void application_set_bot_scheduler(application_t *application, scheduler_t scheduler); bool application_enqueue(application_t *application, const app_command_t *command); bool application_take_next_command(application_t *application, app_command_t *command); lifecycle_result_t application_join(application_t *application, role_t requested_role, const char *name, @@ -18,3 +19,4 @@ lifecycle_result_t application_resume(application_t *application, bool application_session_for_token(const application_t *application, const uint8_t token[kSessionTokenBytes], uint8_t *session_index); lifecycle_result_t application_submit(application_t *application, const app_command_t *command); +bool application_bot_take_turn(application_t *application); diff --git a/include/game_lifecycle.h b/include/game_lifecycle.h index 1edb14f..9338384 100644 --- a/include/game_lifecycle.h +++ b/include/game_lifecycle.h @@ -1,6 +1,7 @@ #pragma once #include "fleet_generator.h" +#include "bot_player.h" #include "game_engine.h" #include "session_manager.h" #include "statistics.h" @@ -28,12 +29,15 @@ typedef struct { session_manager_t sessions; cumulative_statistics_t cumulative[kPlayerCapacity]; random_source_t random; + bot_player_t bot; + scheduler_t bot_scheduler; bool bot_reserved; bool rematch_confirmed[kPlayerCapacity]; uint32_t next_game_id; } game_lifecycle_t; void game_lifecycle_init(game_lifecycle_t *lifecycle, random_source_t random); +void game_lifecycle_set_bot_scheduler(game_lifecycle_t *lifecycle, scheduler_t scheduler); lifecycle_result_t game_lifecycle_join(game_lifecycle_t *lifecycle, role_t requested_role, const char *name, uint8_t *session_index); lifecycle_result_t game_lifecycle_resume(game_lifecycle_t *lifecycle, @@ -47,5 +51,6 @@ lifecycle_result_t game_lifecycle_shot(game_lifecycle_t *lifecycle, uint8_t sess coordinate_t coordinate, shot_result_t *result); lifecycle_result_t game_lifecycle_rematch(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id); lifecycle_result_t game_lifecycle_abort(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id); +bool game_lifecycle_bot_take_turn(game_lifecycle_t *lifecycle); _Static_assert(sizeof(game_lifecycle_t) <= 1600, "lifecycle state grew beyond fixed budget"); diff --git a/include/http_api.h b/include/http_api.h index e11f7b2..ef04ec3 100644 --- a/include/http_api.h +++ b/include/http_api.h @@ -19,6 +19,7 @@ typedef enum { HTTP_API_ROUTE_REMATCH, HTTP_API_ROUTE_ABORT, HTTP_API_ROUTE_STATE, + HTTP_API_ROUTE_STATISTICS, } http_api_route_t; typedef struct { diff --git a/include/statistics.h b/include/statistics.h index 5696d70..09edfa0 100644 --- a/include/statistics.h +++ b/include/statistics.h @@ -4,4 +4,12 @@ #include "game_types.h" -typedef struct { uint16_t games; uint16_t wins; uint16_t losses; uint32_t shots; uint32_t hits; } cumulative_statistics_t; +typedef struct { + uint16_t games; + uint16_t wins; + uint16_t losses; + uint16_t ships_sunk; + uint32_t shots; + uint32_t hits; + uint32_t misses; +} cumulative_statistics_t; diff --git a/src/application.c b/src/application.c index eec7f4b..962efbb 100644 --- a/src/application.c +++ b/src/application.c @@ -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); +} diff --git a/src/game_lifecycle.c b/src/game_lifecycle.c index 6213d0b..3411188 100644 --- a/src/game_lifecycle.c +++ b/src/game_lifecycle.c @@ -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; +} diff --git a/src/http_api.c b/src/http_api.c index c8fe824..494b897 100644 --- a/src/http_api.c +++ b/src/http_api.c @@ -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; diff --git a/src/main.c b/src/main.c index c457946..64ebbe2 100644 --- a/src/main.c +++ b/src/main.c @@ -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(); diff --git a/test/host/Makefile b/test/host/Makefile index 37db7f7..9c50478 100644 --- a/test/host/Makefile +++ b/test/host/Makefile @@ -1,7 +1,7 @@ CC ?= cc CFLAGS ?= -std=c11 -Wall -Wextra -Werror -I../../include -all: test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration +all: test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_command_queue: test_command_queue.c ../../src/command_queue.c $(CC) $(CFLAGS) $^ -o $@ @@ -15,6 +15,7 @@ run: all ./test_http_api ./test_sync_service ./test_human_game_integration + ./test_bot_game_integration test_game_domain: test_game_domain.c ../../src/fleet_generator.c ../../src/game_engine.c $(CC) $(CFLAGS) $^ -o $@ @@ -22,20 +23,23 @@ test_game_domain: test_game_domain.c ../../src/fleet_generator.c ../../src/game_ test_bot_player: test_bot_player.c ../../src/bot_player.c ../../src/fleet_generator.c ../../src/game_engine.c $(CC) $(CFLAGS) $^ -o $@ -test_game_lifecycle: test_game_lifecycle.c ../../src/session_manager.c ../../src/game_lifecycle.c ../../src/fleet_generator.c ../../src/game_engine.c +test_game_lifecycle: test_game_lifecycle.c ../../src/session_manager.c ../../src/game_lifecycle.c ../../src/bot_player.c ../../src/fleet_generator.c ../../src/game_engine.c $(CC) $(CFLAGS) $^ -o $@ test_state_presenter: test_state_presenter.c ../../src/state_presenter.c $(CC) $(CFLAGS) $^ -o $@ -test_http_api: test_http_api.c ../../src/http_api.c ../../src/application.c ../../src/command_queue.c ../../src/session_manager.c ../../src/game_lifecycle.c ../../src/fleet_generator.c ../../src/game_engine.c ../../src/state_presenter.c +test_http_api: test_http_api.c ../../src/http_api.c ../../src/application.c ../../src/command_queue.c ../../src/session_manager.c ../../src/game_lifecycle.c ../../src/bot_player.c ../../src/fleet_generator.c ../../src/game_engine.c ../../src/state_presenter.c $(CC) $(CFLAGS) $^ -o $@ -test_sync_service: test_sync_service.c ../../src/sync_service.c ../../src/application.c ../../src/command_queue.c ../../src/session_manager.c ../../src/game_lifecycle.c ../../src/fleet_generator.c ../../src/game_engine.c ../../src/state_presenter.c +test_sync_service: test_sync_service.c ../../src/sync_service.c ../../src/application.c ../../src/command_queue.c ../../src/session_manager.c ../../src/game_lifecycle.c ../../src/bot_player.c ../../src/fleet_generator.c ../../src/game_engine.c ../../src/state_presenter.c $(CC) $(CFLAGS) $^ -o $@ -test_human_game_integration: test_human_game_integration.c ../../src/http_api.c ../../src/application.c ../../src/command_queue.c ../../src/session_manager.c ../../src/game_lifecycle.c ../../src/fleet_generator.c ../../src/game_engine.c ../../src/state_presenter.c +test_human_game_integration: test_human_game_integration.c ../../src/http_api.c ../../src/application.c ../../src/command_queue.c ../../src/session_manager.c ../../src/game_lifecycle.c ../../src/bot_player.c ../../src/fleet_generator.c ../../src/game_engine.c ../../src/state_presenter.c + $(CC) $(CFLAGS) $^ -o $@ + +test_bot_game_integration: test_bot_game_integration.c ../../src/session_manager.c ../../src/game_lifecycle.c ../../src/bot_player.c ../../src/fleet_generator.c ../../src/game_engine.c $(CC) $(CFLAGS) $^ -o $@ clean: - rm -f test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration + rm -f test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration diff --git a/test/host/test_bot_game_integration.c b/test/host/test_bot_game_integration.c new file mode 100644 index 0000000..bdc9f2e --- /dev/null +++ b/test/host/test_bot_game_integration.c @@ -0,0 +1,107 @@ +#include +#include +#include + +#include "game_lifecycle.h" + +typedef struct { uint32_t value; } test_random_t; +typedef struct { uint32_t delay_ms; uint8_t calls; } test_scheduler_t; + +static uint32_t next_random(void *context) { + test_random_t *random = context; + random->value = random->value * 1664525U + 1013904223U; + return random->value; +} + +static bool schedule_after(void *context, uint32_t delay_ms) { + test_scheduler_t *scheduler = context; + scheduler->delay_ms = delay_ms; + ++scheduler->calls; + return true; +} + +static coordinate_t first_cell(const board_t *board, cell_t cell) { + for (uint8_t index = 0; index < kBoardCellCount; ++index) { + if (board->cells[index] == cell) return (coordinate_t){.x = (uint8_t)(index % kBoardWidth), .y = (uint8_t)(index / kBoardWidth)}; + } + assert(false); + return (coordinate_t){0}; +} + +static void test_bot_game_lifecycle(void) { + test_random_t random = {.value = 101U}; + test_scheduler_t scheduler = {0}; + game_lifecycle_t lifecycle; + game_lifecycle_init(&lifecycle, (random_source_t){.next_u32 = next_random, .context = &random}); + game_lifecycle_set_bot_scheduler(&lifecycle, (scheduler_t){.schedule_after_ms = schedule_after, .context = &scheduler}); + uint8_t player = 0; + assert(game_lifecycle_join(&lifecycle, ROLE_PLAYER_1, "Alice", &player) == LIFECYCLE_RESULT_OK); + const uint32_t game_id = lifecycle.game.state.game_id; + assert(game_lifecycle_configure(&lifecycle, player, game_id, MODE_BOT) == LIFECYCLE_RESULT_OK); + assert(game_lifecycle_start(&lifecycle, player, game_id) == LIFECYCLE_RESULT_OK); + assert(lifecycle.game.state.phase == PHASE_IN_PROGRESS && lifecycle.game.state.mode == MODE_BOT); + + if (lifecycle.game.state.current_player == 0U) { + assert(game_lifecycle_shot(&lifecycle, player, game_id, first_cell(&lifecycle.game.state.boards[1], CELL_WATER), NULL) == LIFECYCLE_RESULT_OK); + } + assert(lifecycle.game.state.current_player == 1U); + assert(lifecycle.bot.turn_pending && scheduler.calls == 1U); + assert(scheduler.delay_ms >= 500U && scheduler.delay_ms <= 900U); + assert(game_lifecycle_bot_take_turn(&lifecycle)); + assert(lifecycle.game.state.statistics[1].shots == 1U); + assert(lifecycle.game.state.statistics[1].shots == lifecycle.game.state.statistics[1].hits + lifecycle.game.state.statistics[1].misses); + + for (uint16_t attempts = 0; attempts < kBoardCellCount && lifecycle.game.state.current_player == 1U && + lifecycle.game.state.phase == PHASE_IN_PROGRESS; ++attempts) { + assert(game_lifecycle_bot_take_turn(&lifecycle)); + } + assert(lifecycle.game.state.phase == PHASE_IN_PROGRESS && lifecycle.game.state.current_player == 0U); + while (lifecycle.game.state.phase == PHASE_IN_PROGRESS) { + assert(game_lifecycle_shot(&lifecycle, player, game_id, first_cell(&lifecycle.game.state.boards[1], CELL_SHIP), NULL) == LIFECYCLE_RESULT_OK); + } + assert(lifecycle.game.state.winner == 0U); + assert(lifecycle.game.state.statistics[0].ships_sunk == kFleetShipCount); + for (uint8_t side = 0; side < kPlayerCapacity; ++side) { + const match_statistics_t *match = &lifecycle.game.state.statistics[side]; + const cumulative_statistics_t *total = &lifecycle.cumulative[side]; + assert(total->games == 1U && total->shots == match->shots && total->hits == match->hits && + total->misses == match->misses && total->ships_sunk == match->ships_sunk); + } + assert(lifecycle.cumulative[0].wins == 1U && lifecycle.cumulative[1].losses == 1U); + + assert(game_lifecycle_rematch(&lifecycle, player, game_id) == LIFECYCLE_RESULT_OK); + assert(lifecycle.game.state.phase == PHASE_IN_PROGRESS && lifecycle.game.state.game_id != game_id); + assert(lifecycle.game.state.statistics[0].shots == 0U && lifecycle.game.state.statistics[1].shots == 0U); + assert(lifecycle.cumulative[0].games == 1U && lifecycle.cumulative[0].wins == 1U); + + game_lifecycle_init(&lifecycle, (random_source_t){.next_u32 = next_random, .context = &random}); + assert(lifecycle.game.state.phase == PHASE_LOBBY && lifecycle.game.state.game_id == 1U); + assert(lifecycle.cumulative[0].games == 0U && lifecycle.cumulative[1].games == 0U); + assert(!lifecycle.sessions.entries[0].occupied && !lifecycle.sessions.entries[1].occupied); +} + +static void test_bot_can_receive_the_first_turn(void) { + for (uint32_t seed = 1U; seed < 100U; ++seed) { + test_random_t random = {.value = seed}; + test_scheduler_t scheduler = {0}; + game_lifecycle_t lifecycle; + game_lifecycle_init(&lifecycle, (random_source_t){.next_u32 = next_random, .context = &random}); + game_lifecycle_set_bot_scheduler(&lifecycle, (scheduler_t){.schedule_after_ms = schedule_after, .context = &scheduler}); + uint8_t player = 0; + assert(game_lifecycle_join(&lifecycle, ROLE_PLAYER_1, "Alice", &player) == LIFECYCLE_RESULT_OK); + assert(game_lifecycle_configure(&lifecycle, player, lifecycle.game.state.game_id, MODE_BOT) == LIFECYCLE_RESULT_OK); + assert(game_lifecycle_start(&lifecycle, player, lifecycle.game.state.game_id) == LIFECYCLE_RESULT_OK); + if (lifecycle.game.state.current_player != 1U) continue; + assert(lifecycle.bot.turn_pending && scheduler.calls == 1U); + assert(scheduler.delay_ms >= 500U && scheduler.delay_ms <= 900U); + return; + } + assert(false); +} + +int main(void) { + test_bot_game_lifecycle(); + test_bot_can_receive_the_first_turn(); + puts("bot game integration tests passed"); + return 0; +} diff --git a/test/host/test_http_api.c b/test/host/test_http_api.c index 7b2b8fd..90c69e6 100644 --- a/test/host/test_http_api.c +++ b/test/host/test_http_api.c @@ -140,6 +140,11 @@ static void test_sessions_commands_and_state(void) { assert(response.status == 200U && strstr(response.body, "\"viewer\":\"player1\"") != NULL); response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, "bad"); expect_code(&response, 401U, "UNAUTHORIZED"); + response = call(&api, HTTP_API_ROUTE_STATISTICS, HTTP_API_GET, NULL, player_1_token); + assert(response.status == 200U && strstr(response.body, "\"viewer\":\"player1\"") != NULL && + strstr(response.body, "\"match\":[[") != NULL && strstr(response.body, "\"cumulative\":[[") != NULL); + response = call(&api, HTTP_API_ROUTE_STATISTICS, HTTP_API_GET, NULL, "bad"); + expect_code(&response, 401U, "UNAUTHORIZED"); snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1,\"x\":10,\"y\":0}", player_1_token); response = call(&api, HTTP_API_ROUTE_SHOT, HTTP_API_POST, command, NULL);