diff --git a/PLANS.md b/PLANS.md index 2a61b55..24e07f0 100644 --- a/PLANS.md +++ b/PLANS.md @@ -459,11 +459,11 @@ object placement and bounded memory use. `{"status":"serving","healthy":true,"version":"0.5.0","errorCode":0}`. - These deterministic, validator, resource, representative-map, build, and physical-board results provide concrete evidence for all six acceptance - criteria. Milestone 006 remains `NOT_STARTED`. + criteria and completed Milestone 005. ## Milestone 006 — Authoritative movement and visibility core -**Status:** `NOT_STARTED` +**Status:** `COMPLETE` ### Objective @@ -497,9 +497,66 @@ server-enforced role-specific visibility. ### Evidence -Completion evidence is not yet available because this milestone has not -started. Required evidence includes host-test output, serialized payload -inspection, and deterministic command-order tests. +- On 2026-09-04, + `PLATFORMIO_SETTING_ENABLE_TELEMETRY=no /home/sasa/.platformio/penv/bin/pio + project config` exited successfully and resolved the production and isolated + `milestone-006-device-test` environments from `espressif32@7.0.1`, ESP-IDF, + the `esp32-c6-devkitm-1` profile, 4 MB Flash, and the root partition and + sdkconfig files. +- `./scripts/run_host_tests.sh` passed every preceding suite and reported + `game movement, cooldown, and FIFO ordering: PASS (commands 1-7, states + 0-3)`, `game bounded command queue: PASS (16 entries)`, `game shared-cell + meeting event: PASS (one event)`, and `game visibility and payload security: + PASS (r3=49/16, r6=64/49, observer=64)`. The same suite serialized a complete + default `128 x 64` observer snapshot as 345 chunks and reported a maximum + chunk size of 405 bytes against the 512-byte limit. A separate build with + `-fsanitize=address,undefined` repeated the game-core suite without an + address or undefined-behavior finding; leak detection was disabled because + the sandbox executes under `ptrace`. +- The deterministic movement cases proved that queued commands are processed + in assigned FIFO order, each accepted direction changes exactly one + coordinate by one cell, and Player 1 and Player 2 may act at the same server + timestamp. Countdown, cooldown, out-of-order timestamp, closed-wall, + malformed-direction, observer, and full-queue rejections left player state, + accepted-move counters, cooldown clocks, and the authoritative state + sequence unchanged. Entering the other player's occupied cell left both + players there and emitted one meeting event. +- Projection tests covered both player roles and reused a snapshot after an + opponent changed from visible to hidden. Radius-three squares contained 49 + cells at an interior position and 16 at a corner; radius-six squares + contained 64 and 49 respectively on the `8 x 8` fixture. An internal wall + did not remove cells. Player structures and every serialized 512-byte-bounded + JSON chunk omitted hidden cell 63, its hidden key record, and stale opponent + state, while the observer contained all 64 cells, all ten fixture objects, + and both player positions. The serializer also emitted the versioned + envelope, authoritative sequence, phase, meeting sequence, and chunk + metadata. +- `/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1` completed + with `[SUCCESS]` and reported 47,508 of 327,680 RAM bytes and 1,005,274 of + 2,097,152 application bytes in use. The production `firmware.bin` measured + 1,005,904 bytes against the 2,097,152-byte factory partition. + `/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1 --target + buildfs` completed with `[SUCCESS]`; the independent partition check reported + `littlefs.bin uses 2031616/2031616 bytes in partition littlefs`. +- The isolated device image built with `[SUCCESS]`, using 124,468 of 327,680 + RAM bytes and 1,016,242 of 2,097,152 application bytes. Its independent + partition check reported `firmware.bin uses 1016880/2097152 bytes in + partition factory`. Upload with `/home/sasa/.platformio/penv/bin/pio run -e + milestone-006-device-test --target upload --upload-port /dev/ttyACM0` + identified an ESP32-C6FH4 revision 0.2 with 4 MB embedded Flash and + hash-verified every written image. +- The physical board logged an accepted command with command sequence one, + state sequence one, and the new position. It projected 28 player cells into + two bounded chunks and the complete 8,192-cell, 30-object observer state into + 345 bounded chunks; the first serialized chunk was 372 bytes. It then logged + `fixed=34612/40960 bytes, runtime=5/50 ms, heap_delta=0` and + `M006 physical game test: PASS`. +- The production image was restored and hash-verified after the isolated test. + A live `GET /health` returned + `{"status":"serving","healthy":true,"version":"0.6.0","errorCode":0}`. + These host, serialization, build, resource, and physical-board results + provide concrete evidence for all six acceptance criteria. Milestone 007 + remains `NOT_STARTED`. ## Milestone 007 — Keys, artifacts, compasses, exits, and results diff --git a/components/maze32_game/CMakeLists.txt b/components/maze32_game/CMakeLists.txt new file mode 100644 index 0000000..2cc0b6d --- /dev/null +++ b/components/maze32_game/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "maze32_game.c" + INCLUDE_DIRS "include" + REQUIRES maze32_maze +) diff --git a/components/maze32_game/include/maze32_game.h b/components/maze32_game/include/maze32_game.h new file mode 100644 index 0000000..9c33993 --- /dev/null +++ b/components/maze32_game/include/maze32_game.h @@ -0,0 +1,146 @@ +#ifndef MAZE32_GAME_H +#define MAZE32_GAME_H + +#include "maze32_maze.h" +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define MAZE32_GAME_PLAYER_COUNT 2U +#define MAZE32_GAME_COMMAND_CAPACITY 16U +#define MAZE32_GAME_MAX_OBJECTS \ + (6U + 2U * MAZE32_MAZE_MAX_LANDMARK_PAIRS + 2U * MAZE32_MAZE_MAX_ARTIFACT_PAIRS) +#define MAZE32_GAME_VIEW_CELLS_PER_CHUNK 24U +#define MAZE32_GAME_VIEW_OBJECTS_PER_CHUNK 10U +#define MAZE32_GAME_VIEW_CHUNK_BYTES 512U +#define MAZE32_GAME_RAM_BUDGET_BYTES (40U * 1024U) +#define MAZE32_GAME_RUNTIME_BUDGET_MS 50U + +typedef enum { + MAZE32_GAME_PHASE_COUNTDOWN = 0, + MAZE32_GAME_PHASE_ACTIVE, +} maze32_game_phase_t; + +typedef enum { + MAZE32_GAME_ROLE_PLAYER_ONE = 0, + MAZE32_GAME_ROLE_PLAYER_TWO, + MAZE32_GAME_ROLE_OBSERVER, +} maze32_game_role_t; + +typedef enum { + MAZE32_GAME_DIRECTION_NORTH = 0, + MAZE32_GAME_DIRECTION_EAST, + MAZE32_GAME_DIRECTION_SOUTH, + MAZE32_GAME_DIRECTION_WEST, +} maze32_game_direction_t; + +typedef enum { + MAZE32_GAME_RESULT_ACCEPTED = 0, + MAZE32_GAME_RESULT_INVALID_ARGUMENT, + MAZE32_GAME_RESULT_QUEUE_FULL, + MAZE32_GAME_RESULT_QUEUE_EMPTY, + MAZE32_GAME_RESULT_READ_ONLY, + MAZE32_GAME_RESULT_PREMATURE, + MAZE32_GAME_RESULT_RATE_LIMITED, + MAZE32_GAME_RESULT_BLOCKED, + MAZE32_GAME_RESULT_BUFFER_TOO_SMALL, +} maze32_game_result_t; + +typedef struct { + uint32_t movement_cooldown_ms; +} maze32_game_config_t; + +typedef struct { + maze32_cell_t position; + uint64_t last_move_ms; + uint32_t accepted_moves; + uint8_t has_moved; +} maze32_game_player_t; + +typedef struct { + uint32_t command_sequence; + maze32_game_role_t role; + maze32_game_direction_t direction; + uint64_t submitted_ms; +} maze32_game_command_t; + +typedef struct { + maze32_game_result_t result; + uint32_t command_sequence; + uint32_t state_sequence; + uint32_t meeting_sequence; + uint8_t meeting; +} maze32_game_command_result_t; + +typedef struct { + const maze32_maze_t* maze; + maze32_game_config_t config; + maze32_game_phase_t phase; + maze32_game_player_t players[MAZE32_GAME_PLAYER_COUNT]; + maze32_game_command_t commands[MAZE32_GAME_COMMAND_CAPACITY]; + uint8_t command_head; + uint8_t command_count; + uint32_t next_command_sequence; + uint32_t state_sequence; + uint32_t meeting_sequence; +} maze32_game_t; + +typedef enum { + MAZE32_VIEW_OBJECT_KEY = 0, + MAZE32_VIEW_OBJECT_EXIT, + MAZE32_VIEW_OBJECT_COMPASS, + MAZE32_VIEW_OBJECT_LANDMARK, + MAZE32_VIEW_OBJECT_VISION_ARTIFACT, +} maze32_view_object_kind_t; + +typedef struct { + uint16_t cell_index; + uint8_t passages; +} maze32_view_cell_t; + +typedef struct { + uint16_t cell_index; + maze32_view_object_kind_t kind; + uint8_t owner; + uint8_t variant; +} maze32_view_object_t; + +typedef struct { + maze32_game_role_t role; + maze32_game_phase_t phase; + uint32_t state_sequence; + uint32_t meeting_sequence; + uint16_t self_cell_index; + uint16_t opponent_cell_index; + uint16_t observer_player_cell_indices[MAZE32_GAME_PLAYER_COUNT]; + uint16_t cell_count; + uint8_t object_count; + uint8_t opponent_visible; + maze32_view_cell_t cells[MAZE32_MAZE_MAX_CELLS]; + maze32_view_object_t objects[MAZE32_GAME_MAX_OBJECTS]; +} maze32_view_t; + +maze32_game_config_t maze32_game_default_config(void); +maze32_game_result_t maze32_game_init(maze32_game_t* game, const maze32_maze_t* maze, + const maze32_game_config_t* config); +void maze32_game_set_phase(maze32_game_t* game, maze32_game_phase_t phase); +maze32_game_result_t maze32_game_enqueue_move(maze32_game_t* game, maze32_game_role_t role, + maze32_game_direction_t direction, + uint64_t submitted_ms, uint32_t* command_sequence); +maze32_game_command_result_t maze32_game_process_next(maze32_game_t* game); +maze32_game_result_t maze32_game_project_view(const maze32_game_t* game, maze32_game_role_t role, + uint8_t radius, maze32_view_t* view); +size_t maze32_game_view_chunk_count(const maze32_view_t* view); +maze32_game_result_t maze32_game_serialize_view_chunk(const maze32_view_t* view, size_t chunk_index, + char* output, size_t output_size, + size_t* written); +const char* maze32_game_result_name(maze32_game_result_t result); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/components/maze32_game/maze32_game.c b/components/maze32_game/maze32_game.c new file mode 100644 index 0000000..8970580 --- /dev/null +++ b/components/maze32_game/maze32_game.c @@ -0,0 +1,380 @@ +#include "maze32_game.h" + +#include +#include +#include + +#define MAZE32_GAME_NO_OWNER UINT8_MAX + +static int role_is_player(maze32_game_role_t role) { + return role == MAZE32_GAME_ROLE_PLAYER_ONE || role == MAZE32_GAME_ROLE_PLAYER_TWO; +} + +static int direction_is_valid(maze32_game_direction_t direction) { + return direction >= MAZE32_GAME_DIRECTION_NORTH && direction <= MAZE32_GAME_DIRECTION_WEST; +} + +static maze32_passage_t direction_passage(maze32_game_direction_t direction) { + static const maze32_passage_t PASSAGES[] = { + MAZE32_PASSAGE_NORTH, + MAZE32_PASSAGE_EAST, + MAZE32_PASSAGE_SOUTH, + MAZE32_PASSAGE_WEST, + }; + return PASSAGES[direction]; +} + +static maze32_cell_t moved_cell(maze32_cell_t cell, maze32_game_direction_t direction) { + if (direction == MAZE32_GAME_DIRECTION_NORTH) { + cell.y--; + } else if (direction == MAZE32_GAME_DIRECTION_EAST) { + cell.x++; + } else if (direction == MAZE32_GAME_DIRECTION_SOUTH) { + cell.y++; + } else { + cell.x--; + } + return cell; +} + +static uint16_t cell_index(const maze32_maze_t* maze, maze32_cell_t cell) { + return (uint16_t)(cell.y * maze->config.width + cell.x); +} + +static int valid_cell(const maze32_maze_t* maze, maze32_cell_t cell) { + return cell.x < maze->config.width && cell.y < maze->config.height; +} + +static int is_visible(maze32_cell_t center, maze32_cell_t cell, uint8_t radius) { + uint16_t dx = center.x > cell.x ? center.x - cell.x : cell.x - center.x; + uint16_t dy = center.y > cell.y ? center.y - cell.y : cell.y - center.y; + return dx <= radius && dy <= radius; +} + +maze32_game_config_t maze32_game_default_config(void) { + const maze32_game_config_t config = {.movement_cooldown_ms = 150U}; + return config; +} + +maze32_game_result_t maze32_game_init(maze32_game_t* game, const maze32_maze_t* maze, + const maze32_game_config_t* config) { + if (game == NULL || maze == NULL || config == NULL || maze->config.width == 0U || + maze->config.height == 0U || maze->config.width > MAZE32_MAZE_MAX_WIDTH || + maze->config.height > MAZE32_MAZE_MAX_HEIGHT || + maze->config.landmark_pairs > MAZE32_MAZE_MAX_LANDMARK_PAIRS || + maze->config.artifact_pairs > MAZE32_MAZE_MAX_ARTIFACT_PAIRS || + !valid_cell(maze, maze->starts[0]) || !valid_cell(maze, maze->starts[1])) { + return MAZE32_GAME_RESULT_INVALID_ARGUMENT; + } + memset(game, 0, sizeof(*game)); + game->maze = maze; + game->config = *config; + game->phase = MAZE32_GAME_PHASE_COUNTDOWN; + game->players[0].position = maze->starts[0]; + game->players[1].position = maze->starts[1]; + game->next_command_sequence = 1U; + return MAZE32_GAME_RESULT_ACCEPTED; +} + +void maze32_game_set_phase(maze32_game_t* game, maze32_game_phase_t phase) { + if (game != NULL && + (phase == MAZE32_GAME_PHASE_COUNTDOWN || phase == MAZE32_GAME_PHASE_ACTIVE)) { + game->phase = phase; + } +} + +maze32_game_result_t maze32_game_enqueue_move(maze32_game_t* game, maze32_game_role_t role, + maze32_game_direction_t direction, + uint64_t submitted_ms, uint32_t* command_sequence) { + if (game == NULL || command_sequence == NULL || !direction_is_valid(direction)) { + return MAZE32_GAME_RESULT_INVALID_ARGUMENT; + } + if (!role_is_player(role)) { + return MAZE32_GAME_RESULT_READ_ONLY; + } + if (game->command_count >= MAZE32_GAME_COMMAND_CAPACITY) { + return MAZE32_GAME_RESULT_QUEUE_FULL; + } + uint8_t tail = + (uint8_t)((game->command_head + game->command_count) % MAZE32_GAME_COMMAND_CAPACITY); + maze32_game_command_t* command = &game->commands[tail]; + command->command_sequence = game->next_command_sequence++; + command->role = role; + command->direction = direction; + command->submitted_ms = submitted_ms; + game->command_count++; + *command_sequence = command->command_sequence; + return MAZE32_GAME_RESULT_ACCEPTED; +} + +maze32_game_command_result_t maze32_game_process_next(maze32_game_t* game) { + maze32_game_command_result_t processed = {.result = MAZE32_GAME_RESULT_INVALID_ARGUMENT}; + if (game == NULL) { + return processed; + } + processed.state_sequence = game->state_sequence; + processed.meeting_sequence = game->meeting_sequence; + if (game->command_count == 0U) { + processed.result = MAZE32_GAME_RESULT_QUEUE_EMPTY; + return processed; + } + + maze32_game_command_t command = game->commands[game->command_head]; + game->command_head = (uint8_t)((game->command_head + 1U) % MAZE32_GAME_COMMAND_CAPACITY); + game->command_count--; + processed.command_sequence = command.command_sequence; + if (game->phase != MAZE32_GAME_PHASE_ACTIVE) { + processed.result = MAZE32_GAME_RESULT_PREMATURE; + return processed; + } + + uint8_t player_index = (uint8_t)command.role; + maze32_game_player_t* player = &game->players[player_index]; + if (player->has_moved && + (command.submitted_ms < player->last_move_ms || + command.submitted_ms - player->last_move_ms < game->config.movement_cooldown_ms)) { + processed.result = MAZE32_GAME_RESULT_RATE_LIMITED; + return processed; + } + + maze32_passage_t passage = direction_passage(command.direction); + if (!maze32_maze_has_passage(game->maze, player->position, passage)) { + processed.result = MAZE32_GAME_RESULT_BLOCKED; + return processed; + } + + player->position = moved_cell(player->position, command.direction); + player->last_move_ms = command.submitted_ms; + player->accepted_moves++; + player->has_moved = 1U; + game->state_sequence++; + processed.result = MAZE32_GAME_RESULT_ACCEPTED; + processed.state_sequence = game->state_sequence; + uint8_t opponent_index = (uint8_t)(1U - player_index); + if (player->position.x == game->players[opponent_index].position.x && + player->position.y == game->players[opponent_index].position.y) { + game->meeting_sequence++; + processed.meeting = 1U; + processed.meeting_sequence = game->meeting_sequence; + } + return processed; +} + +static void add_object(const maze32_game_t* game, maze32_view_t* view, maze32_cell_t cell, + maze32_view_object_kind_t kind, uint8_t owner, uint8_t variant, + maze32_cell_t center, uint8_t radius, int reveal_all) { + if (view->object_count >= MAZE32_GAME_MAX_OBJECTS || + (!reveal_all && !is_visible(center, cell, radius))) { + return; + } + maze32_view_object_t* object = &view->objects[view->object_count++]; + object->cell_index = cell_index(game->maze, cell); + object->kind = kind; + object->owner = owner; + object->variant = variant; +} + +static void project_objects(const maze32_game_t* game, maze32_view_t* view, maze32_cell_t center, + uint8_t radius, int reveal_all) { + for (uint8_t owner = 0U; owner < MAZE32_GAME_PLAYER_COUNT; ++owner) { + add_object(game, view, game->maze->keys[owner], MAZE32_VIEW_OBJECT_KEY, owner, 0U, center, + radius, reveal_all); + add_object(game, view, game->maze->exits[owner], MAZE32_VIEW_OBJECT_EXIT, owner, 0U, center, + radius, reveal_all); + add_object(game, view, game->maze->compasses[owner], MAZE32_VIEW_OBJECT_COMPASS, owner, 0U, + center, radius, reveal_all); + } + for (uint8_t pair = 0U; pair < game->maze->config.landmark_pairs; ++pair) { + for (uint8_t side = 0U; side < 2U; ++side) { + add_object(game, view, game->maze->landmarks[pair].cells[side], + MAZE32_VIEW_OBJECT_LANDMARK, MAZE32_GAME_NO_OWNER, + game->maze->landmarks[pair].kind, center, radius, reveal_all); + } + } + for (uint8_t pair = 0U; pair < game->maze->config.artifact_pairs; ++pair) { + for (uint8_t side = 0U; side < 2U; ++side) { + add_object(game, view, game->maze->artifacts[pair][side], + MAZE32_VIEW_OBJECT_VISION_ARTIFACT, MAZE32_GAME_NO_OWNER, pair, center, + radius, reveal_all); + } + } +} + +maze32_game_result_t maze32_game_project_view(const maze32_game_t* game, maze32_game_role_t role, + uint8_t radius, maze32_view_t* view) { + if (game == NULL || game->maze == NULL || view == NULL || + (!role_is_player(role) && role != MAZE32_GAME_ROLE_OBSERVER) || + (role_is_player(role) && radius != 3U && radius != 6U)) { + return MAZE32_GAME_RESULT_INVALID_ARGUMENT; + } + memset(view, 0, sizeof(*view)); + view->role = role; + view->phase = game->phase; + view->state_sequence = game->state_sequence; + view->meeting_sequence = game->meeting_sequence; + view->opponent_cell_index = UINT16_MAX; + int reveal_all = role == MAZE32_GAME_ROLE_OBSERVER; + maze32_cell_t center = {0U, 0U}; + if (role_is_player(role)) { + uint8_t player_index = (uint8_t)role; + center = game->players[player_index].position; + view->self_cell_index = cell_index(game->maze, center); + maze32_cell_t opponent = game->players[1U - player_index].position; + if (is_visible(center, opponent, radius)) { + view->opponent_visible = 1U; + view->opponent_cell_index = cell_index(game->maze, opponent); + } + } else { + view->self_cell_index = UINT16_MAX; + view->observer_player_cell_indices[0] = cell_index(game->maze, game->players[0].position); + view->observer_player_cell_indices[1] = cell_index(game->maze, game->players[1].position); + } + + for (uint16_t y = 0U; y < game->maze->config.height; ++y) { + for (uint16_t x = 0U; x < game->maze->config.width; ++x) { + maze32_cell_t cell = {x, y}; + if (reveal_all || is_visible(center, cell, radius)) { + uint16_t index = cell_index(game->maze, cell); + view->cells[view->cell_count].cell_index = index; + view->cells[view->cell_count].passages = game->maze->passages[index]; + view->cell_count++; + } + } + } + project_objects(game, view, center, radius, reveal_all); + return MAZE32_GAME_RESULT_ACCEPTED; +} + +size_t maze32_game_view_chunk_count(const maze32_view_t* view) { + if (view == NULL) { + return 0U; + } + size_t cell_chunks = (view->cell_count + MAZE32_GAME_VIEW_CELLS_PER_CHUNK - 1U) / + MAZE32_GAME_VIEW_CELLS_PER_CHUNK; + size_t object_chunks = (view->object_count + MAZE32_GAME_VIEW_OBJECTS_PER_CHUNK - 1U) / + MAZE32_GAME_VIEW_OBJECTS_PER_CHUNK; + return cell_chunks + object_chunks; +} + +static int append_text(char* output, size_t output_size, size_t* offset, const char* format, ...) { + va_list arguments; + va_start(arguments, format); + int count = vsnprintf(output + *offset, output_size - *offset, format, arguments); + va_end(arguments); + if (count < 0 || (size_t)count >= output_size - *offset) { + return 0; + } + *offset += (size_t)count; + return 1; +} + +static const char* role_name(maze32_game_role_t role) { + if (role == MAZE32_GAME_ROLE_PLAYER_ONE) { + return "player1"; + } + if (role == MAZE32_GAME_ROLE_PLAYER_TWO) { + return "player2"; + } + return "observer"; +} + +static const char* object_name(maze32_view_object_kind_t kind) { + static const char* const NAMES[] = {"key", "exit", "compass", "landmark", "vision"}; + return NAMES[kind]; +} + +static const char* phase_name(maze32_game_phase_t phase) { + return phase == MAZE32_GAME_PHASE_ACTIVE ? "active" : "countdown"; +} + +maze32_game_result_t maze32_game_serialize_view_chunk(const maze32_view_t* view, size_t chunk_index, + char* output, size_t output_size, + size_t* written) { + if (view == NULL || output == NULL || written == NULL || output_size == 0U || + view->cell_count > MAZE32_MAZE_MAX_CELLS || view->object_count > MAZE32_GAME_MAX_OBJECTS) { + return MAZE32_GAME_RESULT_INVALID_ARGUMENT; + } + size_t total_chunks = maze32_game_view_chunk_count(view); + if (chunk_index >= total_chunks) { + return MAZE32_GAME_RESULT_INVALID_ARGUMENT; + } + size_t offset = 0U; + if (!append_text(output, output_size, &offset, + "{\"v\":1,\"type\":\"view\",\"seq\":%lu,\"payload\":{\"role\":\"%s\"," + "\"phase\":\"%s\",\"meetingSequence\":%lu,\"chunk\":%u,\"chunks\":%u", + (unsigned long)view->state_sequence, role_name(view->role), + phase_name(view->phase), (unsigned long)view->meeting_sequence, + (unsigned int)chunk_index, (unsigned int)total_chunks)) { + return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL; + } + if (view->role == MAZE32_GAME_ROLE_OBSERVER) { + if (!append_text(output, output_size, &offset, ",\"players\":[%u,%u]", + view->observer_player_cell_indices[0], + view->observer_player_cell_indices[1])) { + return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL; + } + } else if (!append_text(output, output_size, &offset, ",\"self\":%u", + (unsigned int)view->self_cell_index)) { + return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL; + } + if (view->role != MAZE32_GAME_ROLE_OBSERVER && view->opponent_visible && + !append_text(output, output_size, &offset, ",\"opponent\":%u", + (unsigned int)view->opponent_cell_index)) { + return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL; + } + if (!append_text(output, output_size, &offset, ",\"cells\":[")) { + return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL; + } + + size_t cell_chunks = (view->cell_count + MAZE32_GAME_VIEW_CELLS_PER_CHUNK - 1U) / + MAZE32_GAME_VIEW_CELLS_PER_CHUNK; + if (chunk_index < cell_chunks) { + size_t first = chunk_index * MAZE32_GAME_VIEW_CELLS_PER_CHUNK; + size_t end = first + MAZE32_GAME_VIEW_CELLS_PER_CHUNK; + if (end > view->cell_count) { + end = view->cell_count; + } + for (size_t index = first; index < end; ++index) { + const maze32_view_cell_t* cell = &view->cells[index]; + if (!append_text(output, output_size, &offset, "%s[%u,%u]", index == first ? "" : ",", + cell->cell_index, cell->passages)) { + return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL; + } + } + } + if (!append_text(output, output_size, &offset, "],\"objects\":[")) { + return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL; + } + + if (chunk_index >= cell_chunks) { + size_t object_chunk = chunk_index - cell_chunks; + size_t first = object_chunk * MAZE32_GAME_VIEW_OBJECTS_PER_CHUNK; + size_t end = first + MAZE32_GAME_VIEW_OBJECTS_PER_CHUNK; + if (end > view->object_count) { + end = view->object_count; + } + for (size_t index = first; index < end; ++index) { + const maze32_view_object_t* object = &view->objects[index]; + if (!append_text(output, output_size, &offset, "%s[\"%s\",%u,%u,%u]", + index == first ? "" : ",", object_name(object->kind), object->owner, + object->variant, object->cell_index)) { + return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL; + } + } + } + if (!append_text(output, output_size, &offset, "]}}")) { + return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL; + } + *written = offset; + return MAZE32_GAME_RESULT_ACCEPTED; +} + +const char* maze32_game_result_name(maze32_game_result_t result) { + static const char* const NAMES[] = {"accepted", "invalid_argument", "queue_full", + "queue_empty", "read_only", "premature", + "rate_limited", "blocked", "buffer_too_small"}; + if (result < MAZE32_GAME_RESULT_ACCEPTED || result > MAZE32_GAME_RESULT_BUFFER_TOO_SMALL) { + return "unknown"; + } + return NAMES[result]; +} diff --git a/docs/GAME_CORE.md b/docs/GAME_CORE.md new file mode 100644 index 0000000..39e2b7c --- /dev/null +++ b/docs/GAME_CORE.md @@ -0,0 +1,45 @@ +# Authoritative game core + +Milestone 006 provides the portable movement engine and view projector. The +ESP remains the only owner of mutable game state. Object interaction, item +effects, exits, results, reconnect behavior, and browser controls remain out of +scope until their later milestones. + +## Command processing + +Move commands enter a fixed 16-entry FIFO. The server assigns a monotonically +increasing command sequence when an entry is accepted into the queue, then +processes entries one at a time. An accepted move crosses exactly one open +north, east, south, or west passage. Countdown commands, closed-wall moves, +out-of-order timestamps, and commands inside the configured per-player +cooldown leave authoritative game state and the state sequence unchanged. + +Each player has an independent cooldown clock. Player positions never block +one another. When an accepted move ends on the other player's cell, that +command emits exactly one meeting event and advances the meeting sequence. +Observers cannot enqueue commands. + +## Projection boundary + +Player projections contain only cells whose Chebyshev distance from the +player is at most the active radius: + +`max(abs(x - player_x), abs(y - player_y)) <= radius` + +The projector supports the normal radius three and vision-effect radius six. +Map boundaries clip the square; walls do not occlude it. Each projection is +rebuilt from zero, so an opponent or object that leaves the current square is +omitted instead of surviving as stale state. Observer projections contain all +maze cells, static objects, and both current player positions. + +The serialized view is split into bounded JSON records of at most 512 bytes, +matching the WebSocket gateway's message bound. +Every record uses the architecture's versioned envelope and carries the +authoritative state sequence, role, match phase, meeting sequence, chunk index, +and chunk count. Cell records contain only the cell index and current passage +flags. Object records contain kind, owner, variant, and cell index. Player +records omit the `opponent` member when that player is not currently visible; +observer records instead carry both positions in `players`. + +Static maze objects are projected for visibility testing, but Milestone 006 +does not pick up, consume, transfer, or otherwise mutate them. diff --git a/platformio.ini b/platformio.ini index fd1c080..728efd6 100644 --- a/platformio.ini +++ b/platformio.ini @@ -48,3 +48,9 @@ extends = env:esp32-c6-devkitm-1 build_flags = ${env:esp32-c6-devkitm-1.build_flags} -DMAZE32_MILESTONE_005_DEVICE_TEST=1 + +[env:milestone-006-device-test] +extends = env:esp32-c6-devkitm-1 +build_flags = + ${env:esp32-c6-devkitm-1.build_flags} + -DMAZE32_MILESTONE_006_DEVICE_TEST=1 diff --git a/scripts/run_host_tests.sh b/scripts/run_host_tests.sh index dd9ffe3..d3ba835 100755 --- a/scripts/run_host_tests.sh +++ b/scripts/run_host_tests.sh @@ -58,4 +58,14 @@ cc -std=c11 -Wall -Wextra -Werror -pedantic \ -o "$test_directory/test_maze_generation" "$test_directory/test_maze_generation" + +cc -std=c11 -Wall -Wextra -Werror -pedantic \ + -I"$project_directory/components/maze32_maze/include" \ + -I"$project_directory/components/maze32_game/include" \ + "$project_directory/components/maze32_maze/maze32_maze.c" \ + "$project_directory/components/maze32_game/maze32_game.c" \ + "$project_directory/test/host/test_game_core.c" \ + -o "$test_directory/test_game_core" + +"$test_directory/test_game_core" python3 "$project_directory/scripts/verify_web_assets.py" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index ed75453..362e201 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,4 +1,4 @@ idf_component_register( SRCS "main.c" - REQUIRES esp_event esp_netif esp_timer heap maze32_config maze32_maze maze32_network maze32_platform maze32_web + REQUIRES esp_event esp_netif esp_timer heap maze32_config maze32_game maze32_maze maze32_network maze32_platform maze32_web ) diff --git a/src/main.c b/src/main.c index 771b2cc..a9c29cd 100644 --- a/src/main.c +++ b/src/main.c @@ -14,6 +14,7 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "maze32_config_store.h" +#include "maze32_game.h" #include "maze32_health.h" #include "maze32_maze.h" #include "maze32_network.h" @@ -25,6 +26,109 @@ static httpd_handle_t web_server; static maze32_network_t network; static maze32_gateway_t gateway; +#ifdef MAZE32_MILESTONE_006_DEVICE_TEST +static maze32_maze_t game_test_maze; +static maze32_maze_workspace_t game_test_workspace; +static maze32_game_t device_test_game; +static maze32_view_t device_test_view; +static char device_test_payload[MAZE32_GAME_VIEW_CHUNK_BYTES]; + +static maze32_game_direction_t first_open_direction(const maze32_maze_t* maze, maze32_cell_t cell) { + static const maze32_game_direction_t DIRECTIONS[] = { + MAZE32_GAME_DIRECTION_NORTH, + MAZE32_GAME_DIRECTION_EAST, + MAZE32_GAME_DIRECTION_SOUTH, + MAZE32_GAME_DIRECTION_WEST, + }; + static const maze32_passage_t PASSAGES[] = { + MAZE32_PASSAGE_NORTH, + MAZE32_PASSAGE_EAST, + MAZE32_PASSAGE_SOUTH, + MAZE32_PASSAGE_WEST, + }; + for (size_t index = 0U; index < sizeof(DIRECTIONS) / sizeof(DIRECTIONS[0]); ++index) { + if (maze32_maze_has_passage(maze, cell, PASSAGES[index])) { + return DIRECTIONS[index]; + } + } + return (maze32_game_direction_t)UINT8_MAX; +} + +static esp_err_t run_game_device_test(void) { + maze32_maze_config_t maze_config = maze32_maze_default_config(); + maze32_maze_report_t maze_report; + if (maze32_maze_generate(&game_test_maze, &game_test_workspace, &maze_config, + UINT32_C(0x5a320006), &maze_report) != MAZE32_MAZE_OK) { + ESP_LOGE(TAG, "M006 physical game test could not generate its maze"); + return ESP_FAIL; + } + + maze32_game_config_t game_config = maze32_game_default_config(); + size_t free_before = heap_caps_get_free_size(MALLOC_CAP_8BIT); + int64_t started_us = esp_timer_get_time(); + if (maze32_game_init(&device_test_game, &game_test_maze, &game_config) != + MAZE32_GAME_RESULT_ACCEPTED) { + return ESP_FAIL; + } + maze32_game_set_phase(&device_test_game, MAZE32_GAME_PHASE_ACTIVE); + maze32_game_direction_t direction = + first_open_direction(&game_test_maze, device_test_game.players[0].position); + uint32_t command_sequence = 0U; + if (direction > MAZE32_GAME_DIRECTION_WEST || + maze32_game_enqueue_move(&device_test_game, MAZE32_GAME_ROLE_PLAYER_ONE, direction, 0U, + &command_sequence) != MAZE32_GAME_RESULT_ACCEPTED) { + return ESP_FAIL; + } + maze32_game_command_result_t command_result = maze32_game_process_next(&device_test_game); + if (command_result.result != MAZE32_GAME_RESULT_ACCEPTED || + command_result.state_sequence != 1U || + maze32_game_project_view(&device_test_game, MAZE32_GAME_ROLE_PLAYER_ONE, 3U, + &device_test_view) != MAZE32_GAME_RESULT_ACCEPTED) { + return ESP_FAIL; + } + uint16_t player_cells = device_test_view.cell_count; + uint8_t player_objects = device_test_view.object_count; + size_t player_chunks = maze32_game_view_chunk_count(&device_test_view); + size_t payload_bytes = 0U; + if (maze32_game_serialize_view_chunk(&device_test_view, 0U, device_test_payload, + sizeof(device_test_payload), + &payload_bytes) != MAZE32_GAME_RESULT_ACCEPTED) { + return ESP_FAIL; + } + if (maze32_game_project_view(&device_test_game, MAZE32_GAME_ROLE_OBSERVER, 0U, + &device_test_view) != MAZE32_GAME_RESULT_ACCEPTED) { + return ESP_FAIL; + } + uint64_t elapsed_us = (uint64_t)(esp_timer_get_time() - started_us); + size_t free_after = heap_caps_get_free_size(MALLOC_CAP_8BIT); + size_t fixed_bytes = + sizeof(device_test_game) + sizeof(device_test_view) + sizeof(device_test_payload); + ESP_LOGI(TAG, "M006 command: command=%" PRIu32 ", state=%" PRIu32 ", result=%s, position=%u,%u", + command_sequence, command_result.state_sequence, + maze32_game_result_name(command_result.result), device_test_game.players[0].position.x, + device_test_game.players[0].position.y); + ESP_LOGI(TAG, + "M006 views: player_cells=%u, player_objects=%u, player_chunks=%u, " + "observer_cells=%u, observer_objects=%u, observer_chunks=%u, first_chunk=%u bytes", + player_cells, player_objects, (unsigned int)player_chunks, device_test_view.cell_count, + device_test_view.object_count, + (unsigned int)maze32_game_view_chunk_count(&device_test_view), + (unsigned int)payload_bytes); + ESP_LOGI(TAG, "M006 budget: fixed=%u/%u bytes, runtime=%" PRIu64 "/%u ms, heap_delta=%d", + (unsigned int)fixed_bytes, (unsigned int)MAZE32_GAME_RAM_BUDGET_BYTES, + elapsed_us / 1000U, (unsigned int)MAZE32_GAME_RUNTIME_BUDGET_MS, + (int)(free_before - free_after)); + if (device_test_view.cell_count != MAZE32_MAZE_DEFAULT_WIDTH * MAZE32_MAZE_DEFAULT_HEIGHT || + fixed_bytes > MAZE32_GAME_RAM_BUDGET_BYTES || + elapsed_us > (uint64_t)MAZE32_GAME_RUNTIME_BUDGET_MS * 1000U || free_before != free_after) { + ESP_LOGE(TAG, "M006 physical game test failed"); + return ESP_FAIL; + } + ESP_LOGI(TAG, "M006 physical game test: PASS"); + return ESP_OK; +} +#endif + #ifdef MAZE32_MILESTONE_005_DEVICE_TEST static maze32_maze_t device_test_maze; static maze32_maze_workspace_t device_test_workspace; @@ -159,6 +263,13 @@ void app_main(void) { } #endif +#ifdef MAZE32_MILESTONE_006_DEVICE_TEST + if (run_game_device_test() != ESP_OK) { + maze32_health_set(&health, MAZE32_HEALTH_ERROR, (int)ESP_FAIL); + return; + } +#endif + vTaskDelay(pdMS_TO_TICKS(1500)); ESP_LOGI(TAG, "maze32 firmware version %s starting", application->version); diff --git a/test/host/test_game_core.c b/test/host/test_game_core.c new file mode 100644 index 0000000..79e8bc8 --- /dev/null +++ b/test/host/test_game_core.c @@ -0,0 +1,318 @@ +#include "maze32_game.h" + +#include +#include +#include + +static uint16_t index_of(uint16_t x, uint16_t y) { + return (uint16_t)(y * 8U + x); +} + +static void open_test_grid(maze32_maze_t* maze) { + memset(maze, 0, sizeof(*maze)); + maze->config.width = 8U; + maze->config.height = 8U; + maze->config.landmark_pairs = 1U; + maze->config.artifact_pairs = 1U; + for (uint16_t y = 0U; y < 8U; ++y) { + for (uint16_t x = 0U; x < 8U; ++x) { + uint8_t passages = 0U; + if (y > 0U) { + passages |= MAZE32_PASSAGE_NORTH; + } + if (x < 7U) { + passages |= MAZE32_PASSAGE_EAST; + } + if (y < 7U) { + passages |= MAZE32_PASSAGE_SOUTH; + } + if (x > 0U) { + passages |= MAZE32_PASSAGE_WEST; + } + maze->passages[index_of(x, y)] = passages; + } + } + maze->starts[0] = (maze32_cell_t){1U, 1U}; + maze->starts[1] = (maze32_cell_t){6U, 6U}; + maze->keys[0] = (maze32_cell_t){2U, 1U}; + maze->keys[1] = (maze32_cell_t){7U, 7U}; + maze->exits[0] = (maze32_cell_t){0U, 0U}; + maze->exits[1] = (maze32_cell_t){7U, 6U}; + maze->compasses[0] = (maze32_cell_t){1U, 0U}; + maze->compasses[1] = (maze32_cell_t){6U, 7U}; + maze->landmarks[0].cells[0] = (maze32_cell_t){0U, 1U}; + maze->landmarks[0].cells[1] = (maze32_cell_t){7U, 6U}; + maze->landmarks[0].kind = 4U; + maze->artifacts[0][0] = (maze32_cell_t){2U, 2U}; + maze->artifacts[0][1] = (maze32_cell_t){6U, 6U}; +} + +static maze32_game_t initialized_game(maze32_maze_t* maze) { + maze32_game_t game; + maze32_game_config_t config = maze32_game_default_config(); + assert(config.movement_cooldown_ms == 150U); + assert(maze32_game_init(&game, maze, &config) == MAZE32_GAME_RESULT_ACCEPTED); + return game; +} + +static void assert_position(const maze32_game_t* game, uint8_t player, uint16_t x, uint16_t y) { + assert(game->players[player].position.x == x); + assert(game->players[player].position.y == y); +} + +static void test_movement_cooldown_and_ordering(void) { + maze32_maze_t maze; + open_test_grid(&maze); + maze32_game_t game = initialized_game(&maze); + uint32_t sequence = 0U; + + assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, + 100U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED); + assert(sequence == 1U); + maze32_game_command_result_t result = maze32_game_process_next(&game); + assert(result.result == MAZE32_GAME_RESULT_PREMATURE); + assert(result.command_sequence == 1U); + assert(game.state_sequence == 0U); + assert_position(&game, 0U, 1U, 1U); + + maze32_game_set_phase(&game, MAZE32_GAME_PHASE_ACTIVE); + assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, + 100U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED); + assert(sequence == 2U); + assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_TWO, MAZE32_GAME_DIRECTION_WEST, + 100U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED); + assert(sequence == 3U); + result = maze32_game_process_next(&game); + assert(result.result == MAZE32_GAME_RESULT_ACCEPTED); + assert(result.command_sequence == 2U && result.state_sequence == 1U); + assert_position(&game, 0U, 2U, 1U); + assert_position(&game, 1U, 6U, 6U); + result = maze32_game_process_next(&game); + assert(result.result == MAZE32_GAME_RESULT_ACCEPTED); + assert(result.command_sequence == 3U && result.state_sequence == 2U); + assert_position(&game, 1U, 5U, 6U); + + assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_SOUTH, + 200U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED); + result = maze32_game_process_next(&game); + assert(result.result == MAZE32_GAME_RESULT_RATE_LIMITED); + assert(game.state_sequence == 2U && game.players[0].last_move_ms == 100U); + assert_position(&game, 0U, 2U, 1U); + + assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_SOUTH, + 250U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED); + result = maze32_game_process_next(&game); + assert(result.result == MAZE32_GAME_RESULT_ACCEPTED); + assert_position(&game, 0U, 2U, 2U); + assert(game.players[0].accepted_moves == 2U); + + uint16_t current = index_of(2U, 2U); + maze.passages[current] &= (uint8_t)~MAZE32_PASSAGE_EAST; + assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, + 400U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED); + result = maze32_game_process_next(&game); + assert(result.result == MAZE32_GAME_RESULT_BLOCKED); + assert(game.state_sequence == 3U && game.players[0].last_move_ms == 250U); + assert_position(&game, 0U, 2U, 2U); + + assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_WEST, + 200U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED); + result = maze32_game_process_next(&game); + assert(result.result == MAZE32_GAME_RESULT_RATE_LIMITED); + assert_position(&game, 0U, 2U, 2U); + + assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_OBSERVER, MAZE32_GAME_DIRECTION_EAST, + 500U, &sequence) == MAZE32_GAME_RESULT_READ_ONLY); + assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_ONE, (maze32_game_direction_t)99, + 500U, &sequence) == MAZE32_GAME_RESULT_INVALID_ARGUMENT); + assert(game.state_sequence == 3U); + printf("game movement, cooldown, and FIFO ordering: PASS (commands 1-7, states 0-3)\n"); +} + +static void test_bounded_queue(void) { + maze32_maze_t maze; + open_test_grid(&maze); + maze32_game_t game = initialized_game(&maze); + uint32_t sequence = 0U; + for (uint32_t index = 0U; index < MAZE32_GAME_COMMAND_CAPACITY; ++index) { + assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_ONE, + MAZE32_GAME_DIRECTION_EAST, index, + &sequence) == MAZE32_GAME_RESULT_ACCEPTED); + assert(sequence == index + 1U); + } + assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, + 20U, &sequence) == MAZE32_GAME_RESULT_QUEUE_FULL); + assert(game.next_command_sequence == MAZE32_GAME_COMMAND_CAPACITY + 1U); + assert(game.state_sequence == 0U); + printf("game bounded command queue: PASS (%u entries)\n", MAZE32_GAME_COMMAND_CAPACITY); +} + +static void test_shared_cell_meeting(void) { + maze32_maze_t maze; + open_test_grid(&maze); + maze.starts[1] = (maze32_cell_t){2U, 1U}; + maze32_game_t game = initialized_game(&maze); + maze32_game_set_phase(&game, MAZE32_GAME_PHASE_ACTIVE); + uint32_t sequence = 0U; + assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, + 10U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED); + maze32_game_command_result_t result = maze32_game_process_next(&game); + assert(result.result == MAZE32_GAME_RESULT_ACCEPTED); + assert(result.meeting == 1U && result.meeting_sequence == 1U); + assert_position(&game, 0U, 2U, 1U); + assert_position(&game, 1U, 2U, 1U); + result = maze32_game_process_next(&game); + assert(result.result == MAZE32_GAME_RESULT_QUEUE_EMPTY); + assert(result.meeting == 0U && result.meeting_sequence == 1U); + printf("game shared-cell meeting event: PASS (one event)\n"); +} + +static int view_has_cell(const maze32_view_t* view, uint16_t cell_index) { + for (uint16_t index = 0U; index < view->cell_count; ++index) { + if (view->cells[index].cell_index == cell_index) { + return 1; + } + } + return 0; +} + +static int view_has_object(const maze32_view_t* view, maze32_view_object_kind_t kind, + uint16_t cell_index) { + for (uint8_t index = 0U; index < view->object_count; ++index) { + if (view->objects[index].kind == kind && view->objects[index].cell_index == cell_index) { + return 1; + } + } + return 0; +} + +static void serialize_and_inspect(const maze32_view_t* view, int expect_hidden) { + char payload[MAZE32_GAME_VIEW_CHUNK_BYTES]; + size_t chunks = maze32_game_view_chunk_count(view); + int saw_last_cell = 0; + int saw_hidden_key = 0; + int saw_players = 0; + for (size_t chunk = 0U; chunk < chunks; ++chunk) { + size_t written = 0U; + assert(maze32_game_serialize_view_chunk(view, chunk, payload, sizeof(payload), &written) == + MAZE32_GAME_RESULT_ACCEPTED); + assert(written < sizeof(payload)); + assert(payload[written] == '\0'); + assert(strstr(payload, "{\"v\":1,\"type\":\"view\",\"seq\":") == payload); + assert(strstr(payload, "\"phase\":\"countdown\"") != NULL); + saw_last_cell |= strstr(payload, "[63,") != NULL; + saw_hidden_key |= strstr(payload, "[\"key\",1,0,63]") != NULL; + saw_players |= strstr(payload, "\"players\":[") != NULL; + if (expect_hidden) { + assert(strstr(payload, "\"opponent\":63") == NULL); + } + } + if (expect_hidden) { + assert(!saw_last_cell && !saw_hidden_key && !saw_players); + } else { + assert(saw_last_cell && saw_hidden_key && saw_players); + } +} + +static void test_visibility_and_serialization(void) { + maze32_maze_t maze; + open_test_grid(&maze); + maze32_game_t game = initialized_game(&maze); + maze32_view_t view; + + game.players[0].position = (maze32_cell_t){3U, 3U}; + game.players[1].position = (maze32_cell_t){4U, 4U}; + maze.passages[index_of(3U, 3U)] = 0U; + assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, 3U, &view) == + MAZE32_GAME_RESULT_ACCEPTED); + assert(view.cell_count == 49U); + assert(view_has_cell(&view, index_of(6U, 6U))); + assert(view.opponent_visible && view.opponent_cell_index == index_of(4U, 4U)); + + game.players[0].position = (maze32_cell_t){0U, 0U}; + game.players[1].position = (maze32_cell_t){7U, 7U}; + assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, 3U, &view) == + MAZE32_GAME_RESULT_ACCEPTED); + assert(view.cell_count == 16U); + assert(!view.opponent_visible && view.opponent_cell_index == UINT16_MAX); + assert(view_has_object(&view, MAZE32_VIEW_OBJECT_KEY, index_of(2U, 1U))); + assert(!view_has_object(&view, MAZE32_VIEW_OBJECT_KEY, index_of(7U, 7U))); + serialize_and_inspect(&view, 1); + + assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_TWO, 3U, &view) == + MAZE32_GAME_RESULT_ACCEPTED); + assert(view.cell_count == 16U && view.self_cell_index == index_of(7U, 7U)); + assert(!view.opponent_visible && !view_has_cell(&view, index_of(0U, 0U))); + assert(view_has_object(&view, MAZE32_VIEW_OBJECT_KEY, index_of(7U, 7U))); + assert(!view_has_object(&view, MAZE32_VIEW_OBJECT_KEY, index_of(2U, 1U))); + char player_two_payload[MAZE32_GAME_VIEW_CHUNK_BYTES]; + for (size_t chunk = 0U; chunk < maze32_game_view_chunk_count(&view); ++chunk) { + size_t written = 0U; + assert(maze32_game_serialize_view_chunk(&view, chunk, player_two_payload, + sizeof(player_two_payload), + &written) == MAZE32_GAME_RESULT_ACCEPTED); + assert(strstr(player_two_payload, "\"role\":\"player2\"") != NULL); + assert(strstr(player_two_payload, "\"opponent\":0") == NULL); + assert(strstr(player_two_payload, "[\"key\",0,0,10]") == NULL); + } + + assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, 6U, &view) == + MAZE32_GAME_RESULT_ACCEPTED); + assert(view.cell_count == 49U); + assert(!view_has_cell(&view, index_of(7U, 7U))); + + game.players[0].position = (maze32_cell_t){3U, 3U}; + assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, 6U, &view) == + MAZE32_GAME_RESULT_ACCEPTED); + assert(view.cell_count == 64U); + assert(view.opponent_visible); + + assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, 0U, &view) == + MAZE32_GAME_RESULT_ACCEPTED); + assert(view.cell_count == 64U); + assert(view.object_count == 10U); + assert(view.observer_player_cell_indices[0] == index_of(3U, 3U)); + assert(view.observer_player_cell_indices[1] == index_of(7U, 7U)); + assert(view.phase == MAZE32_GAME_PHASE_COUNTDOWN); + serialize_and_inspect(&view, 0); + printf("game visibility and payload security: PASS (r3=49/16, r6=64/49, observer=64)\n"); +} + +static void test_default_map_chunk_bounds(void) { + maze32_maze_t maze; + maze32_maze_workspace_t workspace; + maze32_maze_config_t maze_config = maze32_maze_default_config(); + maze32_maze_report_t report; + assert(maze32_maze_generate(&maze, &workspace, &maze_config, UINT32_C(0x5a320006), &report) == + MAZE32_MAZE_OK); + maze32_game_t game = initialized_game(&maze); + maze32_view_t view; + assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, 0U, &view) == + MAZE32_GAME_RESULT_ACCEPTED); + assert(view.cell_count == 8192U && view.object_count == 30U); + size_t chunks = maze32_game_view_chunk_count(&view); + assert(chunks == 345U); + char payload[MAZE32_GAME_VIEW_CHUNK_BYTES]; + size_t largest_chunk = 0U; + for (size_t chunk = 0U; chunk < chunks; ++chunk) { + size_t written = 0U; + assert(maze32_game_serialize_view_chunk(&view, chunk, payload, sizeof(payload), &written) == + MAZE32_GAME_RESULT_ACCEPTED); + if (written > largest_chunk) { + largest_chunk = written; + } + } + assert(largest_chunk < MAZE32_GAME_VIEW_CHUNK_BYTES); + printf("game default observer serialization: PASS (%u chunks, maximum %u bytes)\n", + (unsigned int)chunks, (unsigned int)largest_chunk); +} + +int main(void) { + test_movement_cooldown_and_ordering(); + test_bounded_queue(); + test_shared_cell_meeting(); + test_visibility_and_serialization(); + test_default_map_chunk_bounds(); + puts("game core tests: PASS"); + return 0; +} diff --git a/version.txt b/version.txt index 8f0916f..a918a2a 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.5.0 +0.6.0