diff --git a/PLANS.md b/PLANS.md index ac8e011..e295316 100644 --- a/PLANS.md +++ b/PLANS.md @@ -564,7 +564,7 @@ If all criteria pass, set this milestone to `DONE`, append its execution record, ## Milestone 010 — Implement safe state presentation and bounded serialization -**Status:** `READY` +**Status:** `DONE` **Depends on:** Milestone 009 ### Objective @@ -592,11 +592,22 @@ Produce role-specific state that cannot reveal hidden ships and fits the measure If all criteria pass, set this milestone to `DONE`, append its execution record, and change Milestone 011 from `BLOCKED` to `READY`. +### Execution record + +- Date: 2026-08-28 +- 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. +- Evidence: Added a fixed 512-byte state writer that builds each payload privately and copies it to its caller only on complete success. Player views reveal only their own intact ships; opponents and spectators receive misses and hits only until `FINISHED`, when both boards become public. The presenter emits the locked state schema and lifecycle cumulative wins without serializing session names or retaining per-client state or JSON copies. +- Measurements: `make -C test/host run` passed command queue, domain, bot, lifecycle, and state-presenter suites. Presenter coverage verifies the Player 1, Player 2, spectator, and finished views, locked schema prefix, hidden-cell exclusion, known shot visibility, and unchanged output on insufficient destination capacity. The largest constructed lifecycle payload is 371 B, leaving 141 B (27.5%) below the 512 B hard limit. `pio run -e esp32-c6-devkitm-1` passed with 38,212 / 327,680 B RAM (11.7%) and 1,000,730 / 2,097,152 B flash (47.7%). +- Issues or deviations: HTTP and WebSocket bindings remain Milestones 011 and 012; no transport integration was started. +- Next action: Milestone 011 is ready. Do not start it unless explicitly requested. + --- ## Milestone 011 — Implement the production HTTP API -**Status:** `BLOCKED` +**Status:** `READY` **Depends on:** Milestone 010 ### Objective diff --git a/include/state_presenter.h b/include/state_presenter.h index d47dd7e..0dc7403 100644 --- a/include/state_presenter.h +++ b/include/state_presenter.h @@ -2,6 +2,9 @@ #include +#include "game_lifecycle.h" #include "game_types.h" bool state_presenter_write(const game_state_t *state, role_t viewer, char *output, size_t output_size, size_t *written); +bool state_presenter_write_lifecycle(const game_lifecycle_t *lifecycle, role_t viewer, + char *output, size_t output_size, size_t *written); diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 315f2f1..f039958 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -2,7 +2,7 @@ # without default 'CMakeLists.txt' file. idf_component_register( - SRCS "main.c" "application.c" "command_queue.c" "fleet_generator.c" "game_engine.c" "bot_player.c" "session_manager.c" "game_lifecycle.c" + SRCS "main.c" "application.c" "command_queue.c" "fleet_generator.c" "game_engine.c" "bot_player.c" "session_manager.c" "game_lifecycle.c" "state_presenter.c" INCLUDE_DIRS "../include" REQUIRES esp_event esp_http_server esp_netif esp_wifi esp_littlefs nvs_flash ) diff --git a/src/state_presenter.c b/src/state_presenter.c new file mode 100644 index 0000000..4a871c8 --- /dev/null +++ b/src/state_presenter.c @@ -0,0 +1,90 @@ +#include "state_presenter.h" + +#include +#include + +typedef struct { char data[kStateMessageCapacity]; size_t length; } state_writer_t; + +static bool append_character(state_writer_t *writer, char character) { + if (writer->length + 1U >= sizeof(writer->data)) return false; + writer->data[writer->length++] = character; + return true; +} + +static bool append_text(state_writer_t *writer, const char *text) { + while (*text != '\0') if (!append_character(writer, *text++)) return false; + return true; +} + +static bool append_u32(state_writer_t *writer, uint32_t value) { + char digits[10]; + uint8_t count = 0; + do { digits[count++] = (char)('0' + value % 10U); value /= 10U; } while (value != 0U); + while (count > 0U) if (!append_character(writer, digits[--count])) return false; + return true; +} + +static const char *phase_name(phase_t phase) { + static const char *const names[] = {"lobby", "preparing", "in_progress", "finished", "rematch_wait"}; + return phase <= PHASE_REMATCH_WAIT ? names[phase] : NULL; +} + +static const char *mode_name(game_mode_t mode) { + static const char *const names[] = {"human", "bot"}; + return mode <= MODE_BOT ? names[mode] : NULL; +} + +static const char *role_name(role_t role) { + static const char *const names[] = {"player1", "player2", "spectator"}; + return role <= ROLE_SPECTATOR ? names[role] : NULL; +} + +static char present_cell(cell_t cell, bool reveal_ships) { + if (cell == CELL_MISS) return '2'; + if (cell == CELL_HIT) return '3'; + return cell == CELL_SHIP && reveal_ships ? '1' : '0'; +} + +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; + } + return true; +} + +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; + if (state == NULL || output == NULL || output_size == 0U || phase_name(state->phase) == NULL || + mode_name(state->mode) == NULL || role_name(viewer) == NULL || state->current_player >= kPlayerCapacity) return false; + state_writer_t writer = {0}; + const bool finished = state->phase == PHASE_FINISHED; + const uint16_t wins_0 = cumulative == NULL ? 0U : cumulative[0].wins; + const uint16_t wins_1 = cumulative == NULL ? 0U : cumulative[1].wins; + const bool reveal[2] = {finished || viewer == ROLE_PLAYER_1, finished || viewer == ROLE_PLAYER_2}; + if (!append_text(&writer, "{\"type\":\"state\",\"version\":" ) || !append_u32(&writer, state->version) || + !append_text(&writer, ",\"gameId\":") || !append_u32(&writer, state->game_id) || + !append_text(&writer, ",\"phase\":\"") || !append_text(&writer, phase_name(state->phase)) || + !append_text(&writer, "\",\"mode\":\"") || !append_text(&writer, mode_name(state->mode)) || + !append_text(&writer, "\",\"viewer\":\"") || !append_text(&writer, role_name(viewer)) || + !append_text(&writer, "\",\"turn\":\"") || !append_text(&writer, role_name((role_t)state->current_player)) || + !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; + if (writer.length + 1U > output_size) return false; + writer.data[writer.length] = '\0'; + memcpy(output, writer.data, writer.length + 1U); + if (written != NULL) *written = writer.length; + return true; +} + +bool state_presenter_write(const game_state_t *state, role_t viewer, char *output, size_t output_size, size_t *written) { + return write_state(state, viewer, NULL, output, output_size, written); +} + +bool state_presenter_write_lifecycle(const game_lifecycle_t *lifecycle, role_t viewer, + char *output, size_t output_size, size_t *written) { + return lifecycle != NULL && write_state(&lifecycle->game.state, viewer, lifecycle->cumulative, + output, output_size, written); +} diff --git a/test/host/Makefile b/test/host/Makefile index fc31dc5..2b8af3d 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 +all: test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_command_queue: test_command_queue.c ../../src/command_queue.c ../../src/application.c $(CC) $(CFLAGS) $^ -o $@ @@ -11,6 +11,7 @@ run: all ./test_game_domain ./test_bot_player ./test_game_lifecycle + ./test_state_presenter test_game_domain: test_game_domain.c ../../src/fleet_generator.c ../../src/game_engine.c $(CC) $(CFLAGS) $^ -o $@ @@ -21,5 +22,8 @@ test_bot_player: test_bot_player.c ../../src/bot_player.c ../../src/fleet_genera test_game_lifecycle: test_game_lifecycle.c ../../src/session_manager.c ../../src/game_lifecycle.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 $@ + clean: - rm -f test_command_queue test_game_domain test_bot_player test_game_lifecycle + rm -f test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter diff --git a/test/host/test_state_presenter.c b/test/host/test_state_presenter.c new file mode 100644 index 0000000..8351558 --- /dev/null +++ b/test/host/test_state_presenter.c @@ -0,0 +1,92 @@ +#include +#include +#include + +#include "state_presenter.h" + +static void set_board(board_t *board) { + memset(board, 0, sizeof(*board)); + board->cells[0] = CELL_SHIP; + board->cells[1] = CELL_MISS; + board->cells[2] = CELL_HIT; + board->cells[99] = CELL_SHIP; +} + +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 test_role_views_and_golden_schema(void) { + game_state_t state = {0}; + state.game_id = 42U; + state.version = 9U; + state.phase = PHASE_IN_PROGRESS; + state.mode = MODE_HUMAN; + state.current_player = 1U; + set_board(&state.boards[0]); + set_board(&state.boards[1]); + char output[kStateMessageCapacity] = {0}; + size_t written = 0; + + assert(state_presenter_write(&state, ROLE_PLAYER_1, output, sizeof(output), &written)); + assert(written == strlen(output)); + assert(strstr(output, "{\"type\":\"state\",\"version\":9,\"gameId\":42,\"phase\":\"in_progress\",\"mode\":\"human\",\"viewer\":\"player1\",\"turn\":\"player2\",\"boards\":[\"") == output); + assert(board_start(output, 0)[0] == '1' && board_start(output, 0)[1] == '2' && board_start(output, 0)[2] == '3'); + assert_hidden(output, 1); + + assert(state_presenter_write(&state, ROLE_PLAYER_2, output, sizeof(output), &written)); + assert_hidden(output, 0); + assert(board_start(output, 1)[0] == '1'); + + assert(state_presenter_write(&state, ROLE_SPECTATOR, output, sizeof(output), &written)); + assert_hidden(output, 0); + assert_hidden(output, 1); + assert(board_start(output, 0)[1] == '2' && board_start(output, 1)[2] == '3'); +} + +static void test_finished_and_failure_are_safe(void) { + game_state_t state = {0}; + state.game_id = UINT32_MAX; + state.version = UINT32_MAX; + state.phase = PHASE_FINISHED; + state.mode = MODE_BOT; + state.current_player = 0U; + set_board(&state.boards[0]); + set_board(&state.boards[1]); + char output[kStateMessageCapacity] = {0}; + size_t written = 0; + assert(state_presenter_write(&state, ROLE_SPECTATOR, output, sizeof(output), &written)); + assert(written < sizeof(output)); + assert(board_start(output, 0)[0] == '1' && board_start(output, 1)[99] == '1'); + + game_lifecycle_t lifecycle = {0}; + lifecycle.game.state = state; + lifecycle.game.state.phase = PHASE_REMATCH_WAIT; + lifecycle.game.state.mode = MODE_HUMAN; + 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); + + char unchanged[16]; + memset(unchanged, 'X', sizeof(unchanged)); + size_t failure_written = 99U; + assert(!state_presenter_write(&state, ROLE_PLAYER_1, unchanged, sizeof(unchanged), &failure_written)); + assert(failure_written == 0U); + for (size_t index = 0; index < sizeof(unchanged); ++index) assert(unchanged[index] == 'X'); +} + +int main(void) { + test_role_views_and_golden_schema(); + test_finished_and_failure_are_safe(); + puts("state presenter tests passed"); + return 0; +}