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
+13 -2
View File
@@ -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 500900 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 500900 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
+22 -6
View File
@@ -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; }
+11
View File
@@ -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
+2
View File
@@ -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);
+5
View File
@@ -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");
+1
View File
@@ -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 {
+9 -1
View File
@@ -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;
+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();
+10 -6
View File
@@ -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
+107
View File
@@ -0,0 +1,107 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#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;
}
+5
View File
@@ -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);