feat: complete human-vs-ESP32 gameplay and cumulative statistics
This commit is contained in:
@@ -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
|
||||
|
||||
+22
-6
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
|
||||
+30
-1
@@ -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,
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user