From 63e33510b313cd5f679fc04a5967b4d0ac2df956 Mon Sep 17 00:00:00 2001 From: sasa Date: Fri, 28 Aug 2026 21:27:55 +0300 Subject: [PATCH] feat: prove MVP capacity and make the Go/No-Go decision --- PLANS.md | 10 +- platformio.ini | 1 + sdkconfig.defaults | 1 + src/main.c | 229 ++++++++++++++++++++++++++++++++++++++++----- 4 files changed, 218 insertions(+), 23 deletions(-) create mode 100644 sdkconfig.defaults diff --git a/PLANS.md b/PLANS.md index 3bda3e1..c5097c5 100644 --- a/PLANS.md +++ b/PLANS.md @@ -268,7 +268,7 @@ If all criteria pass, set this milestone to `DONE`, append its execution record, ## Milestone 004 — Prove MVP capacity and make the Go/No-Go decision -**Status:** `READY` +**Status:** `IN PROGRESS` **Depends on:** Milestone 003 ### Objective @@ -298,6 +298,14 @@ Before running the capacity test, define numerical limits based on the board spe Do not adjust a threshold after seeing the result unless the execution record contains an explicit justification. +### Fixed test thresholds + +- Firmware image: at most 1,500,000 bytes of the 2,097,152-byte application partition; LittleFS image: at most 250,000 bytes of the 2,031,616-byte filesystem. These reserve room for the game engine and complete offline interface. +- Minimum free heap: at least 96,000 bytes throughout the run. Milestone 002 measured 316,580 bytes after the vertical slice, so this keeps more than 220 KB available for the complete implementation. +- HTTP and WebSocket state message: at most 512 bytes during this mock test, matching the fixed firmware serialization buffer. +- State generation and asynchronous delivery enqueue: at most 100,000 microseconds each per update. This leaves substantial margin below the 2-second polling interval. +- Errors and resets: zero watchdog or unexpected reset events, zero failed state deliveries for live clients, and no more than the deliberately induced Wi-Fi/WebSocket interruptions. + ### Acceptance criteria - Two players and eight spectators simultaneously receive the state intended for their roles. diff --git a/platformio.ini b/platformio.ini index a8a9a17..e5bf145 100644 --- a/platformio.ini +++ b/platformio.ini @@ -14,6 +14,7 @@ board = esp32-c6-devkitm-1 framework = espidf board_build.partitions = partitions.csv board_build.filesystem = littlefs +board_build.sdkconfig_defaults = sdkconfig.defaults lib_deps = https://github.com/joltwallet/esp_littlefs.git#v1.20.4 monitor_port = /dev/ttyACM0 monitor_speed = 115200 diff --git a/sdkconfig.defaults b/sdkconfig.defaults new file mode 100644 index 0000000..28720b5 --- /dev/null +++ b/sdkconfig.defaults @@ -0,0 +1 @@ +CONFIG_LWIP_MAX_SOCKETS=16 diff --git a/src/main.c b/src/main.c index d2685b5..8b0e514 100644 --- a/src/main.c +++ b/src/main.c @@ -24,10 +24,21 @@ #endif static const char *const kLogTag = "vertical_slice"; -static const char *const kBuildVersion = "m002"; +static const char *const kBuildVersion = "m004"; static const char *const kLittlefsBasePath = "/littlefs"; static const char *const kLittlefsPartitionLabel = "littlefs"; static const size_t kFileChunkBytes = 1024; +enum { + kCapacityPlayerCount = 2, + kCapacitySpectatorCount = 8, + kCapacitySessionCount = kCapacityPlayerCount + kCapacitySpectatorCount, + kCapacityGames = 20, + kCapacityChangesPerGame = 200, + kCapacityBoardCells = 100, + kCapacityShipsPerBoard = 10, + kCapacityTickMs = 25, + kMaxStateMessageBytes = 512, +}; typedef struct { bool configured; @@ -42,12 +53,40 @@ static wifi_state_t s_wifi_state = {0}; static bool s_littlefs_mounted; static httpd_handle_t s_server; static esp_timer_handle_t s_state_timer; -static uint32_t s_state_version; -static uint32_t s_public_counter; +static portMUX_TYPE s_capacity_lock = portMUX_INITIALIZER_UNLOCKED; typedef enum { VIEW_PLAYER_1, VIEW_PLAYER_2, VIEW_SPECTATOR } view_role_t; typedef struct { int fd; view_role_t role; } ws_client_t; -static ws_client_t s_ws_clients[4]; +static ws_client_t s_ws_clients[kCapacitySessionCount]; + +typedef struct { + uint8_t x; + uint8_t y; + uint8_t length; + bool horizontal; + uint8_t hits; +} mock_ship_t; + +typedef struct { + uint8_t cells[2][kCapacityBoardCells]; + mock_ship_t ships[2][kCapacityShipsPerBoard]; + uint8_t session_ids[kCapacitySessionCount][16]; + uint16_t completed_games; + uint16_t player_wins[kCapacityPlayerCount]; + uint32_t game_id; + uint32_t version; + uint16_t changes_in_game; + uint8_t current_player; + bool running; + uint32_t initial_free_heap_bytes; + uint32_t minimum_free_heap_bytes; + uint32_t maximum_json_bytes; + uint32_t maximum_generation_us; + uint32_t maximum_delivery_enqueue_us; + uint32_t websocket_reconnections; +} capacity_state_t; + +static capacity_state_t s_capacity_state; static view_role_t role_from_query(httpd_req_t *request) { char query[48] = {0}; @@ -66,12 +105,29 @@ static const char *role_name(view_role_t role) { } static void remember_ws_client(int fd, view_role_t role) { + size_t reusable_index = sizeof(s_ws_clients) / sizeof(s_ws_clients[0]); for (size_t index = 0; index < sizeof(s_ws_clients) / sizeof(s_ws_clients[0]); ++index) { - if (s_ws_clients[index].fd == fd || s_ws_clients[index].fd == 0) { - s_ws_clients[index] = (ws_client_t){.fd = fd, .role = role}; + if (s_ws_clients[index].fd == fd) { + s_ws_clients[index].role = role; return; } + if (reusable_index == sizeof(s_ws_clients) / sizeof(s_ws_clients[0]) && + (s_ws_clients[index].fd == 0 || + httpd_ws_get_fd_info(s_server, s_ws_clients[index].fd) != HTTPD_WS_CLIENT_WEBSOCKET)) { + reusable_index = index; + } } + if (reusable_index != sizeof(s_ws_clients) / sizeof(s_ws_clients[0])) { + const bool reconnect = s_ws_clients[reusable_index].fd != 0; + s_ws_clients[reusable_index] = (ws_client_t){.fd = fd, .role = role}; + if (reconnect) { + portENTER_CRITICAL(&s_capacity_lock); + if (s_capacity_state.running) ++s_capacity_state.websocket_reconnections; + portEXIT_CRITICAL(&s_capacity_lock); + } + return; + } + ESP_LOGW(kLogTag, "websocket client table is full"); } static view_role_t role_for_fd(int fd) { @@ -81,16 +137,51 @@ static view_role_t role_for_fd(int fd) { return VIEW_SPECTATOR; } +static char public_cell(uint8_t cell, bool reveal_ships) { + if (cell == 1 && !reveal_ships) return '0'; + return (char)('0' + cell); +} + +static int build_state_payload(char *payload, size_t payload_size, view_role_t role) { + capacity_state_t snapshot; + portENTER_CRITICAL(&s_capacity_lock); + snapshot = s_capacity_state; + portEXIT_CRITICAL(&s_capacity_lock); + + char board_a[kCapacityBoardCells + 1]; + char board_b[kCapacityBoardCells + 1]; + const bool reveal_player_1 = role == VIEW_PLAYER_1; + const bool reveal_player_2 = role == VIEW_PLAYER_2; + for (size_t index = 0; index < kCapacityBoardCells; ++index) { + board_a[index] = public_cell(snapshot.cells[0][index], reveal_player_1); + board_b[index] = public_cell(snapshot.cells[1][index], reveal_player_2); + } + board_a[kCapacityBoardCells] = '\0'; + board_b[kCapacityBoardCells] = '\0'; + return snprintf(payload, payload_size, + "{\"version\":%" PRIu32 ",\"game_id\":%" PRIu32 + ",\"viewer\":\"%s\",\"phase\":\"capacity\",\"current_player\":%u" + ",\"boards\":[\"%s\",\"%s\"],\"wins\":[%u,%u]}", + snapshot.version, snapshot.game_id, role_name(role), snapshot.current_player, + board_a, board_b, snapshot.player_wins[0], snapshot.player_wins[1]); +} + static esp_err_t send_state(httpd_handle_t server, int fd, view_role_t role) { - char payload[128]; - const int length = snprintf(payload, sizeof(payload), - "{\"version\":%" PRIu32 ",\"public_counter\":%" PRIu32 - ",\"viewer\":\"%s\"}", s_state_version, s_public_counter, - role_name(role)); + const int64_t started_us = esp_timer_get_time(); + char payload[kMaxStateMessageBytes]; + const int length = build_state_payload(payload, sizeof(payload), role); if (length < 0 || (size_t)length >= sizeof(payload)) return ESP_FAIL; httpd_ws_frame_t frame = {.final = true, .type = HTTPD_WS_TYPE_TEXT, .payload = (uint8_t *)payload, .len = (size_t)length}; - return httpd_ws_send_frame_async(server, fd, &frame); + const esp_err_t result = httpd_ws_send_frame_async(server, fd, &frame); + const uint32_t elapsed_us = (uint32_t)(esp_timer_get_time() - started_us); + portENTER_CRITICAL(&s_capacity_lock); + if ((uint32_t)length > s_capacity_state.maximum_json_bytes) s_capacity_state.maximum_json_bytes = length; + if (elapsed_us > s_capacity_state.maximum_delivery_enqueue_us) { + s_capacity_state.maximum_delivery_enqueue_us = elapsed_us; + } + portEXIT_CRITICAL(&s_capacity_lock); + return result; } static void broadcast_state(void *unused) { @@ -107,23 +198,109 @@ static void broadcast_state(void *unused) { static void state_timer_callback(void *unused) { (void)unused; - ++s_state_version; - ++s_public_counter; - if (s_server != NULL) httpd_queue_work(s_server, broadcast_state, NULL); + bool changed = false; + const int64_t started_us = esp_timer_get_time(); + portENTER_CRITICAL(&s_capacity_lock); + if (s_capacity_state.running) { + const uint16_t shot = s_capacity_state.changes_in_game++ % kCapacityBoardCells; + const uint8_t target = s_capacity_state.current_player == 0 ? 1 : 0; + s_capacity_state.cells[target][shot] = (shot % 5U == 0U) ? 3 : 2; + ++s_capacity_state.version; + s_capacity_state.current_player = (shot % 5U == 0U) ? s_capacity_state.current_player + : (uint8_t)(1U - s_capacity_state.current_player); + const uint32_t free_heap = esp_get_free_heap_size(); + if (free_heap < s_capacity_state.minimum_free_heap_bytes) { + s_capacity_state.minimum_free_heap_bytes = free_heap; + } + if (s_capacity_state.changes_in_game == kCapacityChangesPerGame) { + ++s_capacity_state.completed_games; + ++s_capacity_state.player_wins[s_capacity_state.completed_games % kCapacityPlayerCount]; + s_capacity_state.changes_in_game = 0; + ++s_capacity_state.game_id; + memset(s_capacity_state.cells, 0, sizeof(s_capacity_state.cells)); + if (s_capacity_state.completed_games == kCapacityGames) s_capacity_state.running = false; + } + changed = true; + } + const uint32_t elapsed_us = (uint32_t)(esp_timer_get_time() - started_us); + if (elapsed_us > s_capacity_state.maximum_generation_us) s_capacity_state.maximum_generation_us = elapsed_us; + portEXIT_CRITICAL(&s_capacity_lock); + if (changed && s_server != NULL) httpd_queue_work(s_server, broadcast_state, NULL); } static esp_err_t state_handler(httpd_req_t *request) { - char payload[128]; - const int length = snprintf(payload, sizeof(payload), - "{\"version\":%" PRIu32 ",\"public_counter\":%" PRIu32 - ",\"viewer\":\"%s\"}", s_state_version, s_public_counter, - role_name(role_from_query(request))); + char payload[kMaxStateMessageBytes]; + const int length = build_state_payload(payload, sizeof(payload), role_from_query(request)); if (length < 0 || (size_t)length >= sizeof(payload)) return ESP_FAIL; httpd_resp_set_type(request, "application/json"); httpd_resp_set_hdr(request, "Cache-Control", "no-store"); return httpd_resp_send(request, payload, length); } +static void make_capacity_state(capacity_state_t *state) { + memset(state, 0, sizeof(*state)); + state->game_id = 1; + state->initial_free_heap_bytes = esp_get_free_heap_size(); + state->minimum_free_heap_bytes = state->initial_free_heap_bytes; + for (size_t player = 0; player < 2; ++player) { + for (size_t ship = 0; ship < kCapacityShipsPerBoard; ++ship) { + state->ships[player][ship] = (mock_ship_t){ + .x = (uint8_t)((ship * 3U) % 10U), .y = (uint8_t)((ship * 7U) % 10U), + .length = (uint8_t)(1U + (ship % 4U)), .horizontal = (ship % 2U) == 0U}; + state->cells[player][ship * 10U] = 1; + } + } + for (size_t session = 0; session < kCapacitySessionCount; ++session) { + for (size_t byte = 0; byte < sizeof(state->session_ids[session]); ++byte) { + state->session_ids[session][byte] = (uint8_t)(session * 17U + byte); + } + } +} + +static esp_err_t capacity_run_handler(httpd_req_t *request) { + if (request->content_len != 0) { + return httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "capacity run accepts no body"); + } + capacity_state_t fresh_state; + make_capacity_state(&fresh_state); + portENTER_CRITICAL(&s_capacity_lock); + if (s_capacity_state.running) { + portEXIT_CRITICAL(&s_capacity_lock); + httpd_resp_set_status(request, "409 Conflict"); + return httpd_resp_send(request, "capacity run already active", HTTPD_RESP_USE_STRLEN); + } + fresh_state.running = true; + s_capacity_state = fresh_state; + portEXIT_CRITICAL(&s_capacity_lock); + httpd_resp_set_type(request, "application/json"); + httpd_resp_set_hdr(request, "Cache-Control", "no-store"); + return httpd_resp_sendstr(request, + "{\"ok\":true,\"games\":20,\"changes_per_game\":200,\"sessions\":10}"); +} + +static esp_err_t capacity_metrics_handler(httpd_req_t *request) { + capacity_state_t snapshot; + portENTER_CRITICAL(&s_capacity_lock); + snapshot = s_capacity_state; + portEXIT_CRITICAL(&s_capacity_lock); + char response[512]; + const int length = snprintf( + response, sizeof(response), + "{\"running\":%s,\"completed_games\":%u,\"changes_in_game\":%u,\"sessions\":10" + ",\"initial_free_heap_bytes\":%" PRIu32 ",\"current_free_heap_bytes\":%u" + ",\"minimum_free_heap_bytes\":%" PRIu32 ",\"maximum_json_bytes\":%" PRIu32 + ",\"maximum_generation_us\":%" PRIu32 ",\"maximum_delivery_enqueue_us\":%" PRIu32 + ",\"websocket_reconnections\":%" PRIu32 ",\"reset_reason\":%d}", + snapshot.running ? "true" : "false", snapshot.completed_games, snapshot.changes_in_game, + snapshot.initial_free_heap_bytes, (unsigned int)esp_get_free_heap_size(), + snapshot.minimum_free_heap_bytes, snapshot.maximum_json_bytes, snapshot.maximum_generation_us, + snapshot.maximum_delivery_enqueue_us, snapshot.websocket_reconnections, (int)esp_reset_reason()); + if (length < 0 || (size_t)length >= sizeof(response)) return ESP_FAIL; + httpd_resp_set_type(request, "application/json"); + httpd_resp_set_hdr(request, "Cache-Control", "no-store"); + return httpd_resp_send(request, response, length); +} + static esp_err_t websocket_handler(httpd_req_t *request) { const int fd = httpd_req_to_sockfd(request); if (request->method == HTTP_GET) { @@ -393,7 +570,8 @@ static esp_err_t static_file_handler(httpd_req_t *request) { static esp_err_t start_http_server(void) { httpd_handle_t server = NULL; httpd_config_t config = HTTPD_DEFAULT_CONFIG(); - config.max_uri_handlers = 5; + config.max_uri_handlers = 7; + config.max_open_sockets = 12; config.uri_match_fn = httpd_uri_match_wildcard; config.lru_purge_enable = true; ESP_RETURN_ON_ERROR(httpd_start(&server, &config), kLogTag, "http server start failed"); @@ -401,12 +579,18 @@ static esp_err_t start_http_server(void) { const httpd_uri_t root = {.uri = "/", .method = HTTP_GET, .handler = root_handler}; const httpd_uri_t health = {.uri = "/api/health", .method = HTTP_GET, .handler = health_handler}; const httpd_uri_t state = {.uri = "/api/state", .method = HTTP_GET, .handler = state_handler}; + const httpd_uri_t capacity_run = {.uri = "/api/capacity/run", .method = HTTP_POST, + .handler = capacity_run_handler}; + const httpd_uri_t capacity_metrics = {.uri = "/api/capacity/metrics", .method = HTTP_GET, + .handler = capacity_metrics_handler}; const httpd_uri_t websocket = {.uri = "/ws", .method = HTTP_GET, .handler = websocket_handler, .is_websocket = true}; const httpd_uri_t static_files = {.uri = "/*", .method = HTTP_GET, .handler = static_file_handler}; ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &root), kLogTag, "root route registration failed"); ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &health), kLogTag, "health route registration failed"); ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &state), kLogTag, "state route registration failed"); + ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &capacity_run), kLogTag, "capacity run route registration failed"); + ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &capacity_metrics), kLogTag, "capacity metrics route registration failed"); ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &websocket), kLogTag, "websocket route registration failed"); ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &static_files), kLogTag, "static route registration failed"); ESP_LOGI(kLogTag, "http server started on port %d", config.server_port); @@ -444,10 +628,11 @@ void app_main(void) { ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_event_loop_create_default()); mount_littlefs(); + make_capacity_state(&s_capacity_state); ESP_ERROR_CHECK(start_http_server()); const esp_timer_create_args_t state_timer_args = {.callback = state_timer_callback, .name = "state_tick"}; ESP_ERROR_CHECK(esp_timer_create(&state_timer_args, &s_state_timer)); - ESP_ERROR_CHECK(esp_timer_start_periodic(s_state_timer, 5000000)); + ESP_ERROR_CHECK(esp_timer_start_periodic(s_state_timer, (uint64_t)kCapacityTickMs * 1000U)); result = start_wifi(); if (result != ESP_OK) { ESP_LOGW(kLogTag, "wifi unavailable: %s", esp_err_to_name(result));