feat: prove MVP capacity and make the Go/No-Go decision

This commit is contained in:
2026-08-28 21:27:55 +03:00
parent ef141358e7
commit 63e33510b3
4 changed files with 218 additions and 23 deletions
+9 -1
View File
@@ -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 ## Milestone 004 — Prove MVP capacity and make the Go/No-Go decision
**Status:** `READY` **Status:** `IN PROGRESS`
**Depends on:** Milestone 003 **Depends on:** Milestone 003
### Objective ### 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. 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 ### Acceptance criteria
- Two players and eight spectators simultaneously receive the state intended for their roles. - Two players and eight spectators simultaneously receive the state intended for their roles.
+1
View File
@@ -14,6 +14,7 @@ board = esp32-c6-devkitm-1
framework = espidf framework = espidf
board_build.partitions = partitions.csv board_build.partitions = partitions.csv
board_build.filesystem = littlefs board_build.filesystem = littlefs
board_build.sdkconfig_defaults = sdkconfig.defaults
lib_deps = https://github.com/joltwallet/esp_littlefs.git#v1.20.4 lib_deps = https://github.com/joltwallet/esp_littlefs.git#v1.20.4
monitor_port = /dev/ttyACM0 monitor_port = /dev/ttyACM0
monitor_speed = 115200 monitor_speed = 115200
+1
View File
@@ -0,0 +1 @@
CONFIG_LWIP_MAX_SOCKETS=16
+207 -22
View File
@@ -24,10 +24,21 @@
#endif #endif
static const char *const kLogTag = "vertical_slice"; 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 kLittlefsBasePath = "/littlefs";
static const char *const kLittlefsPartitionLabel = "littlefs"; static const char *const kLittlefsPartitionLabel = "littlefs";
static const size_t kFileChunkBytes = 1024; 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 { typedef struct {
bool configured; bool configured;
@@ -42,12 +53,40 @@ static wifi_state_t s_wifi_state = {0};
static bool s_littlefs_mounted; static bool s_littlefs_mounted;
static httpd_handle_t s_server; static httpd_handle_t s_server;
static esp_timer_handle_t s_state_timer; static esp_timer_handle_t s_state_timer;
static uint32_t s_state_version; static portMUX_TYPE s_capacity_lock = portMUX_INITIALIZER_UNLOCKED;
static uint32_t s_public_counter;
typedef enum { VIEW_PLAYER_1, VIEW_PLAYER_2, VIEW_SPECTATOR } view_role_t; typedef enum { VIEW_PLAYER_1, VIEW_PLAYER_2, VIEW_SPECTATOR } view_role_t;
typedef struct { int fd; view_role_t role; } ws_client_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) { static view_role_t role_from_query(httpd_req_t *request) {
char query[48] = {0}; char query[48] = {0};
@@ -66,13 +105,30 @@ static const char *role_name(view_role_t role) {
} }
static void remember_ws_client(int fd, 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) { 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) { if (s_ws_clients[index].fd == fd) {
s_ws_clients[index] = (ws_client_t){.fd = fd, .role = role}; s_ws_clients[index].role = role;
return; 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) { static view_role_t role_for_fd(int fd) {
for (size_t index = 0; index < sizeof(s_ws_clients) / sizeof(s_ws_clients[0]); ++index) { for (size_t index = 0; index < sizeof(s_ws_clients) / sizeof(s_ws_clients[0]); ++index) {
@@ -81,16 +137,51 @@ static view_role_t role_for_fd(int fd) {
return VIEW_SPECTATOR; 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) { static esp_err_t send_state(httpd_handle_t server, int fd, view_role_t role) {
char payload[128]; const int64_t started_us = esp_timer_get_time();
const int length = snprintf(payload, sizeof(payload), char payload[kMaxStateMessageBytes];
"{\"version\":%" PRIu32 ",\"public_counter\":%" PRIu32 const int length = build_state_payload(payload, sizeof(payload), role);
",\"viewer\":\"%s\"}", s_state_version, s_public_counter,
role_name(role));
if (length < 0 || (size_t)length >= sizeof(payload)) return ESP_FAIL; if (length < 0 || (size_t)length >= sizeof(payload)) return ESP_FAIL;
httpd_ws_frame_t frame = {.final = true, .type = HTTPD_WS_TYPE_TEXT, httpd_ws_frame_t frame = {.final = true, .type = HTTPD_WS_TYPE_TEXT,
.payload = (uint8_t *)payload, .len = (size_t)length}; .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) { static void broadcast_state(void *unused) {
@@ -107,23 +198,109 @@ static void broadcast_state(void *unused) {
static void state_timer_callback(void *unused) { static void state_timer_callback(void *unused) {
(void)unused; (void)unused;
++s_state_version; bool changed = false;
++s_public_counter; const int64_t started_us = esp_timer_get_time();
if (s_server != NULL) httpd_queue_work(s_server, broadcast_state, NULL); 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) { static esp_err_t state_handler(httpd_req_t *request) {
char payload[128]; char payload[kMaxStateMessageBytes];
const int length = snprintf(payload, sizeof(payload), const int length = build_state_payload(payload, sizeof(payload), role_from_query(request));
"{\"version\":%" PRIu32 ",\"public_counter\":%" PRIu32
",\"viewer\":\"%s\"}", s_state_version, s_public_counter,
role_name(role_from_query(request)));
if (length < 0 || (size_t)length >= sizeof(payload)) return ESP_FAIL; if (length < 0 || (size_t)length >= sizeof(payload)) return ESP_FAIL;
httpd_resp_set_type(request, "application/json"); httpd_resp_set_type(request, "application/json");
httpd_resp_set_hdr(request, "Cache-Control", "no-store"); httpd_resp_set_hdr(request, "Cache-Control", "no-store");
return httpd_resp_send(request, payload, length); 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) { static esp_err_t websocket_handler(httpd_req_t *request) {
const int fd = httpd_req_to_sockfd(request); const int fd = httpd_req_to_sockfd(request);
if (request->method == HTTP_GET) { 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) { static esp_err_t start_http_server(void) {
httpd_handle_t server = NULL; httpd_handle_t server = NULL;
httpd_config_t config = HTTPD_DEFAULT_CONFIG(); 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.uri_match_fn = httpd_uri_match_wildcard;
config.lru_purge_enable = true; config.lru_purge_enable = true;
ESP_RETURN_ON_ERROR(httpd_start(&server, &config), kLogTag, "http server start failed"); 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 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 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 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, const httpd_uri_t websocket = {.uri = "/ws", .method = HTTP_GET, .handler = websocket_handler,
.is_websocket = true}; .is_websocket = true};
const httpd_uri_t static_files = {.uri = "/*", .method = HTTP_GET, .handler = static_file_handler}; 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, &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, &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, &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, &websocket), kLogTag, "websocket route registration failed");
ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &static_files), kLogTag, "static 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); 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_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default()); ESP_ERROR_CHECK(esp_event_loop_create_default());
mount_littlefs(); mount_littlefs();
make_capacity_state(&s_capacity_state);
ESP_ERROR_CHECK(start_http_server()); ESP_ERROR_CHECK(start_http_server());
const esp_timer_create_args_t state_timer_args = {.callback = state_timer_callback, .name = "state_tick"}; 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_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(); result = start_wifi();
if (result != ESP_OK) { if (result != ESP_OK) {
ESP_LOGW(kLogTag, "wifi unavailable: %s", esp_err_to_name(result)); ESP_LOGW(kLogTag, "wifi unavailable: %s", esp_err_to_name(result));