feat(game): implement authoritative movement and visibility core
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
idf_component_register(
|
||||
SRCS "maze32_game.c"
|
||||
INCLUDE_DIRS "include"
|
||||
REQUIRES maze32_maze
|
||||
)
|
||||
@@ -0,0 +1,146 @@
|
||||
#ifndef MAZE32_GAME_H
|
||||
#define MAZE32_GAME_H
|
||||
|
||||
#include "maze32_maze.h"
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#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
|
||||
@@ -0,0 +1,380 @@
|
||||
#include "maze32_game.h"
|
||||
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#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];
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+1
-1
@@ -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
|
||||
)
|
||||
|
||||
+111
@@ -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);
|
||||
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
#include "maze32_game.h"
|
||||
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
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;
|
||||
}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.5.0
|
||||
0.6.0
|
||||
|
||||
Reference in New Issue
Block a user