From 650ade2726244d0a8770df2b9d9c131e77a0782e Mon Sep 17 00:00:00 2001 From: sasa Date: Sat, 29 Aug 2026 21:31:21 +0300 Subject: [PATCH] feat: complete human-vs-human gameplay end to end --- PLANS.md | 15 ++- data/app.js | 12 +- docs/API_CONTRACT.md | 8 +- src/state_presenter.c | 32 ++++- test/host/Makefile | 8 +- test/host/test_human_game_integration.c | 158 ++++++++++++++++++++++++ test/host/test_state_presenter.c | 17 ++- 7 files changed, 237 insertions(+), 13 deletions(-) create mode 100644 test/host/test_human_game_integration.c diff --git a/PLANS.md b/PLANS.md index cce5448..107053f 100644 --- a/PLANS.md +++ b/PLANS.md @@ -738,7 +738,7 @@ If all criteria pass, set this milestone to `DONE`, append its execution record, ## Milestone 014 — Complete human-vs-human gameplay end to end -**Status:** `READY` +**Status:** `DONE` **Depends on:** Milestone 013 ### Objective @@ -765,11 +765,22 @@ Integrate and prove the complete two-device human-vs-human journey before enabli If all criteria pass, set this milestone to `DONE`, append its execution record, and change Milestone 015 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 two-device confirmation. +- Evidence: The role-safe state contract now identifies sunk hits, exposes the authoritative winner after completion, and carries compact per-player match statistics. The Russian result screen renders these values. Added a deterministic host integration journey through the actual HTTP command path: two player joins, spectator join, role-safe board filtering, player and spectator token resume before and during the game, a complete generated human-versus-human game, turn handoff after a miss, all ten ships sunk, finish/revealed boards/statistics, dual rematch confirmation with a new game ID, and disconnected-player abort. Existing state serialization coverage also exercises maximum statistics values within the fixed message buffer. +- Measurements: `make -C test/host run` passed command queue, domain, bot, lifecycle, state-presenter, HTTP API, synchronization, and the new human-game integration suites. `node --check data/app.js`, gzip integrity checks, and compressed JavaScript syntax checks passed. Compressed/source web assets total 31,909 B. `pio run -e esp32-c6-devkitm-1 -t buildfs` and `pio run -e esp32-c6-devkitm-1` passed; firmware uses 39,348 / 327,680 B RAM (12.0%) and 1,015,002 / 2,097,152 B flash (48.4%). +- Issues or deviations: No firmware upload or physical client testing was performed. The two-phone full-game, real Wi-Fi/WebSocket reconnect, and browser-rendered spectator checks remain required device validation steps. +- Next action: Milestone 015 is ready. Do not start it unless explicitly requested. + --- ## Milestone 015 — Complete human-vs-ESP32 gameplay and cumulative statistics -**Status:** `BLOCKED` +**Status:** `READY` **Depends on:** Milestone 014 ### Objective diff --git a/data/app.js b/data/app.js index 9dc6213..1cc7a4d 100644 --- a/data/app.js +++ b/data/app.js @@ -84,8 +84,11 @@ function safeState(payload) { return payload && payload.type === 'state' && Array.isArray(payload.boards) && payload.boards.length === 2 && - payload.boards.every(board => typeof board === 'string' && /^[0123]{100}$/.test(board)) && - typeof payload.version === 'number' && typeof payload.gameId === 'number'; + payload.boards.every(board => typeof board === 'string' && /^[01234]{100}$/.test(board)) && + typeof payload.version === 'number' && typeof payload.gameId === 'number' && + (payload.winner === null || payload.winner === 0 || payload.winner === 1) && Array.isArray(payload.statistics) && + payload.statistics.length === 2 && payload.statistics.every(entry => Array.isArray(entry) && entry.length === 4 && + entry.every(value => Number.isInteger(value) && value >= 0)); } function acceptState(payload) { @@ -109,6 +112,7 @@ if (value === '1') return ['ship', 'Корабль']; if (value === '2') return ['miss', 'Промах']; if (value === '3') return ['hit', 'Попадание']; + if (value === '4') return ['sunk', 'Потопленный корабль']; return ['', 'Вода']; } @@ -208,7 +212,9 @@ function renderResult() { showScreen('result'); const waiting = state.phase === 'rematch_wait'; - ui.result.textContent = waiting ? 'Ожидается подтверждение повторной игры.' : 'Поля раскрыты. Можно подтвердить повторную игру.'; + 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 ? 'Ожидается подтверждение повторной игры.' : 'Поля раскрыты. Можно подтвердить повторную игру.'}`; ui.rematch.hidden = !isPlayer(); ui.rematch.disabled = !isPlayer(); ui.rematch.textContent = waiting ? 'Подтвердить повторно' : 'Сыграть ещё'; diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 19d6578..3e8bb24 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -16,7 +16,7 @@ All numeric fields are decimal JSON integers, never strings. | `gameId`, `version` | unsigned 32-bit integer | | `x`, `y` | integer 0–9 | | `name` | 1–20 scalar values, at most 80 UTF-8 bytes | -| `board` | exactly 100 ASCII cells: `0` unknown/water, `1` own ship, `2` miss, `3` hit | +| `board` | exactly 100 ASCII cells: `0` unknown/water, `1` revealed ship, `2` miss, `3` hit, `4` sunk hit | Every state-changing request has `token` and `gameId`; they must precede any mutation validation. A stale `gameId` is rejected as `STALE_GAME`. @@ -66,12 +66,14 @@ The HTTP state response and WebSocket `state` event use this single 512-byte maximum schema: ```json -{"type":"state","version":17,"gameId":4,"phase":"in_progress","mode":"human","viewer":"player1","turn":"player2","boards":["000...100 cells...","000...100 cells..."],"wins":[0,0]} +{"type":"state","version":17,"gameId":4,"phase":"in_progress","mode":"human","viewer":"player1","turn":"player2","boards":["000...100 cells...","000...100 cells..."],"wins":[0,0],"winner":null,"statistics":[[3,2,1,1],[4,1,3,0]]} ``` `boards[0]` belongs to player 1 and `boards[1]` to player 2. The presenter replaces every unauthorized unhit ship with `0`. In `finished`, both boards may -contain `1`. No other event contains a board. +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. ## WebSocket diff --git a/src/state_presenter.c b/src/state_presenter.c index 4a871c8..3cec3b1 100644 --- a/src/state_presenter.c +++ b/src/state_presenter.c @@ -45,13 +45,36 @@ static char present_cell(cell_t cell, bool reveal_ships) { return cell == CELL_SHIP && reveal_ships ? '1' : '0'; } +static bool cell_is_sunk(const board_t *board, uint8_t cell_index) { + const uint8_t x = (uint8_t)(cell_index % kBoardWidth); + const uint8_t y = (uint8_t)(cell_index / kBoardWidth); + for (uint8_t index = 0; index < kFleetShipCount; ++index) { + const ship_t *ship = &board->ships[index]; + for (uint8_t offset = 0; offset < ship->length; ++offset) { + const uint8_t ship_x = ship->horizontal ? (uint8_t)(ship->x + offset) : ship->x; + const uint8_t ship_y = ship->horizontal ? ship->y : (uint8_t)(ship->y + offset); + if (ship_x == x && ship_y == y) return ship->hits == ship->length; + } + } + return false; +} + static bool append_board(state_writer_t *writer, const board_t *board, bool reveal_ships) { for (uint8_t index = 0; index < kBoardCellCount; ++index) { - if (!append_character(writer, present_cell(board->cells[index], reveal_ships))) return false; + const char cell = board->cells[index] == CELL_HIT && cell_is_sunk(board, index) ? '4' : + present_cell(board->cells[index], reveal_ships); + if (!append_character(writer, cell)) return false; } return true; } +static bool append_statistics(state_writer_t *writer, const match_statistics_t *statistics) { + return append_character(writer, '[') && append_u32(writer, statistics->shots) && + append_character(writer, ',') && append_u32(writer, statistics->hits) && + append_character(writer, ',') && append_u32(writer, statistics->misses) && + append_character(writer, ',') && append_u32(writer, statistics->ships_sunk) && append_character(writer, ']'); +} + static bool write_state(const game_state_t *state, role_t viewer, const cumulative_statistics_t *cumulative, char *output, size_t output_size, size_t *written) { if (written != NULL) *written = 0; @@ -71,7 +94,12 @@ static bool write_state(const game_state_t *state, role_t viewer, const cumulati !append_text(&writer, "\",\"boards\":[\"") || !append_board(&writer, &state->boards[0], reveal[0]) || !append_text(&writer, "\",\"") || !append_board(&writer, &state->boards[1], reveal[1]) || !append_text(&writer, "\"],\"wins\":[") || !append_u32(&writer, wins_0) || - !append_character(&writer, ',') || !append_u32(&writer, wins_1) || !append_text(&writer, "]}")) return false; + !append_character(&writer, ',') || !append_u32(&writer, wins_1) || + !append_text(&writer, "],\"winner\":") || + !(state->winner < kPlayerCapacity ? append_u32(&writer, state->winner) : append_text(&writer, "null")) || + !append_text(&writer, ",\"statistics\":[") || !append_statistics(&writer, &state->statistics[0]) || + !append_character(&writer, ',') || !append_statistics(&writer, &state->statistics[1]) || + !append_text(&writer, "]}")) return false; if (writer.length + 1U > output_size) return false; writer.data[writer.length] = '\0'; memcpy(output, writer.data, writer.length + 1U); diff --git a/test/host/Makefile b/test/host/Makefile index 3fa7761..37db7f7 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 +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_command_queue: test_command_queue.c ../../src/command_queue.c $(CC) $(CFLAGS) $^ -o $@ @@ -14,6 +14,7 @@ run: all ./test_state_presenter ./test_http_api ./test_sync_service + ./test_human_game_integration test_game_domain: test_game_domain.c ../../src/fleet_generator.c ../../src/game_engine.c $(CC) $(CFLAGS) $^ -o $@ @@ -33,5 +34,8 @@ test_http_api: test_http_api.c ../../src/http_api.c ../../src/application.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/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 + $(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 + 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 diff --git a/test/host/test_human_game_integration.c b/test/host/test_human_game_integration.c new file mode 100644 index 0000000..52f832c --- /dev/null +++ b/test/host/test_human_game_integration.c @@ -0,0 +1,158 @@ +#include +#include +#include + +#include "http_api.h" + +typedef struct { uint32_t value; } test_random_t; + +static uint32_t next_random(void *context) { + test_random_t *random = context; + random->value = random->value * 1664525U + 1013904223U; + return random->value; +} + +static http_api_response_t call(http_api_t *api, http_api_route_t route, http_api_method_t method, + const char *body, const char *token) { + http_api_response_t response; + const http_api_request_t request = {.method = method, .route = route, .content_type_json = true, + .body = body, .body_length = body == NULL ? 0U : strlen(body), .session_token = token}; + assert(http_api_handle(api, &request, &response)); + assert(response.body_length == strlen(response.body)); + return response; +} + +static void token_from_response(const http_api_response_t *response, char token[33]) { + const char *start = strstr(response->body, "\"token\":\""); + assert(start != NULL); + memcpy(token, start + strlen("\"token\":\""), 32U); + token[32] = '\0'; +} + +static const char *board_start(const char *json, uint8_t board) { + const char *first = strstr(json, "\"boards\":[\""); + assert(first != NULL); + first += strlen("\"boards\":[\""); + return board == 0U ? first : first + kBoardCellCount + 3U; +} + +static void assert_hidden(const char *json, uint8_t board) { + const char *cells = board_start(json, board); + for (uint8_t index = 0; index < kBoardCellCount; ++index) assert(cells[index] != '1'); +} + +static void resume(http_api_t *api, const char token[33], const char *role) { + char body[48]; + snprintf(body, sizeof(body), "{\"token\":\"%s\"}", token); + const http_api_response_t response = call(api, HTTP_API_ROUTE_RESUME, HTTP_API_POST, body, NULL); + assert(response.status == 200U && strstr(response.body, role) != NULL); +} + +static void command(http_api_t *api, http_api_route_t route, const char token[33], uint32_t game_id) { + char body[80]; + snprintf(body, sizeof(body), "{\"token\":\"%s\",\"gameId\":%u}", token, (unsigned int)game_id); + const http_api_response_t response = call(api, route, HTTP_API_POST, body, NULL); + assert(response.status == 200U); +} + +static void shot(http_api_t *api, const char token[33], uint32_t game_id, coordinate_t coordinate) { + char body[96]; + snprintf(body, sizeof(body), "{\"token\":\"%s\",\"gameId\":%u,\"x\":%u,\"y\":%u}", token, + (unsigned int)game_id, coordinate.x, coordinate.y); + const http_api_response_t response = call(api, HTTP_API_ROUTE_SHOT, HTTP_API_POST, body, NULL); + assert(response.status == 200U); +} + +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_human_game_journey(void) { + test_random_t random = {.value = 57U}; + application_t application; + application_init(&application, (random_source_t){.next_u32 = next_random, .context = &random}); + http_api_t api; + http_api_init(&api, &application); + char player_tokens[2][33]; + + http_api_response_t response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST, + "{\"name\":\"Алиса\",\"requestedRole\":\"player1\"}", NULL); + assert(response.status == 200U); + token_from_response(&response, player_tokens[0]); + response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST, + "{\"name\":\"Борис\",\"requestedRole\":\"player2\"}", NULL); + assert(response.status == 200U); + token_from_response(&response, player_tokens[1]); + response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST, + "{\"name\":\"Зритель\",\"requestedRole\":\"spectator\"}", NULL); + assert(response.status == 200U); + char spectator_token[33]; + token_from_response(&response, spectator_token); + + resume(&api, player_tokens[0], "player1"); + resume(&api, player_tokens[1], "player2"); + resume(&api, spectator_token, "spectator"); + const uint32_t game_id = application.lifecycle.game.state.game_id; + char config[96]; + snprintf(config, sizeof(config), "{\"token\":\"%s\",\"gameId\":%u,\"mode\":\"human\"}", + player_tokens[0], (unsigned int)game_id); + response = call(&api, HTTP_API_ROUTE_CONFIG, HTTP_API_POST, config, NULL); + assert(response.status == 200U); + command(&api, HTTP_API_ROUTE_START, player_tokens[0], game_id); + assert(application.lifecycle.game.state.phase == PHASE_IN_PROGRESS); + + response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, player_tokens[0]); + assert(response.status == 200U && board_start(response.body, 0)[0] <= '3'); + assert_hidden(response.body, 1U); + response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, player_tokens[1]); + assert(response.status == 200U); + assert_hidden(response.body, 0U); + response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, spectator_token); + assert(response.status == 200U); + assert_hidden(response.body, 0U); + assert_hidden(response.body, 1U); + + uint8_t first_player = application.lifecycle.game.state.current_player; + shot(&api, player_tokens[first_player], game_id, first_cell(&application.lifecycle.game.state.boards[first_player ^ 1U], CELL_WATER)); + const uint8_t winner = application.lifecycle.game.state.current_player; + assert(winner == (first_player ^ 1U)); + bool saw_sunk = false; + while (application.lifecycle.game.state.phase == PHASE_IN_PROGRESS) { + const coordinate_t target = first_cell(&application.lifecycle.game.state.boards[winner ^ 1U], CELL_SHIP); + shot(&api, player_tokens[winner], game_id, target); + response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, spectator_token); + saw_sunk = saw_sunk || strchr(board_start(response.body, winner ^ 1U), '4') != NULL; + } + assert(saw_sunk); + assert(application.lifecycle.game.state.winner == winner); + assert(application.lifecycle.game.state.statistics[winner].shots == 20U); + assert(application.lifecycle.game.state.statistics[winner].hits == 20U); + assert(application.lifecycle.game.state.statistics[winner].ships_sunk == 10U); + assert(application.lifecycle.game.state.statistics[first_player].misses == 1U); + response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, spectator_token); + assert(response.status == 200U && strstr(response.body, "\"winner\":") != NULL); + assert(strstr(response.body, "\"statistics\":[") != NULL); + assert(strchr(board_start(response.body, 0U), '1') != NULL || strchr(board_start(response.body, 1U), '1') != NULL); + + resume(&api, player_tokens[0], "player1"); + resume(&api, player_tokens[1], "player2"); + command(&api, HTTP_API_ROUTE_REMATCH, player_tokens[0], game_id); + assert(application.lifecycle.game.state.phase == PHASE_REMATCH_WAIT); + resume(&api, spectator_token, "spectator"); + command(&api, HTTP_API_ROUTE_REMATCH, player_tokens[1], game_id); + assert(application.lifecycle.game.state.phase == PHASE_IN_PROGRESS && application.lifecycle.game.state.game_id != game_id); + + assert(game_lifecycle_disconnect(&application.lifecycle, 1U) == LIFECYCLE_RESULT_OK); + command(&api, HTTP_API_ROUTE_ABORT, player_tokens[0], application.lifecycle.game.state.game_id); + assert(application.lifecycle.game.state.phase == PHASE_LOBBY); +} + +int main(void) { + test_human_game_journey(); + puts("human game integration tests passed"); + return 0; +} diff --git a/test/host/test_state_presenter.c b/test/host/test_state_presenter.c index 8351558..f691bdf 100644 --- a/test/host/test_state_presenter.c +++ b/test/host/test_state_presenter.c @@ -31,6 +31,7 @@ static void test_role_views_and_golden_schema(void) { state.phase = PHASE_IN_PROGRESS; state.mode = MODE_HUMAN; state.current_player = 1U; + state.winner = kPlayerCapacity; set_board(&state.boards[0]); set_board(&state.boards[1]); char output[kStateMessageCapacity] = {0}; @@ -50,6 +51,7 @@ static void test_role_views_and_golden_schema(void) { assert_hidden(output, 0); assert_hidden(output, 1); assert(board_start(output, 0)[1] == '2' && board_start(output, 1)[2] == '3'); + assert(strstr(output, "\"winner\":null,\"statistics\":[[0,0,0,0],[0,0,0,0]]") != NULL); } static void test_finished_and_failure_are_safe(void) { @@ -71,10 +73,23 @@ static void test_finished_and_failure_are_safe(void) { lifecycle.game.state = state; lifecycle.game.state.phase = PHASE_REMATCH_WAIT; lifecycle.game.state.mode = MODE_HUMAN; + lifecycle.game.state.winner = 1U; + lifecycle.game.state.statistics[0] = (match_statistics_t){.shots = 9U, .hits = 4U, .misses = 5U, .ships_sunk = 2U}; + lifecycle.game.state.statistics[1] = (match_statistics_t){.shots = 8U, .hits = 5U, .misses = 3U, .ships_sunk = 3U}; lifecycle.cumulative[0].wins = UINT16_MAX; lifecycle.cumulative[1].wins = UINT16_MAX; assert(state_presenter_write_lifecycle(&lifecycle, ROLE_SPECTATOR, output, sizeof(output), &written)); - assert(written == 371U); + assert(written < sizeof(output)); + assert(strstr(output, "\"winner\":1,\"statistics\":[[9,4,5,2],[8,5,3,3]]") != NULL); + + lifecycle.game.state.statistics[0] = (match_statistics_t){UINT16_MAX, UINT16_MAX, UINT16_MAX, UINT8_MAX}; + lifecycle.game.state.statistics[1] = (match_statistics_t){UINT16_MAX, UINT16_MAX, UINT16_MAX, UINT8_MAX}; + assert(state_presenter_write_lifecycle(&lifecycle, ROLE_SPECTATOR, output, sizeof(output), &written)); + assert(written < sizeof(output)); + + state.boards[0].ships[0] = (ship_t){.x = 2U, .y = 0U, .length = 1U, .hits = 1U, .horizontal = true}; + assert(state_presenter_write(&state, ROLE_SPECTATOR, output, sizeof(output), &written)); + assert(board_start(output, 0)[2] == '4'); char unchanged[16]; memset(unchanged, 'X', sizeof(unchanged));