Compare commits
15 Commits
115a3bd0a1
...
5a806fb5b8
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a806fb5b8 | |||
| 1396bad76d | |||
| 381eacf403 | |||
| 37704028c5 | |||
| 3827ba7ec7 | |||
| f043b63732 | |||
| 5346f9296f | |||
| 4b1ac6ca5a | |||
| a2a8f4a59d | |||
| cf187bdd1c | |||
| 7902800ff7 | |||
| c730cb818d | |||
| ffdaa299bc | |||
| d042c3ce90 | |||
| 9339578dc9 |
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
.pio/
|
.pio/
|
||||||
.vscode/
|
.vscode/
|
||||||
.obsidian/
|
.obsidian/
|
||||||
|
data/*.gz
|
||||||
|
|||||||
@@ -98,9 +98,9 @@ Do not silently choose one requirement over another.
|
|||||||
### Build and verification
|
### Build and verification
|
||||||
|
|
||||||
- Parse the project configuration with
|
- Parse the project configuration with
|
||||||
`/home/sasa/.platformio/penv/bin/pio project config` after changing
|
`pio project config` after changing
|
||||||
`platformio.ini`.
|
`platformio.ini`.
|
||||||
- Build with `/home/sasa/.platformio/penv/bin/pio run` once firmware sources
|
- Build with `pio run` once firmware sources
|
||||||
exist.
|
exist.
|
||||||
- Add host-side deterministic tests for portable game logic before relying only
|
- Add host-side deterministic tests for portable game logic before relying only
|
||||||
on device tests.
|
on device tests.
|
||||||
|
|||||||
+2
-2
@@ -231,7 +231,7 @@ conceptual records include:
|
|||||||
outgoing queue.
|
outgoing queue.
|
||||||
|
|
||||||
The maze representation must be sized from configured dimensions with checked
|
The maze representation must be sized from configured dimensions with checked
|
||||||
arithmetic. The default 8,192-cell map must not require a per-cell heap
|
arithmetic. The default 1,024-cell map must not require a per-cell heap
|
||||||
allocation.
|
allocation.
|
||||||
|
|
||||||
## 8. Visibility projection
|
## 8. Visibility projection
|
||||||
@@ -317,7 +317,7 @@ The browser application has four presentation modes:
|
|||||||
|
|
||||||
Player controls support keyboard movement, touch swipes, and an explicit
|
Player controls support keyboard movement, touch swipes, and an explicit
|
||||||
drop-key action. The UI renders only server-provided visible state. The observer
|
drop-key action. The UI renders only server-provided visible state. The observer
|
||||||
view supports fitting, panning, or zooming the complete `128 x 64` map without
|
view supports fitting, panning, or zooming the complete `32 x 32` map without
|
||||||
changing server authority.
|
changing server authority.
|
||||||
|
|
||||||
`docs/UI_STYLE.md` is the canonical browser presentation contract. The player
|
`docs/UI_STYLE.md` is the canonical browser presentation contract. The player
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ approximately five minutes. There is no match time limit.
|
|||||||
|
|
||||||
### Dimensions and representation
|
### Dimensions and representation
|
||||||
|
|
||||||
- The initial configuration is `width = 128`, `height = 64`, or 8,192 cells.
|
- The initial configuration is `width = 32`, `height = 32`, or 1,024 cells.
|
||||||
- Width and height are runtime configuration values and must not be hard-coded
|
- Width and height are runtime configuration values and must not be hard-coded
|
||||||
into game logic.
|
into game logic.
|
||||||
- Movement is orthogonal between cells; diagonal movement does not exist.
|
- Movement is orthogonal between cells; diagonal movement does not exist.
|
||||||
@@ -274,7 +274,7 @@ board:
|
|||||||
refresh, enter the lobby, and start through the owner-controlled countdown.
|
refresh, enter the lobby, and start through the owner-controlled countdown.
|
||||||
4. Four additional browsers can observe the complete match simultaneously
|
4. Four additional browsers can observe the complete match simultaneously
|
||||||
without receiving control authority.
|
without receiving control authority.
|
||||||
5. Repeatedly generated `128 x 64` maps are connected, centrally symmetric,
|
5. Repeatedly generated `32 x 32` maps are connected, centrally symmetric,
|
||||||
contain cycles and multiple cross-half passages, and preserve equal
|
contain cycles and multiple cross-half passages, and preserve equal
|
||||||
player-relative placement metrics.
|
player-relative placement metrics.
|
||||||
6. Player clients never receive cells or dynamic objects outside their current
|
6. Player clients never receive cells or dynamic objects outside their current
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ $HOME/.platformio/penv/bin/pio device monitor
|
|||||||
A successful boot reports the firmware version, LittleFS capacity and use,
|
A successful boot reports the firmware version, LittleFS capacity and use,
|
||||||
network state, HTTP service startup, and health state `serving`.
|
network state, HTTP service startup, and health state `serving`.
|
||||||
|
|
||||||
|
The production integration/load procedure and its fixed resource thresholds
|
||||||
|
are documented in `docs/INTEGRATION_TESTING.md` and
|
||||||
|
`docs/RESOURCE_BUDGET.md`. Runtime counters and internal-memory samples are
|
||||||
|
available from `GET /diagnostics`; the endpoint contains no credentials,
|
||||||
|
session tokens, or game projection.
|
||||||
|
|
||||||
## Wi-Fi provisioning and recovery
|
## Wi-Fi provisioning and recovery
|
||||||
|
|
||||||
With no saved credentials, or after a failed station connection, the device
|
With no saved credentials, or after a failed station connection, the device
|
||||||
|
|||||||
@@ -11,10 +11,17 @@ extern "C" {
|
|||||||
|
|
||||||
#define MAZE32_GAME_PLAYER_COUNT 2U
|
#define MAZE32_GAME_PLAYER_COUNT 2U
|
||||||
#define MAZE32_GAME_COMMAND_CAPACITY 16U
|
#define MAZE32_GAME_COMMAND_CAPACITY 16U
|
||||||
|
#define MAZE32_GAME_KEY_COUNT 2U
|
||||||
|
#define MAZE32_GAME_NO_KEY UINT8_MAX
|
||||||
|
#define MAZE32_GAME_VISION_MOVES 10U
|
||||||
|
#define MAZE32_GAME_DRAW_WINDOW_MS 200U
|
||||||
|
#define MAZE32_GAME_RECONNECT_TIMEOUT_MS 60000U
|
||||||
|
#define MAZE32_GAME_MAX_ARTIFACTS (2U * MAZE32_MAZE_MAX_ARTIFACT_PAIRS)
|
||||||
#define MAZE32_GAME_MAX_OBJECTS \
|
#define MAZE32_GAME_MAX_OBJECTS \
|
||||||
(6U + 2U * MAZE32_MAZE_MAX_LANDMARK_PAIRS + 2U * MAZE32_MAZE_MAX_ARTIFACT_PAIRS)
|
(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_CELLS_PER_CHUNK 12U
|
||||||
#define MAZE32_GAME_VIEW_OBJECTS_PER_CHUNK 10U
|
#define MAZE32_GAME_OBSERVER_FIRST_CHUNK_CELLS 8U
|
||||||
|
#define MAZE32_GAME_VIEW_OBJECTS_PER_CHUNK 6U
|
||||||
#define MAZE32_GAME_VIEW_CHUNK_BYTES 512U
|
#define MAZE32_GAME_VIEW_CHUNK_BYTES 512U
|
||||||
#define MAZE32_GAME_RAM_BUDGET_BYTES (40U * 1024U)
|
#define MAZE32_GAME_RAM_BUDGET_BYTES (40U * 1024U)
|
||||||
#define MAZE32_GAME_RUNTIME_BUDGET_MS 50U
|
#define MAZE32_GAME_RUNTIME_BUDGET_MS 50U
|
||||||
@@ -22,6 +29,9 @@ extern "C" {
|
|||||||
typedef enum {
|
typedef enum {
|
||||||
MAZE32_GAME_PHASE_COUNTDOWN = 0,
|
MAZE32_GAME_PHASE_COUNTDOWN = 0,
|
||||||
MAZE32_GAME_PHASE_ACTIVE,
|
MAZE32_GAME_PHASE_ACTIVE,
|
||||||
|
MAZE32_GAME_PHASE_FINISH_PENDING,
|
||||||
|
MAZE32_GAME_PHASE_PAUSED,
|
||||||
|
MAZE32_GAME_PHASE_FINISHED,
|
||||||
} maze32_game_phase_t;
|
} maze32_game_phase_t;
|
||||||
|
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -37,6 +47,30 @@ typedef enum {
|
|||||||
MAZE32_GAME_DIRECTION_WEST,
|
MAZE32_GAME_DIRECTION_WEST,
|
||||||
} maze32_game_direction_t;
|
} maze32_game_direction_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
MAZE32_GAME_COMMAND_MOVE = 0,
|
||||||
|
MAZE32_GAME_COMMAND_DROP_KEY,
|
||||||
|
} maze32_game_command_type_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
MAZE32_GAME_FINAL_NONE = 0,
|
||||||
|
MAZE32_GAME_FINAL_PLAYER_ONE,
|
||||||
|
MAZE32_GAME_FINAL_PLAYER_TWO,
|
||||||
|
MAZE32_GAME_FINAL_DRAW,
|
||||||
|
} maze32_game_final_result_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
MAZE32_GAME_COMPASS_NONE = 0,
|
||||||
|
MAZE32_GAME_COMPASS_NORTH,
|
||||||
|
MAZE32_GAME_COMPASS_NORTH_EAST,
|
||||||
|
MAZE32_GAME_COMPASS_EAST,
|
||||||
|
MAZE32_GAME_COMPASS_SOUTH_EAST,
|
||||||
|
MAZE32_GAME_COMPASS_SOUTH,
|
||||||
|
MAZE32_GAME_COMPASS_SOUTH_WEST,
|
||||||
|
MAZE32_GAME_COMPASS_WEST,
|
||||||
|
MAZE32_GAME_COMPASS_NORTH_WEST,
|
||||||
|
} maze32_game_compass_direction_t;
|
||||||
|
|
||||||
typedef enum {
|
typedef enum {
|
||||||
MAZE32_GAME_RESULT_ACCEPTED = 0,
|
MAZE32_GAME_RESULT_ACCEPTED = 0,
|
||||||
MAZE32_GAME_RESULT_INVALID_ARGUMENT,
|
MAZE32_GAME_RESULT_INVALID_ARGUMENT,
|
||||||
@@ -47,6 +81,9 @@ typedef enum {
|
|||||||
MAZE32_GAME_RESULT_RATE_LIMITED,
|
MAZE32_GAME_RESULT_RATE_LIMITED,
|
||||||
MAZE32_GAME_RESULT_BLOCKED,
|
MAZE32_GAME_RESULT_BLOCKED,
|
||||||
MAZE32_GAME_RESULT_BUFFER_TOO_SMALL,
|
MAZE32_GAME_RESULT_BUFFER_TOO_SMALL,
|
||||||
|
MAZE32_GAME_RESULT_NO_KEY,
|
||||||
|
MAZE32_GAME_RESULT_PLAYER_EXITED,
|
||||||
|
MAZE32_GAME_RESULT_MATCH_FINISHED,
|
||||||
} maze32_game_result_t;
|
} maze32_game_result_t;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
@@ -57,11 +94,33 @@ typedef struct {
|
|||||||
maze32_cell_t position;
|
maze32_cell_t position;
|
||||||
uint64_t last_move_ms;
|
uint64_t last_move_ms;
|
||||||
uint32_t accepted_moves;
|
uint32_t accepted_moves;
|
||||||
|
uint8_t carried_key;
|
||||||
|
uint8_t vision_moves_remaining;
|
||||||
|
maze32_game_compass_direction_t compass_hint;
|
||||||
uint8_t has_moved;
|
uint8_t has_moved;
|
||||||
|
uint8_t exited;
|
||||||
|
uint8_t connected;
|
||||||
|
uint64_t reconnect_deadline_ms;
|
||||||
} maze32_game_player_t;
|
} maze32_game_player_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
MAZE32_GAME_KEY_GROUND = 0,
|
||||||
|
MAZE32_GAME_KEY_CARRIED,
|
||||||
|
} maze32_game_key_location_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
maze32_game_key_location_t location;
|
||||||
|
maze32_cell_t ground_position;
|
||||||
|
uint8_t carrier;
|
||||||
|
} maze32_game_key_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint8_t open;
|
||||||
|
} maze32_game_exit_t;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
uint32_t command_sequence;
|
uint32_t command_sequence;
|
||||||
|
maze32_game_command_type_t type;
|
||||||
maze32_game_role_t role;
|
maze32_game_role_t role;
|
||||||
maze32_game_direction_t direction;
|
maze32_game_direction_t direction;
|
||||||
uint64_t submitted_ms;
|
uint64_t submitted_ms;
|
||||||
@@ -73,6 +132,18 @@ typedef struct {
|
|||||||
uint32_t state_sequence;
|
uint32_t state_sequence;
|
||||||
uint32_t meeting_sequence;
|
uint32_t meeting_sequence;
|
||||||
uint8_t meeting;
|
uint8_t meeting;
|
||||||
|
uint8_t moved;
|
||||||
|
uint8_t dropped_key;
|
||||||
|
uint8_t picked_key;
|
||||||
|
uint8_t returned_keys;
|
||||||
|
uint8_t activated_artifact;
|
||||||
|
uint8_t opened_exit;
|
||||||
|
uint8_t outward_exit;
|
||||||
|
uint8_t finished;
|
||||||
|
uint8_t key;
|
||||||
|
uint8_t artifact;
|
||||||
|
maze32_game_compass_direction_t compass_hint;
|
||||||
|
maze32_game_final_result_t final_result;
|
||||||
} maze32_game_command_result_t;
|
} maze32_game_command_result_t;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
@@ -80,12 +151,21 @@ typedef struct {
|
|||||||
maze32_game_config_t config;
|
maze32_game_config_t config;
|
||||||
maze32_game_phase_t phase;
|
maze32_game_phase_t phase;
|
||||||
maze32_game_player_t players[MAZE32_GAME_PLAYER_COUNT];
|
maze32_game_player_t players[MAZE32_GAME_PLAYER_COUNT];
|
||||||
|
maze32_game_key_t keys[MAZE32_GAME_KEY_COUNT];
|
||||||
|
maze32_game_exit_t exits[MAZE32_GAME_PLAYER_COUNT];
|
||||||
|
uint8_t consumed_artifacts[MAZE32_GAME_MAX_ARTIFACTS];
|
||||||
maze32_game_command_t commands[MAZE32_GAME_COMMAND_CAPACITY];
|
maze32_game_command_t commands[MAZE32_GAME_COMMAND_CAPACITY];
|
||||||
uint8_t command_head;
|
uint8_t command_head;
|
||||||
uint8_t command_count;
|
uint8_t command_count;
|
||||||
uint32_t next_command_sequence;
|
uint32_t next_command_sequence;
|
||||||
uint32_t state_sequence;
|
uint32_t state_sequence;
|
||||||
uint32_t meeting_sequence;
|
uint32_t meeting_sequence;
|
||||||
|
maze32_game_final_result_t final_result;
|
||||||
|
uint8_t first_exited_player;
|
||||||
|
uint8_t has_exit_time[MAZE32_GAME_PLAYER_COUNT];
|
||||||
|
uint64_t exit_times_ms[MAZE32_GAME_PLAYER_COUNT];
|
||||||
|
maze32_game_phase_t phase_before_pause;
|
||||||
|
uint64_t paused_started_ms;
|
||||||
} maze32_game_t;
|
} maze32_game_t;
|
||||||
|
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -113,10 +193,27 @@ typedef struct {
|
|||||||
maze32_game_phase_t phase;
|
maze32_game_phase_t phase;
|
||||||
uint32_t state_sequence;
|
uint32_t state_sequence;
|
||||||
uint32_t meeting_sequence;
|
uint32_t meeting_sequence;
|
||||||
|
maze32_game_final_result_t final_result;
|
||||||
|
uint16_t width;
|
||||||
|
uint16_t height;
|
||||||
uint16_t self_cell_index;
|
uint16_t self_cell_index;
|
||||||
uint16_t opponent_cell_index;
|
uint16_t opponent_cell_index;
|
||||||
uint16_t observer_player_cell_indices[MAZE32_GAME_PLAYER_COUNT];
|
uint16_t observer_player_cell_indices[MAZE32_GAME_PLAYER_COUNT];
|
||||||
uint16_t cell_count;
|
uint16_t cell_count;
|
||||||
|
uint8_t self_carried_key;
|
||||||
|
uint8_t self_vision_moves_remaining;
|
||||||
|
maze32_game_compass_direction_t self_compass_hint;
|
||||||
|
uint8_t self_exit_open;
|
||||||
|
uint8_t self_exited;
|
||||||
|
uint8_t opponent_carried_key;
|
||||||
|
uint8_t observer_carried_keys[MAZE32_GAME_PLAYER_COUNT];
|
||||||
|
uint8_t observer_vision_moves_remaining[MAZE32_GAME_PLAYER_COUNT];
|
||||||
|
maze32_game_compass_direction_t observer_compass_hints[MAZE32_GAME_PLAYER_COUNT];
|
||||||
|
uint8_t observer_exited[MAZE32_GAME_PLAYER_COUNT];
|
||||||
|
uint8_t observer_connected[MAZE32_GAME_PLAYER_COUNT];
|
||||||
|
uint8_t observer_exit_open[MAZE32_GAME_PLAYER_COUNT];
|
||||||
|
uint8_t observer_has_exit_time[MAZE32_GAME_PLAYER_COUNT];
|
||||||
|
uint64_t observer_exit_times_ms[MAZE32_GAME_PLAYER_COUNT];
|
||||||
uint8_t object_count;
|
uint8_t object_count;
|
||||||
uint8_t opponent_visible;
|
uint8_t opponent_visible;
|
||||||
maze32_view_cell_t cells[MAZE32_MAZE_MAX_CELLS];
|
maze32_view_cell_t cells[MAZE32_MAZE_MAX_CELLS];
|
||||||
@@ -130,9 +227,16 @@ 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_result_t maze32_game_enqueue_move(maze32_game_t* game, maze32_game_role_t role,
|
||||||
maze32_game_direction_t direction,
|
maze32_game_direction_t direction,
|
||||||
uint64_t submitted_ms, uint32_t* command_sequence);
|
uint64_t submitted_ms, uint32_t* command_sequence);
|
||||||
|
maze32_game_result_t maze32_game_enqueue_drop_key(maze32_game_t* game, maze32_game_role_t role,
|
||||||
|
uint64_t submitted_ms,
|
||||||
|
uint32_t* command_sequence);
|
||||||
maze32_game_command_result_t maze32_game_process_next(maze32_game_t* game);
|
maze32_game_command_result_t maze32_game_process_next(maze32_game_t* game);
|
||||||
|
int maze32_game_advance_time(maze32_game_t* game, uint64_t now_ms);
|
||||||
|
maze32_game_result_t maze32_game_set_player_connected(maze32_game_t* game, uint8_t player_index,
|
||||||
|
int connected, uint64_t now_ms);
|
||||||
|
uint8_t maze32_game_player_view_radius(const maze32_game_t* game, uint8_t player_index);
|
||||||
maze32_game_result_t maze32_game_project_view(const maze32_game_t* game, maze32_game_role_t role,
|
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);
|
maze32_view_t* view);
|
||||||
size_t maze32_game_view_chunk_count(const 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,
|
maze32_game_result_t maze32_game_serialize_view_chunk(const maze32_view_t* view, size_t chunk_index,
|
||||||
char* output, size_t output_size,
|
char* output, size_t output_size,
|
||||||
|
|||||||
@@ -15,12 +15,8 @@ static int direction_is_valid(maze32_game_direction_t direction) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static maze32_passage_t direction_passage(maze32_game_direction_t direction) {
|
static maze32_passage_t direction_passage(maze32_game_direction_t direction) {
|
||||||
static const maze32_passage_t PASSAGES[] = {
|
static const maze32_passage_t PASSAGES[] = {MAZE32_PASSAGE_NORTH, MAZE32_PASSAGE_EAST,
|
||||||
MAZE32_PASSAGE_NORTH,
|
MAZE32_PASSAGE_SOUTH, MAZE32_PASSAGE_WEST};
|
||||||
MAZE32_PASSAGE_EAST,
|
|
||||||
MAZE32_PASSAGE_SOUTH,
|
|
||||||
MAZE32_PASSAGE_WEST,
|
|
||||||
};
|
|
||||||
return PASSAGES[direction];
|
return PASSAGES[direction];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,12 +41,24 @@ static int valid_cell(const maze32_maze_t* maze, maze32_cell_t cell) {
|
|||||||
return cell.x < maze->config.width && cell.y < maze->config.height;
|
return cell.x < maze->config.width && cell.y < maze->config.height;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int cells_equal(maze32_cell_t first, maze32_cell_t second) {
|
||||||
|
return first.x == second.x && first.y == second.y;
|
||||||
|
}
|
||||||
|
|
||||||
static int is_visible(maze32_cell_t center, maze32_cell_t cell, uint8_t radius) {
|
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 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;
|
uint16_t dy = center.y > cell.y ? center.y - cell.y : cell.y - center.y;
|
||||||
return dx <= radius && dy <= radius;
|
return dx <= radius && dy <= radius;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int direction_leaves_map(const maze32_maze_t* maze, maze32_cell_t cell,
|
||||||
|
maze32_game_direction_t direction) {
|
||||||
|
return (direction == MAZE32_GAME_DIRECTION_NORTH && cell.y == 0U) ||
|
||||||
|
(direction == MAZE32_GAME_DIRECTION_EAST && cell.x + 1U == maze->config.width) ||
|
||||||
|
(direction == MAZE32_GAME_DIRECTION_SOUTH && cell.y + 1U == maze->config.height) ||
|
||||||
|
(direction == MAZE32_GAME_DIRECTION_WEST && cell.x == 0U);
|
||||||
|
}
|
||||||
|
|
||||||
maze32_game_config_t maze32_game_default_config(void) {
|
maze32_game_config_t maze32_game_default_config(void) {
|
||||||
const maze32_game_config_t config = {.movement_cooldown_ms = 150U};
|
const maze32_game_config_t config = {.movement_cooldown_ms = 150U};
|
||||||
return config;
|
return config;
|
||||||
@@ -70,23 +78,39 @@ maze32_game_result_t maze32_game_init(maze32_game_t* game, const maze32_maze_t*
|
|||||||
game->maze = maze;
|
game->maze = maze;
|
||||||
game->config = *config;
|
game->config = *config;
|
||||||
game->phase = MAZE32_GAME_PHASE_COUNTDOWN;
|
game->phase = MAZE32_GAME_PHASE_COUNTDOWN;
|
||||||
game->players[0].position = maze->starts[0];
|
game->phase_before_pause = MAZE32_GAME_PHASE_ACTIVE;
|
||||||
game->players[1].position = maze->starts[1];
|
|
||||||
game->next_command_sequence = 1U;
|
game->next_command_sequence = 1U;
|
||||||
|
game->first_exited_player = MAZE32_GAME_NO_OWNER;
|
||||||
|
for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) {
|
||||||
|
game->players[player].position = maze->starts[player];
|
||||||
|
game->players[player].carried_key = MAZE32_GAME_NO_KEY;
|
||||||
|
game->players[player].connected = 1U;
|
||||||
|
game->keys[player].location = MAZE32_GAME_KEY_GROUND;
|
||||||
|
game->keys[player].ground_position = maze->keys[player];
|
||||||
|
game->keys[player].carrier = MAZE32_GAME_NO_OWNER;
|
||||||
|
}
|
||||||
return MAZE32_GAME_RESULT_ACCEPTED;
|
return MAZE32_GAME_RESULT_ACCEPTED;
|
||||||
}
|
}
|
||||||
|
|
||||||
void maze32_game_set_phase(maze32_game_t* game, maze32_game_phase_t phase) {
|
void maze32_game_set_phase(maze32_game_t* game, maze32_game_phase_t phase) {
|
||||||
if (game != NULL &&
|
if (game != NULL && phase >= MAZE32_GAME_PHASE_COUNTDOWN &&
|
||||||
(phase == MAZE32_GAME_PHASE_COUNTDOWN || phase == MAZE32_GAME_PHASE_ACTIVE)) {
|
phase <= MAZE32_GAME_PHASE_FINISHED) {
|
||||||
game->phase = phase;
|
if (phase == MAZE32_GAME_PHASE_ACTIVE &&
|
||||||
|
(!game->players[0].connected || !game->players[1].connected)) {
|
||||||
|
game->phase_before_pause = MAZE32_GAME_PHASE_ACTIVE;
|
||||||
|
game->phase = MAZE32_GAME_PHASE_PAUSED;
|
||||||
|
} else {
|
||||||
|
game->phase = phase;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
maze32_game_result_t maze32_game_enqueue_move(maze32_game_t* game, maze32_game_role_t role,
|
static maze32_game_result_t enqueue_command(maze32_game_t* game, maze32_game_role_t role,
|
||||||
maze32_game_direction_t direction,
|
maze32_game_command_type_t type,
|
||||||
uint64_t submitted_ms, uint32_t* command_sequence) {
|
maze32_game_direction_t direction,
|
||||||
if (game == NULL || command_sequence == NULL || !direction_is_valid(direction)) {
|
uint64_t submitted_ms, uint32_t* command_sequence) {
|
||||||
|
if (game == NULL || command_sequence == NULL ||
|
||||||
|
(type == MAZE32_GAME_COMMAND_MOVE && !direction_is_valid(direction))) {
|
||||||
return MAZE32_GAME_RESULT_INVALID_ARGUMENT;
|
return MAZE32_GAME_RESULT_INVALID_ARGUMENT;
|
||||||
}
|
}
|
||||||
if (!role_is_player(role)) {
|
if (!role_is_player(role)) {
|
||||||
@@ -99,6 +123,7 @@ maze32_game_result_t maze32_game_enqueue_move(maze32_game_t* game, maze32_game_r
|
|||||||
(uint8_t)((game->command_head + game->command_count) % MAZE32_GAME_COMMAND_CAPACITY);
|
(uint8_t)((game->command_head + game->command_count) % MAZE32_GAME_COMMAND_CAPACITY);
|
||||||
maze32_game_command_t* command = &game->commands[tail];
|
maze32_game_command_t* command = &game->commands[tail];
|
||||||
command->command_sequence = game->next_command_sequence++;
|
command->command_sequence = game->next_command_sequence++;
|
||||||
|
command->type = type;
|
||||||
command->role = role;
|
command->role = role;
|
||||||
command->direction = direction;
|
command->direction = direction;
|
||||||
command->submitted_ms = submitted_ms;
|
command->submitted_ms = submitted_ms;
|
||||||
@@ -107,59 +132,358 @@ maze32_game_result_t maze32_game_enqueue_move(maze32_game_t* game, maze32_game_r
|
|||||||
return MAZE32_GAME_RESULT_ACCEPTED;
|
return MAZE32_GAME_RESULT_ACCEPTED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return enqueue_command(game, role, MAZE32_GAME_COMMAND_MOVE, direction, submitted_ms,
|
||||||
|
command_sequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
maze32_game_result_t maze32_game_enqueue_drop_key(maze32_game_t* game, maze32_game_role_t role,
|
||||||
|
uint64_t submitted_ms,
|
||||||
|
uint32_t* command_sequence) {
|
||||||
|
return enqueue_command(game, role, MAZE32_GAME_COMMAND_DROP_KEY, MAZE32_GAME_DIRECTION_NORTH,
|
||||||
|
submitted_ms, command_sequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
int maze32_game_advance_time(maze32_game_t* game, uint64_t now_ms) {
|
||||||
|
if (game == NULL) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (game->phase == MAZE32_GAME_PHASE_PAUSED) {
|
||||||
|
for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) {
|
||||||
|
if (!game->players[player].connected &&
|
||||||
|
now_ms >= game->players[player].reconnect_deadline_ms) {
|
||||||
|
game->phase = MAZE32_GAME_PHASE_FINISHED;
|
||||||
|
game->final_result = MAZE32_GAME_FINAL_DRAW;
|
||||||
|
game->paused_started_ms = 0U;
|
||||||
|
game->command_head = 0U;
|
||||||
|
game->command_count = 0U;
|
||||||
|
game->state_sequence++;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (game->phase != MAZE32_GAME_PHASE_FINISH_PENDING ||
|
||||||
|
game->first_exited_player >= MAZE32_GAME_PLAYER_COUNT) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
uint64_t first_exit_ms = game->exit_times_ms[game->first_exited_player];
|
||||||
|
if (now_ms <= first_exit_ms || now_ms - first_exit_ms <= MAZE32_GAME_DRAW_WINDOW_MS) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
game->phase = MAZE32_GAME_PHASE_FINISHED;
|
||||||
|
game->final_result = game->first_exited_player == 0U ? MAZE32_GAME_FINAL_PLAYER_ONE
|
||||||
|
: MAZE32_GAME_FINAL_PLAYER_TWO;
|
||||||
|
game->state_sequence++;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
maze32_game_result_t maze32_game_set_player_connected(maze32_game_t* game, uint8_t player_index,
|
||||||
|
int connected, uint64_t now_ms) {
|
||||||
|
if (game == NULL || player_index >= MAZE32_GAME_PLAYER_COUNT) {
|
||||||
|
return MAZE32_GAME_RESULT_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
maze32_game_player_t* player = &game->players[player_index];
|
||||||
|
uint8_t normalized = connected ? 1U : 0U;
|
||||||
|
if (player->connected == normalized) {
|
||||||
|
return MAZE32_GAME_RESULT_ACCEPTED;
|
||||||
|
}
|
||||||
|
if (game->phase == MAZE32_GAME_PHASE_FINISHED) {
|
||||||
|
return MAZE32_GAME_RESULT_MATCH_FINISHED;
|
||||||
|
}
|
||||||
|
if (normalized) {
|
||||||
|
if (game->phase == MAZE32_GAME_PHASE_PAUSED && now_ms >= player->reconnect_deadline_ms) {
|
||||||
|
maze32_game_advance_time(game, now_ms);
|
||||||
|
return MAZE32_GAME_RESULT_MATCH_FINISHED;
|
||||||
|
}
|
||||||
|
player->connected = 1U;
|
||||||
|
player->reconnect_deadline_ms = 0U;
|
||||||
|
if (game->phase == MAZE32_GAME_PHASE_PAUSED && game->players[0].connected &&
|
||||||
|
game->players[1].connected) {
|
||||||
|
if (game->phase_before_pause == MAZE32_GAME_PHASE_FINISH_PENDING &&
|
||||||
|
game->first_exited_player < MAZE32_GAME_PLAYER_COUNT &&
|
||||||
|
now_ms > game->paused_started_ms) {
|
||||||
|
uint64_t pause_duration_ms = now_ms - game->paused_started_ms;
|
||||||
|
uint64_t* exit_time_ms = &game->exit_times_ms[game->first_exited_player];
|
||||||
|
*exit_time_ms = *exit_time_ms > UINT64_MAX - pause_duration_ms
|
||||||
|
? UINT64_MAX
|
||||||
|
: *exit_time_ms + pause_duration_ms;
|
||||||
|
}
|
||||||
|
game->phase = game->phase_before_pause;
|
||||||
|
game->paused_started_ms = 0U;
|
||||||
|
}
|
||||||
|
game->state_sequence++;
|
||||||
|
return MAZE32_GAME_RESULT_ACCEPTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
player->connected = 0U;
|
||||||
|
player->reconnect_deadline_ms = now_ms > UINT64_MAX - MAZE32_GAME_RECONNECT_TIMEOUT_MS
|
||||||
|
? UINT64_MAX
|
||||||
|
: now_ms + MAZE32_GAME_RECONNECT_TIMEOUT_MS;
|
||||||
|
if (game->phase == MAZE32_GAME_PHASE_COUNTDOWN || game->phase == MAZE32_GAME_PHASE_ACTIVE ||
|
||||||
|
game->phase == MAZE32_GAME_PHASE_FINISH_PENDING) {
|
||||||
|
game->phase_before_pause = game->phase;
|
||||||
|
game->phase = MAZE32_GAME_PHASE_PAUSED;
|
||||||
|
game->paused_started_ms = now_ms;
|
||||||
|
game->command_head = 0U;
|
||||||
|
game->command_count = 0U;
|
||||||
|
}
|
||||||
|
game->state_sequence++;
|
||||||
|
return MAZE32_GAME_RESULT_ACCEPTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void accept_player_move(maze32_game_player_t* player, uint64_t submitted_ms) {
|
||||||
|
player->last_move_ms = submitted_ms;
|
||||||
|
player->accepted_moves++;
|
||||||
|
player->has_moved = 1U;
|
||||||
|
player->compass_hint = MAZE32_GAME_COMPASS_NONE;
|
||||||
|
if (player->vision_moves_remaining > 0U) {
|
||||||
|
player->vision_moves_remaining--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint8_t artifact_at_player(const maze32_game_t* game, maze32_cell_t position) {
|
||||||
|
for (uint8_t pair = 0U; pair < game->maze->config.artifact_pairs; ++pair) {
|
||||||
|
for (uint8_t side = 0U; side < 2U; ++side) {
|
||||||
|
uint8_t artifact = (uint8_t)(pair * 2U + side);
|
||||||
|
if (!game->consumed_artifacts[artifact] &&
|
||||||
|
cells_equal(game->maze->artifacts[pair][side], position)) {
|
||||||
|
return artifact;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return UINT8_MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint8_t ground_key_at_player(const maze32_game_t* game, maze32_cell_t position) {
|
||||||
|
for (uint8_t key = 0U; key < MAZE32_GAME_KEY_COUNT; ++key) {
|
||||||
|
if (game->keys[key].location == MAZE32_GAME_KEY_GROUND &&
|
||||||
|
cells_equal(game->keys[key].ground_position, position)) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return MAZE32_GAME_NO_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint8_t return_keys_to_owners(maze32_game_t* game) {
|
||||||
|
uint8_t carried[MAZE32_GAME_PLAYER_COUNT];
|
||||||
|
uint8_t returned = 0U;
|
||||||
|
for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) {
|
||||||
|
carried[player] = game->players[player].carried_key;
|
||||||
|
if (carried[player] != MAZE32_GAME_NO_KEY) {
|
||||||
|
game->players[player].carried_key = MAZE32_GAME_NO_KEY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) {
|
||||||
|
uint8_t key = carried[player];
|
||||||
|
if (key != MAZE32_GAME_NO_KEY) {
|
||||||
|
game->players[key].carried_key = key;
|
||||||
|
game->keys[key].location = MAZE32_GAME_KEY_CARRIED;
|
||||||
|
game->keys[key].carrier = key;
|
||||||
|
returned |= (uint8_t)(1U << key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return returned;
|
||||||
|
}
|
||||||
|
|
||||||
|
static maze32_game_compass_direction_t compass_direction(maze32_cell_t from, maze32_cell_t to) {
|
||||||
|
int horizontal = (to.x > from.x) - (to.x < from.x);
|
||||||
|
int vertical = (to.y > from.y) - (to.y < from.y);
|
||||||
|
if (vertical < 0 && horizontal == 0) {
|
||||||
|
return MAZE32_GAME_COMPASS_NORTH;
|
||||||
|
}
|
||||||
|
if (vertical < 0 && horizontal > 0) {
|
||||||
|
return MAZE32_GAME_COMPASS_NORTH_EAST;
|
||||||
|
}
|
||||||
|
if (vertical == 0 && horizontal > 0) {
|
||||||
|
return MAZE32_GAME_COMPASS_EAST;
|
||||||
|
}
|
||||||
|
if (vertical > 0 && horizontal > 0) {
|
||||||
|
return MAZE32_GAME_COMPASS_SOUTH_EAST;
|
||||||
|
}
|
||||||
|
if (vertical > 0 && horizontal == 0) {
|
||||||
|
return MAZE32_GAME_COMPASS_SOUTH;
|
||||||
|
}
|
||||||
|
if (vertical > 0 && horizontal < 0) {
|
||||||
|
return MAZE32_GAME_COMPASS_SOUTH_WEST;
|
||||||
|
}
|
||||||
|
if (vertical == 0 && horizontal < 0) {
|
||||||
|
return MAZE32_GAME_COMPASS_WEST;
|
||||||
|
}
|
||||||
|
if (vertical < 0 && horizontal < 0) {
|
||||||
|
return MAZE32_GAME_COMPASS_NORTH_WEST;
|
||||||
|
}
|
||||||
|
return MAZE32_GAME_COMPASS_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void resolve_cell_entry(maze32_game_t* game, uint8_t player_index,
|
||||||
|
maze32_game_command_result_t* processed) {
|
||||||
|
maze32_game_player_t* player = &game->players[player_index];
|
||||||
|
uint8_t artifact = artifact_at_player(game, player->position);
|
||||||
|
if (artifact != UINT8_MAX) {
|
||||||
|
game->consumed_artifacts[artifact] = 1U;
|
||||||
|
player->vision_moves_remaining = MAZE32_GAME_VISION_MOVES;
|
||||||
|
processed->activated_artifact = 1U;
|
||||||
|
processed->artifact = artifact;
|
||||||
|
}
|
||||||
|
if (player->carried_key == MAZE32_GAME_NO_KEY) {
|
||||||
|
uint8_t key = ground_key_at_player(game, player->position);
|
||||||
|
if (key != MAZE32_GAME_NO_KEY) {
|
||||||
|
player->carried_key = key;
|
||||||
|
game->keys[key].location = MAZE32_GAME_KEY_CARRIED;
|
||||||
|
game->keys[key].carrier = player_index;
|
||||||
|
processed->picked_key = 1U;
|
||||||
|
processed->key = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t opponent_index = (uint8_t)(1U - player_index);
|
||||||
|
if (cells_equal(player->position, game->players[opponent_index].position)) {
|
||||||
|
game->meeting_sequence++;
|
||||||
|
processed->meeting = 1U;
|
||||||
|
processed->meeting_sequence = game->meeting_sequence;
|
||||||
|
processed->returned_keys = return_keys_to_owners(game);
|
||||||
|
}
|
||||||
|
if (cells_equal(player->position, game->maze->compasses[player_index])) {
|
||||||
|
player->compass_hint = compass_direction(player->position, game->maze->exits[player_index]);
|
||||||
|
processed->compass_hint = player->compass_hint;
|
||||||
|
}
|
||||||
|
if (!game->exits[player_index].open &&
|
||||||
|
cells_equal(player->position, game->maze->exits[player_index]) &&
|
||||||
|
player->carried_key == player_index) {
|
||||||
|
game->exits[player_index].open = 1U;
|
||||||
|
processed->opened_exit = 1U;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void finish_outward_move(maze32_game_t* game, uint8_t player_index, uint64_t submitted_ms,
|
||||||
|
maze32_game_command_result_t* processed) {
|
||||||
|
maze32_game_player_t* player = &game->players[player_index];
|
||||||
|
accept_player_move(player, submitted_ms);
|
||||||
|
player->exited = 1U;
|
||||||
|
game->has_exit_time[player_index] = 1U;
|
||||||
|
game->exit_times_ms[player_index] = submitted_ms;
|
||||||
|
processed->moved = 1U;
|
||||||
|
processed->outward_exit = 1U;
|
||||||
|
if (game->first_exited_player == MAZE32_GAME_NO_OWNER) {
|
||||||
|
game->first_exited_player = player_index;
|
||||||
|
game->phase = MAZE32_GAME_PHASE_FINISH_PENDING;
|
||||||
|
} else {
|
||||||
|
game->phase = MAZE32_GAME_PHASE_FINISHED;
|
||||||
|
game->final_result = MAZE32_GAME_FINAL_DRAW;
|
||||||
|
processed->finished = 1U;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void process_drop_key(maze32_game_t* game, uint8_t player_index,
|
||||||
|
maze32_game_command_result_t* processed) {
|
||||||
|
maze32_game_player_t* player = &game->players[player_index];
|
||||||
|
if (player->carried_key == MAZE32_GAME_NO_KEY) {
|
||||||
|
processed->result = MAZE32_GAME_RESULT_NO_KEY;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uint8_t key = player->carried_key;
|
||||||
|
player->carried_key = MAZE32_GAME_NO_KEY;
|
||||||
|
game->keys[key].location = MAZE32_GAME_KEY_GROUND;
|
||||||
|
game->keys[key].ground_position = player->position;
|
||||||
|
game->keys[key].carrier = MAZE32_GAME_NO_OWNER;
|
||||||
|
game->state_sequence++;
|
||||||
|
processed->result = MAZE32_GAME_RESULT_ACCEPTED;
|
||||||
|
processed->state_sequence = game->state_sequence;
|
||||||
|
processed->dropped_key = 1U;
|
||||||
|
processed->key = key;
|
||||||
|
}
|
||||||
|
|
||||||
maze32_game_command_result_t maze32_game_process_next(maze32_game_t* game) {
|
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};
|
maze32_game_command_result_t processed = {
|
||||||
|
.result = MAZE32_GAME_RESULT_INVALID_ARGUMENT,
|
||||||
|
.key = MAZE32_GAME_NO_KEY,
|
||||||
|
.artifact = UINT8_MAX,
|
||||||
|
};
|
||||||
if (game == NULL) {
|
if (game == NULL) {
|
||||||
return processed;
|
return processed;
|
||||||
}
|
}
|
||||||
processed.state_sequence = game->state_sequence;
|
processed.state_sequence = game->state_sequence;
|
||||||
processed.meeting_sequence = game->meeting_sequence;
|
processed.meeting_sequence = game->meeting_sequence;
|
||||||
|
processed.final_result = game->final_result;
|
||||||
if (game->command_count == 0U) {
|
if (game->command_count == 0U) {
|
||||||
processed.result = MAZE32_GAME_RESULT_QUEUE_EMPTY;
|
processed.result = MAZE32_GAME_RESULT_QUEUE_EMPTY;
|
||||||
return processed;
|
return processed;
|
||||||
}
|
}
|
||||||
|
|
||||||
maze32_game_command_t command = game->commands[game->command_head];
|
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_head = (uint8_t)((game->command_head + 1U) % MAZE32_GAME_COMMAND_CAPACITY);
|
||||||
game->command_count--;
|
game->command_count--;
|
||||||
processed.command_sequence = command.command_sequence;
|
processed.command_sequence = command.command_sequence;
|
||||||
if (game->phase != MAZE32_GAME_PHASE_ACTIVE) {
|
|
||||||
|
if (maze32_game_advance_time(game, command.submitted_ms)) {
|
||||||
|
processed.result = MAZE32_GAME_RESULT_MATCH_FINISHED;
|
||||||
|
processed.state_sequence = game->state_sequence;
|
||||||
|
processed.final_result = game->final_result;
|
||||||
|
processed.finished = 1U;
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
if (game->phase == MAZE32_GAME_PHASE_FINISHED) {
|
||||||
|
processed.result = MAZE32_GAME_RESULT_MATCH_FINISHED;
|
||||||
|
processed.final_result = game->final_result;
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
if (game->phase != MAZE32_GAME_PHASE_ACTIVE &&
|
||||||
|
game->phase != MAZE32_GAME_PHASE_FINISH_PENDING) {
|
||||||
processed.result = MAZE32_GAME_RESULT_PREMATURE;
|
processed.result = MAZE32_GAME_RESULT_PREMATURE;
|
||||||
return processed;
|
return processed;
|
||||||
}
|
}
|
||||||
|
|
||||||
uint8_t player_index = (uint8_t)command.role;
|
uint8_t player_index = (uint8_t)command.role;
|
||||||
maze32_game_player_t* player = &game->players[player_index];
|
maze32_game_player_t* player = &game->players[player_index];
|
||||||
|
if (player->exited) {
|
||||||
|
processed.result = MAZE32_GAME_RESULT_PLAYER_EXITED;
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
if (command.type == MAZE32_GAME_COMMAND_DROP_KEY) {
|
||||||
|
process_drop_key(game, player_index, &processed);
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
if (player->has_moved &&
|
if (player->has_moved &&
|
||||||
(command.submitted_ms < player->last_move_ms ||
|
(command.submitted_ms < player->last_move_ms ||
|
||||||
command.submitted_ms - player->last_move_ms < game->config.movement_cooldown_ms)) {
|
command.submitted_ms - player->last_move_ms < game->config.movement_cooldown_ms)) {
|
||||||
processed.result = MAZE32_GAME_RESULT_RATE_LIMITED;
|
processed.result = MAZE32_GAME_RESULT_RATE_LIMITED;
|
||||||
return processed;
|
return processed;
|
||||||
}
|
}
|
||||||
|
if (direction_leaves_map(game->maze, player->position, command.direction)) {
|
||||||
maze32_passage_t passage = direction_passage(command.direction);
|
if (!cells_equal(player->position, game->maze->exits[player_index]) ||
|
||||||
if (!maze32_maze_has_passage(game->maze, player->position, passage)) {
|
!game->exits[player_index].open) {
|
||||||
processed.result = MAZE32_GAME_RESULT_BLOCKED;
|
processed.result = MAZE32_GAME_RESULT_BLOCKED;
|
||||||
return processed;
|
return processed;
|
||||||
|
}
|
||||||
|
finish_outward_move(game, player_index, command.submitted_ms, &processed);
|
||||||
|
} else {
|
||||||
|
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);
|
||||||
|
accept_player_move(player, command.submitted_ms);
|
||||||
|
processed.moved = 1U;
|
||||||
|
resolve_cell_entry(game, player_index, &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++;
|
game->state_sequence++;
|
||||||
processed.result = MAZE32_GAME_RESULT_ACCEPTED;
|
processed.result = MAZE32_GAME_RESULT_ACCEPTED;
|
||||||
processed.state_sequence = game->state_sequence;
|
processed.state_sequence = game->state_sequence;
|
||||||
uint8_t opponent_index = (uint8_t)(1U - player_index);
|
processed.meeting_sequence = game->meeting_sequence;
|
||||||
if (player->position.x == game->players[opponent_index].position.x &&
|
processed.final_result = game->final_result;
|
||||||
player->position.y == game->players[opponent_index].position.y) {
|
|
||||||
game->meeting_sequence++;
|
|
||||||
processed.meeting = 1U;
|
|
||||||
processed.meeting_sequence = game->meeting_sequence;
|
|
||||||
}
|
|
||||||
return processed;
|
return processed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uint8_t maze32_game_player_view_radius(const maze32_game_t* game, uint8_t player_index) {
|
||||||
|
if (game == NULL || player_index >= MAZE32_GAME_PLAYER_COUNT) {
|
||||||
|
return 0U;
|
||||||
|
}
|
||||||
|
return game->players[player_index].vision_moves_remaining > 0U ? 6U : 3U;
|
||||||
|
}
|
||||||
|
|
||||||
static void add_object(const maze32_game_t* game, maze32_view_t* view, maze32_cell_t cell,
|
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_view_object_kind_t kind, uint8_t owner, uint8_t variant,
|
||||||
maze32_cell_t center, uint8_t radius, int reveal_all) {
|
maze32_cell_t center, uint8_t radius, int reveal_all) {
|
||||||
@@ -177,10 +501,12 @@ static void add_object(const maze32_game_t* game, maze32_view_t* view, maze32_ce
|
|||||||
static void project_objects(const maze32_game_t* game, maze32_view_t* view, maze32_cell_t center,
|
static void project_objects(const maze32_game_t* game, maze32_view_t* view, maze32_cell_t center,
|
||||||
uint8_t radius, int reveal_all) {
|
uint8_t radius, int reveal_all) {
|
||||||
for (uint8_t owner = 0U; owner < MAZE32_GAME_PLAYER_COUNT; ++owner) {
|
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,
|
if (game->keys[owner].location == MAZE32_GAME_KEY_GROUND) {
|
||||||
radius, reveal_all);
|
add_object(game, view, game->keys[owner].ground_position, MAZE32_VIEW_OBJECT_KEY, owner,
|
||||||
add_object(game, view, game->maze->exits[owner], MAZE32_VIEW_OBJECT_EXIT, owner, 0U, center,
|
0U, center, radius, reveal_all);
|
||||||
radius, reveal_all);
|
}
|
||||||
|
add_object(game, view, game->maze->exits[owner], MAZE32_VIEW_OBJECT_EXIT, owner,
|
||||||
|
game->exits[owner].open, center, radius, reveal_all);
|
||||||
add_object(game, view, game->maze->compasses[owner], MAZE32_VIEW_OBJECT_COMPASS, owner, 0U,
|
add_object(game, view, game->maze->compasses[owner], MAZE32_VIEW_OBJECT_COMPASS, owner, 0U,
|
||||||
center, radius, reveal_all);
|
center, radius, reveal_all);
|
||||||
}
|
}
|
||||||
@@ -193,18 +519,20 @@ static void project_objects(const maze32_game_t* game, maze32_view_t* view, maze
|
|||||||
}
|
}
|
||||||
for (uint8_t pair = 0U; pair < game->maze->config.artifact_pairs; ++pair) {
|
for (uint8_t pair = 0U; pair < game->maze->config.artifact_pairs; ++pair) {
|
||||||
for (uint8_t side = 0U; side < 2U; ++side) {
|
for (uint8_t side = 0U; side < 2U; ++side) {
|
||||||
add_object(game, view, game->maze->artifacts[pair][side],
|
uint8_t artifact = (uint8_t)(pair * 2U + side);
|
||||||
MAZE32_VIEW_OBJECT_VISION_ARTIFACT, MAZE32_GAME_NO_OWNER, pair, center,
|
if (!game->consumed_artifacts[artifact]) {
|
||||||
radius, reveal_all);
|
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,
|
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) {
|
maze32_view_t* view) {
|
||||||
if (game == NULL || game->maze == NULL || view == NULL ||
|
if (game == NULL || game->maze == NULL || view == NULL ||
|
||||||
(!role_is_player(role) && role != MAZE32_GAME_ROLE_OBSERVER) ||
|
(!role_is_player(role) && role != MAZE32_GAME_ROLE_OBSERVER)) {
|
||||||
(role_is_player(role) && radius != 3U && radius != 6U)) {
|
|
||||||
return MAZE32_GAME_RESULT_INVALID_ARGUMENT;
|
return MAZE32_GAME_RESULT_INVALID_ARGUMENT;
|
||||||
}
|
}
|
||||||
memset(view, 0, sizeof(*view));
|
memset(view, 0, sizeof(*view));
|
||||||
@@ -212,22 +540,47 @@ maze32_game_result_t maze32_game_project_view(const maze32_game_t* game, maze32_
|
|||||||
view->phase = game->phase;
|
view->phase = game->phase;
|
||||||
view->state_sequence = game->state_sequence;
|
view->state_sequence = game->state_sequence;
|
||||||
view->meeting_sequence = game->meeting_sequence;
|
view->meeting_sequence = game->meeting_sequence;
|
||||||
|
view->final_result = game->final_result;
|
||||||
|
view->width = game->maze->config.width;
|
||||||
|
view->height = game->maze->config.height;
|
||||||
|
view->self_carried_key = MAZE32_GAME_NO_KEY;
|
||||||
|
view->opponent_carried_key = MAZE32_GAME_NO_KEY;
|
||||||
view->opponent_cell_index = UINT16_MAX;
|
view->opponent_cell_index = UINT16_MAX;
|
||||||
int reveal_all = role == MAZE32_GAME_ROLE_OBSERVER;
|
int reveal_all = role == MAZE32_GAME_ROLE_OBSERVER;
|
||||||
|
uint8_t radius = 0U;
|
||||||
maze32_cell_t center = {0U, 0U};
|
maze32_cell_t center = {0U, 0U};
|
||||||
if (role_is_player(role)) {
|
if (role_is_player(role)) {
|
||||||
uint8_t player_index = (uint8_t)role;
|
uint8_t player_index = (uint8_t)role;
|
||||||
center = game->players[player_index].position;
|
maze32_game_player_t player = game->players[player_index];
|
||||||
|
center = player.position;
|
||||||
|
radius = maze32_game_player_view_radius(game, player_index);
|
||||||
view->self_cell_index = cell_index(game->maze, center);
|
view->self_cell_index = cell_index(game->maze, center);
|
||||||
maze32_cell_t opponent = game->players[1U - player_index].position;
|
view->self_carried_key = player.carried_key;
|
||||||
if (is_visible(center, opponent, radius)) {
|
view->self_vision_moves_remaining = player.vision_moves_remaining;
|
||||||
|
view->self_compass_hint = player.compass_hint;
|
||||||
|
view->self_exit_open = game->exits[player_index].open;
|
||||||
|
view->self_exited = player.exited;
|
||||||
|
maze32_game_player_t opponent = game->players[1U - player_index];
|
||||||
|
if (!opponent.exited && is_visible(center, opponent.position, radius)) {
|
||||||
view->opponent_visible = 1U;
|
view->opponent_visible = 1U;
|
||||||
view->opponent_cell_index = cell_index(game->maze, opponent);
|
view->opponent_cell_index = cell_index(game->maze, opponent.position);
|
||||||
|
view->opponent_carried_key = opponent.carried_key;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
view->self_cell_index = UINT16_MAX;
|
view->self_cell_index = UINT16_MAX;
|
||||||
view->observer_player_cell_indices[0] = cell_index(game->maze, game->players[0].position);
|
for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) {
|
||||||
view->observer_player_cell_indices[1] = cell_index(game->maze, game->players[1].position);
|
view->observer_player_cell_indices[player] =
|
||||||
|
cell_index(game->maze, game->players[player].position);
|
||||||
|
view->observer_carried_keys[player] = game->players[player].carried_key;
|
||||||
|
view->observer_vision_moves_remaining[player] =
|
||||||
|
game->players[player].vision_moves_remaining;
|
||||||
|
view->observer_compass_hints[player] = game->players[player].compass_hint;
|
||||||
|
view->observer_exited[player] = game->players[player].exited;
|
||||||
|
view->observer_connected[player] = game->players[player].connected;
|
||||||
|
view->observer_exit_open[player] = game->exits[player].open;
|
||||||
|
view->observer_has_exit_time[player] = game->has_exit_time[player];
|
||||||
|
view->observer_exit_times_ms[player] = game->exit_times_ms[player];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (uint16_t y = 0U; y < game->maze->config.height; ++y) {
|
for (uint16_t y = 0U; y < game->maze->config.height; ++y) {
|
||||||
@@ -245,12 +598,24 @@ maze32_game_result_t maze32_game_project_view(const maze32_game_t* game, maze32_
|
|||||||
return MAZE32_GAME_RESULT_ACCEPTED;
|
return MAZE32_GAME_RESULT_ACCEPTED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static size_t cell_chunk_count(const maze32_view_t* view) {
|
||||||
|
if (view->role != MAZE32_GAME_ROLE_OBSERVER || view->cell_count == 0U) {
|
||||||
|
return (view->cell_count + MAZE32_GAME_VIEW_CELLS_PER_CHUNK - 1U) /
|
||||||
|
MAZE32_GAME_VIEW_CELLS_PER_CHUNK;
|
||||||
|
}
|
||||||
|
if (view->cell_count <= MAZE32_GAME_OBSERVER_FIRST_CHUNK_CELLS) {
|
||||||
|
return 1U;
|
||||||
|
}
|
||||||
|
size_t remaining = view->cell_count - MAZE32_GAME_OBSERVER_FIRST_CHUNK_CELLS;
|
||||||
|
return 1U +
|
||||||
|
(remaining + MAZE32_GAME_VIEW_CELLS_PER_CHUNK - 1U) / MAZE32_GAME_VIEW_CELLS_PER_CHUNK;
|
||||||
|
}
|
||||||
|
|
||||||
size_t maze32_game_view_chunk_count(const maze32_view_t* view) {
|
size_t maze32_game_view_chunk_count(const maze32_view_t* view) {
|
||||||
if (view == NULL) {
|
if (view == NULL) {
|
||||||
return 0U;
|
return 0U;
|
||||||
}
|
}
|
||||||
size_t cell_chunks = (view->cell_count + MAZE32_GAME_VIEW_CELLS_PER_CHUNK - 1U) /
|
size_t cell_chunks = cell_chunk_count(view);
|
||||||
MAZE32_GAME_VIEW_CELLS_PER_CHUNK;
|
|
||||||
size_t object_chunks = (view->object_count + MAZE32_GAME_VIEW_OBJECTS_PER_CHUNK - 1U) /
|
size_t object_chunks = (view->object_count + MAZE32_GAME_VIEW_OBJECTS_PER_CHUNK - 1U) /
|
||||||
MAZE32_GAME_VIEW_OBJECTS_PER_CHUNK;
|
MAZE32_GAME_VIEW_OBJECTS_PER_CHUNK;
|
||||||
return cell_chunks + object_chunks;
|
return cell_chunks + object_chunks;
|
||||||
@@ -284,14 +649,93 @@ static const char* object_name(maze32_view_object_kind_t kind) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static const char* phase_name(maze32_game_phase_t phase) {
|
static const char* phase_name(maze32_game_phase_t phase) {
|
||||||
return phase == MAZE32_GAME_PHASE_ACTIVE ? "active" : "countdown";
|
static const char* const NAMES[] = {"countdown", "active", "finish_pending", "paused",
|
||||||
|
"finished"};
|
||||||
|
return NAMES[phase];
|
||||||
|
}
|
||||||
|
|
||||||
|
static const char* final_result_name(maze32_game_final_result_t result) {
|
||||||
|
static const char* const NAMES[] = {"none", "player1", "player2", "draw"};
|
||||||
|
return NAMES[result];
|
||||||
|
}
|
||||||
|
|
||||||
|
static const char* compass_name(maze32_game_compass_direction_t direction) {
|
||||||
|
static const char* const NAMES[] = {"none", "n", "ne", "e", "se", "s", "sw", "w", "nw"};
|
||||||
|
return NAMES[direction];
|
||||||
|
}
|
||||||
|
|
||||||
|
static int append_nullable_key(char* output, size_t output_size, size_t* offset, const char* name,
|
||||||
|
uint8_t key) {
|
||||||
|
if (key == MAZE32_GAME_NO_KEY) {
|
||||||
|
return append_text(output, output_size, offset, ",\"%s\":null", name);
|
||||||
|
}
|
||||||
|
return append_text(output, output_size, offset, ",\"%s\":%u", name, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int append_player_status(const maze32_view_t* view, char* output, size_t output_size,
|
||||||
|
size_t* offset) {
|
||||||
|
if (!append_text(output, output_size, offset,
|
||||||
|
",\"self\":%u,\"visionMoves\":%u,\"hint\":\"%s\",\"ownExitOpen\":%u,"
|
||||||
|
"\"exited\":%u",
|
||||||
|
view->self_cell_index, view->self_vision_moves_remaining,
|
||||||
|
compass_name(view->self_compass_hint), view->self_exit_open,
|
||||||
|
view->self_exited) ||
|
||||||
|
!append_nullable_key(output, output_size, offset, "inventory", view->self_carried_key)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (view->opponent_visible &&
|
||||||
|
(!append_text(output, output_size, offset, ",\"opponent\":%u", view->opponent_cell_index) ||
|
||||||
|
!append_nullable_key(output, output_size, offset, "opponentKey",
|
||||||
|
view->opponent_carried_key))) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int append_observer_status(const maze32_view_t* view, char* output, size_t output_size,
|
||||||
|
size_t* offset) {
|
||||||
|
if (!append_text(output, output_size, offset, ",\"players\":[")) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) {
|
||||||
|
if (!append_text(output, output_size, offset,
|
||||||
|
"%s{\"cell\":%u,\"visionMoves\":%u,\"hint\":\"%s\",\"exited\":%u,"
|
||||||
|
"\"connected\":%u",
|
||||||
|
player == 0U ? "" : ",", view->observer_player_cell_indices[player],
|
||||||
|
view->observer_vision_moves_remaining[player],
|
||||||
|
compass_name(view->observer_compass_hints[player]),
|
||||||
|
view->observer_exited[player], view->observer_connected[player]) ||
|
||||||
|
!append_nullable_key(output, output_size, offset, "key",
|
||||||
|
view->observer_carried_keys[player]) ||
|
||||||
|
!append_text(output, output_size, offset, "}")) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!append_text(output, output_size, offset, "],\"exits\":[%u,%u],\"exitTimes\":[",
|
||||||
|
view->observer_exit_open[0], view->observer_exit_open[1])) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) {
|
||||||
|
if (view->observer_has_exit_time[player]) {
|
||||||
|
if (!append_text(output, output_size, offset, "%s%llu", player == 0U ? "" : ",",
|
||||||
|
(unsigned long long)view->observer_exit_times_ms[player])) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
} else if (!append_text(output, output_size, offset, "%snull", player == 0U ? "" : ",")) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return append_text(output, output_size, offset, "]");
|
||||||
}
|
}
|
||||||
|
|
||||||
maze32_game_result_t maze32_game_serialize_view_chunk(const maze32_view_t* view, size_t chunk_index,
|
maze32_game_result_t maze32_game_serialize_view_chunk(const maze32_view_t* view, size_t chunk_index,
|
||||||
char* output, size_t output_size,
|
char* output, size_t output_size,
|
||||||
size_t* written) {
|
size_t* written) {
|
||||||
if (view == NULL || output == NULL || written == NULL || output_size == 0U ||
|
if (view == NULL || output == NULL || written == NULL || output_size == 0U ||
|
||||||
view->cell_count > MAZE32_MAZE_MAX_CELLS || view->object_count > MAZE32_GAME_MAX_OBJECTS) {
|
view->cell_count > MAZE32_MAZE_MAX_CELLS || view->object_count > MAZE32_GAME_MAX_OBJECTS ||
|
||||||
|
view->role > MAZE32_GAME_ROLE_OBSERVER || view->phase > MAZE32_GAME_PHASE_FINISHED ||
|
||||||
|
view->final_result > MAZE32_GAME_FINAL_DRAW ||
|
||||||
|
view->self_compass_hint > MAZE32_GAME_COMPASS_NORTH_WEST) {
|
||||||
return MAZE32_GAME_RESULT_INVALID_ARGUMENT;
|
return MAZE32_GAME_RESULT_INVALID_ARGUMENT;
|
||||||
}
|
}
|
||||||
size_t total_chunks = maze32_game_view_chunk_count(view);
|
size_t total_chunks = maze32_game_view_chunk_count(view);
|
||||||
@@ -301,36 +745,37 @@ maze32_game_result_t maze32_game_serialize_view_chunk(const maze32_view_t* view,
|
|||||||
size_t offset = 0U;
|
size_t offset = 0U;
|
||||||
if (!append_text(output, output_size, &offset,
|
if (!append_text(output, output_size, &offset,
|
||||||
"{\"v\":1,\"type\":\"view\",\"seq\":%lu,\"payload\":{\"role\":\"%s\","
|
"{\"v\":1,\"type\":\"view\",\"seq\":%lu,\"payload\":{\"role\":\"%s\","
|
||||||
"\"phase\":\"%s\",\"meetingSequence\":%lu,\"chunk\":%u,\"chunks\":%u",
|
"\"phase\":\"%s\",\"result\":\"%s\",\"width\":%u,\"height\":%u,"
|
||||||
|
"\"meetingSequence\":%lu,"
|
||||||
|
"\"chunk\":%u,\"chunks\":%u",
|
||||||
(unsigned long)view->state_sequence, role_name(view->role),
|
(unsigned long)view->state_sequence, role_name(view->role),
|
||||||
phase_name(view->phase), (unsigned long)view->meeting_sequence,
|
phase_name(view->phase), final_result_name(view->final_result), view->width,
|
||||||
(unsigned int)chunk_index, (unsigned int)total_chunks)) {
|
view->height, (unsigned long)view->meeting_sequence, (unsigned int)chunk_index,
|
||||||
|
(unsigned int)total_chunks)) {
|
||||||
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
|
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
|
||||||
}
|
}
|
||||||
if (view->role == MAZE32_GAME_ROLE_OBSERVER) {
|
int status_appended = 1;
|
||||||
if (!append_text(output, output_size, &offset, ",\"players\":[%u,%u]",
|
if (chunk_index == 0U) {
|
||||||
view->observer_player_cell_indices[0],
|
status_appended = view->role == MAZE32_GAME_ROLE_OBSERVER
|
||||||
view->observer_player_cell_indices[1])) {
|
? append_observer_status(view, output, output_size, &offset)
|
||||||
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
|
: append_player_status(view, output, output_size, &offset);
|
||||||
}
|
|
||||||
} 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 &&
|
if (!status_appended || !append_text(output, output_size, &offset, ",\"cells\":[")) {
|
||||||
!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;
|
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
|
||||||
}
|
}
|
||||||
|
|
||||||
size_t cell_chunks = (view->cell_count + MAZE32_GAME_VIEW_CELLS_PER_CHUNK - 1U) /
|
size_t cell_chunks = cell_chunk_count(view);
|
||||||
MAZE32_GAME_VIEW_CELLS_PER_CHUNK;
|
|
||||||
if (chunk_index < cell_chunks) {
|
if (chunk_index < cell_chunks) {
|
||||||
size_t first = chunk_index * MAZE32_GAME_VIEW_CELLS_PER_CHUNK;
|
size_t first = chunk_index * MAZE32_GAME_VIEW_CELLS_PER_CHUNK;
|
||||||
|
if (view->role == MAZE32_GAME_ROLE_OBSERVER) {
|
||||||
|
first = chunk_index == 0U ? 0U
|
||||||
|
: MAZE32_GAME_OBSERVER_FIRST_CHUNK_CELLS +
|
||||||
|
(chunk_index - 1U) * MAZE32_GAME_VIEW_CELLS_PER_CHUNK;
|
||||||
|
}
|
||||||
size_t end = first + MAZE32_GAME_VIEW_CELLS_PER_CHUNK;
|
size_t end = first + MAZE32_GAME_VIEW_CELLS_PER_CHUNK;
|
||||||
|
if (view->role == MAZE32_GAME_ROLE_OBSERVER && chunk_index == 0U) {
|
||||||
|
end = MAZE32_GAME_OBSERVER_FIRST_CHUNK_CELLS;
|
||||||
|
}
|
||||||
if (end > view->cell_count) {
|
if (end > view->cell_count) {
|
||||||
end = view->cell_count;
|
end = view->cell_count;
|
||||||
}
|
}
|
||||||
@@ -345,7 +790,6 @@ maze32_game_result_t maze32_game_serialize_view_chunk(const maze32_view_t* view,
|
|||||||
if (!append_text(output, output_size, &offset, "],\"objects\":[")) {
|
if (!append_text(output, output_size, &offset, "],\"objects\":[")) {
|
||||||
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
|
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chunk_index >= cell_chunks) {
|
if (chunk_index >= cell_chunks) {
|
||||||
size_t object_chunk = 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 first = object_chunk * MAZE32_GAME_VIEW_OBJECTS_PER_CHUNK;
|
||||||
@@ -355,6 +799,9 @@ maze32_game_result_t maze32_game_serialize_view_chunk(const maze32_view_t* view,
|
|||||||
}
|
}
|
||||||
for (size_t index = first; index < end; ++index) {
|
for (size_t index = first; index < end; ++index) {
|
||||||
const maze32_view_object_t* object = &view->objects[index];
|
const maze32_view_object_t* object = &view->objects[index];
|
||||||
|
if (object->kind > MAZE32_VIEW_OBJECT_VISION_ARTIFACT) {
|
||||||
|
return MAZE32_GAME_RESULT_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
if (!append_text(output, output_size, &offset, "%s[\"%s\",%u,%u,%u]",
|
if (!append_text(output, output_size, &offset, "%s[\"%s\",%u,%u,%u]",
|
||||||
index == first ? "" : ",", object_name(object->kind), object->owner,
|
index == first ? "" : ",", object_name(object->kind), object->owner,
|
||||||
object->variant, object->cell_index)) {
|
object->variant, object->cell_index)) {
|
||||||
@@ -370,10 +817,12 @@ maze32_game_result_t maze32_game_serialize_view_chunk(const maze32_view_t* view,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const char* maze32_game_result_name(maze32_game_result_t result) {
|
const char* maze32_game_result_name(maze32_game_result_t result) {
|
||||||
static const char* const NAMES[] = {"accepted", "invalid_argument", "queue_full",
|
static const char* const NAMES[] = {
|
||||||
"queue_empty", "read_only", "premature",
|
"accepted", "invalid_argument", "queue_full", "queue_empty",
|
||||||
"rate_limited", "blocked", "buffer_too_small"};
|
"read_only", "premature", "rate_limited", "blocked",
|
||||||
if (result < MAZE32_GAME_RESULT_ACCEPTED || result > MAZE32_GAME_RESULT_BUFFER_TOO_SMALL) {
|
"buffer_too_small", "no_key", "player_exited", "match_finished",
|
||||||
|
};
|
||||||
|
if (result < MAZE32_GAME_RESULT_ACCEPTED || result > MAZE32_GAME_RESULT_MATCH_FINISHED) {
|
||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
return NAMES[result];
|
return NAMES[result];
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ maze32_lobby_result_t maze32_lobby_restore(maze32_lobby_t* lobby, int connection
|
|||||||
void maze32_lobby_disconnect(maze32_lobby_t* lobby, int connection_id);
|
void maze32_lobby_disconnect(maze32_lobby_t* lobby, int connection_id);
|
||||||
maze32_lobby_result_t maze32_lobby_start(maze32_lobby_t* lobby, int connection_id, uint64_t now_ms);
|
maze32_lobby_result_t maze32_lobby_start(maze32_lobby_t* lobby, int connection_id, uint64_t now_ms);
|
||||||
int maze32_lobby_activate_if_due(maze32_lobby_t* lobby, uint64_t now_ms);
|
int maze32_lobby_activate_if_due(maze32_lobby_t* lobby, uint64_t now_ms);
|
||||||
|
void maze32_lobby_reset_match(maze32_lobby_t* lobby);
|
||||||
const maze32_session_t* maze32_lobby_session_for_connection(const maze32_lobby_t* lobby,
|
const maze32_session_t* maze32_lobby_session_for_connection(const maze32_lobby_t* lobby,
|
||||||
int connection_id);
|
int connection_id);
|
||||||
unsigned int maze32_lobby_connected_observers(const maze32_lobby_t* lobby);
|
unsigned int maze32_lobby_connected_observers(const maze32_lobby_t* lobby);
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ typedef enum {
|
|||||||
MAZE32_MESSAGE_JOIN,
|
MAZE32_MESSAGE_JOIN,
|
||||||
MAZE32_MESSAGE_RESTORE,
|
MAZE32_MESSAGE_RESTORE,
|
||||||
MAZE32_MESSAGE_START,
|
MAZE32_MESSAGE_START,
|
||||||
|
MAZE32_MESSAGE_MOVE,
|
||||||
|
MAZE32_MESSAGE_DROP_KEY,
|
||||||
|
MAZE32_MESSAGE_RESTART,
|
||||||
MAZE32_MESSAGE_HEARTBEAT,
|
MAZE32_MESSAGE_HEARTBEAT,
|
||||||
MAZE32_MESSAGE_SUGGEST_NAME,
|
|
||||||
} maze32_message_type_t;
|
} maze32_message_type_t;
|
||||||
|
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -39,6 +41,7 @@ typedef struct {
|
|||||||
maze32_role_t role;
|
maze32_role_t role;
|
||||||
char name[MAZE32_PLAYER_NAME_MAX_BYTES + 1U];
|
char name[MAZE32_PLAYER_NAME_MAX_BYTES + 1U];
|
||||||
char token[MAZE32_SESSION_TOKEN_HEX_LENGTH + 1U];
|
char token[MAZE32_SESSION_TOKEN_HEX_LENGTH + 1U];
|
||||||
|
char direction[6U];
|
||||||
} maze32_client_message_t;
|
} maze32_client_message_t;
|
||||||
|
|
||||||
maze32_protocol_result_t maze32_protocol_parse(const char* data, size_t length,
|
maze32_protocol_result_t maze32_protocol_parse(const char* data, size_t length,
|
||||||
|
|||||||
@@ -195,6 +195,16 @@ int maze32_lobby_activate_if_due(maze32_lobby_t* lobby, uint64_t now_ms) {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void maze32_lobby_reset_match(maze32_lobby_t* lobby) {
|
||||||
|
if (lobby == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lobby->phase = MAZE32_LOBBY_WAITING;
|
||||||
|
lobby->countdown_started_ms = 0U;
|
||||||
|
lobby->activation_ms = 0U;
|
||||||
|
lobby->revision++;
|
||||||
|
}
|
||||||
|
|
||||||
const maze32_session_t* maze32_lobby_session_for_connection(const maze32_lobby_t* lobby,
|
const maze32_session_t* maze32_lobby_session_for_connection(const maze32_lobby_t* lobby,
|
||||||
int connection_id) {
|
int connection_id) {
|
||||||
if (lobby == NULL) {
|
if (lobby == NULL) {
|
||||||
|
|||||||
@@ -123,6 +123,15 @@ static int payload_field_allowed(maze32_message_type_t type, const char* key, ch
|
|||||||
*fields |= 1U;
|
*fields |= 1U;
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
if (type == MAZE32_MESSAGE_MOVE && strcmp(key, "direction") == 0 && !(*fields & 1U)) {
|
||||||
|
if (strcmp(value, "north") != 0 && strcmp(value, "east") != 0 &&
|
||||||
|
strcmp(value, "south") != 0 && strcmp(value, "west") != 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
memcpy(message->direction, value, strlen(value) + 1U);
|
||||||
|
*fields |= 1U;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +168,9 @@ static int parse_payload(json_cursor_t* cursor, maze32_client_message_t* message
|
|||||||
if (message->type == MAZE32_MESSAGE_RESTORE) {
|
if (message->type == MAZE32_MESSAGE_RESTORE) {
|
||||||
return fields == 1U;
|
return fields == 1U;
|
||||||
}
|
}
|
||||||
|
if (message->type == MAZE32_MESSAGE_MOVE) {
|
||||||
|
return fields == 1U;
|
||||||
|
}
|
||||||
return fields == 0U;
|
return fields == 0U;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,12 +184,18 @@ static maze32_message_type_t message_type(const char* value) {
|
|||||||
if (strcmp(value, "start") == 0) {
|
if (strcmp(value, "start") == 0) {
|
||||||
return MAZE32_MESSAGE_START;
|
return MAZE32_MESSAGE_START;
|
||||||
}
|
}
|
||||||
|
if (strcmp(value, "move") == 0) {
|
||||||
|
return MAZE32_MESSAGE_MOVE;
|
||||||
|
}
|
||||||
|
if (strcmp(value, "drop_key") == 0) {
|
||||||
|
return MAZE32_MESSAGE_DROP_KEY;
|
||||||
|
}
|
||||||
|
if (strcmp(value, "restart") == 0) {
|
||||||
|
return MAZE32_MESSAGE_RESTART;
|
||||||
|
}
|
||||||
if (strcmp(value, "heartbeat") == 0) {
|
if (strcmp(value, "heartbeat") == 0) {
|
||||||
return MAZE32_MESSAGE_HEARTBEAT;
|
return MAZE32_MESSAGE_HEARTBEAT;
|
||||||
}
|
}
|
||||||
if (strcmp(value, "suggest_name") == 0) {
|
|
||||||
return MAZE32_MESSAGE_SUGGEST_NAME;
|
|
||||||
}
|
|
||||||
return MAZE32_MESSAGE_INVALID;
|
return MAZE32_MESSAGE_INVALID;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,11 @@
|
|||||||
extern "C" {
|
extern "C" {
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#define MAZE32_MAZE_MAX_WIDTH 128U
|
#define MAZE32_MAZE_MAX_WIDTH 32U
|
||||||
#define MAZE32_MAZE_MAX_HEIGHT 64U
|
#define MAZE32_MAZE_MAX_HEIGHT 32U
|
||||||
#define MAZE32_MAZE_MAX_CELLS (MAZE32_MAZE_MAX_WIDTH * MAZE32_MAZE_MAX_HEIGHT)
|
#define MAZE32_MAZE_MAX_CELLS (MAZE32_MAZE_MAX_WIDTH * MAZE32_MAZE_MAX_HEIGHT)
|
||||||
#define MAZE32_MAZE_DEFAULT_WIDTH 128U
|
#define MAZE32_MAZE_DEFAULT_WIDTH 32U
|
||||||
#define MAZE32_MAZE_DEFAULT_HEIGHT 64U
|
#define MAZE32_MAZE_DEFAULT_HEIGHT 32U
|
||||||
#define MAZE32_MAZE_MAX_LANDMARK_PAIRS 16U
|
#define MAZE32_MAZE_MAX_LANDMARK_PAIRS 16U
|
||||||
#define MAZE32_MAZE_MAX_ARTIFACT_PAIRS 16U
|
#define MAZE32_MAZE_MAX_ARTIFACT_PAIRS 16U
|
||||||
#define MAZE32_MAZE_RAM_BUDGET_BYTES (64U * 1024U)
|
#define MAZE32_MAZE_RAM_BUDGET_BYTES (64U * 1024U)
|
||||||
|
|||||||
@@ -571,11 +571,11 @@ maze32_maze_config_t maze32_maze_default_config(void) {
|
|||||||
.width = MAZE32_MAZE_DEFAULT_WIDTH,
|
.width = MAZE32_MAZE_DEFAULT_WIDTH,
|
||||||
.height = MAZE32_MAZE_DEFAULT_HEIGHT,
|
.height = MAZE32_MAZE_DEFAULT_HEIGHT,
|
||||||
.initial_view_radius = 3U,
|
.initial_view_radius = 3U,
|
||||||
.cross_half_pairs = 3U,
|
.cross_half_pairs = 2U,
|
||||||
.extra_cycle_pairs = 64U,
|
.extra_cycle_pairs = 8U,
|
||||||
.minimum_exit_separation = 64U,
|
.minimum_exit_separation = 22U,
|
||||||
.landmark_pairs = 8U,
|
.landmark_pairs = 2U,
|
||||||
.artifact_pairs = 4U,
|
.artifact_pairs = 1U,
|
||||||
.maximum_attempts = 8U,
|
.maximum_attempts = 8U,
|
||||||
};
|
};
|
||||||
return config;
|
return config;
|
||||||
|
|||||||
@@ -35,36 +35,33 @@ typedef struct {
|
|||||||
typedef struct {
|
typedef struct {
|
||||||
EventGroupHandle_t events;
|
EventGroupHandle_t events;
|
||||||
SemaphoreHandle_t operation_mutex;
|
SemaphoreHandle_t operation_mutex;
|
||||||
esp_netif_t *station_netif;
|
esp_netif_t* station_netif;
|
||||||
esp_netif_t *access_point_netif;
|
esp_netif_t* access_point_netif;
|
||||||
volatile maze32_network_state_t state;
|
volatile maze32_network_state_t state;
|
||||||
|
volatile int station_wait_active;
|
||||||
unsigned int retry_count;
|
unsigned int retry_count;
|
||||||
char access_point_ssid[24];
|
char access_point_ssid[24];
|
||||||
maze32_reconfiguration_guard_fn reconfiguration_guard;
|
maze32_reconfiguration_guard_fn reconfiguration_guard;
|
||||||
void *guard_context;
|
void* guard_context;
|
||||||
httpd_handle_t server;
|
httpd_handle_t server;
|
||||||
} maze32_network_t;
|
} maze32_network_t;
|
||||||
|
|
||||||
esp_err_t maze32_network_init(maze32_network_t *network,
|
esp_err_t maze32_network_init(maze32_network_t* network, maze32_reconfiguration_guard_fn guard,
|
||||||
maze32_reconfiguration_guard_fn guard,
|
void* guard_context);
|
||||||
void *guard_context);
|
esp_err_t maze32_network_start(maze32_network_t* network);
|
||||||
esp_err_t maze32_network_start(maze32_network_t *network);
|
esp_err_t maze32_network_apply_credentials(maze32_network_t* network,
|
||||||
esp_err_t maze32_network_apply_credentials(maze32_network_t *network,
|
const maze32_wifi_credentials_t* credentials);
|
||||||
const maze32_wifi_credentials_t *credentials);
|
esp_err_t maze32_network_reset_credentials(maze32_network_t* network);
|
||||||
esp_err_t maze32_network_reset_credentials(maze32_network_t *network);
|
void maze32_network_retry_saved_credentials(void* context);
|
||||||
void maze32_network_retry_saved_credentials(void *context);
|
void maze32_network_return_to_provisioning(void* context);
|
||||||
void maze32_network_return_to_provisioning(void *context);
|
esp_err_t maze32_network_scan(maze32_network_t* network, maze32_wifi_scan_result_t* results,
|
||||||
esp_err_t maze32_network_scan(maze32_network_t *network,
|
size_t capacity, size_t* count);
|
||||||
maze32_wifi_scan_result_t *results,
|
maze32_network_state_t maze32_network_state(const maze32_network_t* network);
|
||||||
size_t capacity,
|
const char* maze32_network_state_name(maze32_network_state_t state);
|
||||||
size_t *count);
|
const char* maze32_network_access_point_ssid(const maze32_network_t* network);
|
||||||
maze32_network_state_t maze32_network_state(const maze32_network_t *network);
|
esp_err_t maze32_network_ip_address(const maze32_network_t* network, char* output,
|
||||||
const char *maze32_network_state_name(maze32_network_state_t state);
|
|
||||||
const char *maze32_network_access_point_ssid(const maze32_network_t *network);
|
|
||||||
esp_err_t maze32_network_ip_address(const maze32_network_t *network,
|
|
||||||
char *output,
|
|
||||||
size_t output_size);
|
size_t output_size);
|
||||||
esp_err_t maze32_provisioning_register(httpd_handle_t server, maze32_network_t *network);
|
esp_err_t maze32_provisioning_register(httpd_handle_t server, maze32_network_t* network);
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,42 +13,61 @@
|
|||||||
#define MAZE32_STATION_RETRY_LIMIT 3U
|
#define MAZE32_STATION_RETRY_LIMIT 3U
|
||||||
#define MAZE32_STATION_TIMEOUT_MS 12000U
|
#define MAZE32_STATION_TIMEOUT_MS 12000U
|
||||||
|
|
||||||
static const char *TAG = "maze32_network";
|
static const char* TAG = "maze32_network";
|
||||||
static maze32_network_t *active_network;
|
static maze32_network_t* active_network;
|
||||||
|
|
||||||
static void set_state(maze32_network_t *network, maze32_network_state_t state)
|
static void set_state(maze32_network_t* network, maze32_network_state_t state) {
|
||||||
{
|
|
||||||
network->state = state;
|
network->state = state;
|
||||||
ESP_LOGI(TAG, "Network state: %s", maze32_network_state_name(state));
|
ESP_LOGI(TAG, "Network state: %s", maze32_network_state_name(state));
|
||||||
}
|
}
|
||||||
|
|
||||||
static void network_event(void *argument,
|
static void network_event(void* argument, esp_event_base_t event_base, int32_t event_id,
|
||||||
esp_event_base_t event_base,
|
void* event_data) {
|
||||||
int32_t event_id,
|
|
||||||
void *event_data)
|
|
||||||
{
|
|
||||||
(void)event_data;
|
(void)event_data;
|
||||||
maze32_network_t *network = argument;
|
maze32_network_t* network = argument;
|
||||||
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START &&
|
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START &&
|
||||||
network->state == MAZE32_NETWORK_STATION_CONNECTING) {
|
network->state == MAZE32_NETWORK_STATION_CONNECTING) {
|
||||||
esp_wifi_connect();
|
esp_wifi_connect();
|
||||||
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED &&
|
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED &&
|
||||||
network->state == MAZE32_NETWORK_STATION_CONNECTING) {
|
(network->state == MAZE32_NETWORK_STATION_CONNECTING ||
|
||||||
|
network->state == MAZE32_NETWORK_STATION_CONNECTED)) {
|
||||||
|
if (network->state == MAZE32_NETWORK_STATION_CONNECTED) {
|
||||||
|
set_state(network, MAZE32_NETWORK_STATION_CONNECTING);
|
||||||
|
network->retry_count = 0U;
|
||||||
|
}
|
||||||
if (network->retry_count < MAZE32_STATION_RETRY_LIMIT) {
|
if (network->retry_count < MAZE32_STATION_RETRY_LIMIT) {
|
||||||
++network->retry_count;
|
++network->retry_count;
|
||||||
ESP_LOGW(TAG, "Station connection failed; retry %u/%u",
|
ESP_LOGW(TAG, "Station connection failed; retry %u/%u", network->retry_count,
|
||||||
network->retry_count, MAZE32_STATION_RETRY_LIMIT);
|
MAZE32_STATION_RETRY_LIMIT);
|
||||||
esp_wifi_connect();
|
esp_wifi_connect();
|
||||||
} else {
|
} else if (network->station_wait_active) {
|
||||||
xEventGroupSetBits(network->events, MAZE32_FAILED_BIT);
|
xEventGroupSetBits(network->events, MAZE32_FAILED_BIT);
|
||||||
|
} else if (network->server != NULL) {
|
||||||
|
set_state(network, MAZE32_NETWORK_PROVISIONING);
|
||||||
|
esp_err_t result =
|
||||||
|
httpd_queue_work(network->server, maze32_network_return_to_provisioning, network);
|
||||||
|
if (result != ESP_OK) {
|
||||||
|
ESP_LOGE(TAG, "Could not schedule runtime fallback: %s", esp_err_to_name(result));
|
||||||
|
set_state(network, MAZE32_NETWORK_ERROR);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
set_state(network, MAZE32_NETWORK_ERROR);
|
||||||
}
|
}
|
||||||
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
|
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
|
||||||
xEventGroupSetBits(network->events, MAZE32_CONNECTED_BIT);
|
if (network->station_wait_active) {
|
||||||
|
xEventGroupSetBits(network->events, MAZE32_CONNECTED_BIT);
|
||||||
|
} else if (network->state == MAZE32_NETWORK_STATION_CONNECTING) {
|
||||||
|
network->retry_count = 0U;
|
||||||
|
set_state(network, MAZE32_NETWORK_STATION_CONNECTED);
|
||||||
|
char address[16];
|
||||||
|
if (maze32_network_ip_address(network, address, sizeof(address)) == ESP_OK) {
|
||||||
|
ESP_LOGI(TAG, "Runtime station reconnect succeeded: IP=%s", address);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static esp_err_t stop_wifi(void)
|
static esp_err_t stop_wifi(void) {
|
||||||
{
|
|
||||||
esp_err_t result = esp_wifi_stop();
|
esp_err_t result = esp_wifi_stop();
|
||||||
if (result == ESP_ERR_WIFI_NOT_STARTED || result == ESP_ERR_WIFI_NOT_INIT) {
|
if (result == ESP_ERR_WIFI_NOT_STARTED || result == ESP_ERR_WIFI_NOT_INIT) {
|
||||||
return ESP_OK;
|
return ESP_OK;
|
||||||
@@ -56,8 +75,7 @@ static esp_err_t stop_wifi(void)
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
static esp_err_t start_provisioning(maze32_network_t *network)
|
static esp_err_t start_provisioning(maze32_network_t* network) {
|
||||||
{
|
|
||||||
set_state(network, MAZE32_NETWORK_PROVISIONING);
|
set_state(network, MAZE32_NETWORK_PROVISIONING);
|
||||||
esp_err_t result = stop_wifi();
|
esp_err_t result = stop_wifi();
|
||||||
if (result != ESP_OK) {
|
if (result != ESP_OK) {
|
||||||
@@ -89,9 +107,8 @@ static esp_err_t start_provisioning(maze32_network_t *network)
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
static esp_err_t connect_station(maze32_network_t *network,
|
static esp_err_t connect_station(maze32_network_t* network,
|
||||||
const maze32_wifi_credentials_t *credentials)
|
const maze32_wifi_credentials_t* credentials) {
|
||||||
{
|
|
||||||
esp_err_t result = stop_wifi();
|
esp_err_t result = stop_wifi();
|
||||||
if (result != ESP_OK) {
|
if (result != ESP_OK) {
|
||||||
return result;
|
return result;
|
||||||
@@ -113,19 +130,19 @@ static esp_err_t connect_station(maze32_network_t *network,
|
|||||||
result = esp_wifi_set_config(WIFI_IF_STA, &station);
|
result = esp_wifi_set_config(WIFI_IF_STA, &station);
|
||||||
}
|
}
|
||||||
if (result == ESP_OK) {
|
if (result == ESP_OK) {
|
||||||
|
network->station_wait_active = 1;
|
||||||
result = esp_wifi_start();
|
result = esp_wifi_start();
|
||||||
}
|
}
|
||||||
if (result != ESP_OK) {
|
if (result != ESP_OK) {
|
||||||
|
network->station_wait_active = 0;
|
||||||
ESP_LOGE(TAG, "Station startup failed: %s", esp_err_to_name(result));
|
ESP_LOGE(TAG, "Station startup failed: %s", esp_err_to_name(result));
|
||||||
return start_provisioning(network);
|
return start_provisioning(network);
|
||||||
}
|
}
|
||||||
|
|
||||||
EventBits_t bits = xEventGroupWaitBits(
|
EventBits_t bits =
|
||||||
network->events,
|
xEventGroupWaitBits(network->events, MAZE32_CONNECTED_BIT | MAZE32_FAILED_BIT, pdTRUE,
|
||||||
MAZE32_CONNECTED_BIT | MAZE32_FAILED_BIT,
|
pdFALSE, pdMS_TO_TICKS(MAZE32_STATION_TIMEOUT_MS));
|
||||||
pdTRUE,
|
network->station_wait_active = 0;
|
||||||
pdFALSE,
|
|
||||||
pdMS_TO_TICKS(MAZE32_STATION_TIMEOUT_MS));
|
|
||||||
if ((bits & MAZE32_CONNECTED_BIT) != 0U) {
|
if ((bits & MAZE32_CONNECTED_BIT) != 0U) {
|
||||||
set_state(network, MAZE32_NETWORK_STATION_CONNECTED);
|
set_state(network, MAZE32_NETWORK_STATION_CONNECTED);
|
||||||
char address[16];
|
char address[16];
|
||||||
@@ -139,22 +156,18 @@ static esp_err_t connect_station(maze32_network_t *network,
|
|||||||
return start_provisioning(network);
|
return start_provisioning(network);
|
||||||
}
|
}
|
||||||
|
|
||||||
static int save_credentials(const maze32_wifi_credentials_t *credentials, void *context)
|
static int save_credentials(const maze32_wifi_credentials_t* credentials, void* context) {
|
||||||
{
|
|
||||||
(void)context;
|
(void)context;
|
||||||
return maze32_config_store_save(credentials) == ESP_OK ? 0 : -1;
|
return maze32_config_store_save(credentials) == ESP_OK ? 0 : -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
static int clear_credentials(void *context)
|
static int clear_credentials(void* context) {
|
||||||
{
|
|
||||||
(void)context;
|
(void)context;
|
||||||
return maze32_config_store_clear() == ESP_OK ? 0 : -1;
|
return maze32_config_store_clear() == ESP_OK ? 0 : -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_err_t maze32_network_init(maze32_network_t *network,
|
esp_err_t maze32_network_init(maze32_network_t* network, maze32_reconfiguration_guard_fn guard,
|
||||||
maze32_reconfiguration_guard_fn guard,
|
void* guard_context) {
|
||||||
void *guard_context)
|
|
||||||
{
|
|
||||||
if (network == NULL || guard == NULL || active_network != NULL) {
|
if (network == NULL || guard == NULL || active_network != NULL) {
|
||||||
return ESP_ERR_INVALID_ARG;
|
return ESP_ERR_INVALID_ARG;
|
||||||
}
|
}
|
||||||
@@ -173,8 +186,8 @@ esp_err_t maze32_network_init(maze32_network_t *network,
|
|||||||
if (result != ESP_OK) {
|
if (result != ESP_OK) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
snprintf(network->access_point_ssid, sizeof(network->access_point_ssid),
|
snprintf(network->access_point_ssid, sizeof(network->access_point_ssid), "maze32-%02X%02X",
|
||||||
"maze32-%02X%02X", mac[4], mac[5]);
|
mac[4], mac[5]);
|
||||||
|
|
||||||
wifi_init_config_t wifi_configuration = WIFI_INIT_CONFIG_DEFAULT();
|
wifi_init_config_t wifi_configuration = WIFI_INIT_CONFIG_DEFAULT();
|
||||||
result = esp_wifi_init(&wifi_configuration);
|
result = esp_wifi_init(&wifi_configuration);
|
||||||
@@ -196,8 +209,7 @@ esp_err_t maze32_network_init(maze32_network_t *network,
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_err_t maze32_network_start(maze32_network_t *network)
|
esp_err_t maze32_network_start(maze32_network_t* network) {
|
||||||
{
|
|
||||||
if (network == NULL || xSemaphoreTake(network->operation_mutex, portMAX_DELAY) != pdTRUE) {
|
if (network == NULL || xSemaphoreTake(network->operation_mutex, portMAX_DELAY) != pdTRUE) {
|
||||||
return ESP_ERR_INVALID_ARG;
|
return ESP_ERR_INVALID_ARG;
|
||||||
}
|
}
|
||||||
@@ -214,16 +226,13 @@ esp_err_t maze32_network_start(maze32_network_t *network)
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_err_t maze32_network_apply_credentials(maze32_network_t *network,
|
esp_err_t maze32_network_apply_credentials(maze32_network_t* network,
|
||||||
const maze32_wifi_credentials_t *credentials)
|
const maze32_wifi_credentials_t* credentials) {
|
||||||
{
|
|
||||||
if (network == NULL) {
|
if (network == NULL) {
|
||||||
return ESP_ERR_INVALID_ARG;
|
return ESP_ERR_INVALID_ARG;
|
||||||
}
|
}
|
||||||
int result = maze32_wifi_config_apply(credentials,
|
int result = maze32_wifi_config_apply(credentials, network->reconfiguration_guard,
|
||||||
network->reconfiguration_guard,
|
save_credentials, network->guard_context);
|
||||||
save_credentials,
|
|
||||||
network->guard_context);
|
|
||||||
if (result == -1) {
|
if (result == -1) {
|
||||||
return ESP_ERR_INVALID_ARG;
|
return ESP_ERR_INVALID_ARG;
|
||||||
}
|
}
|
||||||
@@ -233,13 +242,11 @@ esp_err_t maze32_network_apply_credentials(maze32_network_t *network,
|
|||||||
return result == 0 ? ESP_OK : ESP_FAIL;
|
return result == 0 ? ESP_OK : ESP_FAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_err_t maze32_network_reset_credentials(maze32_network_t *network)
|
esp_err_t maze32_network_reset_credentials(maze32_network_t* network) {
|
||||||
{
|
|
||||||
if (network == NULL) {
|
if (network == NULL) {
|
||||||
return ESP_ERR_INVALID_ARG;
|
return ESP_ERR_INVALID_ARG;
|
||||||
}
|
}
|
||||||
int result = maze32_wifi_config_reset(network->reconfiguration_guard,
|
int result = maze32_wifi_config_reset(network->reconfiguration_guard, clear_credentials,
|
||||||
clear_credentials,
|
|
||||||
network->guard_context);
|
network->guard_context);
|
||||||
if (result == -2) {
|
if (result == -2) {
|
||||||
return ESP_ERR_INVALID_STATE;
|
return ESP_ERR_INVALID_STATE;
|
||||||
@@ -247,9 +254,8 @@ esp_err_t maze32_network_reset_credentials(maze32_network_t *network)
|
|||||||
return result == 0 ? ESP_OK : ESP_FAIL;
|
return result == 0 ? ESP_OK : ESP_FAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
void maze32_network_retry_saved_credentials(void *context)
|
void maze32_network_retry_saved_credentials(void* context) {
|
||||||
{
|
maze32_network_t* network = context;
|
||||||
maze32_network_t *network = context;
|
|
||||||
if (network == NULL || xSemaphoreTake(network->operation_mutex, portMAX_DELAY) != pdTRUE) {
|
if (network == NULL || xSemaphoreTake(network->operation_mutex, portMAX_DELAY) != pdTRUE) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -265,9 +271,8 @@ void maze32_network_retry_saved_credentials(void *context)
|
|||||||
xSemaphoreGive(network->operation_mutex);
|
xSemaphoreGive(network->operation_mutex);
|
||||||
}
|
}
|
||||||
|
|
||||||
void maze32_network_return_to_provisioning(void *context)
|
void maze32_network_return_to_provisioning(void* context) {
|
||||||
{
|
maze32_network_t* network = context;
|
||||||
maze32_network_t *network = context;
|
|
||||||
if (network == NULL || xSemaphoreTake(network->operation_mutex, portMAX_DELAY) != pdTRUE) {
|
if (network == NULL || xSemaphoreTake(network->operation_mutex, portMAX_DELAY) != pdTRUE) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -278,11 +283,8 @@ void maze32_network_return_to_provisioning(void *context)
|
|||||||
xSemaphoreGive(network->operation_mutex);
|
xSemaphoreGive(network->operation_mutex);
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_err_t maze32_network_scan(maze32_network_t *network,
|
esp_err_t maze32_network_scan(maze32_network_t* network, maze32_wifi_scan_result_t* results,
|
||||||
maze32_wifi_scan_result_t *results,
|
size_t capacity, size_t* count) {
|
||||||
size_t capacity,
|
|
||||||
size_t *count)
|
|
||||||
{
|
|
||||||
if (network == NULL || results == NULL || count == NULL || capacity == 0U ||
|
if (network == NULL || results == NULL || count == NULL || capacity == 0U ||
|
||||||
capacity > MAZE32_WIFI_SCAN_LIMIT ||
|
capacity > MAZE32_WIFI_SCAN_LIMIT ||
|
||||||
xSemaphoreTake(network->operation_mutex, pdMS_TO_TICKS(1000U)) != pdTRUE) {
|
xSemaphoreTake(network->operation_mutex, pdMS_TO_TICKS(1000U)) != pdTRUE) {
|
||||||
@@ -313,34 +315,34 @@ esp_err_t maze32_network_scan(maze32_network_t *network,
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
maze32_network_state_t maze32_network_state(const maze32_network_t *network)
|
maze32_network_state_t maze32_network_state(const maze32_network_t* network) {
|
||||||
{
|
|
||||||
return network == NULL ? MAZE32_NETWORK_ERROR : network->state;
|
return network == NULL ? MAZE32_NETWORK_ERROR : network->state;
|
||||||
}
|
}
|
||||||
|
|
||||||
const char *maze32_network_state_name(maze32_network_state_t state)
|
const char* maze32_network_state_name(maze32_network_state_t state) {
|
||||||
{
|
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case MAZE32_NETWORK_BOOTING: return "booting";
|
case MAZE32_NETWORK_BOOTING:
|
||||||
case MAZE32_NETWORK_STATION_CONNECTING: return "station_connecting";
|
return "booting";
|
||||||
case MAZE32_NETWORK_STATION_CONNECTED: return "station_connected";
|
case MAZE32_NETWORK_STATION_CONNECTING:
|
||||||
case MAZE32_NETWORK_PROVISIONING: return "provisioning";
|
return "station_connecting";
|
||||||
case MAZE32_NETWORK_ERROR: return "error";
|
case MAZE32_NETWORK_STATION_CONNECTED:
|
||||||
default: return "unknown";
|
return "station_connected";
|
||||||
|
case MAZE32_NETWORK_PROVISIONING:
|
||||||
|
return "provisioning";
|
||||||
|
case MAZE32_NETWORK_ERROR:
|
||||||
|
return "error";
|
||||||
|
default:
|
||||||
|
return "unknown";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const char *maze32_network_access_point_ssid(const maze32_network_t *network)
|
const char* maze32_network_access_point_ssid(const maze32_network_t* network) {
|
||||||
{
|
|
||||||
return network == NULL ? "" : network->access_point_ssid;
|
return network == NULL ? "" : network->access_point_ssid;
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_err_t maze32_network_ip_address(const maze32_network_t *network,
|
esp_err_t maze32_network_ip_address(const maze32_network_t* network, char* output,
|
||||||
char *output,
|
size_t output_size) {
|
||||||
size_t output_size)
|
if (network == NULL || output == NULL || output_size < 16U || network->station_netif == NULL) {
|
||||||
{
|
|
||||||
if (network == NULL || output == NULL || output_size < 16U ||
|
|
||||||
network->station_netif == NULL) {
|
|
||||||
return ESP_ERR_INVALID_ARG;
|
return ESP_ERR_INVALID_ARG;
|
||||||
}
|
}
|
||||||
esp_netif_ip_info_t information;
|
esp_netif_ip_info_t information;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
idf_component_register(
|
idf_component_register(
|
||||||
SRCS "maze32_web.c" "maze32_gateway.c"
|
SRCS "maze32_web.c" "maze32_gateway.c"
|
||||||
INCLUDE_DIRS "include"
|
INCLUDE_DIRS "include"
|
||||||
REQUIRES esp_http_server esp_timer heap maze32_lobby maze32_platform
|
REQUIRES esp_http_server esp_timer heap maze32_game maze32_lobby maze32_maze maze32_platform
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
#include "esp_err.h"
|
#include "esp_err.h"
|
||||||
#include "esp_http_server.h"
|
#include "esp_http_server.h"
|
||||||
#include "esp_timer.h"
|
#include "esp_timer.h"
|
||||||
|
#include "maze32_game.h"
|
||||||
#include "maze32_lobby.h"
|
#include "maze32_lobby.h"
|
||||||
|
#include "maze32_maze.h"
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
extern "C" {
|
extern "C" {
|
||||||
@@ -17,6 +19,8 @@ extern "C" {
|
|||||||
#define MAZE32_GATEWAY_QUEUE_LIMIT 12U
|
#define MAZE32_GATEWAY_QUEUE_LIMIT 12U
|
||||||
#define MAZE32_GATEWAY_OUTGOING_LIMIT 768U
|
#define MAZE32_GATEWAY_OUTGOING_LIMIT 768U
|
||||||
#define MAZE32_GATEWAY_RATE_LIMIT_PER_SECOND 20U
|
#define MAZE32_GATEWAY_RATE_LIMIT_PER_SECOND 20U
|
||||||
|
#define MAZE32_GATEWAY_RATE_LIMIT_CLOSE_THRESHOLD 40U
|
||||||
|
#define MAZE32_GATEWAY_PLAYER_LATENCY_BUDGET_MS 500U
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
int active;
|
int active;
|
||||||
@@ -24,14 +28,36 @@ typedef struct {
|
|||||||
uint32_t last_client_sequence;
|
uint32_t last_client_sequence;
|
||||||
uint64_t rate_window_started_ms;
|
uint64_t rate_window_started_ms;
|
||||||
unsigned int messages_in_window;
|
unsigned int messages_in_window;
|
||||||
|
int closing;
|
||||||
} maze32_gateway_connection_t;
|
} maze32_gateway_connection_t;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
int socket_fd;
|
int socket_fd;
|
||||||
size_t length;
|
size_t length;
|
||||||
|
int drop_if_busy;
|
||||||
char payload[MAZE32_GATEWAY_OUTGOING_LIMIT];
|
char payload[MAZE32_GATEWAY_OUTGOING_LIMIT];
|
||||||
} maze32_gateway_outgoing_t;
|
} maze32_gateway_outgoing_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint32_t connection_high_water;
|
||||||
|
uint32_t queue_high_water;
|
||||||
|
uint32_t messages_received;
|
||||||
|
uint32_t messages_sent;
|
||||||
|
uint32_t messages_dropped;
|
||||||
|
uint32_t send_failures;
|
||||||
|
uint32_t malformed_messages;
|
||||||
|
uint32_t oversized_messages;
|
||||||
|
uint32_t rate_limited_messages;
|
||||||
|
uint32_t accepted_game_commands;
|
||||||
|
uint32_t matches_started;
|
||||||
|
uint32_t matches_completed;
|
||||||
|
uint32_t matches_reset;
|
||||||
|
uint64_t maximum_handler_us;
|
||||||
|
uint64_t maximum_send_us;
|
||||||
|
size_t minimum_internal_free_bytes;
|
||||||
|
size_t minimum_internal_largest_block_bytes;
|
||||||
|
} maze32_gateway_metrics_t;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
httpd_handle_t server;
|
httpd_handle_t server;
|
||||||
maze32_lobby_t lobby;
|
maze32_lobby_t lobby;
|
||||||
@@ -40,8 +66,14 @@ typedef struct {
|
|||||||
size_t outgoing_head;
|
size_t outgoing_head;
|
||||||
size_t outgoing_count;
|
size_t outgoing_count;
|
||||||
uint32_t server_sequence;
|
uint32_t server_sequence;
|
||||||
unsigned int suggestion_index;
|
|
||||||
esp_timer_handle_t countdown_timer;
|
esp_timer_handle_t countdown_timer;
|
||||||
|
esp_timer_handle_t reconnect_timer;
|
||||||
|
esp_timer_handle_t finish_timer;
|
||||||
|
maze32_gateway_metrics_t metrics;
|
||||||
|
maze32_maze_t maze;
|
||||||
|
maze32_maze_workspace_t maze_workspace;
|
||||||
|
maze32_game_t game;
|
||||||
|
maze32_view_t view;
|
||||||
} maze32_gateway_t;
|
} maze32_gateway_t;
|
||||||
|
|
||||||
esp_err_t maze32_gateway_init(maze32_gateway_t* gateway);
|
esp_err_t maze32_gateway_init(maze32_gateway_t* gateway);
|
||||||
@@ -49,6 +81,8 @@ void maze32_gateway_set_server(maze32_gateway_t* gateway, httpd_handle_t server)
|
|||||||
esp_err_t maze32_gateway_register(httpd_handle_t server, maze32_gateway_t* gateway);
|
esp_err_t maze32_gateway_register(httpd_handle_t server, maze32_gateway_t* gateway);
|
||||||
void maze32_gateway_close_socket(httpd_handle_t server, int socket_fd);
|
void maze32_gateway_close_socket(httpd_handle_t server, int socket_fd);
|
||||||
int maze32_gateway_can_reconfigure(const maze32_gateway_t* gateway);
|
int maze32_gateway_can_reconfigure(const maze32_gateway_t* gateway);
|
||||||
|
size_t maze32_gateway_format_diagnostics(maze32_gateway_t* gateway, char* output,
|
||||||
|
size_t output_size);
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
#include "maze32_gateway.h"
|
#include "maze32_gateway.h"
|
||||||
|
|
||||||
|
#include <errno.h>
|
||||||
|
#include <fcntl.h>
|
||||||
#include <inttypes.h>
|
#include <inttypes.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
#include <sys/select.h>
|
||||||
#include <sys/socket.h>
|
#include <sys/socket.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
|
|
||||||
@@ -12,14 +15,75 @@
|
|||||||
#include "maze32_protocol.h"
|
#include "maze32_protocol.h"
|
||||||
|
|
||||||
static const char* TAG = "maze32_gateway";
|
static const char* TAG = "maze32_gateway";
|
||||||
static const char* NAME_SUGGESTIONS[] = {
|
|
||||||
"Байт-Беглец", "Кот-Навигатор", "Сэр Тупичок", "Пиксельный Ёж", "Лорд Лабиринт",
|
|
||||||
};
|
|
||||||
|
|
||||||
static uint64_t now_ms(void) {
|
static uint64_t now_ms(void) {
|
||||||
return (uint64_t)(esp_timer_get_time() / 1000);
|
return (uint64_t)(esp_timer_get_time() / 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static size_t internal_free_bytes(void) {
|
||||||
|
return heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
static size_t internal_largest_block_bytes(void) {
|
||||||
|
return heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
static unsigned int active_connection_count(const maze32_gateway_t* gateway) {
|
||||||
|
unsigned int count = 0U;
|
||||||
|
for (size_t index = 0U; index < MAZE32_GATEWAY_CONNECTION_LIMIT; ++index) {
|
||||||
|
if (gateway->connections[index].active) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int gateway_send_all(httpd_handle_t server, int socket_fd, const char* buffer, size_t length,
|
||||||
|
int flags) {
|
||||||
|
(void)server;
|
||||||
|
if (buffer == NULL) {
|
||||||
|
return HTTPD_SOCK_ERR_INVALID;
|
||||||
|
}
|
||||||
|
size_t offset = 0U;
|
||||||
|
while (offset < length) {
|
||||||
|
int sent = send(socket_fd, buffer + offset, length - offset, flags);
|
||||||
|
if (sent > 0) {
|
||||||
|
offset += (size_t)sent;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (sent < 0 && errno == EINTR) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (sent < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
|
||||||
|
return HTTPD_SOCK_ERR_TIMEOUT;
|
||||||
|
}
|
||||||
|
return HTTPD_SOCK_ERR_FAIL;
|
||||||
|
}
|
||||||
|
return (int)offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void sample_resources(maze32_gateway_t* gateway, const char* label) {
|
||||||
|
size_t free_bytes = internal_free_bytes();
|
||||||
|
size_t largest_block = internal_largest_block_bytes();
|
||||||
|
if (gateway->metrics.minimum_internal_free_bytes == 0U ||
|
||||||
|
free_bytes < gateway->metrics.minimum_internal_free_bytes) {
|
||||||
|
gateway->metrics.minimum_internal_free_bytes = free_bytes;
|
||||||
|
}
|
||||||
|
if (gateway->metrics.minimum_internal_largest_block_bytes == 0U ||
|
||||||
|
largest_block < gateway->metrics.minimum_internal_largest_block_bytes) {
|
||||||
|
gateway->metrics.minimum_internal_largest_block_bytes = largest_block;
|
||||||
|
}
|
||||||
|
if (label != NULL) {
|
||||||
|
ESP_LOGI(TAG,
|
||||||
|
"M010 resources: label=%s active=%u queue=%u free=%u largest=%u min_free=%u "
|
||||||
|
"min_largest=%u",
|
||||||
|
label, active_connection_count(gateway), (unsigned int)gateway->outgoing_count,
|
||||||
|
(unsigned int)free_bytes, (unsigned int)largest_block,
|
||||||
|
(unsigned int)gateway->metrics.minimum_internal_free_bytes,
|
||||||
|
(unsigned int)gateway->metrics.minimum_internal_largest_block_bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static maze32_gateway_connection_t* find_connection(maze32_gateway_t* gateway, int socket_fd) {
|
static maze32_gateway_connection_t* find_connection(maze32_gateway_t* gateway, int socket_fd) {
|
||||||
for (size_t index = 0U; index < MAZE32_GATEWAY_CONNECTION_LIMIT; ++index) {
|
for (size_t index = 0U; index < MAZE32_GATEWAY_CONNECTION_LIMIT; ++index) {
|
||||||
if (gateway->connections[index].active &&
|
if (gateway->connections[index].active &&
|
||||||
@@ -41,20 +105,19 @@ static maze32_gateway_connection_t* open_connection(maze32_gateway_t* gateway, i
|
|||||||
memset(connection, 0, sizeof(*connection));
|
memset(connection, 0, sizeof(*connection));
|
||||||
connection->active = 1;
|
connection->active = 1;
|
||||||
connection->socket_fd = socket_fd;
|
connection->socket_fd = socket_fd;
|
||||||
|
if (httpd_sess_set_send_override(gateway->server, socket_fd, gateway_send_all) !=
|
||||||
|
ESP_OK) {
|
||||||
|
ESP_LOGW(TAG, "Could not install bounded send callback for fd=%d", socket_fd);
|
||||||
|
}
|
||||||
connection->rate_window_started_ms = now_ms();
|
connection->rate_window_started_ms = now_ms();
|
||||||
unsigned int active_count = 0U;
|
unsigned int active_count = active_connection_count(gateway);
|
||||||
for (size_t count_index = 0U; count_index < MAZE32_GATEWAY_CONNECTION_LIMIT;
|
if (active_count > gateway->metrics.connection_high_water) {
|
||||||
++count_index) {
|
gateway->metrics.connection_high_water = active_count;
|
||||||
if (gateway->connections[count_index].active) {
|
|
||||||
active_count++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ESP_LOGI(TAG, "WebSocket connected: fd=%d, active=%u/%u", socket_fd, active_count,
|
ESP_LOGI(TAG, "WebSocket connected: fd=%d, active=%u/%u", socket_fd, active_count,
|
||||||
(unsigned int)MAZE32_GATEWAY_CONNECTION_LIMIT);
|
(unsigned int)MAZE32_GATEWAY_CONNECTION_LIMIT);
|
||||||
if (active_count == MAZE32_GATEWAY_CONNECTION_LIMIT) {
|
if (active_count == MAZE32_GATEWAY_CONNECTION_LIMIT) {
|
||||||
ESP_LOGI(TAG, "Six-client heap: free=%u bytes, largest=%u bytes",
|
sample_resources(gateway, "six_clients");
|
||||||
(unsigned int)heap_caps_get_free_size(MALLOC_CAP_8BIT),
|
|
||||||
(unsigned int)heap_caps_get_largest_free_block(MALLOC_CAP_8BIT));
|
|
||||||
}
|
}
|
||||||
return connection;
|
return connection;
|
||||||
}
|
}
|
||||||
@@ -112,10 +175,16 @@ static uint32_t next_server_sequence(maze32_gateway_t* gateway) {
|
|||||||
return gateway->server_sequence;
|
return gateway->server_sequence;
|
||||||
}
|
}
|
||||||
|
|
||||||
static int enqueue(maze32_gateway_t* gateway, int socket_fd, const char* payload) {
|
static int enqueue_with_policy(maze32_gateway_t* gateway, int socket_fd, const char* payload,
|
||||||
|
int drop_if_busy) {
|
||||||
size_t length = strlen(payload);
|
size_t length = strlen(payload);
|
||||||
if (gateway->outgoing_count >= MAZE32_GATEWAY_QUEUE_LIMIT ||
|
if (gateway->outgoing_count >= MAZE32_GATEWAY_QUEUE_LIMIT ||
|
||||||
length >= MAZE32_GATEWAY_OUTGOING_LIMIT) {
|
length >= MAZE32_GATEWAY_OUTGOING_LIMIT) {
|
||||||
|
if (drop_if_busy) {
|
||||||
|
gateway->metrics.messages_dropped++;
|
||||||
|
} else {
|
||||||
|
gateway->metrics.send_failures++;
|
||||||
|
}
|
||||||
ESP_LOGW(TAG, "Bounded outgoing queue rejected a message for fd=%d", socket_fd);
|
ESP_LOGW(TAG, "Bounded outgoing queue rejected a message for fd=%d", socket_fd);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -123,12 +192,33 @@ static int enqueue(maze32_gateway_t* gateway, int socket_fd, const char* payload
|
|||||||
maze32_gateway_outgoing_t* entry = &gateway->outgoing[index];
|
maze32_gateway_outgoing_t* entry = &gateway->outgoing[index];
|
||||||
entry->socket_fd = socket_fd;
|
entry->socket_fd = socket_fd;
|
||||||
entry->length = length;
|
entry->length = length;
|
||||||
|
entry->drop_if_busy = drop_if_busy;
|
||||||
memcpy(entry->payload, payload, length + 1U);
|
memcpy(entry->payload, payload, length + 1U);
|
||||||
gateway->outgoing_count++;
|
gateway->outgoing_count++;
|
||||||
|
if (gateway->outgoing_count > gateway->metrics.queue_high_water) {
|
||||||
|
gateway->metrics.queue_high_water = (uint32_t)gateway->outgoing_count;
|
||||||
|
}
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void drain_queue(maze32_gateway_t* gateway) {
|
static int enqueue(maze32_gateway_t* gateway, int socket_fd, const char* payload) {
|
||||||
|
return enqueue_with_policy(gateway, socket_fd, payload, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void close_slow_connection(maze32_gateway_t* gateway, int socket_fd) {
|
||||||
|
maze32_gateway_connection_t* connection = find_connection(gateway, socket_fd);
|
||||||
|
if (connection == NULL || connection->closing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connection->closing = 1;
|
||||||
|
ESP_LOGW(TAG, "Closing an unresponsive WebSocket: fd=%d", socket_fd);
|
||||||
|
if (httpd_sess_trigger_close(gateway->server, socket_fd) != ESP_OK) {
|
||||||
|
ESP_LOGW(TAG, "Could not schedule close for fd=%d", socket_fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int drain_queue(maze32_gateway_t* gateway) {
|
||||||
|
int success = 1;
|
||||||
while (gateway->outgoing_count > 0U) {
|
while (gateway->outgoing_count > 0U) {
|
||||||
maze32_gateway_outgoing_t* entry = &gateway->outgoing[gateway->outgoing_head];
|
maze32_gateway_outgoing_t* entry = &gateway->outgoing[gateway->outgoing_head];
|
||||||
httpd_ws_frame_t frame = {
|
httpd_ws_frame_t frame = {
|
||||||
@@ -138,14 +228,109 @@ static void drain_queue(maze32_gateway_t* gateway) {
|
|||||||
.payload = (uint8_t*)entry->payload,
|
.payload = (uint8_t*)entry->payload,
|
||||||
.len = entry->length,
|
.len = entry->length,
|
||||||
};
|
};
|
||||||
esp_err_t result = httpd_ws_send_frame_async(gateway->server, entry->socket_fd, &frame);
|
int ready = 1;
|
||||||
if (result != ESP_OK) {
|
int original_flags = -1;
|
||||||
ESP_LOGW(TAG, "WebSocket send failed for fd=%d: %s", entry->socket_fd,
|
if (entry->drop_if_busy) {
|
||||||
esp_err_to_name(result));
|
fd_set write_set;
|
||||||
|
FD_ZERO(&write_set);
|
||||||
|
FD_SET(entry->socket_fd, &write_set);
|
||||||
|
struct timeval timeout = {0};
|
||||||
|
ready = select(entry->socket_fd + 1, NULL, &write_set, NULL, &timeout) > 0 &&
|
||||||
|
FD_ISSET(entry->socket_fd, &write_set);
|
||||||
|
if (ready) {
|
||||||
|
original_flags = fcntl(entry->socket_fd, F_GETFL, 0);
|
||||||
|
if (original_flags >= 0) {
|
||||||
|
(void)fcntl(entry->socket_fd, F_SETFL, original_flags | O_NONBLOCK);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ready) {
|
||||||
|
gateway->metrics.messages_dropped++;
|
||||||
|
} else {
|
||||||
|
int64_t started_us = esp_timer_get_time();
|
||||||
|
esp_err_t result = httpd_ws_send_frame_async(gateway->server, entry->socket_fd, &frame);
|
||||||
|
uint64_t elapsed_us = (uint64_t)(esp_timer_get_time() - started_us);
|
||||||
|
if (elapsed_us > gateway->metrics.maximum_send_us) {
|
||||||
|
gateway->metrics.maximum_send_us = elapsed_us;
|
||||||
|
}
|
||||||
|
if (result == ESP_OK) {
|
||||||
|
gateway->metrics.messages_sent++;
|
||||||
|
} else {
|
||||||
|
gateway->metrics.send_failures++;
|
||||||
|
success = 0;
|
||||||
|
ESP_LOGW(TAG, "WebSocket send failed for fd=%d: %s", entry->socket_fd,
|
||||||
|
esp_err_to_name(result));
|
||||||
|
close_slow_connection(gateway, entry->socket_fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (original_flags >= 0) {
|
||||||
|
(void)fcntl(entry->socket_fd, F_SETFL, original_flags);
|
||||||
}
|
}
|
||||||
gateway->outgoing_head = (gateway->outgoing_head + 1U) % MAZE32_GATEWAY_QUEUE_LIMIT;
|
gateway->outgoing_head = (gateway->outgoing_head + 1U) % MAZE32_GATEWAY_QUEUE_LIMIT;
|
||||||
gateway->outgoing_count--;
|
gateway->outgoing_count--;
|
||||||
}
|
}
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
static maze32_game_role_t game_role_for_session(const maze32_session_t* session) {
|
||||||
|
if (session->role == MAZE32_ROLE_OBSERVER) {
|
||||||
|
return MAZE32_GAME_ROLE_OBSERVER;
|
||||||
|
}
|
||||||
|
return session->player_slot == 1U ? MAZE32_GAME_ROLE_PLAYER_ONE : MAZE32_GAME_ROLE_PLAYER_TWO;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void send_view(maze32_gateway_t* gateway, const maze32_session_t* session,
|
||||||
|
int full_observer_view) {
|
||||||
|
if (session == NULL || !session->connected ||
|
||||||
|
maze32_game_project_view(&gateway->game, game_role_for_session(session), &gateway->view) !=
|
||||||
|
MAZE32_GAME_RESULT_ACCEPTED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (session->role == MAZE32_ROLE_OBSERVER && !full_observer_view) {
|
||||||
|
gateway->view.cell_count = 0U;
|
||||||
|
}
|
||||||
|
size_t chunks = maze32_game_view_chunk_count(&gateway->view);
|
||||||
|
for (size_t chunk = 0U; chunk < chunks; ++chunk) {
|
||||||
|
char payload[MAZE32_GAME_VIEW_CHUNK_BYTES];
|
||||||
|
size_t written = 0U;
|
||||||
|
if (maze32_game_serialize_view_chunk(&gateway->view, chunk, payload, sizeof(payload),
|
||||||
|
&written) != MAZE32_GAME_RESULT_ACCEPTED) {
|
||||||
|
ESP_LOGE(TAG, "View serialization failed for fd=%d, chunk=%u", session->connection_id,
|
||||||
|
(unsigned int)chunk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(void)written;
|
||||||
|
int drop_if_busy = session->role == MAZE32_ROLE_OBSERVER && !full_observer_view;
|
||||||
|
if (!enqueue_with_policy(gateway, session->connection_id, payload, drop_if_busy)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!drain_queue(gateway)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void broadcast_views(maze32_gateway_t* gateway, int full_observer_view) {
|
||||||
|
for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) {
|
||||||
|
send_view(gateway, &gateway->lobby.sessions[index], full_observer_view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void broadcast_match_event(maze32_gateway_t* gateway, const char* type,
|
||||||
|
const char* payload) {
|
||||||
|
char response[MAZE32_GATEWAY_OUTGOING_LIMIT];
|
||||||
|
int written = snprintf(response, sizeof(response),
|
||||||
|
"{\"v\":1,\"type\":\"%s\",\"seq\":%" PRIu32 ",\"payload\":%s}", type,
|
||||||
|
next_server_sequence(gateway), payload);
|
||||||
|
if (written < 0 || (size_t)written >= sizeof(response)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) {
|
||||||
|
const maze32_session_t* session = &gateway->lobby.sessions[index];
|
||||||
|
if (session->role != MAZE32_ROLE_NONE && session->connected) {
|
||||||
|
enqueue(gateway, session->connection_id, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void send_error(maze32_gateway_t* gateway, int socket_fd, const char* request_id,
|
static void send_error(maze32_gateway_t* gateway, int socket_fd, const char* request_id,
|
||||||
@@ -329,52 +514,6 @@ static void send_lobby_error(maze32_gateway_t* gateway, int socket_fd, const cha
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void send_suggestion(maze32_gateway_t* gateway, int socket_fd, const char* request_id) {
|
|
||||||
const char* suggestion = NULL;
|
|
||||||
size_t suggestion_count = sizeof(NAME_SUGGESTIONS) / sizeof(NAME_SUGGESTIONS[0]);
|
|
||||||
for (size_t attempt = 0U; attempt < suggestion_count; ++attempt) {
|
|
||||||
size_t index = gateway->suggestion_index++ % suggestion_count;
|
|
||||||
int occupied = 0;
|
|
||||||
for (size_t session_index = 0U; session_index < MAZE32_SESSION_LIMIT; ++session_index) {
|
|
||||||
const maze32_session_t* session = &gateway->lobby.sessions[session_index];
|
|
||||||
if (session->role == MAZE32_ROLE_PLAYER &&
|
|
||||||
strcmp(session->name, NAME_SUGGESTIONS[index]) == 0) {
|
|
||||||
occupied = 1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!occupied) {
|
|
||||||
suggestion = NAME_SUGGESTIONS[index];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (suggestion == NULL) {
|
|
||||||
send_error(gateway, socket_fd, request_id, "no_suggestion",
|
|
||||||
"Все смешные имена уже разобрали");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = "";
|
|
||||||
size_t length = 0U;
|
|
||||||
char prefix[128];
|
|
||||||
int written =
|
|
||||||
snprintf(prefix, sizeof(prefix), "{\"v\":1,\"type\":\"name_suggestion\",\"seq\":%" PRIu32,
|
|
||||||
next_server_sequence(gateway));
|
|
||||||
if (written < 0 || (size_t)written >= sizeof(prefix) ||
|
|
||||||
!append_text(response, sizeof(response), &length, prefix)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (request_id[0] != '\0' &&
|
|
||||||
(!append_text(response, sizeof(response), &length, ",\"requestId\":") ||
|
|
||||||
!append_json_string(response, sizeof(response), &length, request_id))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (append_text(response, sizeof(response), &length, ",\"payload\":{\"name\":") &&
|
|
||||||
append_json_string(response, sizeof(response), &length, suggestion) &&
|
|
||||||
append_text(response, sizeof(response), &length, "}}")) {
|
|
||||||
enqueue(gateway, socket_fd, response);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static void broadcast_countdown(maze32_gateway_t* gateway) {
|
static void broadcast_countdown(maze32_gateway_t* gateway) {
|
||||||
char response[MAZE32_GATEWAY_OUTGOING_LIMIT];
|
char response[MAZE32_GATEWAY_OUTGOING_LIMIT];
|
||||||
int written = snprintf(response, sizeof(response),
|
int written = snprintf(response, sizeof(response),
|
||||||
@@ -399,6 +538,7 @@ static void countdown_finished_work(void* context) {
|
|||||||
if (!maze32_lobby_activate_if_due(&gateway->lobby, now_ms())) {
|
if (!maze32_lobby_activate_if_due(&gateway->lobby, now_ms())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
maze32_game_set_phase(&gateway->game, MAZE32_GAME_PHASE_ACTIVE);
|
||||||
char response[MAZE32_GATEWAY_OUTGOING_LIMIT];
|
char response[MAZE32_GATEWAY_OUTGOING_LIMIT];
|
||||||
int written = snprintf(response, sizeof(response),
|
int written = snprintf(response, sizeof(response),
|
||||||
"{\"v\":1,\"type\":\"active\",\"seq\":%" PRIu32
|
"{\"v\":1,\"type\":\"active\",\"seq\":%" PRIu32
|
||||||
@@ -412,8 +552,11 @@ static void countdown_finished_work(void* context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
drain_queue(gateway);
|
||||||
broadcast_snapshot(gateway);
|
broadcast_snapshot(gateway);
|
||||||
drain_queue(gateway);
|
drain_queue(gateway);
|
||||||
|
broadcast_views(gateway, 1);
|
||||||
|
drain_queue(gateway);
|
||||||
ESP_LOGI(TAG, "Lobby became active at server time %" PRIu64 " ms",
|
ESP_LOGI(TAG, "Lobby became active at server time %" PRIu64 " ms",
|
||||||
gateway->lobby.activation_ms);
|
gateway->lobby.activation_ms);
|
||||||
}
|
}
|
||||||
@@ -428,6 +571,116 @@ static void countdown_timer_callback(void* context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void schedule_reconnect_deadline(maze32_gateway_t* gateway);
|
||||||
|
static void schedule_finish_deadline(maze32_gateway_t* gateway);
|
||||||
|
|
||||||
|
static const char* final_result_name(maze32_game_final_result_t result) {
|
||||||
|
switch (result) {
|
||||||
|
case MAZE32_GAME_FINAL_PLAYER_ONE:
|
||||||
|
return "player1";
|
||||||
|
case MAZE32_GAME_FINAL_PLAYER_TWO:
|
||||||
|
return "player2";
|
||||||
|
case MAZE32_GAME_FINAL_DRAW:
|
||||||
|
return "draw";
|
||||||
|
default:
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void complete_match(maze32_gateway_t* gateway) {
|
||||||
|
char payload[48];
|
||||||
|
int written = snprintf(payload, sizeof(payload), "{\"result\":\"%s\"}",
|
||||||
|
final_result_name(gateway->game.final_result));
|
||||||
|
esp_timer_stop(gateway->finish_timer);
|
||||||
|
if (written >= 0 && (size_t)written < sizeof(payload)) {
|
||||||
|
broadcast_match_event(gateway, "match_result", payload);
|
||||||
|
}
|
||||||
|
broadcast_views(gateway, 0);
|
||||||
|
drain_queue(gateway);
|
||||||
|
gateway->metrics.matches_completed++;
|
||||||
|
sample_resources(gateway, "match_complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void reconnect_deadline_work(void* context) {
|
||||||
|
maze32_gateway_t* gateway = context;
|
||||||
|
if (maze32_game_advance_time(&gateway->game, now_ms())) {
|
||||||
|
complete_match(gateway);
|
||||||
|
ESP_LOGI(TAG, "Player reconnect deadline expired; match ended in a draw");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
schedule_reconnect_deadline(gateway);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void finish_deadline_work(void* context) {
|
||||||
|
maze32_gateway_t* gateway = context;
|
||||||
|
if (maze32_game_advance_time(&gateway->game, now_ms())) {
|
||||||
|
complete_match(gateway);
|
||||||
|
ESP_LOGI(TAG, "Authoritative finish window closed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
schedule_finish_deadline(gateway);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void finish_timer_callback(void* context) {
|
||||||
|
maze32_gateway_t* gateway = context;
|
||||||
|
if (gateway->server != NULL) {
|
||||||
|
esp_err_t result = httpd_queue_work(gateway->server, finish_deadline_work, gateway);
|
||||||
|
if (result != ESP_OK) {
|
||||||
|
ESP_LOGE(TAG, "Could not queue finish deadline: %s", esp_err_to_name(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void schedule_finish_deadline(maze32_gateway_t* gateway) {
|
||||||
|
esp_timer_stop(gateway->finish_timer);
|
||||||
|
if (gateway->game.phase != MAZE32_GAME_PHASE_FINISH_PENDING ||
|
||||||
|
gateway->game.first_exited_player >= MAZE32_GAME_PLAYER_COUNT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uint64_t exit_ms = gateway->game.exit_times_ms[gateway->game.first_exited_player];
|
||||||
|
uint64_t window_ms = MAZE32_GAME_DRAW_WINDOW_MS + 1U;
|
||||||
|
uint64_t deadline = exit_ms > UINT64_MAX - window_ms ? UINT64_MAX : exit_ms + window_ms;
|
||||||
|
uint64_t current_ms = now_ms();
|
||||||
|
uint64_t delay_ms = deadline > current_ms ? deadline - current_ms : 1U;
|
||||||
|
esp_err_t result = esp_timer_start_once(gateway->finish_timer, delay_ms * 1000U);
|
||||||
|
if (result != ESP_OK) {
|
||||||
|
ESP_LOGE(TAG, "Finish timer could not start: %s", esp_err_to_name(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void reconnect_timer_callback(void* context) {
|
||||||
|
maze32_gateway_t* gateway = context;
|
||||||
|
if (gateway->server != NULL) {
|
||||||
|
esp_err_t result = httpd_queue_work(gateway->server, reconnect_deadline_work, gateway);
|
||||||
|
if (result != ESP_OK) {
|
||||||
|
ESP_LOGE(TAG, "Could not queue reconnect deadline: %s", esp_err_to_name(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void schedule_reconnect_deadline(maze32_gateway_t* gateway) {
|
||||||
|
esp_timer_stop(gateway->reconnect_timer);
|
||||||
|
if (gateway->game.phase != MAZE32_GAME_PHASE_PAUSED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uint64_t deadline = UINT64_MAX;
|
||||||
|
for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) {
|
||||||
|
if (!gateway->game.players[player].connected &&
|
||||||
|
gateway->game.players[player].reconnect_deadline_ms < deadline) {
|
||||||
|
deadline = gateway->game.players[player].reconnect_deadline_ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deadline == UINT64_MAX) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uint64_t current_ms = now_ms();
|
||||||
|
uint64_t delay_ms = deadline > current_ms ? deadline - current_ms : 1U;
|
||||||
|
esp_err_t result = esp_timer_start_once(gateway->reconnect_timer, delay_ms * 1000U);
|
||||||
|
if (result != ESP_OK) {
|
||||||
|
ESP_LOGE(TAG, "Reconnect timer could not start: %s", esp_err_to_name(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static int rate_limit_allows(maze32_gateway_connection_t* connection, uint64_t current_ms) {
|
static int rate_limit_allows(maze32_gateway_connection_t* connection, uint64_t current_ms) {
|
||||||
if (current_ms - connection->rate_window_started_ms >= 1000U) {
|
if (current_ms - connection->rate_window_started_ms >= 1000U) {
|
||||||
connection->rate_window_started_ms = current_ms;
|
connection->rate_window_started_ms = current_ms;
|
||||||
@@ -437,13 +690,172 @@ static int rate_limit_allows(maze32_gateway_connection_t* connection, uint64_t c
|
|||||||
return connection->messages_in_window <= MAZE32_GATEWAY_RATE_LIMIT_PER_SECOND;
|
return connection->messages_in_window <= MAZE32_GATEWAY_RATE_LIMIT_PER_SECOND;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static maze32_game_direction_t parse_direction(const char* direction) {
|
||||||
|
if (strcmp(direction, "north") == 0) {
|
||||||
|
return MAZE32_GAME_DIRECTION_NORTH;
|
||||||
|
}
|
||||||
|
if (strcmp(direction, "east") == 0) {
|
||||||
|
return MAZE32_GAME_DIRECTION_EAST;
|
||||||
|
}
|
||||||
|
if (strcmp(direction, "south") == 0) {
|
||||||
|
return MAZE32_GAME_DIRECTION_SOUTH;
|
||||||
|
}
|
||||||
|
return MAZE32_GAME_DIRECTION_WEST;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void send_game_error(maze32_gateway_t* gateway, int socket_fd, const char* request_id,
|
||||||
|
maze32_game_result_t result) {
|
||||||
|
const char* message = "Команда игры отклонена сервером";
|
||||||
|
if (result == MAZE32_GAME_RESULT_READ_ONLY) {
|
||||||
|
message = "Наблюдатели смотрят, но не двигают героев";
|
||||||
|
} else if (result == MAZE32_GAME_RESULT_PREMATURE) {
|
||||||
|
message = "Матч сейчас на паузе или ещё не начался";
|
||||||
|
} else if (result == MAZE32_GAME_RESULT_RATE_LIMITED) {
|
||||||
|
message = "Слишком быстро: лабиринт просит соблюдать интервал";
|
||||||
|
} else if (result == MAZE32_GAME_RESULT_BLOCKED) {
|
||||||
|
message = "Там стена. Очень убедительная";
|
||||||
|
} else if (result == MAZE32_GAME_RESULT_NO_KEY) {
|
||||||
|
message = "Карман пуст — бросать нечего";
|
||||||
|
} else if (result == MAZE32_GAME_RESULT_MATCH_FINISHED) {
|
||||||
|
message = "Гонка уже закончилась";
|
||||||
|
}
|
||||||
|
send_error(gateway, socket_fd, request_id, maze32_game_result_name(result), message);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handle_game_command(maze32_gateway_t* gateway, const maze32_session_t* session,
|
||||||
|
const maze32_client_message_t* message) {
|
||||||
|
if (session->role == MAZE32_ROLE_OBSERVER) {
|
||||||
|
ESP_LOGW(TAG, "Observer mutation rejected: fd=%d, type=%u", session->connection_id,
|
||||||
|
(unsigned int)message->type);
|
||||||
|
send_game_error(gateway, session->connection_id, message->request_id,
|
||||||
|
MAZE32_GAME_RESULT_READ_ONLY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (gateway->lobby.phase != MAZE32_LOBBY_ACTIVE) {
|
||||||
|
send_game_error(gateway, session->connection_id, message->request_id,
|
||||||
|
MAZE32_GAME_RESULT_PREMATURE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uint32_t command_sequence = 0U;
|
||||||
|
maze32_game_role_t role = game_role_for_session(session);
|
||||||
|
maze32_game_result_t result =
|
||||||
|
message->type == MAZE32_MESSAGE_MOVE
|
||||||
|
? maze32_game_enqueue_move(&gateway->game, role, parse_direction(message->direction),
|
||||||
|
now_ms(), &command_sequence)
|
||||||
|
: maze32_game_enqueue_drop_key(&gateway->game, role, now_ms(), &command_sequence);
|
||||||
|
maze32_game_command_result_t processed = {.result = result};
|
||||||
|
if (result == MAZE32_GAME_RESULT_ACCEPTED) {
|
||||||
|
processed = maze32_game_process_next(&gateway->game);
|
||||||
|
result = processed.result;
|
||||||
|
}
|
||||||
|
int finished_now = processed.finished && gateway->game.phase == MAZE32_GAME_PHASE_FINISHED;
|
||||||
|
if (result != MAZE32_GAME_RESULT_ACCEPTED) {
|
||||||
|
if (finished_now) {
|
||||||
|
complete_match(gateway);
|
||||||
|
}
|
||||||
|
send_game_error(gateway, session->connection_id, message->request_id, result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
char acknowledgement[160] = "";
|
||||||
|
char prefix[96];
|
||||||
|
size_t length = 0U;
|
||||||
|
int written = snprintf(prefix, sizeof(prefix),
|
||||||
|
"{\"v\":1,\"type\":\"ack\",\"seq\":%" PRIu32 ",\"requestId\":",
|
||||||
|
next_server_sequence(gateway));
|
||||||
|
if (written >= 0 && (size_t)written < sizeof(prefix) &&
|
||||||
|
append_text(acknowledgement, sizeof(acknowledgement), &length, prefix) &&
|
||||||
|
append_json_string(acknowledgement, sizeof(acknowledgement), &length,
|
||||||
|
message->request_id) &&
|
||||||
|
(written = snprintf(prefix, sizeof(prefix),
|
||||||
|
",\"payload\":{\"commandSequence\":%" PRIu32
|
||||||
|
",\"stateSequence\":%" PRIu32 "}}",
|
||||||
|
command_sequence, gateway->game.state_sequence)) >= 0 &&
|
||||||
|
(size_t)written < sizeof(prefix) &&
|
||||||
|
append_text(acknowledgement, sizeof(acknowledgement), &length, prefix)) {
|
||||||
|
enqueue(gateway, session->connection_id, acknowledgement);
|
||||||
|
}
|
||||||
|
gateway->metrics.accepted_game_commands++;
|
||||||
|
if (finished_now) {
|
||||||
|
complete_match(gateway);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (gateway->game.phase == MAZE32_GAME_PHASE_FINISH_PENDING) {
|
||||||
|
schedule_finish_deadline(gateway);
|
||||||
|
}
|
||||||
|
broadcast_views(gateway, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t initialize_game(maze32_gateway_t* gateway) {
|
||||||
|
maze32_maze_config_t maze_config = maze32_maze_default_config();
|
||||||
|
maze32_maze_report_t maze_report;
|
||||||
|
if (maze32_maze_generate(&gateway->maze, &gateway->maze_workspace, &maze_config, esp_random(),
|
||||||
|
&maze_report) != MAZE32_MAZE_OK) {
|
||||||
|
ESP_LOGE(TAG, "Maze generation failed: %s", maze32_maze_result_name(maze_report.result));
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
maze32_game_config_t game_config = maze32_game_default_config();
|
||||||
|
return maze32_game_init(&gateway->game, &gateway->maze, &game_config) ==
|
||||||
|
MAZE32_GAME_RESULT_ACCEPTED
|
||||||
|
? ESP_OK
|
||||||
|
: ESP_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handle_restart(maze32_gateway_t* gateway, const maze32_session_t* session,
|
||||||
|
const maze32_client_message_t* message) {
|
||||||
|
if (session->role == MAZE32_ROLE_OBSERVER) {
|
||||||
|
ESP_LOGW(TAG, "Observer mutation rejected: fd=%d, type=restart", session->connection_id);
|
||||||
|
send_game_error(gateway, session->connection_id, message->request_id,
|
||||||
|
MAZE32_GAME_RESULT_READ_ONLY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (session->player_slot != 1U) {
|
||||||
|
send_lobby_error(gateway, session->connection_id, message->request_id,
|
||||||
|
MAZE32_LOBBY_NOT_OWNER);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (gateway->game.phase != MAZE32_GAME_PHASE_FINISHED) {
|
||||||
|
send_error(gateway, session->connection_id, message->request_id, "invalid_phase",
|
||||||
|
"Новая гонка доступна после финиша текущей");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!maze32_lobby_ready(&gateway->lobby)) {
|
||||||
|
send_lobby_error(gateway, session->connection_id, message->request_id,
|
||||||
|
MAZE32_LOBBY_NOT_READY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (initialize_game(gateway) != ESP_OK) {
|
||||||
|
send_error(gateway, session->connection_id, message->request_id, "maze_generation_failed",
|
||||||
|
"Лабиринт отказался пересаживаться. Попробуйте ещё раз");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
esp_timer_stop(gateway->countdown_timer);
|
||||||
|
esp_timer_stop(gateway->reconnect_timer);
|
||||||
|
esp_timer_stop(gateway->finish_timer);
|
||||||
|
maze32_lobby_reset_match(&gateway->lobby);
|
||||||
|
gateway->metrics.matches_reset++;
|
||||||
|
char acknowledgement[192] = "";
|
||||||
|
char prefix[96];
|
||||||
|
size_t length = 0U;
|
||||||
|
int written = snprintf(prefix, sizeof(prefix),
|
||||||
|
"{\"v\":1,\"type\":\"ack\",\"seq\":%" PRIu32 ",\"requestId\":",
|
||||||
|
next_server_sequence(gateway));
|
||||||
|
if (written >= 0 && (size_t)written < sizeof(prefix) &&
|
||||||
|
append_text(acknowledgement, sizeof(acknowledgement), &length, prefix) &&
|
||||||
|
append_json_string(acknowledgement, sizeof(acknowledgement), &length,
|
||||||
|
message->request_id) &&
|
||||||
|
append_text(acknowledgement, sizeof(acknowledgement), &length,
|
||||||
|
",\"payload\":{\"command\":\"restart\"}}")) {
|
||||||
|
enqueue(gateway, session->connection_id, acknowledgement);
|
||||||
|
}
|
||||||
|
broadcast_match_event(gateway, "reset", "{}");
|
||||||
|
broadcast_snapshot(gateway);
|
||||||
|
drain_queue(gateway);
|
||||||
|
sample_resources(gateway, "match_reset");
|
||||||
|
}
|
||||||
|
|
||||||
static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_t* connection,
|
static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_t* connection,
|
||||||
const maze32_client_message_t* message) {
|
const maze32_client_message_t* message) {
|
||||||
int socket_fd = connection->socket_fd;
|
int socket_fd = connection->socket_fd;
|
||||||
if (message->type == MAZE32_MESSAGE_SUGGEST_NAME) {
|
|
||||||
send_suggestion(gateway, socket_fd, message->request_id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (message->type == MAZE32_MESSAGE_JOIN) {
|
if (message->type == MAZE32_MESSAGE_JOIN) {
|
||||||
if (maze32_lobby_session_for_connection(&gateway->lobby, socket_fd) != NULL) {
|
if (maze32_lobby_session_for_connection(&gateway->lobby, socket_fd) != NULL) {
|
||||||
send_error(gateway, socket_fd, message->request_id, "already_joined",
|
send_error(gateway, socket_fd, message->request_id, "already_joined",
|
||||||
@@ -464,6 +876,9 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_
|
|||||||
}
|
}
|
||||||
send_session(gateway, socket_fd, session, message->request_id);
|
send_session(gateway, socket_fd, session, message->request_id);
|
||||||
broadcast_snapshot(gateway);
|
broadcast_snapshot(gateway);
|
||||||
|
if (gateway->lobby.phase == MAZE32_LOBBY_ACTIVE) {
|
||||||
|
send_view(gateway, session, 1);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message->type == MAZE32_MESSAGE_RESTORE) {
|
if (message->type == MAZE32_MESSAGE_RESTORE) {
|
||||||
@@ -480,7 +895,30 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
send_session(gateway, socket_fd, session, message->request_id);
|
send_session(gateway, socket_fd, session, message->request_id);
|
||||||
|
if (session->role == MAZE32_ROLE_PLAYER && gateway->lobby.phase != MAZE32_LOBBY_WAITING) {
|
||||||
|
maze32_game_phase_t paused_phase = gateway->game.phase;
|
||||||
|
maze32_game_result_t reconnect_result = maze32_game_set_player_connected(
|
||||||
|
&gateway->game, (uint8_t)(session->player_slot - 1U), 1, now_ms());
|
||||||
|
if (reconnect_result == MAZE32_GAME_RESULT_ACCEPTED) {
|
||||||
|
schedule_reconnect_deadline(gateway);
|
||||||
|
if (paused_phase == MAZE32_GAME_PHASE_PAUSED &&
|
||||||
|
gateway->game.phase != MAZE32_GAME_PHASE_PAUSED) {
|
||||||
|
broadcast_match_event(gateway, "resume", "{}");
|
||||||
|
ESP_LOGI(TAG, "Player %u restored; match resumed", session->player_slot);
|
||||||
|
}
|
||||||
|
if (gateway->game.phase == MAZE32_GAME_PHASE_FINISH_PENDING) {
|
||||||
|
schedule_finish_deadline(gateway);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
broadcast_snapshot(gateway);
|
broadcast_snapshot(gateway);
|
||||||
|
if (gateway->lobby.phase == MAZE32_LOBBY_ACTIVE) {
|
||||||
|
if (session->role == MAZE32_ROLE_OBSERVER) {
|
||||||
|
send_view(gateway, session, 1);
|
||||||
|
} else {
|
||||||
|
broadcast_views(gateway, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,11 +930,16 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message->type == MAZE32_MESSAGE_START) {
|
if (message->type == MAZE32_MESSAGE_START) {
|
||||||
|
if (session->role == MAZE32_ROLE_OBSERVER) {
|
||||||
|
ESP_LOGW(TAG, "Observer mutation rejected: fd=%d, type=start", socket_fd);
|
||||||
|
}
|
||||||
maze32_lobby_result_t result = maze32_lobby_start(&gateway->lobby, socket_fd, now_ms());
|
maze32_lobby_result_t result = maze32_lobby_start(&gateway->lobby, socket_fd, now_ms());
|
||||||
if (result != MAZE32_LOBBY_OK) {
|
if (result != MAZE32_LOBBY_OK) {
|
||||||
send_lobby_error(gateway, socket_fd, message->request_id, result);
|
send_lobby_error(gateway, socket_fd, message->request_id, result);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
gateway->metrics.matches_started++;
|
||||||
|
sample_resources(gateway, "match_start");
|
||||||
broadcast_snapshot(gateway);
|
broadcast_snapshot(gateway);
|
||||||
broadcast_countdown(gateway);
|
broadcast_countdown(gateway);
|
||||||
esp_err_t timer_result =
|
esp_err_t timer_result =
|
||||||
@@ -506,6 +949,14 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (message->type == MAZE32_MESSAGE_MOVE || message->type == MAZE32_MESSAGE_DROP_KEY) {
|
||||||
|
handle_game_command(gateway, session, message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message->type == MAZE32_MESSAGE_RESTART) {
|
||||||
|
handle_restart(gateway, session, message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (message->type == MAZE32_MESSAGE_HEARTBEAT) {
|
if (message->type == MAZE32_MESSAGE_HEARTBEAT) {
|
||||||
char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = "";
|
char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = "";
|
||||||
size_t length = 0U;
|
size_t length = 0U;
|
||||||
@@ -523,7 +974,7 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static esp_err_t websocket_handler(httpd_req_t* request) {
|
static esp_err_t websocket_handler_impl(httpd_req_t* request) {
|
||||||
maze32_gateway_t* gateway = request->user_ctx;
|
maze32_gateway_t* gateway = request->user_ctx;
|
||||||
int socket_fd = httpd_req_to_sockfd(request);
|
int socket_fd = httpd_req_to_sockfd(request);
|
||||||
maze32_gateway_connection_t* connection = find_connection(gateway, socket_fd);
|
maze32_gateway_connection_t* connection = find_connection(gateway, socket_fd);
|
||||||
@@ -540,7 +991,9 @@ static esp_err_t websocket_handler(httpd_req_t* request) {
|
|||||||
if (result != ESP_OK) {
|
if (result != ESP_OK) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
gateway->metrics.messages_received++;
|
||||||
if (frame.len > MAZE32_PROTOCOL_MESSAGE_LIMIT) {
|
if (frame.len > MAZE32_PROTOCOL_MESSAGE_LIMIT) {
|
||||||
|
gateway->metrics.oversized_messages++;
|
||||||
ESP_LOGW(TAG, "Oversized WebSocket frame rejected: fd=%d, length=%u", socket_fd,
|
ESP_LOGW(TAG, "Oversized WebSocket frame rejected: fd=%d, length=%u", socket_fd,
|
||||||
(unsigned int)frame.len);
|
(unsigned int)frame.len);
|
||||||
shutdown(socket_fd, SHUT_RDWR);
|
shutdown(socket_fd, SHUT_RDWR);
|
||||||
@@ -562,8 +1015,13 @@ static esp_err_t websocket_handler(httpd_req_t* request) {
|
|||||||
}
|
}
|
||||||
payload[frame.len] = '\0';
|
payload[frame.len] = '\0';
|
||||||
if (!rate_limit_allows(connection, now_ms())) {
|
if (!rate_limit_allows(connection, now_ms())) {
|
||||||
|
gateway->metrics.rate_limited_messages++;
|
||||||
send_error(gateway, socket_fd, "", "rate_limited", "Слишком много команд");
|
send_error(gateway, socket_fd, "", "rate_limited", "Слишком много команд");
|
||||||
drain_queue(gateway);
|
drain_queue(gateway);
|
||||||
|
if (connection->messages_in_window > MAZE32_GATEWAY_RATE_LIMIT_CLOSE_THRESHOLD) {
|
||||||
|
ESP_LOGW(TAG, "Sustained rate-limit abuse; closing fd=%d", socket_fd);
|
||||||
|
close_slow_connection(gateway, socket_fd);
|
||||||
|
}
|
||||||
return ESP_OK;
|
return ESP_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,6 +1029,7 @@ static esp_err_t websocket_handler(httpd_req_t* request) {
|
|||||||
maze32_protocol_result_t parse_result =
|
maze32_protocol_result_t parse_result =
|
||||||
maze32_protocol_parse((const char*)payload, frame.len, &message);
|
maze32_protocol_parse((const char*)payload, frame.len, &message);
|
||||||
if (parse_result != MAZE32_PROTOCOL_OK) {
|
if (parse_result != MAZE32_PROTOCOL_OK) {
|
||||||
|
gateway->metrics.malformed_messages++;
|
||||||
send_error(gateway, socket_fd, "", maze32_protocol_result_name(parse_result),
|
send_error(gateway, socket_fd, "", maze32_protocol_result_name(parse_result),
|
||||||
"Пакет не прошёл проверку протокола");
|
"Пакет не прошёл проверку протокола");
|
||||||
drain_queue(gateway);
|
drain_queue(gateway);
|
||||||
@@ -588,6 +1047,20 @@ static esp_err_t websocket_handler(httpd_req_t* request) {
|
|||||||
return ESP_OK;
|
return ESP_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static esp_err_t websocket_handler(httpd_req_t* request) {
|
||||||
|
maze32_gateway_t* gateway = request->user_ctx;
|
||||||
|
int64_t started_us = esp_timer_get_time();
|
||||||
|
esp_err_t result = websocket_handler_impl(request);
|
||||||
|
uint64_t elapsed_us = (uint64_t)(esp_timer_get_time() - started_us);
|
||||||
|
if (gateway != NULL) {
|
||||||
|
if (elapsed_us > gateway->metrics.maximum_handler_us) {
|
||||||
|
gateway->metrics.maximum_handler_us = elapsed_us;
|
||||||
|
}
|
||||||
|
sample_resources(gateway, NULL);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
static esp_err_t websocket_pre_handshake(httpd_req_t* request) {
|
static esp_err_t websocket_pre_handshake(httpd_req_t* request) {
|
||||||
maze32_gateway_t* gateway = request->user_ctx;
|
maze32_gateway_t* gateway = request->user_ctx;
|
||||||
int socket_fd = httpd_req_to_sockfd(request);
|
int socket_fd = httpd_req_to_sockfd(request);
|
||||||
@@ -604,6 +1077,9 @@ esp_err_t maze32_gateway_init(maze32_gateway_t* gateway) {
|
|||||||
}
|
}
|
||||||
memset(gateway, 0, sizeof(*gateway));
|
memset(gateway, 0, sizeof(*gateway));
|
||||||
maze32_lobby_init(&gateway->lobby);
|
maze32_lobby_init(&gateway->lobby);
|
||||||
|
if (initialize_game(gateway) != ESP_OK) {
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
const esp_timer_create_args_t timer_arguments = {
|
const esp_timer_create_args_t timer_arguments = {
|
||||||
.callback = countdown_timer_callback,
|
.callback = countdown_timer_callback,
|
||||||
.arg = gateway,
|
.arg = gateway,
|
||||||
@@ -611,7 +1087,37 @@ esp_err_t maze32_gateway_init(maze32_gateway_t* gateway) {
|
|||||||
.name = "lobby_countdown",
|
.name = "lobby_countdown",
|
||||||
.skip_unhandled_events = true,
|
.skip_unhandled_events = true,
|
||||||
};
|
};
|
||||||
return esp_timer_create(&timer_arguments, &gateway->countdown_timer);
|
esp_err_t result = esp_timer_create(&timer_arguments, &gateway->countdown_timer);
|
||||||
|
if (result != ESP_OK) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const esp_timer_create_args_t reconnect_timer_arguments = {
|
||||||
|
.callback = reconnect_timer_callback,
|
||||||
|
.arg = gateway,
|
||||||
|
.dispatch_method = ESP_TIMER_TASK,
|
||||||
|
.name = "reconnect_deadline",
|
||||||
|
.skip_unhandled_events = true,
|
||||||
|
};
|
||||||
|
result = esp_timer_create(&reconnect_timer_arguments, &gateway->reconnect_timer);
|
||||||
|
if (result != ESP_OK) {
|
||||||
|
esp_timer_delete(gateway->countdown_timer);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const esp_timer_create_args_t finish_timer_arguments = {
|
||||||
|
.callback = finish_timer_callback,
|
||||||
|
.arg = gateway,
|
||||||
|
.dispatch_method = ESP_TIMER_TASK,
|
||||||
|
.name = "finish_deadline",
|
||||||
|
.skip_unhandled_events = true,
|
||||||
|
};
|
||||||
|
result = esp_timer_create(&finish_timer_arguments, &gateway->finish_timer);
|
||||||
|
if (result != ESP_OK) {
|
||||||
|
esp_timer_delete(gateway->reconnect_timer);
|
||||||
|
esp_timer_delete(gateway->countdown_timer);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
sample_resources(gateway, "startup");
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
void maze32_gateway_set_server(maze32_gateway_t* gateway, httpd_handle_t server) {
|
void maze32_gateway_set_server(maze32_gateway_t* gateway, httpd_handle_t server) {
|
||||||
@@ -648,10 +1154,40 @@ void maze32_gateway_close_socket(httpd_handle_t server, int socket_fd) {
|
|||||||
if (gateway != NULL) {
|
if (gateway != NULL) {
|
||||||
maze32_gateway_connection_t* connection = find_connection(gateway, socket_fd);
|
maze32_gateway_connection_t* connection = find_connection(gateway, socket_fd);
|
||||||
if (connection != NULL) {
|
if (connection != NULL) {
|
||||||
|
const maze32_session_t* session =
|
||||||
|
maze32_lobby_session_for_connection(&gateway->lobby, socket_fd);
|
||||||
|
unsigned int disconnected_slot = 0U;
|
||||||
|
if (session != NULL && session->role == MAZE32_ROLE_PLAYER &&
|
||||||
|
gateway->lobby.phase != MAZE32_LOBBY_WAITING) {
|
||||||
|
disconnected_slot = session->player_slot;
|
||||||
|
uint64_t disconnected_at_ms = now_ms();
|
||||||
|
if (maze32_game_set_player_connected(
|
||||||
|
&gateway->game, (uint8_t)(disconnected_slot - 1U), 0, disconnected_at_ms) ==
|
||||||
|
MAZE32_GAME_RESULT_ACCEPTED) {
|
||||||
|
char payload[96];
|
||||||
|
int written = snprintf(
|
||||||
|
payload, sizeof(payload), "{\"slot\":%u,\"deadlineMs\":%" PRIu64 "}",
|
||||||
|
disconnected_slot,
|
||||||
|
gateway->game.players[disconnected_slot - 1U].reconnect_deadline_ms);
|
||||||
|
if (written >= 0 && (size_t)written < sizeof(payload)) {
|
||||||
|
broadcast_match_event(gateway, "pause", payload);
|
||||||
|
}
|
||||||
|
if (gateway->game.phase_before_pause == MAZE32_GAME_PHASE_FINISH_PENDING) {
|
||||||
|
esp_timer_stop(gateway->finish_timer);
|
||||||
|
}
|
||||||
|
schedule_reconnect_deadline(gateway);
|
||||||
|
ESP_LOGI(TAG, "Player %u disconnected; match paused for %u ms",
|
||||||
|
disconnected_slot, (unsigned int)MAZE32_GAME_RECONNECT_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
maze32_lobby_disconnect(&gateway->lobby, socket_fd);
|
maze32_lobby_disconnect(&gateway->lobby, socket_fd);
|
||||||
memset(connection, 0, sizeof(*connection));
|
memset(connection, 0, sizeof(*connection));
|
||||||
broadcast_snapshot(gateway);
|
broadcast_snapshot(gateway);
|
||||||
|
if (disconnected_slot != 0U) {
|
||||||
|
broadcast_views(gateway, 0);
|
||||||
|
}
|
||||||
drain_queue(gateway);
|
drain_queue(gateway);
|
||||||
|
sample_resources(gateway, NULL);
|
||||||
ESP_LOGI(TAG, "WebSocket disconnected: fd=%d", socket_fd);
|
ESP_LOGI(TAG, "WebSocket disconnected: fd=%d", socket_fd);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -661,3 +1197,42 @@ void maze32_gateway_close_socket(httpd_handle_t server, int socket_fd) {
|
|||||||
int maze32_gateway_can_reconfigure(const maze32_gateway_t* gateway) {
|
int maze32_gateway_can_reconfigure(const maze32_gateway_t* gateway) {
|
||||||
return gateway != NULL && maze32_lobby_can_reconfigure(&gateway->lobby);
|
return gateway != NULL && maze32_lobby_can_reconfigure(&gateway->lobby);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
size_t maze32_gateway_format_diagnostics(maze32_gateway_t* gateway, char* output,
|
||||||
|
size_t output_size) {
|
||||||
|
if (gateway == NULL || output == NULL || output_size == 0U) {
|
||||||
|
return 0U;
|
||||||
|
}
|
||||||
|
sample_resources(gateway, NULL);
|
||||||
|
int written = snprintf(
|
||||||
|
output, output_size,
|
||||||
|
"{\"activeConnections\":%u,\"connectionHighWater\":%" PRIu32
|
||||||
|
",\"queueDepth\":%u,\"queueHighWater\":%" PRIu32 ",\"messagesReceived\":%" PRIu32
|
||||||
|
",\"messagesSent\":%" PRIu32 ",\"messagesDropped\":%" PRIu32 ",\"sendFailures\":%" PRIu32
|
||||||
|
",\"malformedMessages\":%" PRIu32 ",\"oversizedMessages\":%" PRIu32
|
||||||
|
",\"rateLimitedMessages\":%" PRIu32 ",\"acceptedGameCommands\":%" PRIu32
|
||||||
|
",\"matchesStarted\":%" PRIu32 ",\"matchesCompleted\":%" PRIu32 ",\"matchesReset\":%" PRIu32
|
||||||
|
",\"maximumHandlerUs\":%" PRIu64 ",\"maximumSendUs\":%" PRIu64
|
||||||
|
",\"internalFreeBytes\":%u,\"internalLargestBlockBytes\":%u,"
|
||||||
|
"\"minimumInternalFreeBytes\":%u,\"minimumInternalLargestBlockBytes\":%u,"
|
||||||
|
"\"limits\":{\"connections\":%u,\"messageBytes\":%u,\"outgoingBytes\":%u,"
|
||||||
|
"\"queueEntries\":%u,\"gameCommands\":%u,\"lwipSockets\":%u,"
|
||||||
|
"\"playerLatencyBudgetMs\":%u,\"psramRequired\":false}}",
|
||||||
|
active_connection_count(gateway), gateway->metrics.connection_high_water,
|
||||||
|
(unsigned int)gateway->outgoing_count, gateway->metrics.queue_high_water,
|
||||||
|
gateway->metrics.messages_received, gateway->metrics.messages_sent,
|
||||||
|
gateway->metrics.messages_dropped, gateway->metrics.send_failures,
|
||||||
|
gateway->metrics.malformed_messages, gateway->metrics.oversized_messages,
|
||||||
|
gateway->metrics.rate_limited_messages, gateway->metrics.accepted_game_commands,
|
||||||
|
gateway->metrics.matches_started, gateway->metrics.matches_completed,
|
||||||
|
gateway->metrics.matches_reset, gateway->metrics.maximum_handler_us,
|
||||||
|
gateway->metrics.maximum_send_us, (unsigned int)internal_free_bytes(),
|
||||||
|
(unsigned int)internal_largest_block_bytes(),
|
||||||
|
(unsigned int)gateway->metrics.minimum_internal_free_bytes,
|
||||||
|
(unsigned int)gateway->metrics.minimum_internal_largest_block_bytes,
|
||||||
|
(unsigned int)MAZE32_GATEWAY_CONNECTION_LIMIT, (unsigned int)MAZE32_PROTOCOL_MESSAGE_LIMIT,
|
||||||
|
(unsigned int)MAZE32_GATEWAY_OUTGOING_LIMIT, (unsigned int)MAZE32_GATEWAY_QUEUE_LIMIT,
|
||||||
|
(unsigned int)MAZE32_GAME_COMMAND_CAPACITY, (unsigned int)CONFIG_LWIP_MAX_SOCKETS,
|
||||||
|
(unsigned int)MAZE32_GATEWAY_PLAYER_LATENCY_BUDGET_MS);
|
||||||
|
return written < 0 || (size_t)written >= output_size ? 0U : (size_t)written;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "maze32_web.h"
|
#include "maze32_web.h"
|
||||||
|
|
||||||
|
#include <fcntl.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
|
|
||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
@@ -7,22 +8,37 @@
|
|||||||
#define MAZE32_STATIC_CHUNK_SIZE 1024
|
#define MAZE32_STATIC_CHUNK_SIZE 1024
|
||||||
|
|
||||||
static const char* TAG = "maze32_web";
|
static const char* TAG = "maze32_web";
|
||||||
static const char* INDEX_PATH = "/littlefs/index.html.gz";
|
|
||||||
|
typedef struct {
|
||||||
|
const char* path;
|
||||||
|
const char* content_type;
|
||||||
|
} maze32_static_asset_t;
|
||||||
|
|
||||||
|
static const maze32_static_asset_t INDEX_ASSET = {"/littlefs/index.html.gz",
|
||||||
|
"text/html; charset=utf-8"};
|
||||||
|
static const maze32_static_asset_t PLAYER_STYLE_ASSET = {"/littlefs/player.css.gz", "text/css"};
|
||||||
|
static const maze32_static_asset_t PLAYER_SCRIPT_ASSET = {"/littlefs/player.js.gz",
|
||||||
|
"text/javascript; charset=utf-8"};
|
||||||
|
static const maze32_static_asset_t ICON_ASSET = {"/littlefs/icons.svg.gz", "image/svg+xml"};
|
||||||
|
|
||||||
static void preserve_static_context(void* context) {
|
static void preserve_static_context(void* context) {
|
||||||
(void)context;
|
(void)context;
|
||||||
}
|
}
|
||||||
|
|
||||||
static esp_err_t send_index(httpd_req_t* request) {
|
static esp_err_t send_static_asset(httpd_req_t* request) {
|
||||||
FILE* file = fopen(INDEX_PATH, "rb");
|
const maze32_static_asset_t* asset = request->user_ctx;
|
||||||
|
if (asset == NULL) {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
FILE* file = fopen(asset->path, "rb");
|
||||||
if (file == NULL) {
|
if (file == NULL) {
|
||||||
ESP_LOGE(TAG, "Static asset is unavailable: %s", INDEX_PATH);
|
ESP_LOGE(TAG, "Static asset is unavailable: %s", asset->path);
|
||||||
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR,
|
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR,
|
||||||
"Приложение временно недоступно");
|
"Приложение временно недоступно");
|
||||||
return ESP_FAIL;
|
return ESP_FAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
httpd_resp_set_type(request, "text/html; charset=utf-8");
|
httpd_resp_set_type(request, asset->content_type);
|
||||||
httpd_resp_set_hdr(request, "Content-Encoding", "gzip");
|
httpd_resp_set_hdr(request, "Content-Encoding", "gzip");
|
||||||
httpd_resp_set_hdr(request, "Cache-Control", "no-cache");
|
httpd_resp_set_hdr(request, "Cache-Control", "no-cache");
|
||||||
|
|
||||||
@@ -37,7 +53,7 @@ static esp_err_t send_index(httpd_req_t* request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (ferror(file) != 0) {
|
if (ferror(file) != 0) {
|
||||||
ESP_LOGE(TAG, "Failed while reading static asset: %s", INDEX_PATH);
|
ESP_LOGE(TAG, "Failed while reading static asset: %s", asset->path);
|
||||||
result = ESP_FAIL;
|
result = ESP_FAIL;
|
||||||
}
|
}
|
||||||
fclose(file);
|
fclose(file);
|
||||||
@@ -67,6 +83,20 @@ static esp_err_t send_health(httpd_req_t* request) {
|
|||||||
return httpd_resp_send(request, response, length);
|
return httpd_resp_send(request, response, length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static esp_err_t send_diagnostics(httpd_req_t* request) {
|
||||||
|
maze32_gateway_t* gateway = request->user_ctx;
|
||||||
|
char response[1024];
|
||||||
|
size_t length = maze32_gateway_format_diagnostics(gateway, response, sizeof(response));
|
||||||
|
if (length == 0U) {
|
||||||
|
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR,
|
||||||
|
"Диагностика временно недоступна");
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
httpd_resp_set_type(request, "application/json");
|
||||||
|
httpd_resp_set_hdr(request, "Cache-Control", "no-store");
|
||||||
|
return httpd_resp_send(request, response, (ssize_t)length);
|
||||||
|
}
|
||||||
|
|
||||||
esp_err_t maze32_web_start(httpd_handle_t* server, maze32_health_t* health,
|
esp_err_t maze32_web_start(httpd_handle_t* server, maze32_health_t* health,
|
||||||
maze32_gateway_t* gateway) {
|
maze32_gateway_t* gateway) {
|
||||||
if (server == NULL || health == NULL || gateway == NULL) {
|
if (server == NULL || health == NULL || gateway == NULL) {
|
||||||
@@ -74,11 +104,11 @@ esp_err_t maze32_web_start(httpd_handle_t* server, maze32_health_t* health,
|
|||||||
}
|
}
|
||||||
|
|
||||||
httpd_config_t configuration = HTTPD_DEFAULT_CONFIG();
|
httpd_config_t configuration = HTTPD_DEFAULT_CONFIG();
|
||||||
configuration.max_uri_handlers = 9;
|
configuration.max_uri_handlers = 13;
|
||||||
configuration.max_open_sockets = 9;
|
configuration.max_open_sockets = 9;
|
||||||
configuration.lru_purge_enable = false;
|
configuration.lru_purge_enable = false;
|
||||||
configuration.recv_wait_timeout = 5;
|
configuration.recv_wait_timeout = 5;
|
||||||
configuration.send_wait_timeout = 1;
|
configuration.send_wait_timeout = 5;
|
||||||
configuration.global_user_ctx = gateway;
|
configuration.global_user_ctx = gateway;
|
||||||
configuration.global_user_ctx_free_fn = preserve_static_context;
|
configuration.global_user_ctx_free_fn = preserve_static_context;
|
||||||
configuration.close_fn = maze32_gateway_close_socket;
|
configuration.close_fn = maze32_gateway_close_socket;
|
||||||
@@ -91,14 +121,32 @@ esp_err_t maze32_web_start(httpd_handle_t* server, maze32_health_t* health,
|
|||||||
const httpd_uri_t root = {
|
const httpd_uri_t root = {
|
||||||
.uri = "/",
|
.uri = "/",
|
||||||
.method = HTTP_GET,
|
.method = HTTP_GET,
|
||||||
.handler = send_index,
|
.handler = send_static_asset,
|
||||||
.user_ctx = NULL,
|
.user_ctx = (void*)&INDEX_ASSET,
|
||||||
};
|
};
|
||||||
const httpd_uri_t index = {
|
const httpd_uri_t index = {
|
||||||
.uri = "/index.html",
|
.uri = "/index.html",
|
||||||
.method = HTTP_GET,
|
.method = HTTP_GET,
|
||||||
.handler = send_index,
|
.handler = send_static_asset,
|
||||||
.user_ctx = NULL,
|
.user_ctx = (void*)&INDEX_ASSET,
|
||||||
|
};
|
||||||
|
const httpd_uri_t player_style = {
|
||||||
|
.uri = "/player.css",
|
||||||
|
.method = HTTP_GET,
|
||||||
|
.handler = send_static_asset,
|
||||||
|
.user_ctx = (void*)&PLAYER_STYLE_ASSET,
|
||||||
|
};
|
||||||
|
const httpd_uri_t player_script = {
|
||||||
|
.uri = "/player.js",
|
||||||
|
.method = HTTP_GET,
|
||||||
|
.handler = send_static_asset,
|
||||||
|
.user_ctx = (void*)&PLAYER_SCRIPT_ASSET,
|
||||||
|
};
|
||||||
|
const httpd_uri_t icons = {
|
||||||
|
.uri = "/icons.svg",
|
||||||
|
.method = HTTP_GET,
|
||||||
|
.handler = send_static_asset,
|
||||||
|
.user_ctx = (void*)&ICON_ASSET,
|
||||||
};
|
};
|
||||||
const httpd_uri_t health_uri = {
|
const httpd_uri_t health_uri = {
|
||||||
.uri = "/health",
|
.uri = "/health",
|
||||||
@@ -106,14 +154,32 @@ esp_err_t maze32_web_start(httpd_handle_t* server, maze32_health_t* health,
|
|||||||
.handler = send_health,
|
.handler = send_health,
|
||||||
.user_ctx = health,
|
.user_ctx = health,
|
||||||
};
|
};
|
||||||
|
const httpd_uri_t diagnostics_uri = {
|
||||||
|
.uri = "/diagnostics",
|
||||||
|
.method = HTTP_GET,
|
||||||
|
.handler = send_diagnostics,
|
||||||
|
.user_ctx = gateway,
|
||||||
|
};
|
||||||
|
|
||||||
result = httpd_register_uri_handler(*server, &root);
|
result = httpd_register_uri_handler(*server, &root);
|
||||||
if (result == ESP_OK) {
|
if (result == ESP_OK) {
|
||||||
result = httpd_register_uri_handler(*server, &index);
|
result = httpd_register_uri_handler(*server, &index);
|
||||||
}
|
}
|
||||||
|
if (result == ESP_OK) {
|
||||||
|
result = httpd_register_uri_handler(*server, &player_style);
|
||||||
|
}
|
||||||
|
if (result == ESP_OK) {
|
||||||
|
result = httpd_register_uri_handler(*server, &player_script);
|
||||||
|
}
|
||||||
|
if (result == ESP_OK) {
|
||||||
|
result = httpd_register_uri_handler(*server, &icons);
|
||||||
|
}
|
||||||
if (result == ESP_OK) {
|
if (result == ESP_OK) {
|
||||||
result = httpd_register_uri_handler(*server, &health_uri);
|
result = httpd_register_uri_handler(*server, &health_uri);
|
||||||
}
|
}
|
||||||
|
if (result == ESP_OK) {
|
||||||
|
result = httpd_register_uri_handler(*server, &diagnostics_uri);
|
||||||
|
}
|
||||||
if (result != ESP_OK) {
|
if (result != ESP_OK) {
|
||||||
httpd_stop(*server);
|
httpd_stop(*server);
|
||||||
*server = NULL;
|
*server = NULL;
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,72 @@
|
|||||||
|
# MVP gameplay balance
|
||||||
|
|
||||||
|
This document locks the compact gameplay configuration evaluated for Milestone
|
||||||
|
014. Human confirmation of the revised map belongs in
|
||||||
|
`docs/PLAYTEST_REPORT.md`.
|
||||||
|
|
||||||
|
## Locked configuration
|
||||||
|
|
||||||
|
| Parameter | Locked value | Purpose |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| Maze dimensions | 32 x 32 cells | Square map with one eighth of the former area |
|
||||||
|
| Initial vision radius | 3 cells | Current-view-only exploration |
|
||||||
|
| Cross-half connection pairs | 2 | Four seam crossings between conceptual halves |
|
||||||
|
| Extra cycle pairs | 8 | Proportional route density without removing dead ends |
|
||||||
|
| Minimum start-to-exit separation | 22 cells | Linear scaling by approximately the square root of eight |
|
||||||
|
| Landmark pairs | 2 | Four spatial-memory cues in symmetric positions |
|
||||||
|
| Vision-artifact pairs | 1 | Two one-use artifacts; preserves the MVP minimum |
|
||||||
|
| Compass pairs | 1 | One compass for each player's own exit |
|
||||||
|
| Movement cooldown | 150 ms | Server-enforced minimum accepted-move interval |
|
||||||
|
| Enhanced-vision duration | 10 successful moves | MVP artifact duration |
|
||||||
|
| Draw window | 200 ms | Authoritative simultaneous-exit boundary |
|
||||||
|
| Match timer | None | Matches end only by defined result conditions |
|
||||||
|
|
||||||
|
The values come from `maze32_maze_default_config()` and
|
||||||
|
`maze32_game_default_config()`. They are server-owned defaults; browser
|
||||||
|
clients cannot alter them.
|
||||||
|
|
||||||
|
## Deterministic generation evidence
|
||||||
|
|
||||||
|
`test/host/test_maze_generation.c` generates and validates seeds 0 through
|
||||||
|
1,023 with this configuration. For every generated map it proves reachability,
|
||||||
|
central symmetry, positive cycles and dead ends, at least four cross-half
|
||||||
|
connections, paired player-relative route distances, and this balance
|
||||||
|
invariant:
|
||||||
|
|
||||||
|
```
|
||||||
|
start_to_key_distance >= key_to_exit_distance
|
||||||
|
```
|
||||||
|
|
||||||
|
The test measures shortest traversable routes, not straight-line distance. It
|
||||||
|
therefore establishes that finding a key is never made easier by a shorter
|
||||||
|
required route than the later exit search. It does not substitute for human
|
||||||
|
search behaviour in a maze with fog of war, landmarks, compasses, and
|
||||||
|
artifacts.
|
||||||
|
|
||||||
|
The most recent recorded sweep (2026-09-08) reported:
|
||||||
|
|
||||||
|
| Metric | Observed range across 1,024 seeds |
|
||||||
|
| --- | ---: |
|
||||||
|
| Cycles | 19..19 |
|
||||||
|
| Dead ends | 74..128 |
|
||||||
|
| Start to key | 93..422 cells |
|
||||||
|
| Key to exit | 84..352 cells |
|
||||||
|
| Start to exit | 26..242 cells |
|
||||||
|
| Generation attempts | 1..2 |
|
||||||
|
|
||||||
|
At the 150 ms cooldown, an uninterrupted shortest 93-cell key route takes at
|
||||||
|
least 13.95 seconds of accepted-move time. Actual match duration also includes
|
||||||
|
route discovery, current-view-only navigation, the exit search, detours,
|
||||||
|
interactions, and player decisions. The approximately five-minute target must
|
||||||
|
be confirmed by the representative human-playtest procedure; no hard timer is
|
||||||
|
introduced to force that outcome.
|
||||||
|
|
||||||
|
## Tuning decision
|
||||||
|
|
||||||
|
The topology and object counts were reduced with the map area while retaining
|
||||||
|
the three-cell base view, ten-move artifact effect, 150 ms cooldown, and 200 ms
|
||||||
|
draw window. These interaction values remain unchanged until representative
|
||||||
|
playtesting of the compact map supplies evidence for a further adjustment. If
|
||||||
|
playtesting identifies a material duration or navigation problem, change only
|
||||||
|
the smallest relevant locked value, rerun the 1,024-seed suite, record the new
|
||||||
|
range here, and repeat the affected playtests.
|
||||||
+73
-13
@@ -1,9 +1,8 @@
|
|||||||
# Authoritative game core
|
# Authoritative game core
|
||||||
|
|
||||||
Milestone 006 provides the portable movement engine and view projector. The
|
Milestones 006, 007, and 009 provide the portable movement engine, gameplay
|
||||||
ESP remains the only owner of mutable game state. Object interaction, item
|
object systems, terminal results, view projector, and disconnect recovery. The
|
||||||
effects, exits, results, reconnect behavior, and browser controls remain out of
|
ESP remains the only owner of mutable game state.
|
||||||
scope until their later milestones.
|
|
||||||
|
|
||||||
## Command processing
|
## Command processing
|
||||||
|
|
||||||
@@ -22,11 +21,12 @@ Observers cannot enqueue commands.
|
|||||||
## Projection boundary
|
## Projection boundary
|
||||||
|
|
||||||
Player projections contain only cells whose Chebyshev distance from the
|
Player projections contain only cells whose Chebyshev distance from the
|
||||||
player is at most the active radius:
|
player is at most the server-owned active radius:
|
||||||
|
|
||||||
`max(abs(x - player_x), abs(y - player_y)) <= radius`
|
`max(abs(x - player_x), abs(y - player_y)) <= radius`
|
||||||
|
|
||||||
The projector supports the normal radius three and vision-effect radius six.
|
The projector selects the normal radius three or vision-effect radius six from
|
||||||
|
authoritative player state; clients cannot request a radius.
|
||||||
Map boundaries clip the square; walls do not occlude it. Each projection is
|
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
|
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
|
omitted instead of surviving as stale state. Observer projections contain all
|
||||||
@@ -35,11 +35,71 @@ maze cells, static objects, and both current player positions.
|
|||||||
The serialized view is split into bounded JSON records of at most 512 bytes,
|
The serialized view is split into bounded JSON records of at most 512 bytes,
|
||||||
matching the WebSocket gateway's message bound.
|
matching the WebSocket gateway's message bound.
|
||||||
Every record uses the architecture's versioned envelope and carries the
|
Every record uses the architecture's versioned envelope and carries the
|
||||||
authoritative state sequence, role, match phase, meeting sequence, chunk index,
|
authoritative state sequence, role, match phase, maze width and height,
|
||||||
and chunk count. Cell records contain only the cell index and current passage
|
meeting sequence, chunk index, and chunk count. Role status appears once in chunk zero instead of being
|
||||||
flags. Object records contain kind, owner, variant, and cell index. Player
|
duplicated across the snapshot. Cell records contain only the cell index and
|
||||||
records omit the `opponent` member when that player is not currently visible;
|
current passage flags. Object records contain kind, owner, variant, and cell
|
||||||
observer records instead carry both positions in `players`.
|
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
|
## Gameplay resolution
|
||||||
does not pick up, consume, transfer, or otherwise mutate them.
|
|
||||||
|
Every accepted in-maze move resolves in this order:
|
||||||
|
|
||||||
|
1. Decrement an existing vision effect.
|
||||||
|
2. Consume and activate an artifact on the destination cell.
|
||||||
|
3. Pick up the lowest-owner-numbered ground key when the inventory is empty.
|
||||||
|
4. Detect a meeting and atomically return all carried keys to their owners.
|
||||||
|
5. Activate the moving player's own compass.
|
||||||
|
6. Open the moving player's own exit when they carry their own key.
|
||||||
|
|
||||||
|
The deterministic key priority matters only if voluntary drops place both keys
|
||||||
|
on one cell. Each key always has exactly one authoritative location: a ground
|
||||||
|
cell or one carrier. Inventory has one slot, dropping places the key on the
|
||||||
|
current cell, and carrying an opponent's key is allowed. A meeting clears both
|
||||||
|
inventory slots before assigning every carried key to its owner, so crossed
|
||||||
|
keys swap atomically without duplication or loss.
|
||||||
|
|
||||||
|
An unconsumed vision artifact is removed on entry and sets the player's effect
|
||||||
|
counter to ten. Only later successful moves decrement the counter; failed
|
||||||
|
moves and key drops do not. Entering another unconsumed artifact resets the
|
||||||
|
counter to ten rather than stacking effects.
|
||||||
|
|
||||||
|
Entering the player's associated compass produces one of eight direct compass
|
||||||
|
bearings toward that player's own exit. It provides neither a coordinate nor a
|
||||||
|
route. The hint remains through rejected commands and is cleared by the next
|
||||||
|
successful move unless that move activates the compass again.
|
||||||
|
|
||||||
|
## Exits and results
|
||||||
|
|
||||||
|
Entering an owner's exit while carrying the matching key opens it but does not
|
||||||
|
finish the match. The player must issue another movement command pointing out
|
||||||
|
of the map from that open exit. The first accepted outward move starts a
|
||||||
|
200-millisecond finish window. A different player exiting at the same timestamp
|
||||||
|
or up to and including 200 milliseconds later produces a draw. Once time is
|
||||||
|
strictly more than 200 milliseconds later, advancing the authoritative clock
|
||||||
|
finalizes the first player as winner. Opponent exits never open or permit an
|
||||||
|
outward move for the wrong player.
|
||||||
|
|
||||||
|
Player snapshots always contain their own inventory, remaining vision moves,
|
||||||
|
compass hint, and own-exit status. An opponent's carried key is included only
|
||||||
|
while that opponent is currently visible. Observer snapshots contain both
|
||||||
|
inventories and effect counters, both exit states and authoritative exit
|
||||||
|
timestamps, every remaining ground object, and the public match result.
|
||||||
|
|
||||||
|
## Disconnect recovery
|
||||||
|
|
||||||
|
Both players start connected. Disconnecting either player during `active` or
|
||||||
|
`finish_pending` stores that exact phase, clears queued commands, enters
|
||||||
|
`paused`, and assigns the disconnected player a monotonic deadline 60,000 ms
|
||||||
|
after detection. Commands processed during the pause are rejected before any
|
||||||
|
position, inventory, effect counter, or result can change.
|
||||||
|
If the disconnect occurs during `finish_pending`, the remaining 200 ms result
|
||||||
|
window is preserved and continues only after both players reconnect.
|
||||||
|
|
||||||
|
Restoring the reserved player session before the deadline reconnects the same
|
||||||
|
player record. The stored phase resumes only when both player records are
|
||||||
|
connected, so a second disconnected player cannot be bypassed. Reaching a
|
||||||
|
disconnected player's deadline ends the match with the authoritative `draw`
|
||||||
|
result and clears queued commands. Observer connection state never enters this
|
||||||
|
state machine.
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# On-device integration testing
|
||||||
|
|
||||||
|
This procedure verifies Milestone 010 on the ESP32-C6FH4 without PSRAM or any
|
||||||
|
assumed GPIO. Run it against production firmware and the production LittleFS
|
||||||
|
image. Start from a freshly booted device with no occupied lobby sessions.
|
||||||
|
|
||||||
|
## Build and delivery
|
||||||
|
|
||||||
|
```sh
|
||||||
|
/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1
|
||||||
|
/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1 --target buildfs
|
||||||
|
/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1 --target upload --upload-port /dev/ttyACM0
|
||||||
|
/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1 --target uploadfs --upload-port /dev/ttyACM0
|
||||||
|
```
|
||||||
|
|
||||||
|
Monitor the USB serial log at 115200 baud. Confirm application version
|
||||||
|
`0.14.0`, the 4 MB partition table, successful LittleFS mount, station or
|
||||||
|
fallback network startup, registration of all HTTP/WebSocket routes, and
|
||||||
|
health state `serving`.
|
||||||
|
|
||||||
|
## Resource and load scenario
|
||||||
|
|
||||||
|
Keep the development host connected to `Mazda7`; do not switch it to the
|
||||||
|
device fallback access point. When the board has station address
|
||||||
|
`192.168.1.50`, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 scripts/ws_protocol_client.py 192.168.1.50 \
|
||||||
|
--verify-m010 --stress-moves 80 --repeat-matches 2
|
||||||
|
```
|
||||||
|
|
||||||
|
The scenario intentionally takes more than two minutes because both repeated
|
||||||
|
matches use the real 60-second reconnect deadline. It performs these checks:
|
||||||
|
|
||||||
|
1. Connect two players and four observers and assemble all four complete
|
||||||
|
1,024-cell observer projections.
|
||||||
|
2. Verify heartbeat round-trip latency for all six sessions.
|
||||||
|
3. Stop consuming one observer stream while Player 1 performs accepted moves;
|
||||||
|
intermediate observer updates may be dropped, but player acknowledgements
|
||||||
|
must remain within the reported 500 ms budget.
|
||||||
|
4. Flood a second observer with malformed messages. The fixed rate limiter
|
||||||
|
closes sustained abuse, and the players remain responsive.
|
||||||
|
5. Complete and authoritatively reset two matches through the real disconnect
|
||||||
|
timeout. Each reset generates a new server-owned maze while preserving the
|
||||||
|
connected sessions.
|
||||||
|
6. Fetch `/diagnostics` before, during, and after the run. Assert the connection
|
||||||
|
and queue high-water marks remain within six and twelve, internal free heap
|
||||||
|
stays above 32 KiB, the largest internal block stays above 16 KiB, and the
|
||||||
|
post-reset free-heap samples remain within a 32 KiB range.
|
||||||
|
|
||||||
|
Capture the command output, the final diagnostics JSON, and serial lines that
|
||||||
|
begin with `M010 resources:`. The JSON counters are monotonic since boot and
|
||||||
|
include connections, queue use, dropped messages, failures, malformed/rate-
|
||||||
|
limited input, commands, match cycles, maximum handler/send time, and current
|
||||||
|
and minimum internal heap samples.
|
||||||
|
|
||||||
|
## Station, fallback, and browser recovery
|
||||||
|
|
||||||
|
These checks involve network state and must be performed from a separate phone
|
||||||
|
or computer so the development host retains its Mazda7 internet connection.
|
||||||
|
|
||||||
|
1. Reboot with the saved Mazda7 configuration and confirm
|
||||||
|
`/api/wifi/status` reports `station_connected`. Reload both player pages
|
||||||
|
during an active match and verify their tokens restore the same slots and
|
||||||
|
authoritative state.
|
||||||
|
2. From the separate client, exercise the established provisioning procedure
|
||||||
|
with an intentionally unavailable test network. Confirm the bounded station
|
||||||
|
attempts return the device to `maze32-XXXX` at
|
||||||
|
`http://192.168.4.1/provision` and that the Russian page remains usable.
|
||||||
|
3. Restore Mazda7 through the provisioning page without recording its
|
||||||
|
password. Reboot and confirm station connection, browser recovery, `/health`,
|
||||||
|
and `/diagnostics` again.
|
||||||
|
|
||||||
|
Do not record an acceptance result unless the physical logs and samples are
|
||||||
|
available. Do not use an LED, a board pin, or a PSRAM capability as a test
|
||||||
|
oracle.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Milestone 016 movement-latency procedure
|
||||||
|
|
||||||
|
## Measurement model
|
||||||
|
|
||||||
|
Each accepted movement uses one correlation path. Browser times use
|
||||||
|
`performance.now()` and ESP times use its monotonic timer; do not subtract a
|
||||||
|
browser timestamp from an ESP timestamp.
|
||||||
|
|
||||||
|
| Interval | Clock | Measurement point |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| pointer feedback | browser | pointer event to next animation frame |
|
||||||
|
| command to acknowledgement | browser | `move` send to matching `ack` |
|
||||||
|
| command to complete view | browser | `move` send to matching assembled view sequence |
|
||||||
|
| command to painted view | browser | `move` send to next frame after matching view render |
|
||||||
|
| handler and send | ESP | existing `/diagnostics` maxima |
|
||||||
|
| network round trip | client | ICMP and WebSocket heartbeat samples |
|
||||||
|
|
||||||
|
The accepted command acknowledgement contains the authoritative `stateSequence`.
|
||||||
|
The browser records a sample only after the complete view with that sequence is
|
||||||
|
rendered and the next animation frame occurs. Samples are local-only, capped at
|
||||||
|
256, and do not alter a command, projection, or server result.
|
||||||
|
|
||||||
|
## Controlled runs
|
||||||
|
|
||||||
|
1. Record the firmware build identity, device address, Wi-Fi access point,
|
||||||
|
client device, browser/version, signal conditions, and viewport.
|
||||||
|
2. Collect 100 `ping -c 100 -q <device>` samples. Record loss, min, average,
|
||||||
|
maximum, and deviation.
|
||||||
|
3. Run 200 accepted moves per player with two players and no observers. Include
|
||||||
|
direction changes, a sustained hold, a release, and one blocked move.
|
||||||
|
4. Repeat with two players and four observers. Capture `/diagnostics` before
|
||||||
|
and after each run.
|
||||||
|
5. For each browser, copy
|
||||||
|
`window.maze32PlayerApp.latencySamples()` after the run. Compute p50, p95,
|
||||||
|
p99, and maximum for all browser intervals. Keep raw samples only for the
|
||||||
|
duration of the report and do not store player names or tokens.
|
||||||
|
6. Compare command-to-acknowledgement with command-to-painted-view, ESP handler
|
||||||
|
and send maxima, queue high-water marks, observer view chunk count, and
|
||||||
|
ICMP/WebSocket round trips. Attribute a delay only when the matching
|
||||||
|
interval is elevated.
|
||||||
|
|
||||||
|
## Safety checks
|
||||||
|
|
||||||
|
- Confirm no dropped, failed, malformed, oversized, or rate-limited message
|
||||||
|
increased unexpectedly outside the deliberate blocked-move case.
|
||||||
|
- Confirm queue high water stays within its published limit and browser sample
|
||||||
|
storage remains at or below 256 entries.
|
||||||
|
- Record blocked-command rejection separately; it does not count as an
|
||||||
|
accepted-move sample.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Milestone 017 latency remediation report
|
||||||
|
|
||||||
|
## Baseline
|
||||||
|
|
||||||
|
Milestone 016 recorded an unsuitable local Wi-Fi tail: 100 HTTP requests had
|
||||||
|
7.5 ms minimum, 186.7 ms average, 508.0 ms p95, 713.3 ms p99, and 985.4 ms
|
||||||
|
maximum round-trip time. The available browser samples ranged from 19 to 548
|
||||||
|
ms for command acknowledgement and from 30 to 551 ms for an authoritative
|
||||||
|
painted view. ESP handler and send measurements were materially smaller during
|
||||||
|
the partial runs. The M016 project-owner-approved deviation records that the
|
||||||
|
remaining controlled runs could not be completed on that path.
|
||||||
|
|
||||||
|
## Browser remediation
|
||||||
|
|
||||||
|
The player now uses the centered avatar as a 96 by 96 CSS-pixel transparent
|
||||||
|
touch target. Pointer movement changes only a local avatar deflection capped at
|
||||||
|
28 CSS pixels. A 12-pixel dead zone, diagonal hysteresis, and a 360-to-170 ms
|
||||||
|
monotonic cadence resolve a cardinal movement intention without exceeding the
|
||||||
|
150 ms server cooldown.
|
||||||
|
|
||||||
|
The browser sends at most one move at a time. While that command awaits an
|
||||||
|
acknowledgement or error, changing drag direction replaces one local latest
|
||||||
|
intention. Release removes the unsent intention. Consequently a slow socket
|
||||||
|
cannot accumulate a client-side movement backlog. No browser code predicts a
|
||||||
|
maze position, collision result, inventory, visibility, timer, or outcome.
|
||||||
|
|
||||||
|
Each received projection is translated so its authoritative `self` cell is
|
||||||
|
directly below the fixed avatar. This replaces rectangle-centering, which could
|
||||||
|
put the fixed avatar over a wall when a visible projection reached a maze edge.
|
||||||
|
Drag displacement is measured from the pointer-down position rather than from
|
||||||
|
the edge of the transparent target, preventing an unintended move on touch.
|
||||||
|
|
||||||
|
## Required deployment-network remediation and retest
|
||||||
|
|
||||||
|
Do not use the M016 Wi-Fi path for latency acceptance. Place the ESP and both
|
||||||
|
test clients on the same dedicated 5 GHz access point or the ESP fallback AP,
|
||||||
|
with no WAN routing, captive portal, VPN, power-saving transition, or active
|
||||||
|
bulk traffic. Record access-point model/band/channel, client hardware,
|
||||||
|
browser/version, RSSI, device address, build identity, and viewport.
|
||||||
|
|
||||||
|
Repeat the M016 procedure for two players with zero observers and with four
|
||||||
|
observers. For each player, collect at least 200 accepted moves and export:
|
||||||
|
|
||||||
|
```js
|
||||||
|
window.maze32PlayerApp.pointerFeedbackSamples()
|
||||||
|
window.maze32PlayerApp.latencySamples()
|
||||||
|
```
|
||||||
|
|
||||||
|
Record p50, p95, p99, and maximum values separately for pointer feedback,
|
||||||
|
command acknowledgement, complete authoritative view, and painted
|
||||||
|
authoritative view. Capture `/diagnostics` before and after each run and 100
|
||||||
|
ICMP plus application/WebSocket round trips. The acceptance target remains
|
||||||
|
painted-update p95 at or below 100 ms, maximum at or below 150 ms, and no
|
||||||
|
accepted sample at or above 500 ms. Do not mark this report as a passing
|
||||||
|
device measurement until those controlled results exist.
|
||||||
@@ -7,13 +7,13 @@ later milestones.
|
|||||||
## Representation and limits
|
## Representation and limits
|
||||||
|
|
||||||
- Dimensions are runtime configuration values. Supported maps are even-width,
|
- Dimensions are runtime configuration values. Supported maps are even-width,
|
||||||
even-height rectangles from `8 x 8` through `128 x 64` and contain at most
|
even-height rectangles from `8 x 8` through `32 x 32` and contain at most
|
||||||
8,192 cells.
|
1,024 cells.
|
||||||
- Each cell uses one byte of four-direction passage flags. A fixed bit set,
|
- Each cell uses one byte of four-direction passage flags. A fixed bit set,
|
||||||
traversal queue, and distance array form the reusable generation workspace.
|
traversal queue, and distance array form the reusable generation workspace.
|
||||||
- Starts, keys, boundary exits, compass cells, landmarks, and vision artifacts
|
- Starts, keys, boundary exits, compass cells, landmarks, and vision artifacts
|
||||||
use bounded fixed arrays. No generation or validation path calls a heap API.
|
use bounded fixed arrays. No generation or validation path calls a heap API.
|
||||||
- The maze plus workspace budget is 64 KiB. The default `128 x 64`
|
- The maze plus workspace budget is 64 KiB. The default `32 x 32`
|
||||||
configuration targets generation within 250 ms on the ESP32-C6.
|
configuration targets generation within 250 ms on the ESP32-C6.
|
||||||
- Candidate regeneration is capped by `maximum_attempts`; failure returns a
|
- Candidate regeneration is capped by `maximum_attempts`; failure returns a
|
||||||
diagnostic result instead of retrying indefinitely.
|
diagnostic result instead of retrying indefinitely.
|
||||||
@@ -35,10 +35,10 @@ topology, placements, metrics, and hash.
|
|||||||
|
|
||||||
Player 2's start and every required object are the exact rotational counterpart
|
Player 2's start and every required object are the exact rotational counterpart
|
||||||
of Player 1's placement. Starts and exits are boundary cells, and the default
|
of Player 1's placement. Starts and exits are boundary cells, and the default
|
||||||
requires at least 64 cells of Manhattan separation between each start and its
|
requires at least 22 cells of Manhattan separation between each start and its
|
||||||
exit. Keys are selected outside the owners' initial Chebyshev-radius-three
|
exit. Keys are selected outside the owners' initial Chebyshev-radius-three
|
||||||
views. The default places eight landmark pairs and four vision-artifact pairs,
|
views. The default places two landmark pairs and one vision-artifact pair, with
|
||||||
with no placement collisions.
|
no placement collisions.
|
||||||
|
|
||||||
Validation independently checks reciprocal passages, rotational symmetry,
|
Validation independently checks reciprocal passages, rotational symmetry,
|
||||||
full reachability, edge and cycle counts, dead ends, half crossings, placement
|
full reachability, edge and cycle counts, dead ends, half crossings, placement
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# MVP acceptance evidence register
|
||||||
|
|
||||||
|
This register maps the MVP acceptance criteria to existing evidence and the
|
||||||
|
remaining Milestone 011 evidence. It prevents release approval from relying on
|
||||||
|
unrecorded assumptions.
|
||||||
|
|
||||||
|
| MVP criterion | Evidence | Status |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1. Firmware and LittleFS fit | Milestone 010 target builds: application 1,028,956 / 2,097,152 bytes; LittleFS 2,031,616 / 2,031,616 bytes. | Passing; repeat for release candidate. |
|
||||||
|
| 2. Persistent station and fallback provisioning | Milestone 010 owner confirmation: persisted station connection and bounded retry; fallback provisioning page worked. | Passing. |
|
||||||
|
| 3. Two-player lobby, countdown, and refresh | Host lobby/protocol and browser tests; Milestone 010 owner confirmation that both player tokens restored their slots. | Passing. |
|
||||||
|
| 4. Four read-only observers | Milestone 010 six-client target run assembled four complete observer maps; Milestone 009 authorization tests. | Passing. |
|
||||||
|
| 5. Fair repeated 32 x 32 maps | Milestone 014 1,024-seed host sweep and maze validation; locked metrics in `docs/BALANCE_REPORT.md`. | Passing at host level; target playtest pending. |
|
||||||
|
| 6. Projection security | Host game visibility and payload-security tests; player and observer browser-contract tests. | Passing. |
|
||||||
|
| 7. Controls and cooldown | Host game-core and browser keyboard/swipe tests; 150 ms locked cooldown. | Passing. |
|
||||||
|
| 8. Key and inventory mechanics | Host game-mechanics tests covering pickup, drop, sabotage, meeting return, and exits. | Passing. |
|
||||||
|
| 9. Artifact and compass rules | Host game-mechanics tests covering ten successful moves and role-specific compass projections. | Passing. |
|
||||||
|
| 10. Victory and draw window | Host game-mechanics test covering outward exit and 200 ms draw boundary. | Passing. |
|
||||||
|
| 11. Disconnect recovery | Host disconnect-recovery tests and Milestone 009 physical acceptance evidence. | Passing. |
|
||||||
|
| 12. Approximately five-minute normal match | The Milestone 011 evidence applies to the superseded `128 x 64` configuration. Repeat representative testing for the compact map or approve a new deviation. | Pending Milestone 014 confirmation. |
|
||||||
|
| 13. Russian offline UI and browser coverage | Milestone 008 owner checks in Chrome 152, Firefox 155, Safari 26.6, and Edge 152.0.4191.53; asset verification enforces the offline and 1.5 MiB budget. | Passing; repeat asset check for release candidate. |
|
||||||
|
|
||||||
|
## Release-candidate record
|
||||||
|
|
||||||
|
Before marking Milestone 011 complete, add the exact date, application version,
|
||||||
|
firmware and LittleFS build commands, sizes, and the web-asset reproducibility
|
||||||
|
digest from the final candidate below. This record must match the tested image.
|
||||||
|
|
||||||
|
| Field | Release-candidate value |
|
||||||
|
| --- | --- |
|
||||||
|
| Date | 2026-09-08 |
|
||||||
|
| Application version | 0.10.0 |
|
||||||
|
| Firmware build command and size | `pio run -e esp32-c6-devkitm-1`; 1,028,956 / 2,097,152 application bytes; `firmware.bin` 1,029,584 bytes, SHA-256 `e654643cb9f16ec03c8d54d981da32ab8e64616f99d23d4a6bb14c8ac7f69c17` |
|
||||||
|
| LittleFS build command and size | `pio run -e esp32-c6-devkitm-1 --target buildfs`; 2,031,616 / 2,031,616 bytes; SHA-256 `526e2d03ca4473adb0fe075f4b075aaaeec85ef982240f6578e8cbbb4bec7f35` |
|
||||||
|
| Web asset digest and compressed size | `8f3207a208c26aa9f0faff0b0fd8db8e8f8e44537bd6db2c0f914ca5f352920a`; 19,253 / 1,572,864 bytes |
|
||||||
|
| Approved deviations | Criterion 12: omit playtest count and timing statistics; project owner confirmed representative physical playtests passed without issues. |
|
||||||
|
|
||||||
|
No pending criterion is treated as passing evidence. The only approved
|
||||||
|
deviation is recorded above.
|
||||||
|
|
||||||
|
## Milestone 014 compact-map candidate
|
||||||
|
|
||||||
|
The following host-verified candidate supersedes the map configuration in the
|
||||||
|
Milestone 011 release record. It is not the final accepted release until the
|
||||||
|
representative compact-map playtest is confirmed.
|
||||||
|
|
||||||
|
| Field | Compact-map candidate value |
|
||||||
|
| --- | --- |
|
||||||
|
| Date | 2026-09-08 |
|
||||||
|
| Application version | 0.14.0 |
|
||||||
|
| Firmware build command and size | `pio run -e esp32-c6-devkitm-1`; 1,027,934 / 2,097,152 application bytes; `firmware.bin` 1,028,560 bytes, SHA-256 `428e7690bed50d30caf61aca174d838f00d7de1a603f14ea669e996e3d167123` |
|
||||||
|
| LittleFS build command and size | `pio run -e esp32-c6-devkitm-1 --target buildfs`; 2,031,616 / 2,031,616 bytes; SHA-256 `467f0a07413c83bff5ea246b5063289eb61f8bf40b95d16d639b3241ceda8a9d` |
|
||||||
|
| Web asset digest and compressed size | `3461d20742e1999c1489465d7f3a68317d56b3acfc5ac5003341e1cf2d478eed`; 20,314 / 1,572,864 bytes |
|
||||||
|
| Pending evidence | Representative two-player confirmation for the `32 x 32` configuration. |
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Observer browser mode
|
||||||
|
|
||||||
|
Milestone 009 adds a password-free observer role to the same local browser
|
||||||
|
application. The role is still authenticated by its temporary session token,
|
||||||
|
but joining it does not request a player name or consume either player slot.
|
||||||
|
At most four observer sessions are connected at once.
|
||||||
|
|
||||||
|
## Projection and authorization
|
||||||
|
|
||||||
|
An observer renders only the server's `observer` view projection. That
|
||||||
|
projection contains all maze cells, remaining objects, both player positions,
|
||||||
|
inventories, effects, exit state, phase, and result. The browser does not
|
||||||
|
simulate movements or infer updates. Each player status also reflects the
|
||||||
|
server-owned WebSocket connection state. A complete projection establishes the
|
||||||
|
server-approved static geometry on join, restoration, and match start.
|
||||||
|
Subsequent bounded projections replace all dynamic player and object state
|
||||||
|
while reusing only that authoritative geometry, avoiding a 1,024-cell
|
||||||
|
retransmission after every move.
|
||||||
|
|
||||||
|
The observer screen contains no movement, start, or drop-key control. This is
|
||||||
|
only presentation-level defense: the gateway also rejects and logs observer
|
||||||
|
`start`, `move`, and `drop_key` attempts before they reach authoritative
|
||||||
|
mutation. Disconnecting an observer changes only lobby presence and never
|
||||||
|
pauses the game.
|
||||||
|
|
||||||
|
## Viewport and navigation
|
||||||
|
|
||||||
|
At `1024 x 768` CSS pixels and above, the complete `32 x 32` default map is
|
||||||
|
initially scaled to fit the available stage. The observer can zoom with the
|
||||||
|
labelled buttons or mouse wheel and pan with pointer dragging. Zoom is bounded
|
||||||
|
between 0.25 and 6 times the eight-pixel base cell size; the **Вся карта**
|
||||||
|
control restores the calculated fit and centered position.
|
||||||
|
|
||||||
|
When either viewport dimension is below the supported minimum, the map and
|
||||||
|
controls are hidden and the Russian message **Нужен экран побольше** explains
|
||||||
|
the `1024 x 768` requirement. All assets remain local and use the shared
|
||||||
|
retro-arcade visual language.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Player browser application
|
||||||
|
|
||||||
|
Milestone 008 provides the responsive player-facing browser application. The
|
||||||
|
browser remains a renderer and input device: it sends movement and key-drop
|
||||||
|
intentions, and it never calculates a legal move, inventory change, timer,
|
||||||
|
visibility region, compass result, or match result.
|
||||||
|
|
||||||
|
## Local assets
|
||||||
|
|
||||||
|
The application consists of `index.html`, `player.css`, `player.js`, and the
|
||||||
|
locally authored `icons.svg` sprite. The deterministic asset build compresses
|
||||||
|
each file with gzip for LittleFS. There are no remote fonts, frameworks,
|
||||||
|
analytics, icon services, or runtime network dependencies beyond the local ESP.
|
||||||
|
|
||||||
|
## Input contract
|
||||||
|
|
||||||
|
Arrow keys and WASD map to `north`, `east`, `south`, and `west` move intentions.
|
||||||
|
Touch swipes use the same four strings after a 28 CSS-pixel threshold. Phone
|
||||||
|
layouts hide the direction pad and retain swipes; tablet and desktop layouts
|
||||||
|
show the direction pad and also accept swipes. The persistent `OK` control
|
||||||
|
sends `drop_key` and exposes the accessible label `Сбросить ключ`.
|
||||||
|
|
||||||
|
The centered player avatar is also the touch movement handle. Its transparent
|
||||||
|
target is 96 by 96 CSS pixels while the visible avatar can deflect at most 28
|
||||||
|
CSS pixels. A 12-pixel dead zone and diagonal hysteresis resolve only cardinal
|
||||||
|
intentions. Drag displacement selects a monotonically bounded local cadence
|
||||||
|
from 360 ms to 170 ms, which remains slower than the authoritative 150 ms
|
||||||
|
cooldown. The browser keeps one sent move in flight and coalesces all later
|
||||||
|
drag changes into one latest intention; release discards that unsent intention.
|
||||||
|
The deflection is local input feedback only. The maze redraws only after a
|
||||||
|
server-authoritative projection and the browser never predicts a cell change.
|
||||||
|
For every projection, the browser translates only the received cells so the
|
||||||
|
authoritative `self` cell stays under the centered avatar; this also keeps the
|
||||||
|
avatar off a wall when the visible projection reaches a maze boundary.
|
||||||
|
|
||||||
|
Commands use the version-one protocol envelope and contain intention data only:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"v":1,"type":"move","seq":7,"requestId":"move-7","payload":{"direction":"west"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"v":1,"type":"drop_key","seq":8,"requestId":"drop_key-8","payload":{}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Projection handling
|
||||||
|
|
||||||
|
View chunks are grouped by authoritative view sequence and rendered only after
|
||||||
|
all chunks arrive. Each completed view replaces the previous maze DOM in full.
|
||||||
|
The client does not merge a new view with previously visible cells, infer
|
||||||
|
missing passages, or retain an explored map. Maze dimensions, cells, objects,
|
||||||
|
player position, opponent visibility, inventory, vision duration, compass hint,
|
||||||
|
phase, and result all come from the server projection.
|
||||||
|
|
||||||
|
The session token is the only game-related value saved in browser storage. On
|
||||||
|
reload, the client sends that token in a `restore` intention and waits for a
|
||||||
|
fresh role-appropriate server projection. Position, inventory, effects, hints,
|
||||||
|
maze cells, and results are never restored from browser storage.
|
||||||
|
|
||||||
|
## Presentation states
|
||||||
|
|
||||||
|
Name selection, lobby, countdown, active play, pause, and match result use the
|
||||||
|
shared dark retro-arcade visual system. The fixed player status identifies the
|
||||||
|
player and labels inventory, remaining boosted-vision moves, compass direction,
|
||||||
|
and match phase. Modal overlays distinguish countdown, pause, and results.
|
||||||
|
Rejected movement produces a local wall-bump animation without revealing any
|
||||||
|
additional maze state. All player-facing copy and browser-generated errors are
|
||||||
|
Russian; protocol identifiers remain English.
|
||||||
|
|
||||||
|
Sound and supported vibration are enabled after the first pointer or keyboard
|
||||||
|
interaction. Both are progressive enhancements: unsupported devices continue
|
||||||
|
without an error.
|
||||||
|
|
||||||
|
Observer presentation and its read-only interaction boundary are documented in
|
||||||
|
`OBSERVER_UI.md`.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Milestone 011 playtest report
|
||||||
|
|
||||||
|
## Protocol
|
||||||
|
|
||||||
|
Run representative completed two-player matches on the target ESP32-C6 with
|
||||||
|
the locked configuration in `docs/BALANCE_REPORT.md`. Use two active players
|
||||||
|
and, where practical, one observer. Do not impose a time limit or coach a
|
||||||
|
route. Start timing when the authoritative countdown completes and stop at the
|
||||||
|
authoritative result. A draw, reconnect timeout, abandoned match, or technical
|
||||||
|
failure is recorded separately and does not count as a completed match.
|
||||||
|
|
||||||
|
For each completed match, record the maze seed or map hash, both players'
|
||||||
|
experience level, winner or draw, elapsed time, whether either player used a
|
||||||
|
vision artifact or compass, and any gameplay defect. Do not record session
|
||||||
|
tokens, Wi-Fi passwords, or other credentials.
|
||||||
|
|
||||||
|
Use at least five completed matches involving more than one pair of players.
|
||||||
|
The target is that ordinary completed matches cluster around five minutes;
|
||||||
|
there is deliberately no hard timer. If a result materially misses that target
|
||||||
|
or a participant reports a balance issue, describe it and repeat the affected
|
||||||
|
tests after any parameter change.
|
||||||
|
|
||||||
|
## Results
|
||||||
|
|
||||||
|
On 2026-09-08, the project owner confirmed that the representative physical
|
||||||
|
two-player playtests covered by this protocol worked as expected, with no
|
||||||
|
gameplay defects or balance issues observed. The owner explicitly approved a
|
||||||
|
deviation from the protocol's count and elapsed-time statistics: individual
|
||||||
|
match timing, seed or hash, and per-run fields are not required for MVP
|
||||||
|
acceptance. No hard match timer was introduced.
|
||||||
|
|
||||||
|
## Assessment
|
||||||
|
|
||||||
|
The project owner accepted the approximately five-minute target based on the
|
||||||
|
representative playtests above and approved the absence of a quantified timing
|
||||||
|
sample. This is an approved evidence deviation, not a claim of statistical
|
||||||
|
measurement. The locked configuration remains unchanged because no issue was
|
||||||
|
reported.
|
||||||
|
|
||||||
|
## Milestone 014 compact-map revalidation
|
||||||
|
|
||||||
|
The earlier results apply to the superseded `128 x 64` configuration. Test the
|
||||||
|
new `32 x 32` configuration with two players after the Milestone 014 firmware
|
||||||
|
and web assets are installed. Confirm that navigation remains understandable,
|
||||||
|
both players consider the symmetric race fair, no gameplay-blocking defect is
|
||||||
|
observed, and the match duration remains acceptable without a hard timer.
|
||||||
|
|
||||||
|
Representative compact-map results have not yet been recorded.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Pre-reader usability observation guide
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Use this short, consent-based observation to verify the player interface with
|
||||||
|
a child who does not rely on reading. It is an acceptance activity for
|
||||||
|
Milestone 015, not a source of analytics. Do not record names, images, audio,
|
||||||
|
contact details, or any other identifying information.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
- Use the player screen at a `412 x 916` CSS-pixel viewport with sound enabled.
|
||||||
|
- Start a normal two-player match with the observer screen hidden.
|
||||||
|
- An adult may read a prompt once but must not point to the answer or operate
|
||||||
|
the child's device.
|
||||||
|
|
||||||
|
## Prompts
|
||||||
|
|
||||||
|
Ask the child to demonstrate each task. Mark it as completed only when the
|
||||||
|
child succeeds without reading the Russian support text.
|
||||||
|
|
||||||
|
| Task | Expected non-reading cue |
|
||||||
|
| --- | --- |
|
||||||
|
| Move the hero | centered character drag, direction arrow, and map movement |
|
||||||
|
| Find and pick up a key | key shape and pickup feedback |
|
||||||
|
| Recognize farther vision | eye symbol and map expansion |
|
||||||
|
| Follow the compass | arrow direction and compass icon |
|
||||||
|
| Find the exit | outlined exit shape and owner character shape |
|
||||||
|
| Recognize a wall | wall shape plus rejection feedback |
|
||||||
|
| Recognize the result | result icon/overlay plus local feedback |
|
||||||
|
|
||||||
|
## Record
|
||||||
|
|
||||||
|
Record only the date, viewport, browser, whether Russian labels were read,
|
||||||
|
and the seven task outcomes. Summarize unclear cues and any adult assistance.
|
||||||
|
If no representative child observation is feasible, the project owner may
|
||||||
|
approve a documented deviation instead; include the reason and the remaining
|
||||||
|
risk in the active milestone evidence.
|
||||||
+34
-8
@@ -30,27 +30,53 @@ is only echoed for correlation; it grants no authority.
|
|||||||
Player names contain 1–32 UTF-8 bytes, have no control bytes or outer spaces,
|
Player names contain 1–32 UTF-8 bytes, have no control bytes or outer spaces,
|
||||||
and are unique within the current lobby.
|
and are unique within the current lobby.
|
||||||
- `restore`: `{"token":"32 lowercase hexadecimal characters"}`.
|
- `restore`: `{"token":"32 lowercase hexadecimal characters"}`.
|
||||||
- `suggest_name`: `{}`.
|
|
||||||
- `start`: `{}`. Only connected Player 1 may use it, and both player slots must
|
- `start`: `{}`. Only connected Player 1 may use it, and both player slots must
|
||||||
be connected.
|
be connected.
|
||||||
|
- `move`: `{"direction":"north|east|south|west"}`. Only a player in an active,
|
||||||
|
unpaused match may submit movement intent.
|
||||||
|
- `drop_key`: `{}`. Only a player in an active, unpaused match may submit the
|
||||||
|
key-drop intent.
|
||||||
|
- `restart`: `{}`. Only connected Player 1 may reset a finished match, and both
|
||||||
|
players must be connected. Sessions are preserved while a new authoritative
|
||||||
|
maze and game state are created.
|
||||||
- `heartbeat`: `{}` after a session has been joined or restored.
|
- `heartbeat`: `{}` after a session has been joined or restored.
|
||||||
|
|
||||||
No Milestone 005 maze or movement messages are implemented by this milestone.
|
|
||||||
|
|
||||||
## Server messages
|
## Server messages
|
||||||
|
|
||||||
- `session`: the private role result containing `role`, `slot`, and temporary
|
- `session`: the private role result containing `role`, `slot`, and temporary
|
||||||
`token`. The token is never included in a shared snapshot.
|
`token`. The token is never included in a shared snapshot.
|
||||||
- `name_suggestion`: an available humorous Russian player name.
|
|
||||||
- `lobby`: the authoritative phase, revision, readiness, public player slots,
|
- `lobby`: the authoritative phase, revision, readiness, public player slots,
|
||||||
connected observer count, and limits.
|
connected observer count, and limits.
|
||||||
- `countdown`: the single authoritative `[3,2,1]` event with common monotonic
|
- `countdown`: the single authoritative `[3,2,1]` event with common monotonic
|
||||||
`startedAtMs` and `activationAtMs` values. It is sent to both players.
|
`startedAtMs` and `activationAtMs` values. It is sent to both players.
|
||||||
- `active`: the exact authoritative activation instant.
|
- `active`: the exact authoritative activation instant.
|
||||||
|
- `view`: a bounded chunk of a role-specific authoritative game projection.
|
||||||
|
Observer chunks contain the complete maze and both player records, including
|
||||||
|
their connection state; player chunks retain the fog-of-war boundary.
|
||||||
|
- `pause`: identifies the disconnected player slot and its monotonic reconnect
|
||||||
|
deadline.
|
||||||
|
- `resume`: reports that both players are attached and authoritative gameplay
|
||||||
|
has resumed.
|
||||||
|
- `match_result`: reports the terminal winner or draw.
|
||||||
|
- `reset`: reports the authoritative return from a finished match to the
|
||||||
|
waiting lobby.
|
||||||
- `ack`: successful heartbeat correlation.
|
- `ack`: successful heartbeat correlation.
|
||||||
- `error`: a stable English `code` and Russian player-facing `message`.
|
- `error`: a stable English `code` and Russian player-facing `message`.
|
||||||
|
|
||||||
An observer session can receive snapshots but cannot issue `start`. Player
|
An observer session can receive snapshots but cannot issue `start`, `move`, or
|
||||||
tokens reserve their original slot after disconnection so a refresh can restore
|
`drop_key`; attempts are rejected with `read_only` where applicable and logged
|
||||||
it. A disconnected observer token may be restored until its bounded slot is
|
by the gateway. Player tokens reserve their original slot after disconnection
|
||||||
reused by a new observer.
|
so a refresh can restore it. A player disconnect pauses the game for at most 60
|
||||||
|
seconds; restoration reattaches the same state, while expiry produces a draw.
|
||||||
|
A disconnected observer token may be restored until its bounded slot is reused
|
||||||
|
by a new observer, and observer disconnects never pause the match.
|
||||||
|
|
||||||
|
## Runtime diagnostics
|
||||||
|
|
||||||
|
`GET /diagnostics` returns bounded operational counters and internal-memory
|
||||||
|
samples without exposing session tokens, Wi-Fi credentials, or hidden maze
|
||||||
|
state. Counters cover connection and queue high-water marks, sent/dropped/
|
||||||
|
failed messages, malformed and rate-limited input, accepted game commands,
|
||||||
|
match cycles, maximum WebSocket handler/send duration, and current/minimum
|
||||||
|
internal free heap and largest block. The response also publishes the fixed
|
||||||
|
limits used by the Milestone 010 physical stress client.
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Runtime resource budget
|
||||||
|
|
||||||
|
Milestone 010 fixes and exposes the following production limits. They are
|
||||||
|
compile-time bounds, not targets that may grow with match duration.
|
||||||
|
|
||||||
|
| Resource | Limit | Defensive behavior |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| WebSocket connections | 6 | A seventh WebSocket handshake is rejected. |
|
||||||
|
| LwIP sockets | 16 | ESP-IDF remains configured by `sdkconfig.defaults`. |
|
||||||
|
| Client message | 512 bytes | An oversized frame is counted and disconnected. |
|
||||||
|
| Outgoing message | 768 bytes | An over-limit message cannot enter the queue. |
|
||||||
|
| Outgoing queue | 12 entries | Queue overflow is counted; memory cannot grow. |
|
||||||
|
| Game command queue | 16 entries | Excess commands receive `queue_full`. |
|
||||||
|
| Input rate | 20 messages/second | Excess messages are rejected; sustained abuse above 40 in the same window is disconnected. |
|
||||||
|
| Player response budget | 500 ms | The physical stress client fails above this round-trip threshold. |
|
||||||
|
| Observer update policy | Latest bounded projection | A busy observer may lose intermediate dynamic chunks and recover on a newer projection. Failed or stalled required sends close the socket. |
|
||||||
|
| Reconnect deadline | 60,000 ms | Gameplay pauses, then ends in a draw at expiry. |
|
||||||
|
|
||||||
|
## Build-time measurements
|
||||||
|
|
||||||
|
The Milestone 010 production build on 2026-09-05 reported 124,276 of 327,680
|
||||||
|
static RAM bytes and 1,028,600 of 2,097,152 application bytes. The independent
|
||||||
|
binary check measured 1,029,232 bytes in the 2 MiB factory partition, leaving
|
||||||
|
1,067,920 bytes of application margin. The compressed web
|
||||||
|
payload totals 19,253 bytes against its 1.5 MiB budget, leaving 1,553,611 bytes
|
||||||
|
of payload margin. LittleFS remains the fixed 1,984 KiB partition and its image
|
||||||
|
is checked against `partitions.csv` on every build.
|
||||||
|
|
||||||
|
The global authoritative gateway, including its one maze, one projection
|
||||||
|
buffer, fixed queues, sessions, timers, and metrics, occupies 86,840 bytes of
|
||||||
|
static storage in the production ELF. It does not allocate a maze or
|
||||||
|
projection per client. No production source or test requires PSRAM or a GPIO.
|
||||||
|
|
||||||
|
## Runtime measurements
|
||||||
|
|
||||||
|
`GET /diagnostics` returns current and minimum internal 8-bit heap values and
|
||||||
|
the largest internal free block. It deliberately samples
|
||||||
|
`MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT`, so external RAM cannot make a failing
|
||||||
|
internal-memory result appear healthy. Serial samples use the same capability
|
||||||
|
mask.
|
||||||
|
|
||||||
|
Final six-client, slow-client, repeated-match, and network-recovery samples
|
||||||
|
belong in the active milestone evidence in `PLANS.md`. Build-time numbers in
|
||||||
|
this document must be refreshed if the production image changes before that
|
||||||
|
evidence is recorded.
|
||||||
+41
-10
@@ -49,11 +49,13 @@ are not MVP requirements.
|
|||||||
- Use frequent but purposeful animation for screen transitions, movement,
|
- Use frequent but purposeful animation for screen transitions, movement,
|
||||||
pickups, game events, and humorous feedback.
|
pickups, game events, and humorous feedback.
|
||||||
- Pending controls blink until their command is resolved.
|
- Pending controls blink until their command is resolved.
|
||||||
- A reduced-motion mode is not required for the MVP.
|
- A visible reduced-motion toggle is required in player mode. It must suppress
|
||||||
|
non-essential animation without changing gameplay feedback or authority.
|
||||||
- Sound and vibration are enabled by default. They begin only after the first
|
- Sound and vibration are enabled by default. They begin only after the first
|
||||||
user interaction when required by the browser, and are used only when the
|
user interaction when required by the browser, and are used only when the
|
||||||
browser and device support them.
|
browser and device support them.
|
||||||
- Dedicated sound and vibration toggles are not required for the MVP.
|
- A visible mute toggle is required in player mode. It disables local sound and
|
||||||
|
vibration feedback only; it must never change protocol behavior.
|
||||||
|
|
||||||
## 5. Shared controls
|
## 5. Shared controls
|
||||||
|
|
||||||
@@ -62,10 +64,16 @@ are not MVP requirements.
|
|||||||
- Visible keyboard-focus outlines are not required for the MVP.
|
- Visible keyboard-focus outlines are not required for the MVP.
|
||||||
- On computers, movement works from the keyboard and from an on-screen
|
- On computers, movement works from the keyboard and from an on-screen
|
||||||
directional control cluster fixed in the lower-right corner.
|
directional control cluster fixed in the lower-right corner.
|
||||||
- On tablets, an on-screen directional control cluster is fixed in the
|
- On touch devices, the centered player avatar is the movement handle. Its
|
||||||
lower-right corner for right-handed use. Touch swipes remain supported.
|
transparent touch target is at least 96 by 96 CSS pixels and its visible
|
||||||
- On phones, movement uses directional swipes and no on-screen directional
|
character remains unobscured. During a drag, it deflects no more than 28 CSS
|
||||||
buttons are shown.
|
pixels from center and returns exactly to center on release, cancellation,
|
||||||
|
pause, disconnect, lost pointer capture, hidden document, and browser blur.
|
||||||
|
- The avatar drag has a 12 CSS-pixel dead zone and directional hysteresis. Its
|
||||||
|
bounded repeat cadence changes monotonically from 360 ms at a small
|
||||||
|
intentional displacement to 170 ms at the target edge; it never exceeds the
|
||||||
|
server's 150 ms movement cooldown. Touch swipes remain supported. Phones do
|
||||||
|
not show a directional-button cluster.
|
||||||
- The always-visible key-drop control is labelled `OK`, includes a key icon,
|
- The always-visible key-drop control is labelled `OK`, includes a key icon,
|
||||||
and is positioned closer to the upper edge of the screen than the
|
and is positioned closer to the upper edge of the screen than the
|
||||||
directional controls. Dropping does not require confirmation.
|
directional controls. Dropping does not require confirmation.
|
||||||
@@ -78,6 +86,9 @@ are not MVP requirements.
|
|||||||
- A fixed status panel occupies the top of the screen.
|
- A fixed status panel occupies the top of the screen.
|
||||||
- The persistent player status shows the player name, carried key when one is
|
- The persistent player status shows the player name, carried key when one is
|
||||||
held, active vision effect, and match state.
|
held, active vision effect, and match state.
|
||||||
|
- The player identity is represented by a distinct character shape, not `P1`
|
||||||
|
or `P2`. Object, blocked-move, pause, and result feedback must pair a shape
|
||||||
|
or icon with its color.
|
||||||
- Connection details and the Wi-Fi settings link belong in a secondary panel,
|
- Connection details and the Wi-Fi settings link belong in a secondary panel,
|
||||||
not in the persistent status row.
|
not in the persistent status row.
|
||||||
- Countdown, pause, and result states appear as modal overlays above the map.
|
- Countdown, pause, and result states appear as modal overlays above the map.
|
||||||
@@ -93,9 +104,10 @@ are not MVP requirements.
|
|||||||
boundaries prevent it.
|
boundaries prevent it.
|
||||||
- Render unknown space as a solid background. Never infer, retain, animate, or
|
- Render unknown space as a solid background. Never infer, retain, animate, or
|
||||||
decorate maze geometry outside the server-provided visible projection.
|
decorate maze geometry outside the server-provided visible projection.
|
||||||
- Use a nominal cell size of 20 CSS pixels on phones and 40 CSS pixels on
|
- Compute cell size from the current server projection and available map area.
|
||||||
tablets and computers. Minor adjustment is allowed to fit browser chrome and
|
At the minimum supported phone viewport a common 7 by 7 projection must use
|
||||||
safe areas without making the map unusable.
|
at least 46 CSS-pixel cells, while smaller projections may grow to 72 pixels.
|
||||||
|
Do not alter, retain, or infer hidden maze cells to fill spare space.
|
||||||
- Player zoom and pan controls are not part of the MVP.
|
- Player zoom and pan controls are not part of the MVP.
|
||||||
- Use different colors plus labels, outlines, shapes, or icons to distinguish
|
- Use different colors plus labels, outlines, shapes, or icons to distinguish
|
||||||
player ownership and object types.
|
player ownership and object types.
|
||||||
@@ -108,6 +120,9 @@ are not MVP requirements.
|
|||||||
- Support both portrait and landscape orientations. Suggest landscape when it
|
- Support both portrait and landscape orientations. Suggest landscape when it
|
||||||
materially improves play, but do not block portrait play.
|
materially improves play, but do not block portrait play.
|
||||||
- Respect display safe areas and keep essential status and controls visible.
|
- Respect display safe areas and keep essential status and controls visible.
|
||||||
|
- At `412 x 916`, the map is the dominant visual region; controls overlay it
|
||||||
|
instead of creating an empty lower region. Status remains icon-first and
|
||||||
|
readable without requiring reading as the normal interaction path.
|
||||||
- Scale icons and controls up on tablets and computers while preserving map
|
- Scale icons and controls up on tablets and computers while preserving map
|
||||||
priority and fixed panel placement.
|
priority and fixed panel placement.
|
||||||
- Validate the MVP against the current stable releases of Chrome, Safari,
|
- Validate the MVP against the current stable releases of Chrome, Safari,
|
||||||
@@ -120,7 +135,7 @@ are not MVP requirements.
|
|||||||
pixels. Smaller viewports show a Russian message explaining that a larger
|
pixels. Smaller viewports show a Russian message explaining that a larger
|
||||||
screen is required.
|
screen is required.
|
||||||
- Place player and match status blocks above the full maze.
|
- Place player and match status blocks above the full maze.
|
||||||
- Initially fit the complete `128 x 64` map into the available map area.
|
- Initially fit the complete `32 x 32` map into the available map area.
|
||||||
- Provide zoom and pan without granting any gameplay control or changing
|
- Provide zoom and pan without granting any gameplay control or changing
|
||||||
server authority.
|
server authority.
|
||||||
|
|
||||||
@@ -143,3 +158,19 @@ are not MVP requirements.
|
|||||||
message, and semantic color role manually and through focused browser tests
|
message, and semantic color role manually and through focused browser tests
|
||||||
where practical.
|
where practical.
|
||||||
- Golden-image or screenshot-regression tests are not required for the MVP.
|
- Golden-image or screenshot-regression tests are not required for the MVP.
|
||||||
|
|
||||||
|
## 12. Pre-reader onboarding and usability
|
||||||
|
|
||||||
|
- On a player's first active projection, show an eight-step, wordless-first
|
||||||
|
walkthrough for avatar drag and release, walls, key, vision, compass,
|
||||||
|
exit, and meeting the other player. Each step uses a large icon; short
|
||||||
|
Russian text is secondary support for an adult.
|
||||||
|
- The walkthrough provides a replay button and an explicit Russian skip button
|
||||||
|
for adults. It is browser-local UI only and never changes server state.
|
||||||
|
- A child must be able to distinguish their character, the other character,
|
||||||
|
key, vision, compass, exit, blocked movement, and results through at least
|
||||||
|
two channels such as shape/icon, position, motion, outline, color, sound, or
|
||||||
|
haptic feedback.
|
||||||
|
- Before release, record a representative pre-reader observation using
|
||||||
|
`docs/PRE_READER_USABILITY.md`, or record a project-owner-approved deviation
|
||||||
|
with its reason and impact.
|
||||||
|
|||||||
@@ -54,3 +54,9 @@ extends = env:esp32-c6-devkitm-1
|
|||||||
build_flags =
|
build_flags =
|
||||||
${env:esp32-c6-devkitm-1.build_flags}
|
${env:esp32-c6-devkitm-1.build_flags}
|
||||||
-DMAZE32_MILESTONE_006_DEVICE_TEST=1
|
-DMAZE32_MILESTONE_006_DEVICE_TEST=1
|
||||||
|
|
||||||
|
[env:milestone-007-device-test]
|
||||||
|
extends = env:esp32-c6-devkitm-1
|
||||||
|
build_flags =
|
||||||
|
${env:esp32-c6-devkitm-1.build_flags}
|
||||||
|
-DMAZE32_MILESTONE_007_DEVICE_TEST=1
|
||||||
|
|||||||
@@ -68,4 +68,28 @@ cc -std=c11 -Wall -Wextra -Werror -pedantic \
|
|||||||
-o "$test_directory/test_game_core"
|
-o "$test_directory/test_game_core"
|
||||||
|
|
||||||
"$test_directory/test_game_core"
|
"$test_directory/test_game_core"
|
||||||
|
|
||||||
|
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_mechanics.c" \
|
||||||
|
-o "$test_directory/test_game_mechanics"
|
||||||
|
|
||||||
|
"$test_directory/test_game_mechanics"
|
||||||
|
|
||||||
|
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_disconnect_recovery.c" \
|
||||||
|
-o "$test_directory/test_disconnect_recovery"
|
||||||
|
|
||||||
|
"$test_directory/test_disconnect_recovery"
|
||||||
|
node "$project_directory/test/web/test_player_app.js"
|
||||||
|
python3 "$project_directory/scripts/generate_web_assets.py" \
|
||||||
|
--source "$project_directory/web" \
|
||||||
|
--output "$project_directory/data" >/dev/null
|
||||||
python3 "$project_directory/scripts/verify_web_assets.py"
|
python3 "$project_directory/scripts/verify_web_assets.py"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from generate_web_assets import generate_assets
|
|||||||
|
|
||||||
|
|
||||||
PROJECT_DIRECTORY = Path(__file__).resolve().parent.parent
|
PROJECT_DIRECTORY = Path(__file__).resolve().parent.parent
|
||||||
|
COMPRESSED_PAYLOAD_LIMIT = 1572864
|
||||||
|
|
||||||
|
|
||||||
def tree_digest(directory: Path) -> str:
|
def tree_digest(directory: Path) -> str:
|
||||||
@@ -41,17 +42,25 @@ def main() -> int:
|
|||||||
if first_digest != checked_in_digest:
|
if first_digest != checked_in_digest:
|
||||||
raise AssertionError("Generated web assets are stale; regenerate the data directory")
|
raise AssertionError("Generated web assets are stale; regenerate the data directory")
|
||||||
|
|
||||||
index_source = (source_directory / "index.html").read_bytes()
|
for compressed_path in sorted(path for path in first.rglob("*.gz") if path.is_file()):
|
||||||
index_compressed = (first / "index.html.gz").read_bytes()
|
relative_path = compressed_path.relative_to(first)
|
||||||
if gzip.decompress(index_compressed) != index_source:
|
source_relative = Path(str(relative_path)[:-3])
|
||||||
raise AssertionError("Compressed index does not reproduce its source")
|
if gzip.decompress(compressed_path.read_bytes()) != (
|
||||||
|
source_directory / source_relative
|
||||||
|
).read_bytes():
|
||||||
|
raise AssertionError(f"Compressed asset does not reproduce {source_relative}")
|
||||||
|
|
||||||
provision_source = (source_directory / "provision.html").read_bytes()
|
compressed_size = sum(path.stat().st_size for path in first.rglob("*.gz"))
|
||||||
provision_compressed = (first / "provision.html.gz").read_bytes()
|
if compressed_size > COMPRESSED_PAYLOAD_LIMIT:
|
||||||
if gzip.decompress(provision_compressed) != provision_source:
|
raise AssertionError(
|
||||||
raise AssertionError("Compressed provisioning page does not reproduce its source")
|
f"Compressed web payload is {compressed_size} bytes and exceeds "
|
||||||
|
f"the {COMPRESSED_PAYLOAD_LIMIT}-byte limit"
|
||||||
|
)
|
||||||
|
|
||||||
print(f"web asset reproducibility: PASS ({first_digest})")
|
print(
|
||||||
|
f"web asset reproducibility: PASS ({first_digest}, "
|
||||||
|
f"{compressed_size}/{COMPRESSED_PAYLOAD_LIMIT} bytes)"
|
||||||
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Regular → Executable
+372
-5
@@ -6,13 +6,17 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import http.client
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
import struct
|
import struct
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
MALFORMED_STRESS_MESSAGES = 80
|
||||||
|
|
||||||
|
|
||||||
class WebSocket:
|
class WebSocket:
|
||||||
def __init__(self, host: str, port: int = 80, timeout: float = 8.0):
|
def __init__(self, host: str, port: int = 80, timeout: float = 8.0):
|
||||||
@@ -120,6 +124,360 @@ def join(client: WebSocket, role: str, name: str | None = None) -> dict[str, Any
|
|||||||
return client.receive_type("session")["payload"]
|
return client.receive_type("session")["payload"]
|
||||||
|
|
||||||
|
|
||||||
|
def receive_view(client: WebSocket, maximum_messages: int = 800) -> dict[str, Any]:
|
||||||
|
parts: dict[int, dict[str, Any]] = {}
|
||||||
|
sequence: int | None = None
|
||||||
|
expected = 0
|
||||||
|
for _ in range(maximum_messages):
|
||||||
|
message = client.receive()
|
||||||
|
if message.get("type") != "view":
|
||||||
|
continue
|
||||||
|
payload = message["payload"]
|
||||||
|
if sequence != message["seq"]:
|
||||||
|
sequence = message["seq"]
|
||||||
|
expected = payload["chunks"]
|
||||||
|
parts = {}
|
||||||
|
parts[payload["chunk"]] = payload
|
||||||
|
if len(parts) == expected:
|
||||||
|
first = dict(parts[0])
|
||||||
|
first["cells"] = [cell for index in range(expected) for cell in parts[index]["cells"]]
|
||||||
|
first["objects"] = [
|
||||||
|
item for index in range(expected) for item in parts[index]["objects"]
|
||||||
|
]
|
||||||
|
return first
|
||||||
|
raise RuntimeError("did not receive a complete view")
|
||||||
|
|
||||||
|
|
||||||
|
def open_direction(view: dict[str, Any]) -> str:
|
||||||
|
passages = next(cell[1] for cell in view["cells"] if cell[0] == view["self"])
|
||||||
|
for bit, direction in ((1, "north"), (2, "east"), (4, "south"), (8, "west")):
|
||||||
|
if passages & bit:
|
||||||
|
return direction
|
||||||
|
raise AssertionError("player cell has no open passage")
|
||||||
|
|
||||||
|
|
||||||
|
def diagnostics(host: str) -> dict[str, Any]:
|
||||||
|
connection = http.client.HTTPConnection(host, 80, timeout=8.0)
|
||||||
|
try:
|
||||||
|
connection.request("GET", "/diagnostics")
|
||||||
|
response = connection.getresponse()
|
||||||
|
payload = response.read()
|
||||||
|
if response.status != 200:
|
||||||
|
raise RuntimeError(f"diagnostics returned HTTP {response.status}")
|
||||||
|
return json.loads(payload.decode("utf-8"))
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_m010(host: str, stress_moves: int, repeat_matches: int) -> None:
|
||||||
|
clients: list[WebSocket] = []
|
||||||
|
try:
|
||||||
|
baseline = diagnostics(host)
|
||||||
|
player_1 = WebSocket(host, timeout=30.0)
|
||||||
|
player_2 = WebSocket(host, timeout=30.0)
|
||||||
|
clients.extend((player_1, player_2))
|
||||||
|
join(player_1, "player", "Нагрузочный Один")
|
||||||
|
session_2 = join(player_2, "player", "Нагрузочный Два")
|
||||||
|
observers = [WebSocket(host, timeout=120.0) for _ in range(4)]
|
||||||
|
clients.extend(observers)
|
||||||
|
for observer in observers:
|
||||||
|
join(observer, "observer")
|
||||||
|
|
||||||
|
observer_views: list[dict[str, Any] | None] = [None] * len(observers)
|
||||||
|
failures: list[BaseException] = []
|
||||||
|
|
||||||
|
def read_observer(index: int) -> None:
|
||||||
|
try:
|
||||||
|
observers[index].receive_type("active", attempts=50)
|
||||||
|
observer_views[index] = receive_view(observers[index])
|
||||||
|
except BaseException as error: # Preserve worker failure for the main thread.
|
||||||
|
failures.append(error)
|
||||||
|
|
||||||
|
readers = [threading.Thread(target=read_observer, args=(index,))
|
||||||
|
for index in range(len(observers))]
|
||||||
|
for reader in readers:
|
||||||
|
reader.start()
|
||||||
|
player_views: list[dict[str, Any] | None] = [None, None]
|
||||||
|
player_failures: list[BaseException] = []
|
||||||
|
|
||||||
|
def read_player(index: int, client: WebSocket) -> None:
|
||||||
|
try:
|
||||||
|
client.receive_type("active", attempts=50)
|
||||||
|
player_views[index] = receive_view(client)
|
||||||
|
except BaseException as error: # Preserve worker failure for the main thread.
|
||||||
|
player_failures.append(error)
|
||||||
|
|
||||||
|
player_readers = [
|
||||||
|
threading.Thread(target=read_player, args=(0, player_1)),
|
||||||
|
threading.Thread(target=read_player, args=(1, player_2)),
|
||||||
|
]
|
||||||
|
for reader in player_readers:
|
||||||
|
reader.start()
|
||||||
|
player_1.send("start", {}, "m010-start")
|
||||||
|
for reader in player_readers:
|
||||||
|
reader.join(timeout=180.0)
|
||||||
|
for reader in readers:
|
||||||
|
reader.join(timeout=180.0)
|
||||||
|
assert not player_failures and all(not reader.is_alive() for reader in player_readers), (
|
||||||
|
f"player read failures: {player_failures!r}"
|
||||||
|
)
|
||||||
|
assert not failures and all(not reader.is_alive() for reader in readers), (
|
||||||
|
f"observer read failures: {failures!r}"
|
||||||
|
)
|
||||||
|
player_1_view = player_views[0]
|
||||||
|
assert player_1_view is not None and player_views[1] is not None
|
||||||
|
assert all(view is not None and len(view["cells"]) == 1024
|
||||||
|
for view in observer_views)
|
||||||
|
|
||||||
|
six_client_sample = diagnostics(host)
|
||||||
|
limits = six_client_sample["limits"]
|
||||||
|
assert six_client_sample["activeConnections"] == 6
|
||||||
|
assert six_client_sample["connectionHighWater"] == 6
|
||||||
|
assert six_client_sample["queueHighWater"] <= limits["queueEntries"]
|
||||||
|
print("M010 six-client load: PASS (2 players + 4 complete observers)")
|
||||||
|
|
||||||
|
observers[0].send("restart", {}, "m010-observer-restart")
|
||||||
|
observer_error = observers[0].receive_type("error", attempts=30)
|
||||||
|
assert observer_error["payload"]["code"] == "read_only"
|
||||||
|
|
||||||
|
heartbeat_latencies_ms: list[float] = []
|
||||||
|
for index, client in enumerate(clients):
|
||||||
|
started = time.monotonic()
|
||||||
|
client.send("heartbeat", {}, f"m010-heartbeat-{index}")
|
||||||
|
client.receive_type("ack", attempts=30)
|
||||||
|
heartbeat_latencies_ms.append((time.monotonic() - started) * 1000.0)
|
||||||
|
assert max(heartbeat_latencies_ms) <= limits["playerLatencyBudgetMs"]
|
||||||
|
print(f"M010 six-client responsiveness: PASS (max {max(heartbeat_latencies_ms):.1f} ms)")
|
||||||
|
|
||||||
|
slow_observer = observers[0]
|
||||||
|
slow_observer.socket.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1024)
|
||||||
|
drain_stop = threading.Event()
|
||||||
|
drain_failures: list[BaseException] = []
|
||||||
|
|
||||||
|
def drain_fast_observer(observer: WebSocket) -> None:
|
||||||
|
observer.socket.settimeout(1.0)
|
||||||
|
while not drain_stop.is_set():
|
||||||
|
try:
|
||||||
|
observer.receive()
|
||||||
|
except socket.timeout:
|
||||||
|
continue
|
||||||
|
except BaseException as error: # Preserve an unexpected disconnect.
|
||||||
|
drain_failures.append(error)
|
||||||
|
return
|
||||||
|
|
||||||
|
drainers = [threading.Thread(target=drain_fast_observer, args=(observer,))
|
||||||
|
for observer in observers[1:]]
|
||||||
|
for drainer in drainers:
|
||||||
|
drainer.start()
|
||||||
|
|
||||||
|
opposite = {"north": "south", "east": "west", "south": "north", "west": "east"}
|
||||||
|
direction = open_direction(player_1_view)
|
||||||
|
command_latencies_ms: list[float] = []
|
||||||
|
for move_index in range(stress_moves):
|
||||||
|
time.sleep(0.16)
|
||||||
|
started = time.monotonic()
|
||||||
|
player_1.send("move", {"direction": direction}, f"m010-move-{move_index}")
|
||||||
|
player_1.receive_type("ack", attempts=50)
|
||||||
|
command_latencies_ms.append((time.monotonic() - started) * 1000.0)
|
||||||
|
player_1_view = receive_view(player_1)
|
||||||
|
receive_view(player_2)
|
||||||
|
direction = opposite[direction]
|
||||||
|
|
||||||
|
drain_stop.set()
|
||||||
|
for drainer in drainers:
|
||||||
|
drainer.join(timeout=3.0)
|
||||||
|
assert not drain_failures and all(not drainer.is_alive() for drainer in drainers), (
|
||||||
|
f"observer drain failures: {drain_failures!r}"
|
||||||
|
)
|
||||||
|
for observer in observers[1:]:
|
||||||
|
observer.socket.settimeout(30.0)
|
||||||
|
|
||||||
|
abusive_observer = observers[1]
|
||||||
|
|
||||||
|
def malformed_flood() -> None:
|
||||||
|
try:
|
||||||
|
for _ in range(MALFORMED_STRESS_MESSAGES):
|
||||||
|
abusive_observer.send_raw(b"{")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
flood = threading.Thread(target=malformed_flood)
|
||||||
|
flood.start()
|
||||||
|
stress_heartbeat_latencies_ms: list[float] = []
|
||||||
|
for index in range(10):
|
||||||
|
started = time.monotonic()
|
||||||
|
player_1.send("heartbeat", {}, f"m010-stress-heartbeat-{index}")
|
||||||
|
player_1.receive_type("ack", attempts=50)
|
||||||
|
stress_heartbeat_latencies_ms.append((time.monotonic() - started) * 1000.0)
|
||||||
|
time.sleep(0.02)
|
||||||
|
flood.join(timeout=10.0)
|
||||||
|
assert not flood.is_alive()
|
||||||
|
stressed = diagnostics(host)
|
||||||
|
maximum_latency_ms = max(command_latencies_ms + stress_heartbeat_latencies_ms)
|
||||||
|
assert maximum_latency_ms <= limits["playerLatencyBudgetMs"], (
|
||||||
|
f"maximum player latency {maximum_latency_ms:.1f} ms"
|
||||||
|
)
|
||||||
|
assert stressed["malformedMessages"] > baseline["malformedMessages"]
|
||||||
|
assert stressed["rateLimitedMessages"] > baseline["rateLimitedMessages"]
|
||||||
|
assert stressed["queueHighWater"] <= limits["queueEntries"]
|
||||||
|
assert stressed["minimumInternalFreeBytes"] > 32768
|
||||||
|
assert stressed["minimumInternalLargestBlockBytes"] > 16384
|
||||||
|
print(
|
||||||
|
"M010 slow/malformed stress: PASS "
|
||||||
|
f"(max player latency {maximum_latency_ms:.1f} ms, "
|
||||||
|
f"dropped {stressed['messagesDropped'] - baseline['messagesDropped']})"
|
||||||
|
)
|
||||||
|
|
||||||
|
for observer in observers:
|
||||||
|
observer.close()
|
||||||
|
if observer in clients:
|
||||||
|
clients.remove(observer)
|
||||||
|
observers.clear()
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
reset_heap_samples: list[int] = []
|
||||||
|
for match_index in range(repeat_matches):
|
||||||
|
token_2 = session_2["token"]
|
||||||
|
player_2.close()
|
||||||
|
clients.remove(player_2)
|
||||||
|
player_1.receive_type("pause", attempts=100)
|
||||||
|
player_1.socket.settimeout(70.0)
|
||||||
|
result = player_1.receive_type("match_result", attempts=200)["payload"]
|
||||||
|
assert result["result"] == "draw"
|
||||||
|
|
||||||
|
player_2 = WebSocket(host, timeout=30.0)
|
||||||
|
clients.append(player_2)
|
||||||
|
player_2.send("restore", {"token": token_2}, f"m010-restore-{match_index}")
|
||||||
|
session_2 = player_2.receive_type("session", attempts=50)["payload"]
|
||||||
|
player_1.send("restart", {}, f"m010-restart-{match_index}")
|
||||||
|
player_1.receive_type("ack", attempts=100)
|
||||||
|
time.sleep(0.2)
|
||||||
|
sample = diagnostics(host)
|
||||||
|
reset_heap_samples.append(sample["internalFreeBytes"])
|
||||||
|
if match_index + 1 < repeat_matches:
|
||||||
|
player_views = [None, None]
|
||||||
|
player_failures = []
|
||||||
|
player_readers = [
|
||||||
|
threading.Thread(target=read_player, args=(0, player_1)),
|
||||||
|
threading.Thread(target=read_player, args=(1, player_2)),
|
||||||
|
]
|
||||||
|
for reader in player_readers:
|
||||||
|
reader.start()
|
||||||
|
player_1.send("start", {}, f"m010-start-{match_index + 1}")
|
||||||
|
for reader in player_readers:
|
||||||
|
reader.join(timeout=40.0)
|
||||||
|
assert not player_failures and all(
|
||||||
|
not reader.is_alive() for reader in player_readers
|
||||||
|
)
|
||||||
|
|
||||||
|
final_sample = diagnostics(host)
|
||||||
|
assert final_sample["matchesCompleted"] - baseline["matchesCompleted"] >= repeat_matches
|
||||||
|
assert final_sample["matchesReset"] - baseline["matchesReset"] >= repeat_matches
|
||||||
|
if len(reset_heap_samples) > 1:
|
||||||
|
assert max(reset_heap_samples) - min(reset_heap_samples) <= 32768
|
||||||
|
print(
|
||||||
|
f"M010 repeated-match stability: PASS ({repeat_matches} matches, "
|
||||||
|
f"heap range {max(reset_heap_samples) - min(reset_heap_samples)} bytes)"
|
||||||
|
)
|
||||||
|
print(json.dumps(final_sample, ensure_ascii=False, sort_keys=True))
|
||||||
|
finally:
|
||||||
|
for client in clients:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_m009(host: str) -> None:
|
||||||
|
clients: list[WebSocket] = []
|
||||||
|
try:
|
||||||
|
player_1 = WebSocket(host)
|
||||||
|
player_2 = WebSocket(host)
|
||||||
|
clients.extend((player_1, player_2))
|
||||||
|
join(player_1, "player", "Наблюдаемый Один")
|
||||||
|
session_2 = join(player_2, "player", "Наблюдаемый Два")
|
||||||
|
observers = [WebSocket(host) for _ in range(4)]
|
||||||
|
clients.extend(observers)
|
||||||
|
for observer in observers:
|
||||||
|
join(observer, "observer")
|
||||||
|
observer.socket.settimeout(30.0)
|
||||||
|
|
||||||
|
observer_views: list[dict[str, Any] | None] = [None] * 4
|
||||||
|
failures: list[BaseException] = []
|
||||||
|
|
||||||
|
def read_observer(index: int) -> None:
|
||||||
|
try:
|
||||||
|
observers[index].receive_type("active", attempts=40)
|
||||||
|
observer_views[index] = receive_view(observers[index])
|
||||||
|
except BaseException as error: # Preserve worker failure for the main thread.
|
||||||
|
failures.append(error)
|
||||||
|
|
||||||
|
readers = [threading.Thread(target=read_observer, args=(index,)) for index in range(4)]
|
||||||
|
for reader in readers:
|
||||||
|
reader.start()
|
||||||
|
player_1.send("start", {}, "m009-start")
|
||||||
|
player_1.receive_type("countdown")
|
||||||
|
player_2.receive_type("countdown")
|
||||||
|
player_1.receive_type("active")
|
||||||
|
player_2.receive_type("active")
|
||||||
|
player_1_view = receive_view(player_1)
|
||||||
|
player_2_view = receive_view(player_2)
|
||||||
|
for reader in readers:
|
||||||
|
reader.join(timeout=35.0)
|
||||||
|
assert not failures and all(not reader.is_alive() for reader in readers)
|
||||||
|
assert all(view is not None and len(view["cells"]) == 1024 for view in observer_views)
|
||||||
|
print("four-observer full-map delivery: PASS (1024 cells each)")
|
||||||
|
|
||||||
|
observers[0].send("move", {"direction": "north"}, "observer-move")
|
||||||
|
error = observers[0].receive_type("error")
|
||||||
|
assert error["payload"]["code"] == "read_only"
|
||||||
|
print("observer mutation authorization: PASS (read_only)")
|
||||||
|
|
||||||
|
direction = open_direction(player_1_view)
|
||||||
|
player_1.send("move", {"direction": direction}, "before-observer-close")
|
||||||
|
player_1.receive_type("ack")
|
||||||
|
player_1_view = receive_view(player_1)
|
||||||
|
observers[-1].close()
|
||||||
|
clients.remove(observers[-1])
|
||||||
|
observers.pop()
|
||||||
|
time.sleep(0.2)
|
||||||
|
player_1.send("move", {"direction": open_direction(player_1_view)},
|
||||||
|
"after-observer-close")
|
||||||
|
player_1.receive_type("ack")
|
||||||
|
print("observer disconnect continuity: PASS")
|
||||||
|
|
||||||
|
preserved_cell = player_2_view["self"]
|
||||||
|
preserved_inventory = player_2_view.get("inventory")
|
||||||
|
token_2 = session_2["token"]
|
||||||
|
player_2.close()
|
||||||
|
clients.remove(player_2)
|
||||||
|
pause = player_1.receive_type("pause", attempts=30)["payload"]
|
||||||
|
assert pause["slot"] == 2
|
||||||
|
player_1.send("move", {"direction": "north"}, "paused-move")
|
||||||
|
error = player_1.receive_type("error", attempts=30)
|
||||||
|
assert error["payload"]["code"] == "premature"
|
||||||
|
|
||||||
|
restored = WebSocket(host)
|
||||||
|
clients.append(restored)
|
||||||
|
restored.send("restore", {"token": token_2}, "restore-player-2")
|
||||||
|
restored_session = restored.receive_type("session")["payload"]
|
||||||
|
assert restored_session["slot"] == 2 and restored_session["token"] == token_2
|
||||||
|
player_1.receive_type("resume", attempts=30)
|
||||||
|
restored_view = receive_view(restored)
|
||||||
|
assert restored_view["self"] == preserved_cell
|
||||||
|
assert restored_view.get("inventory") == preserved_inventory
|
||||||
|
print("player pause and reconnect: PASS (same position and inventory)")
|
||||||
|
|
||||||
|
restored.close()
|
||||||
|
clients.remove(restored)
|
||||||
|
player_1.socket.settimeout(70.0)
|
||||||
|
started = time.monotonic()
|
||||||
|
result = player_1.receive_type("match_result", attempts=60)["payload"]
|
||||||
|
elapsed = time.monotonic() - started
|
||||||
|
assert result["result"] == "draw" and elapsed >= 59.0
|
||||||
|
print(f"reconnect timeout draw: PASS ({elapsed:.1f} seconds)")
|
||||||
|
finally:
|
||||||
|
for client in clients:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
def verify(host: str) -> None:
|
def verify(host: str) -> None:
|
||||||
clients: list[WebSocket] = []
|
clients: list[WebSocket] = []
|
||||||
try:
|
try:
|
||||||
@@ -223,14 +581,23 @@ def main() -> None:
|
|||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("host", help="maze32 IP address or hostname")
|
parser.add_argument("host", help="maze32 IP address or hostname")
|
||||||
parser.add_argument("--verify", action="store_true", help="run the Milestone 004 scenario")
|
parser.add_argument("--verify", action="store_true", help="run the Milestone 004 scenario")
|
||||||
|
parser.add_argument("--verify-m009", action="store_true", help="run the Milestone 009 scenario")
|
||||||
|
parser.add_argument("--verify-m010", action="store_true", help="run the Milestone 010 load and stability scenario")
|
||||||
|
parser.add_argument("--stress-moves", type=int, default=80,
|
||||||
|
help="accepted moves sent while one observer is not reading")
|
||||||
|
parser.add_argument("--repeat-matches", type=int, default=2,
|
||||||
|
help="real 60-second timeout matches completed and reset")
|
||||||
arguments = parser.parse_args()
|
arguments = parser.parse_args()
|
||||||
if arguments.verify:
|
if arguments.stress_moves < 1 or arguments.repeat_matches < 1:
|
||||||
|
parser.error("--stress-moves and --repeat-matches must be positive")
|
||||||
|
if arguments.verify_m010:
|
||||||
|
verify_m010(arguments.host, arguments.stress_moves, arguments.repeat_matches)
|
||||||
|
elif arguments.verify_m009:
|
||||||
|
verify_m009(arguments.host)
|
||||||
|
elif arguments.verify:
|
||||||
verify(arguments.host)
|
verify(arguments.host)
|
||||||
else:
|
else:
|
||||||
client = WebSocket(arguments.host)
|
parser.error("choose --verify, --verify-m009, or --verify-m010")
|
||||||
client.send("suggest_name", {}, "manual")
|
|
||||||
print(json.dumps(client.receive(), ensure_ascii=False, indent=2))
|
|
||||||
client.close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+171
-3
@@ -26,6 +26,167 @@ static httpd_handle_t web_server;
|
|||||||
static maze32_network_t network;
|
static maze32_network_t network;
|
||||||
static maze32_gateway_t gateway;
|
static maze32_gateway_t gateway;
|
||||||
|
|
||||||
|
#ifdef MAZE32_MILESTONE_007_DEVICE_TEST
|
||||||
|
static maze32_maze_t mechanics_test_maze;
|
||||||
|
static maze32_maze_workspace_t mechanics_test_workspace;
|
||||||
|
static maze32_game_t mechanics_test_game;
|
||||||
|
static maze32_view_t mechanics_test_view;
|
||||||
|
static char mechanics_test_payload[MAZE32_GAME_VIEW_CHUNK_BYTES];
|
||||||
|
|
||||||
|
static int entry_move_for(const maze32_maze_t* maze, maze32_cell_t target,
|
||||||
|
maze32_cell_t* starting_position, maze32_game_direction_t* direction) {
|
||||||
|
if (target.y > 0U && maze32_maze_has_passage(maze, target, MAZE32_PASSAGE_NORTH)) {
|
||||||
|
*starting_position = (maze32_cell_t){target.x, (uint16_t)(target.y - 1U)};
|
||||||
|
*direction = MAZE32_GAME_DIRECTION_SOUTH;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (target.x + 1U < maze->config.width &&
|
||||||
|
maze32_maze_has_passage(maze, target, MAZE32_PASSAGE_EAST)) {
|
||||||
|
*starting_position = (maze32_cell_t){(uint16_t)(target.x + 1U), target.y};
|
||||||
|
*direction = MAZE32_GAME_DIRECTION_WEST;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (target.y + 1U < maze->config.height &&
|
||||||
|
maze32_maze_has_passage(maze, target, MAZE32_PASSAGE_SOUTH)) {
|
||||||
|
*starting_position = (maze32_cell_t){target.x, (uint16_t)(target.y + 1U)};
|
||||||
|
*direction = MAZE32_GAME_DIRECTION_NORTH;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (target.x > 0U && maze32_maze_has_passage(maze, target, MAZE32_PASSAGE_WEST)) {
|
||||||
|
*starting_position = (maze32_cell_t){(uint16_t)(target.x - 1U), target.y};
|
||||||
|
*direction = MAZE32_GAME_DIRECTION_EAST;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static maze32_game_direction_t outward_direction(const maze32_maze_t* maze,
|
||||||
|
maze32_cell_t exit_cell) {
|
||||||
|
if (exit_cell.x == 0U) {
|
||||||
|
return MAZE32_GAME_DIRECTION_WEST;
|
||||||
|
}
|
||||||
|
if (exit_cell.x + 1U == maze->config.width) {
|
||||||
|
return MAZE32_GAME_DIRECTION_EAST;
|
||||||
|
}
|
||||||
|
if (exit_cell.y == 0U) {
|
||||||
|
return MAZE32_GAME_DIRECTION_NORTH;
|
||||||
|
}
|
||||||
|
return MAZE32_GAME_DIRECTION_SOUTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t run_mechanics_device_test(void) {
|
||||||
|
maze32_maze_config_t maze_config = maze32_maze_default_config();
|
||||||
|
maze32_maze_report_t maze_report;
|
||||||
|
if (maze32_maze_generate(&mechanics_test_maze, &mechanics_test_workspace, &maze_config,
|
||||||
|
UINT32_C(0x5a320007), &maze_report) != MAZE32_MAZE_OK) {
|
||||||
|
ESP_LOGE(TAG, "M007 physical mechanics test could not generate its maze");
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
maze32_game_config_t game_config = maze32_game_default_config();
|
||||||
|
game_config.movement_cooldown_ms = 0U;
|
||||||
|
size_t free_before = heap_caps_get_free_size(MALLOC_CAP_8BIT);
|
||||||
|
int64_t started_us = esp_timer_get_time();
|
||||||
|
if (maze32_game_init(&mechanics_test_game, &mechanics_test_maze, &game_config) !=
|
||||||
|
MAZE32_GAME_RESULT_ACCEPTED) {
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
maze32_game_set_phase(&mechanics_test_game, MAZE32_GAME_PHASE_ACTIVE);
|
||||||
|
|
||||||
|
maze32_cell_t entry_position;
|
||||||
|
maze32_game_direction_t entry_direction;
|
||||||
|
uint32_t command_sequence = 0U;
|
||||||
|
if (!entry_move_for(&mechanics_test_maze, mechanics_test_maze.artifacts[0][0], &entry_position,
|
||||||
|
&entry_direction)) {
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
mechanics_test_game.players[0].position = entry_position;
|
||||||
|
maze32_game_enqueue_move(&mechanics_test_game, MAZE32_GAME_ROLE_PLAYER_ONE, entry_direction,
|
||||||
|
100U, &command_sequence);
|
||||||
|
maze32_game_command_result_t artifact_result = maze32_game_process_next(&mechanics_test_game);
|
||||||
|
|
||||||
|
if (!entry_move_for(&mechanics_test_maze, mechanics_test_maze.keys[0], &entry_position,
|
||||||
|
&entry_direction)) {
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
mechanics_test_game.players[0].position = entry_position;
|
||||||
|
maze32_game_enqueue_move(&mechanics_test_game, MAZE32_GAME_ROLE_PLAYER_ONE, entry_direction,
|
||||||
|
300U, &command_sequence);
|
||||||
|
maze32_game_command_result_t key_result = maze32_game_process_next(&mechanics_test_game);
|
||||||
|
|
||||||
|
if (!entry_move_for(&mechanics_test_maze, mechanics_test_maze.compasses[0], &entry_position,
|
||||||
|
&entry_direction)) {
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
mechanics_test_game.players[0].position = entry_position;
|
||||||
|
maze32_game_enqueue_move(&mechanics_test_game, MAZE32_GAME_ROLE_PLAYER_ONE, entry_direction,
|
||||||
|
500U, &command_sequence);
|
||||||
|
maze32_game_command_result_t compass_result = maze32_game_process_next(&mechanics_test_game);
|
||||||
|
|
||||||
|
if (!entry_move_for(&mechanics_test_maze, mechanics_test_maze.exits[0], &entry_position,
|
||||||
|
&entry_direction)) {
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
mechanics_test_game.players[0].position = entry_position;
|
||||||
|
maze32_game_enqueue_move(&mechanics_test_game, MAZE32_GAME_ROLE_PLAYER_ONE, entry_direction,
|
||||||
|
700U, &command_sequence);
|
||||||
|
maze32_game_command_result_t exit_result = maze32_game_process_next(&mechanics_test_game);
|
||||||
|
maze32_game_enqueue_move(&mechanics_test_game, MAZE32_GAME_ROLE_PLAYER_ONE,
|
||||||
|
outward_direction(&mechanics_test_maze, mechanics_test_maze.exits[0]),
|
||||||
|
900U, &command_sequence);
|
||||||
|
maze32_game_command_result_t outward_result = maze32_game_process_next(&mechanics_test_game);
|
||||||
|
int finalized = maze32_game_advance_time(&mechanics_test_game, 1101U);
|
||||||
|
|
||||||
|
if (artifact_result.result != MAZE32_GAME_RESULT_ACCEPTED ||
|
||||||
|
!artifact_result.activated_artifact || key_result.result != MAZE32_GAME_RESULT_ACCEPTED ||
|
||||||
|
!key_result.picked_key || key_result.key != 0U ||
|
||||||
|
compass_result.compass_hint == MAZE32_GAME_COMPASS_NONE || !exit_result.opened_exit ||
|
||||||
|
!outward_result.outward_exit || !finalized ||
|
||||||
|
mechanics_test_game.final_result != MAZE32_GAME_FINAL_PLAYER_ONE ||
|
||||||
|
maze32_game_project_view(&mechanics_test_game, MAZE32_GAME_ROLE_PLAYER_ONE,
|
||||||
|
&mechanics_test_view) != MAZE32_GAME_RESULT_ACCEPTED) {
|
||||||
|
ESP_LOGE(TAG, "M007 physical mechanics transitions failed");
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
size_t player_chunks = maze32_game_view_chunk_count(&mechanics_test_view);
|
||||||
|
size_t payload_bytes = 0U;
|
||||||
|
if (maze32_game_serialize_view_chunk(&mechanics_test_view, 0U, mechanics_test_payload,
|
||||||
|
sizeof(mechanics_test_payload),
|
||||||
|
&payload_bytes) != MAZE32_GAME_RESULT_ACCEPTED ||
|
||||||
|
maze32_game_project_view(&mechanics_test_game, MAZE32_GAME_ROLE_OBSERVER,
|
||||||
|
&mechanics_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(mechanics_test_game) + sizeof(mechanics_test_view) + sizeof(mechanics_test_payload);
|
||||||
|
ESP_LOGI(TAG,
|
||||||
|
"M007 events: artifact=%u, key=%u, hint=%u, opened=%u, outward=%u, result=%u, "
|
||||||
|
"sequence=%" PRIu32,
|
||||||
|
artifact_result.artifact, key_result.key, compass_result.compass_hint,
|
||||||
|
exit_result.opened_exit, outward_result.outward_exit, mechanics_test_game.final_result,
|
||||||
|
mechanics_test_game.state_sequence);
|
||||||
|
ESP_LOGI(TAG,
|
||||||
|
"M007 views: player_chunks=%u, first_chunk=%u bytes, observer_cells=%u, "
|
||||||
|
"observer_objects=%u, observer_chunks=%u",
|
||||||
|
(unsigned int)player_chunks, (unsigned int)payload_bytes,
|
||||||
|
mechanics_test_view.cell_count, mechanics_test_view.object_count,
|
||||||
|
(unsigned int)maze32_game_view_chunk_count(&mechanics_test_view));
|
||||||
|
ESP_LOGI(TAG, "M007 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 (fixed_bytes > MAZE32_GAME_RAM_BUDGET_BYTES ||
|
||||||
|
elapsed_us > (uint64_t)MAZE32_GAME_RUNTIME_BUDGET_MS * 1000U || free_before != free_after) {
|
||||||
|
ESP_LOGE(TAG, "M007 physical mechanics budget failed");
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
ESP_LOGI(TAG, "M007 physical mechanics test: PASS");
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
#ifdef MAZE32_MILESTONE_006_DEVICE_TEST
|
#ifdef MAZE32_MILESTONE_006_DEVICE_TEST
|
||||||
static maze32_maze_t game_test_maze;
|
static maze32_maze_t game_test_maze;
|
||||||
static maze32_maze_workspace_t game_test_workspace;
|
static maze32_maze_workspace_t game_test_workspace;
|
||||||
@@ -82,7 +243,7 @@ static esp_err_t run_game_device_test(void) {
|
|||||||
maze32_game_command_result_t command_result = maze32_game_process_next(&device_test_game);
|
maze32_game_command_result_t command_result = maze32_game_process_next(&device_test_game);
|
||||||
if (command_result.result != MAZE32_GAME_RESULT_ACCEPTED ||
|
if (command_result.result != MAZE32_GAME_RESULT_ACCEPTED ||
|
||||||
command_result.state_sequence != 1U ||
|
command_result.state_sequence != 1U ||
|
||||||
maze32_game_project_view(&device_test_game, MAZE32_GAME_ROLE_PLAYER_ONE, 3U,
|
maze32_game_project_view(&device_test_game, MAZE32_GAME_ROLE_PLAYER_ONE,
|
||||||
&device_test_view) != MAZE32_GAME_RESULT_ACCEPTED) {
|
&device_test_view) != MAZE32_GAME_RESULT_ACCEPTED) {
|
||||||
return ESP_FAIL;
|
return ESP_FAIL;
|
||||||
}
|
}
|
||||||
@@ -95,8 +256,8 @@ static esp_err_t run_game_device_test(void) {
|
|||||||
&payload_bytes) != MAZE32_GAME_RESULT_ACCEPTED) {
|
&payload_bytes) != MAZE32_GAME_RESULT_ACCEPTED) {
|
||||||
return ESP_FAIL;
|
return ESP_FAIL;
|
||||||
}
|
}
|
||||||
if (maze32_game_project_view(&device_test_game, MAZE32_GAME_ROLE_OBSERVER, 0U,
|
if (maze32_game_project_view(&device_test_game, MAZE32_GAME_ROLE_OBSERVER, &device_test_view) !=
|
||||||
&device_test_view) != MAZE32_GAME_RESULT_ACCEPTED) {
|
MAZE32_GAME_RESULT_ACCEPTED) {
|
||||||
return ESP_FAIL;
|
return ESP_FAIL;
|
||||||
}
|
}
|
||||||
uint64_t elapsed_us = (uint64_t)(esp_timer_get_time() - started_us);
|
uint64_t elapsed_us = (uint64_t)(esp_timer_get_time() - started_us);
|
||||||
@@ -270,6 +431,13 @@ void app_main(void) {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#ifdef MAZE32_MILESTONE_007_DEVICE_TEST
|
||||||
|
if (run_mechanics_device_test() != ESP_OK) {
|
||||||
|
maze32_health_set(&health, MAZE32_HEALTH_ERROR, (int)ESP_FAIL);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
vTaskDelay(pdMS_TO_TICKS(1500));
|
vTaskDelay(pdMS_TO_TICKS(1500));
|
||||||
ESP_LOGI(TAG, "maze32 firmware version %s starting", application->version);
|
ESP_LOGI(TAG, "maze32 firmware version %s starting", application->version);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
#include <assert.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
#include "maze32_game.h"
|
||||||
|
|
||||||
|
static void initialize_game(maze32_maze_t* maze, maze32_maze_workspace_t* workspace,
|
||||||
|
maze32_game_t* game) {
|
||||||
|
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(0x5a320009), &report) ==
|
||||||
|
MAZE32_MAZE_OK);
|
||||||
|
maze32_game_config_t game_config = maze32_game_default_config();
|
||||||
|
game_config.movement_cooldown_ms = 0U;
|
||||||
|
assert(maze32_game_init(game, maze, &game_config) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
maze32_game_set_phase(game, MAZE32_GAME_PHASE_ACTIVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_player_pause_and_reconnect(void) {
|
||||||
|
static maze32_maze_t maze;
|
||||||
|
static maze32_maze_workspace_t workspace;
|
||||||
|
static maze32_game_t game;
|
||||||
|
initialize_game(&maze, &workspace, &game);
|
||||||
|
game.players[0].vision_moves_remaining = 7U;
|
||||||
|
maze32_cell_t position = game.players[0].position;
|
||||||
|
|
||||||
|
assert(maze32_game_set_player_connected(&game, 1U, 0, 1000U) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
assert(game.phase == MAZE32_GAME_PHASE_PAUSED);
|
||||||
|
assert(game.players[1].reconnect_deadline_ms == 61000U);
|
||||||
|
uint32_t sequence = 0U;
|
||||||
|
assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_NORTH,
|
||||||
|
2000U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
assert(maze32_game_process_next(&game).result == MAZE32_GAME_RESULT_PREMATURE);
|
||||||
|
assert(game.players[0].position.x == position.x && game.players[0].position.y == position.y);
|
||||||
|
assert(game.players[0].vision_moves_remaining == 7U);
|
||||||
|
|
||||||
|
assert(maze32_game_set_player_connected(&game, 1U, 1, 60999U) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
assert(game.phase == MAZE32_GAME_PHASE_ACTIVE);
|
||||||
|
assert(game.players[1].reconnect_deadline_ms == 0U);
|
||||||
|
assert(game.players[0].vision_moves_remaining == 7U);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_timeout_draw_and_observer_read_only(void) {
|
||||||
|
static maze32_maze_t maze;
|
||||||
|
static maze32_maze_workspace_t workspace;
|
||||||
|
static maze32_game_t game;
|
||||||
|
static maze32_view_t view;
|
||||||
|
initialize_game(&maze, &workspace, &game);
|
||||||
|
|
||||||
|
uint32_t sequence = 0U;
|
||||||
|
assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_OBSERVER, MAZE32_GAME_DIRECTION_EAST,
|
||||||
|
1U, &sequence) == MAZE32_GAME_RESULT_READ_ONLY);
|
||||||
|
assert(game.state_sequence == 0U);
|
||||||
|
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, &view) ==
|
||||||
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
assert(view.cell_count == maze.config.width * maze.config.height);
|
||||||
|
assert(view.observer_connected[0] && view.observer_connected[1]);
|
||||||
|
|
||||||
|
assert(maze32_game_set_player_connected(&game, 0U, 0, 5000U) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, &view) ==
|
||||||
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
assert(!view.observer_connected[0] && view.observer_connected[1]);
|
||||||
|
assert(!maze32_game_advance_time(&game, 64999U));
|
||||||
|
assert(maze32_game_advance_time(&game, 65000U));
|
||||||
|
assert(game.phase == MAZE32_GAME_PHASE_FINISHED);
|
||||||
|
assert(game.final_result == MAZE32_GAME_FINAL_DRAW);
|
||||||
|
assert(maze32_game_set_player_connected(&game, 0U, 1, 65000U) ==
|
||||||
|
MAZE32_GAME_RESULT_MATCH_FINISHED);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_finish_window_is_frozen_while_paused(void) {
|
||||||
|
static maze32_maze_t maze;
|
||||||
|
static maze32_maze_workspace_t workspace;
|
||||||
|
static maze32_game_t game;
|
||||||
|
initialize_game(&maze, &workspace, &game);
|
||||||
|
game.phase = MAZE32_GAME_PHASE_FINISH_PENDING;
|
||||||
|
game.first_exited_player = 0U;
|
||||||
|
game.has_exit_time[0] = 1U;
|
||||||
|
game.exit_times_ms[0] = 1000U;
|
||||||
|
|
||||||
|
assert(maze32_game_set_player_connected(&game, 1U, 0, 1100U) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
assert(game.phase == MAZE32_GAME_PHASE_PAUSED);
|
||||||
|
assert(maze32_game_set_player_connected(&game, 1U, 1, 60100U) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
assert(game.phase == MAZE32_GAME_PHASE_FINISH_PENDING);
|
||||||
|
assert(game.exit_times_ms[0] == 60000U);
|
||||||
|
assert(!maze32_game_advance_time(&game, 60200U));
|
||||||
|
assert(maze32_game_advance_time(&game, 60201U));
|
||||||
|
assert(game.final_result == MAZE32_GAME_FINAL_PLAYER_ONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
test_player_pause_and_reconnect();
|
||||||
|
test_timeout_draw_and_observer_read_only();
|
||||||
|
test_finish_window_is_frozen_while_paused();
|
||||||
|
puts("disconnect recovery tests: PASS");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -200,6 +200,7 @@ static void serialize_and_inspect(const maze32_view_t* view, int expect_hidden)
|
|||||||
assert(payload[written] == '\0');
|
assert(payload[written] == '\0');
|
||||||
assert(strstr(payload, "{\"v\":1,\"type\":\"view\",\"seq\":") == payload);
|
assert(strstr(payload, "{\"v\":1,\"type\":\"view\",\"seq\":") == payload);
|
||||||
assert(strstr(payload, "\"phase\":\"countdown\"") != NULL);
|
assert(strstr(payload, "\"phase\":\"countdown\"") != NULL);
|
||||||
|
assert(strstr(payload, "\"width\":8,\"height\":8") != NULL);
|
||||||
saw_last_cell |= strstr(payload, "[63,") != NULL;
|
saw_last_cell |= strstr(payload, "[63,") != NULL;
|
||||||
saw_hidden_key |= strstr(payload, "[\"key\",1,0,63]") != NULL;
|
saw_hidden_key |= strstr(payload, "[\"key\",1,0,63]") != NULL;
|
||||||
saw_players |= strstr(payload, "\"players\":[") != NULL;
|
saw_players |= strstr(payload, "\"players\":[") != NULL;
|
||||||
@@ -223,7 +224,7 @@ static void test_visibility_and_serialization(void) {
|
|||||||
game.players[0].position = (maze32_cell_t){3U, 3U};
|
game.players[0].position = (maze32_cell_t){3U, 3U};
|
||||||
game.players[1].position = (maze32_cell_t){4U, 4U};
|
game.players[1].position = (maze32_cell_t){4U, 4U};
|
||||||
maze.passages[index_of(3U, 3U)] = 0U;
|
maze.passages[index_of(3U, 3U)] = 0U;
|
||||||
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, 3U, &view) ==
|
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, &view) ==
|
||||||
MAZE32_GAME_RESULT_ACCEPTED);
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
assert(view.cell_count == 49U);
|
assert(view.cell_count == 49U);
|
||||||
assert(view_has_cell(&view, index_of(6U, 6U)));
|
assert(view_has_cell(&view, index_of(6U, 6U)));
|
||||||
@@ -231,7 +232,7 @@ static void test_visibility_and_serialization(void) {
|
|||||||
|
|
||||||
game.players[0].position = (maze32_cell_t){0U, 0U};
|
game.players[0].position = (maze32_cell_t){0U, 0U};
|
||||||
game.players[1].position = (maze32_cell_t){7U, 7U};
|
game.players[1].position = (maze32_cell_t){7U, 7U};
|
||||||
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, 3U, &view) ==
|
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, &view) ==
|
||||||
MAZE32_GAME_RESULT_ACCEPTED);
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
assert(view.cell_count == 16U);
|
assert(view.cell_count == 16U);
|
||||||
assert(!view.opponent_visible && view.opponent_cell_index == UINT16_MAX);
|
assert(!view.opponent_visible && view.opponent_cell_index == UINT16_MAX);
|
||||||
@@ -239,7 +240,7 @@ static void test_visibility_and_serialization(void) {
|
|||||||
assert(!view_has_object(&view, MAZE32_VIEW_OBJECT_KEY, index_of(7U, 7U)));
|
assert(!view_has_object(&view, MAZE32_VIEW_OBJECT_KEY, index_of(7U, 7U)));
|
||||||
serialize_and_inspect(&view, 1);
|
serialize_and_inspect(&view, 1);
|
||||||
|
|
||||||
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_TWO, 3U, &view) ==
|
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_TWO, &view) ==
|
||||||
MAZE32_GAME_RESULT_ACCEPTED);
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
assert(view.cell_count == 16U && view.self_cell_index == index_of(7U, 7U));
|
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.opponent_visible && !view_has_cell(&view, index_of(0U, 0U)));
|
||||||
@@ -256,18 +257,19 @@ static void test_visibility_and_serialization(void) {
|
|||||||
assert(strstr(player_two_payload, "[\"key\",0,0,10]") == NULL);
|
assert(strstr(player_two_payload, "[\"key\",0,0,10]") == NULL);
|
||||||
}
|
}
|
||||||
|
|
||||||
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, 6U, &view) ==
|
game.players[0].vision_moves_remaining = 1U;
|
||||||
|
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, &view) ==
|
||||||
MAZE32_GAME_RESULT_ACCEPTED);
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
assert(view.cell_count == 49U);
|
assert(view.cell_count == 49U);
|
||||||
assert(!view_has_cell(&view, index_of(7U, 7U)));
|
assert(!view_has_cell(&view, index_of(7U, 7U)));
|
||||||
|
|
||||||
game.players[0].position = (maze32_cell_t){3U, 3U};
|
game.players[0].position = (maze32_cell_t){3U, 3U};
|
||||||
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, 6U, &view) ==
|
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, &view) ==
|
||||||
MAZE32_GAME_RESULT_ACCEPTED);
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
assert(view.cell_count == 64U);
|
assert(view.cell_count == 64U);
|
||||||
assert(view.opponent_visible);
|
assert(view.opponent_visible);
|
||||||
|
|
||||||
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, 0U, &view) ==
|
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, &view) ==
|
||||||
MAZE32_GAME_RESULT_ACCEPTED);
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
assert(view.cell_count == 64U);
|
assert(view.cell_count == 64U);
|
||||||
assert(view.object_count == 10U);
|
assert(view.object_count == 10U);
|
||||||
@@ -286,18 +288,33 @@ static void test_default_map_chunk_bounds(void) {
|
|||||||
assert(maze32_maze_generate(&maze, &workspace, &maze_config, UINT32_C(0x5a320006), &report) ==
|
assert(maze32_maze_generate(&maze, &workspace, &maze_config, UINT32_C(0x5a320006), &report) ==
|
||||||
MAZE32_MAZE_OK);
|
MAZE32_MAZE_OK);
|
||||||
maze32_game_t game = initialized_game(&maze);
|
maze32_game_t game = initialized_game(&maze);
|
||||||
|
game.phase = MAZE32_GAME_PHASE_FINISHED;
|
||||||
|
game.final_result = MAZE32_GAME_FINAL_DRAW;
|
||||||
|
game.state_sequence = UINT32_MAX;
|
||||||
|
game.meeting_sequence = UINT32_MAX;
|
||||||
|
for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) {
|
||||||
|
game.players[player].vision_moves_remaining = MAZE32_GAME_VISION_MOVES;
|
||||||
|
game.players[player].compass_hint = MAZE32_GAME_COMPASS_NORTH_WEST;
|
||||||
|
game.players[player].exited = 1U;
|
||||||
|
game.exits[player].open = 1U;
|
||||||
|
game.has_exit_time[player] = 1U;
|
||||||
|
game.exit_times_ms[player] = UINT64_MAX - player;
|
||||||
|
}
|
||||||
maze32_view_t view;
|
maze32_view_t view;
|
||||||
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, 0U, &view) ==
|
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, &view) ==
|
||||||
MAZE32_GAME_RESULT_ACCEPTED);
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
assert(view.cell_count == 8192U && view.object_count == 30U);
|
assert(view.cell_count == 1024U && view.object_count == 12U);
|
||||||
size_t chunks = maze32_game_view_chunk_count(&view);
|
size_t chunks = maze32_game_view_chunk_count(&view);
|
||||||
assert(chunks == 345U);
|
assert(chunks == 88U);
|
||||||
char payload[MAZE32_GAME_VIEW_CHUNK_BYTES];
|
char payload[MAZE32_GAME_VIEW_CHUNK_BYTES];
|
||||||
size_t largest_chunk = 0U;
|
size_t largest_chunk = 0U;
|
||||||
for (size_t chunk = 0U; chunk < chunks; ++chunk) {
|
for (size_t chunk = 0U; chunk < chunks; ++chunk) {
|
||||||
size_t written = 0U;
|
size_t written = 0U;
|
||||||
assert(maze32_game_serialize_view_chunk(&view, chunk, payload, sizeof(payload), &written) ==
|
assert(maze32_game_serialize_view_chunk(&view, chunk, payload, sizeof(payload), &written) ==
|
||||||
MAZE32_GAME_RESULT_ACCEPTED);
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
if (chunk == 0U) {
|
||||||
|
assert(strstr(payload, "\"connected\":1") != NULL);
|
||||||
|
}
|
||||||
if (written > largest_chunk) {
|
if (written > largest_chunk) {
|
||||||
largest_chunk = written;
|
largest_chunk = written;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,358 @@
|
|||||||
|
#include "maze32_game.h"
|
||||||
|
|
||||||
|
#include <assert.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#define TEST_WIDTH 8U
|
||||||
|
#define TEST_HEIGHT 8U
|
||||||
|
|
||||||
|
static uint16_t index_of(uint16_t x, uint16_t y) {
|
||||||
|
return (uint16_t)(y * TEST_WIDTH + x);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void open_test_grid(maze32_maze_t* maze) {
|
||||||
|
memset(maze, 0, sizeof(*maze));
|
||||||
|
maze->config.width = TEST_WIDTH;
|
||||||
|
maze->config.height = TEST_HEIGHT;
|
||||||
|
maze->config.artifact_pairs = 1U;
|
||||||
|
for (uint16_t y = 0U; y < TEST_HEIGHT; ++y) {
|
||||||
|
for (uint16_t x = 0U; x < TEST_WIDTH; ++x) {
|
||||||
|
uint8_t passages = 0U;
|
||||||
|
if (y > 0U) {
|
||||||
|
passages |= MAZE32_PASSAGE_NORTH;
|
||||||
|
}
|
||||||
|
if (x + 1U < TEST_WIDTH) {
|
||||||
|
passages |= MAZE32_PASSAGE_EAST;
|
||||||
|
}
|
||||||
|
if (y + 1U < TEST_HEIGHT) {
|
||||||
|
passages |= MAZE32_PASSAGE_SOUTH;
|
||||||
|
}
|
||||||
|
if (x > 0U) {
|
||||||
|
passages |= MAZE32_PASSAGE_WEST;
|
||||||
|
}
|
||||||
|
maze->passages[index_of(x, y)] = passages;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
maze->starts[0] = (maze32_cell_t){0U, 1U};
|
||||||
|
maze->starts[1] = (maze32_cell_t){7U, 6U};
|
||||||
|
maze->keys[0] = (maze32_cell_t){2U, 1U};
|
||||||
|
maze->keys[1] = (maze32_cell_t){1U, 1U};
|
||||||
|
maze->exits[0] = (maze32_cell_t){0U, 1U};
|
||||||
|
maze->exits[1] = (maze32_cell_t){7U, 6U};
|
||||||
|
maze->compasses[0] = (maze32_cell_t){1U, 1U};
|
||||||
|
maze->compasses[1] = (maze32_cell_t){6U, 6U};
|
||||||
|
maze->artifacts[0][0] = (maze32_cell_t){1U, 2U};
|
||||||
|
maze->artifacts[0][1] = (maze32_cell_t){7U, 7U};
|
||||||
|
}
|
||||||
|
|
||||||
|
static maze32_game_t active_game(maze32_maze_t* maze) {
|
||||||
|
maze32_game_t game;
|
||||||
|
maze32_game_config_t config = maze32_game_default_config();
|
||||||
|
config.movement_cooldown_ms = 0U;
|
||||||
|
assert(maze32_game_init(&game, maze, &config) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
maze32_game_set_phase(&game, MAZE32_GAME_PHASE_ACTIVE);
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
static maze32_game_command_result_t move_player(maze32_game_t* game, maze32_game_role_t role,
|
||||||
|
maze32_game_direction_t direction,
|
||||||
|
uint64_t submitted_ms) {
|
||||||
|
uint32_t sequence = 0U;
|
||||||
|
assert(maze32_game_enqueue_move(game, role, direction, submitted_ms, &sequence) ==
|
||||||
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
maze32_game_command_result_t result = maze32_game_process_next(game);
|
||||||
|
assert(result.command_sequence == sequence);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static maze32_game_command_result_t drop_key(maze32_game_t* game, maze32_game_role_t role,
|
||||||
|
uint64_t submitted_ms) {
|
||||||
|
uint32_t sequence = 0U;
|
||||||
|
assert(maze32_game_enqueue_drop_key(game, role, submitted_ms, &sequence) ==
|
||||||
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
maze32_game_command_result_t result = maze32_game_process_next(game);
|
||||||
|
assert(result.command_sequence == sequence);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void give_key(maze32_game_t* game, uint8_t carrier, uint8_t key) {
|
||||||
|
game->players[carrier].carried_key = key;
|
||||||
|
game->keys[key].location = MAZE32_GAME_KEY_CARRIED;
|
||||||
|
game->keys[key].carrier = carrier;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void assert_key_conservation(const maze32_game_t* game) {
|
||||||
|
uint8_t references[MAZE32_GAME_KEY_COUNT] = {0U, 0U};
|
||||||
|
for (uint8_t key = 0U; key < MAZE32_GAME_KEY_COUNT; ++key) {
|
||||||
|
if (game->keys[key].location == MAZE32_GAME_KEY_GROUND) {
|
||||||
|
references[key]++;
|
||||||
|
assert(game->keys[key].carrier == UINT8_MAX);
|
||||||
|
} else {
|
||||||
|
assert(game->keys[key].carrier < MAZE32_GAME_PLAYER_COUNT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) {
|
||||||
|
uint8_t key = game->players[player].carried_key;
|
||||||
|
if (key != MAZE32_GAME_NO_KEY) {
|
||||||
|
assert(key < MAZE32_GAME_KEY_COUNT);
|
||||||
|
assert(game->keys[key].location == MAZE32_GAME_KEY_CARRIED);
|
||||||
|
assert(game->keys[key].carrier == player);
|
||||||
|
references[key]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert(references[0] == 1U && references[1] == 1U);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_inventory_drop_and_sabotage(void) {
|
||||||
|
maze32_maze_t maze;
|
||||||
|
open_test_grid(&maze);
|
||||||
|
maze32_game_t game = active_game(&maze);
|
||||||
|
|
||||||
|
maze32_game_command_result_t result =
|
||||||
|
move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, 1U);
|
||||||
|
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED && result.picked_key);
|
||||||
|
assert(result.key == 1U && game.players[0].carried_key == 1U);
|
||||||
|
assert_key_conservation(&game);
|
||||||
|
|
||||||
|
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, 2U);
|
||||||
|
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED && !result.picked_key);
|
||||||
|
assert(game.players[0].carried_key == 1U);
|
||||||
|
assert(game.keys[0].location == MAZE32_GAME_KEY_GROUND);
|
||||||
|
assert_key_conservation(&game);
|
||||||
|
|
||||||
|
result = drop_key(&game, MAZE32_GAME_ROLE_PLAYER_ONE, 3U);
|
||||||
|
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED && result.dropped_key && result.key == 1U);
|
||||||
|
assert(game.players[0].carried_key == MAZE32_GAME_NO_KEY);
|
||||||
|
assert(game.keys[0].location == MAZE32_GAME_KEY_GROUND &&
|
||||||
|
game.keys[1].location == MAZE32_GAME_KEY_GROUND);
|
||||||
|
assert(game.keys[0].ground_position.x == 2U && game.keys[1].ground_position.x == 2U);
|
||||||
|
assert_key_conservation(&game);
|
||||||
|
|
||||||
|
assert(move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_WEST, 4U).result ==
|
||||||
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, 5U);
|
||||||
|
assert(result.picked_key && result.key == 0U);
|
||||||
|
assert(game.players[0].carried_key == 0U);
|
||||||
|
assert(game.keys[1].location == MAZE32_GAME_KEY_GROUND);
|
||||||
|
assert_key_conservation(&game);
|
||||||
|
|
||||||
|
uint32_t state_sequence = game.state_sequence;
|
||||||
|
game.players[0].carried_key = MAZE32_GAME_NO_KEY;
|
||||||
|
game.keys[0].location = MAZE32_GAME_KEY_GROUND;
|
||||||
|
game.keys[0].ground_position = game.players[0].position;
|
||||||
|
game.keys[0].carrier = UINT8_MAX;
|
||||||
|
result = drop_key(&game, MAZE32_GAME_ROLE_PLAYER_ONE, 6U);
|
||||||
|
assert(result.result == MAZE32_GAME_RESULT_NO_KEY && game.state_sequence == state_sequence);
|
||||||
|
assert_key_conservation(&game);
|
||||||
|
printf("game key inventory, drop, and sabotage: PASS\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_atomic_meeting_return(void) {
|
||||||
|
maze32_maze_t maze;
|
||||||
|
open_test_grid(&maze);
|
||||||
|
maze32_game_t game = active_game(&maze);
|
||||||
|
game.players[0].position = (maze32_cell_t){2U, 2U};
|
||||||
|
game.players[1].position = (maze32_cell_t){4U, 2U};
|
||||||
|
give_key(&game, 0U, 1U);
|
||||||
|
give_key(&game, 1U, 0U);
|
||||||
|
assert_key_conservation(&game);
|
||||||
|
|
||||||
|
maze32_game_command_result_t result =
|
||||||
|
move_player(&game, MAZE32_GAME_ROLE_PLAYER_TWO, MAZE32_GAME_DIRECTION_WEST, 10U);
|
||||||
|
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED && !result.meeting);
|
||||||
|
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, 10U);
|
||||||
|
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED && result.meeting);
|
||||||
|
assert(result.returned_keys == 3U);
|
||||||
|
assert(game.players[0].carried_key == 0U && game.players[1].carried_key == 1U);
|
||||||
|
assert(game.keys[0].carrier == 0U && game.keys[1].carrier == 1U);
|
||||||
|
assert_key_conservation(&game);
|
||||||
|
printf("game atomic meeting key return: PASS (opponent keys swapped to owners)\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_owner_exit_restrictions(void) {
|
||||||
|
maze32_maze_t maze;
|
||||||
|
open_test_grid(&maze);
|
||||||
|
maze32_game_t game = active_game(&maze);
|
||||||
|
game.players[0].position = (maze32_cell_t){1U, 1U};
|
||||||
|
give_key(&game, 0U, 1U);
|
||||||
|
maze32_game_command_result_t result =
|
||||||
|
move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_WEST, 20U);
|
||||||
|
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED && !result.opened_exit);
|
||||||
|
assert(!game.exits[0].open);
|
||||||
|
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_WEST, 21U);
|
||||||
|
assert(result.result == MAZE32_GAME_RESULT_BLOCKED && game.phase == MAZE32_GAME_PHASE_ACTIVE);
|
||||||
|
|
||||||
|
open_test_grid(&maze);
|
||||||
|
game = active_game(&maze);
|
||||||
|
game.players[0].position = (maze32_cell_t){6U, 6U};
|
||||||
|
give_key(&game, 0U, 1U);
|
||||||
|
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, 30U);
|
||||||
|
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED && !result.opened_exit);
|
||||||
|
assert(!game.exits[1].open);
|
||||||
|
game.exits[1].open = 1U;
|
||||||
|
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, 31U);
|
||||||
|
assert(result.result == MAZE32_GAME_RESULT_BLOCKED && !game.players[0].exited);
|
||||||
|
|
||||||
|
open_test_grid(&maze);
|
||||||
|
game = active_game(&maze);
|
||||||
|
game.players[0].position = (maze32_cell_t){1U, 1U};
|
||||||
|
give_key(&game, 0U, 0U);
|
||||||
|
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_WEST, 40U);
|
||||||
|
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED && result.opened_exit);
|
||||||
|
assert(game.exits[0].open && game.phase == MAZE32_GAME_PHASE_ACTIVE);
|
||||||
|
assert(game.players[0].carried_key == 0U);
|
||||||
|
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_WEST, 41U);
|
||||||
|
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED && result.outward_exit);
|
||||||
|
assert(game.phase == MAZE32_GAME_PHASE_FINISH_PENDING &&
|
||||||
|
game.final_result == MAZE32_GAME_FINAL_NONE);
|
||||||
|
printf("game owner-only exit opening and outward move: PASS\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_vision_artifact_duration(void) {
|
||||||
|
maze32_maze_t maze;
|
||||||
|
open_test_grid(&maze);
|
||||||
|
maze.starts[0] = (maze32_cell_t){0U, 2U};
|
||||||
|
maze32_game_t game = active_game(&maze);
|
||||||
|
|
||||||
|
maze32_game_command_result_t result =
|
||||||
|
move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, 100U);
|
||||||
|
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED && result.activated_artifact);
|
||||||
|
assert(result.artifact == 0U && game.consumed_artifacts[0]);
|
||||||
|
assert(game.players[0].vision_moves_remaining == MAZE32_GAME_VISION_MOVES);
|
||||||
|
assert(maze32_game_player_view_radius(&game, 0U) == 6U);
|
||||||
|
|
||||||
|
maze.passages[index_of(1U, 2U)] &= (uint8_t)~MAZE32_PASSAGE_NORTH;
|
||||||
|
uint32_t accepted_moves = game.players[0].accepted_moves;
|
||||||
|
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_NORTH, 101U);
|
||||||
|
assert(result.result == MAZE32_GAME_RESULT_BLOCKED);
|
||||||
|
assert(game.players[0].vision_moves_remaining == 10U);
|
||||||
|
assert(game.players[0].accepted_moves == accepted_moves);
|
||||||
|
|
||||||
|
for (uint8_t move = 0U; move < MAZE32_GAME_VISION_MOVES; ++move) {
|
||||||
|
maze32_game_direction_t direction =
|
||||||
|
move % 2U == 0U ? MAZE32_GAME_DIRECTION_WEST : MAZE32_GAME_DIRECTION_EAST;
|
||||||
|
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, direction, 102U + move);
|
||||||
|
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED && !result.activated_artifact);
|
||||||
|
assert(game.players[0].vision_moves_remaining == MAZE32_GAME_VISION_MOVES - move - 1U);
|
||||||
|
if (move < MAZE32_GAME_VISION_MOVES - 1U) {
|
||||||
|
assert(maze32_game_player_view_radius(&game, 0U) == 6U);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert(maze32_game_player_view_radius(&game, 0U) == 3U);
|
||||||
|
printf("game one-use vision duration: PASS (10 successful moves, failed move ignored)\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_compass_and_role_projections(void) {
|
||||||
|
maze32_maze_t maze;
|
||||||
|
open_test_grid(&maze);
|
||||||
|
maze.exits[0] = (maze32_cell_t){7U, 0U};
|
||||||
|
maze32_game_t game = active_game(&maze);
|
||||||
|
give_key(&game, 0U, 1U);
|
||||||
|
|
||||||
|
maze32_game_command_result_t result =
|
||||||
|
move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, 200U);
|
||||||
|
assert(result.compass_hint == MAZE32_GAME_COMPASS_NORTH_EAST);
|
||||||
|
assert(game.players[0].compass_hint == MAZE32_GAME_COMPASS_NORTH_EAST);
|
||||||
|
maze32_view_t view;
|
||||||
|
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, &view) ==
|
||||||
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
assert(view.self_carried_key == 1U && view.self_compass_hint == MAZE32_GAME_COMPASS_NORTH_EAST);
|
||||||
|
char payload[MAZE32_GAME_VIEW_CHUNK_BYTES];
|
||||||
|
size_t written = 0U;
|
||||||
|
assert(maze32_game_serialize_view_chunk(&view, 0U, payload, sizeof(payload), &written) ==
|
||||||
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
assert(strstr(payload, "\"hint\":\"ne\"") != NULL);
|
||||||
|
assert(strstr(payload, "\"inventory\":1") != NULL);
|
||||||
|
assert(strstr(payload, "\"exitTimes\"") == NULL);
|
||||||
|
|
||||||
|
game.keys[0].location = MAZE32_GAME_KEY_CARRIED;
|
||||||
|
game.keys[0].carrier = 1U;
|
||||||
|
game.players[1].carried_key = 0U;
|
||||||
|
game.players[1].position = (maze32_cell_t){2U, 1U};
|
||||||
|
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, &view) ==
|
||||||
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
assert(view.opponent_visible && view.opponent_carried_key == 0U);
|
||||||
|
assert(maze32_game_serialize_view_chunk(&view, 0U, payload, sizeof(payload), &written) ==
|
||||||
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
assert(strstr(payload, "\"opponentKey\":0") != NULL);
|
||||||
|
|
||||||
|
game.players[1].position = (maze32_cell_t){7U, 7U};
|
||||||
|
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_TWO, &view) ==
|
||||||
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
assert(!view.opponent_visible);
|
||||||
|
assert(maze32_game_serialize_view_chunk(&view, 0U, payload, sizeof(payload), &written) ==
|
||||||
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
assert(strstr(payload, "\"hint\":\"ne\"") == NULL);
|
||||||
|
assert(strstr(payload, "\"opponentKey\"") == NULL);
|
||||||
|
|
||||||
|
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, &view) ==
|
||||||
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
assert(view.observer_carried_keys[0] == 1U);
|
||||||
|
assert(maze32_game_serialize_view_chunk(&view, 0U, payload, sizeof(payload), &written) ==
|
||||||
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
assert(strstr(payload, "\"players\":[") != NULL);
|
||||||
|
assert(strstr(payload, "\"key\":1") != NULL);
|
||||||
|
assert(strstr(payload, "\"exitTimes\":[null,null]") != NULL);
|
||||||
|
|
||||||
|
maze.passages[index_of(1U, 1U)] &= (uint8_t)~MAZE32_PASSAGE_NORTH;
|
||||||
|
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_NORTH, 201U);
|
||||||
|
assert(result.result == MAZE32_GAME_RESULT_BLOCKED);
|
||||||
|
assert(game.players[0].compass_hint == MAZE32_GAME_COMPASS_NORTH_EAST);
|
||||||
|
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_WEST, 202U);
|
||||||
|
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
assert(game.players[0].compass_hint == MAZE32_GAME_COMPASS_NONE);
|
||||||
|
printf("game compass bearing and role-specific projections: PASS\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void prepare_open_exits(maze32_game_t* game) {
|
||||||
|
game->players[0].position = game->maze->exits[0];
|
||||||
|
game->players[1].position = game->maze->exits[1];
|
||||||
|
game->exits[0].open = 1U;
|
||||||
|
game->exits[1].open = 1U;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_victory_and_draw_window(void) {
|
||||||
|
maze32_maze_t maze;
|
||||||
|
open_test_grid(&maze);
|
||||||
|
maze32_game_t game = active_game(&maze);
|
||||||
|
prepare_open_exits(&game);
|
||||||
|
maze32_game_command_result_t result =
|
||||||
|
move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_WEST, 1000U);
|
||||||
|
assert(result.outward_exit && !result.finished);
|
||||||
|
assert(game.phase == MAZE32_GAME_PHASE_FINISH_PENDING);
|
||||||
|
assert(!maze32_game_advance_time(&game, 1200U));
|
||||||
|
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_TWO, MAZE32_GAME_DIRECTION_EAST, 1200U);
|
||||||
|
assert(result.outward_exit && result.finished);
|
||||||
|
assert(game.phase == MAZE32_GAME_PHASE_FINISHED && game.final_result == MAZE32_GAME_FINAL_DRAW);
|
||||||
|
|
||||||
|
open_test_grid(&maze);
|
||||||
|
game = active_game(&maze);
|
||||||
|
prepare_open_exits(&game);
|
||||||
|
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_WEST, 2000U);
|
||||||
|
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_TWO, MAZE32_GAME_DIRECTION_EAST, 2201U);
|
||||||
|
assert(result.result == MAZE32_GAME_RESULT_MATCH_FINISHED && result.finished);
|
||||||
|
assert(!game.players[1].exited && game.final_result == MAZE32_GAME_FINAL_PLAYER_ONE);
|
||||||
|
|
||||||
|
open_test_grid(&maze);
|
||||||
|
game = active_game(&maze);
|
||||||
|
prepare_open_exits(&game);
|
||||||
|
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_TWO, MAZE32_GAME_DIRECTION_EAST, 3000U);
|
||||||
|
assert(result.outward_exit && game.final_result == MAZE32_GAME_FINAL_NONE);
|
||||||
|
assert(!maze32_game_advance_time(&game, 3200U));
|
||||||
|
assert(maze32_game_advance_time(&game, 3201U));
|
||||||
|
assert(game.final_result == MAZE32_GAME_FINAL_PLAYER_TWO);
|
||||||
|
printf("game outward result window: PASS (200 ms draw boundary, 201 ms winner)\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
test_inventory_drop_and_sabotage();
|
||||||
|
test_atomic_meeting_return();
|
||||||
|
test_owner_exit_restrictions();
|
||||||
|
test_vision_artifact_duration();
|
||||||
|
test_compass_and_role_projections();
|
||||||
|
test_victory_and_draw_window();
|
||||||
|
puts("game mechanics tests: PASS");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -29,6 +29,21 @@ static void test_protocol_schema(void) {
|
|||||||
assert(message.type == MAZE32_MESSAGE_RESTORE);
|
assert(message.type == MAZE32_MESSAGE_RESTORE);
|
||||||
assert(strcmp(message.token, TOKEN_1) == 0);
|
assert(strcmp(message.token, TOKEN_1) == 0);
|
||||||
|
|
||||||
|
const char* move = "{\"v\":1,\"type\":\"move\",\"seq\":3,\"payload\":{\"direction\":\"west\"}}";
|
||||||
|
assert(maze32_protocol_parse(move, strlen(move), &message) == MAZE32_PROTOCOL_OK);
|
||||||
|
assert(message.type == MAZE32_MESSAGE_MOVE);
|
||||||
|
assert(strcmp(message.direction, "west") == 0);
|
||||||
|
const char* drop_key = "{\"v\":1,\"type\":\"drop_key\",\"seq\":4,\"payload\":{}}";
|
||||||
|
assert(maze32_protocol_parse(drop_key, strlen(drop_key), &message) == MAZE32_PROTOCOL_OK);
|
||||||
|
assert(message.type == MAZE32_MESSAGE_DROP_KEY);
|
||||||
|
const char* restart = "{\"v\":1,\"type\":\"restart\",\"seq\":5,\"payload\":{}}";
|
||||||
|
assert(maze32_protocol_parse(restart, strlen(restart), &message) == MAZE32_PROTOCOL_OK);
|
||||||
|
assert(message.type == MAZE32_MESSAGE_RESTART);
|
||||||
|
const char* diagonal =
|
||||||
|
"{\"v\":1,\"type\":\"move\",\"seq\":6,\"payload\":{\"direction\":\"northwest\"}}";
|
||||||
|
assert(maze32_protocol_parse(diagonal, strlen(diagonal), &message) ==
|
||||||
|
MAZE32_PROTOCOL_INVALID_PAYLOAD);
|
||||||
|
|
||||||
const char* bad_version = "{\"v\":2,\"type\":\"start\",\"seq\":1,\"payload\":{}}";
|
const char* bad_version = "{\"v\":2,\"type\":\"start\",\"seq\":1,\"payload\":{}}";
|
||||||
assert(maze32_protocol_parse(bad_version, strlen(bad_version), &message) ==
|
assert(maze32_protocol_parse(bad_version, strlen(bad_version), &message) ==
|
||||||
MAZE32_PROTOCOL_UNSUPPORTED_VERSION);
|
MAZE32_PROTOCOL_UNSUPPORTED_VERSION);
|
||||||
@@ -52,6 +67,7 @@ static void test_lobby_limits_restoration_and_authority(void) {
|
|||||||
maze32_lobby_init(&lobby);
|
maze32_lobby_init(&lobby);
|
||||||
maze32_session_t* player_1 = NULL;
|
maze32_session_t* player_1 = NULL;
|
||||||
maze32_session_t* player_2 = NULL;
|
maze32_session_t* player_2 = NULL;
|
||||||
|
maze32_session_t* extra = NULL;
|
||||||
assert(maze32_lobby_join_player(&lobby, 10, "Капитан Клавиша", TOKEN_1, &player_1) ==
|
assert(maze32_lobby_join_player(&lobby, 10, "Капитан Клавиша", TOKEN_1, &player_1) ==
|
||||||
MAZE32_LOBBY_OK);
|
MAZE32_LOBBY_OK);
|
||||||
assert(player_1->player_slot == 1U);
|
assert(player_1->player_slot == 1U);
|
||||||
@@ -71,7 +87,11 @@ static void test_lobby_limits_restoration_and_authority(void) {
|
|||||||
assert(observer->role == MAZE32_ROLE_OBSERVER);
|
assert(observer->role == MAZE32_ROLE_OBSERVER);
|
||||||
}
|
}
|
||||||
assert(maze32_lobby_connected_observers(&lobby) == MAZE32_OBSERVER_LIMIT);
|
assert(maze32_lobby_connected_observers(&lobby) == MAZE32_OBSERVER_LIMIT);
|
||||||
maze32_session_t* extra = NULL;
|
maze32_lobby_phase_t phase_before_observer_disconnect = lobby.phase;
|
||||||
|
maze32_lobby_disconnect(&lobby, 20);
|
||||||
|
assert(lobby.phase == phase_before_observer_disconnect);
|
||||||
|
assert(maze32_lobby_connected_observers(&lobby) == MAZE32_OBSERVER_LIMIT - 1U);
|
||||||
|
assert(maze32_lobby_restore(&lobby, 20, OBSERVER_TOKENS[0], &extra) == MAZE32_LOBBY_OK);
|
||||||
revision = lobby.revision;
|
revision = lobby.revision;
|
||||||
assert(maze32_lobby_join_observer(&lobby, 30, OBSERVER_TOKENS[4], &extra) ==
|
assert(maze32_lobby_join_observer(&lobby, 30, OBSERVER_TOKENS[4], &extra) ==
|
||||||
MAZE32_LOBBY_ROLE_FULL);
|
MAZE32_LOBBY_ROLE_FULL);
|
||||||
@@ -92,6 +112,21 @@ static void test_lobby_limits_restoration_and_authority(void) {
|
|||||||
assert(!maze32_lobby_activate_if_due(&lobby, 4999U));
|
assert(!maze32_lobby_activate_if_due(&lobby, 4999U));
|
||||||
assert(maze32_lobby_activate_if_due(&lobby, 5000U));
|
assert(maze32_lobby_activate_if_due(&lobby, 5000U));
|
||||||
assert(lobby.phase == MAZE32_LOBBY_ACTIVE);
|
assert(lobby.phase == MAZE32_LOBBY_ACTIVE);
|
||||||
|
maze32_session_t sessions_before_reset[MAZE32_SESSION_LIMIT];
|
||||||
|
memcpy(sessions_before_reset, lobby.sessions, sizeof(sessions_before_reset));
|
||||||
|
for (uint64_t cycle = 0U; cycle < 128U; ++cycle) {
|
||||||
|
revision = lobby.revision;
|
||||||
|
maze32_lobby_reset_match(&lobby);
|
||||||
|
assert(lobby.phase == MAZE32_LOBBY_WAITING);
|
||||||
|
assert(lobby.countdown_started_ms == 0U && lobby.activation_ms == 0U);
|
||||||
|
assert(lobby.revision == revision + 1U);
|
||||||
|
assert(maze32_lobby_ready(&lobby));
|
||||||
|
assert(memcmp(lobby.sessions, sessions_before_reset, sizeof(sessions_before_reset)) == 0);
|
||||||
|
uint64_t started_ms = 10000U + cycle * 4000U;
|
||||||
|
assert(maze32_lobby_start(&lobby, 10, started_ms) == MAZE32_LOBBY_OK);
|
||||||
|
assert(maze32_lobby_activate_if_due(&lobby, started_ms + MAZE32_COUNTDOWN_DURATION_MS));
|
||||||
|
}
|
||||||
|
puts("lobby repeated-match reset: PASS (128 cycles)");
|
||||||
}
|
}
|
||||||
|
|
||||||
int main(void) {
|
int main(void) {
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ static void test_bounded_regeneration_failure(void) {
|
|||||||
|
|
||||||
static void test_large_seed_set(void) {
|
static void test_large_seed_set(void) {
|
||||||
maze32_maze_config_t config = maze32_maze_default_config();
|
maze32_maze_config_t config = maze32_maze_default_config();
|
||||||
|
assert(config.width == 32U && config.height == 32U);
|
||||||
|
assert((uint32_t)config.width * config.height == 1024U);
|
||||||
|
assert(config.cross_half_pairs == 2U && config.extra_cycle_pairs == 8U);
|
||||||
|
assert(config.minimum_exit_separation == 22U);
|
||||||
|
assert(config.landmark_pairs == 2U && config.artifact_pairs == 1U);
|
||||||
uint16_t minimum_cycles = UINT16_MAX;
|
uint16_t minimum_cycles = UINT16_MAX;
|
||||||
uint16_t maximum_cycles = 0U;
|
uint16_t maximum_cycles = 0U;
|
||||||
uint16_t minimum_dead_ends = UINT16_MAX;
|
uint16_t minimum_dead_ends = UINT16_MAX;
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const project = path.resolve(__dirname, "../..");
|
||||||
|
const player = require(path.join(project, "web/player.js"));
|
||||||
|
|
||||||
|
test("keyboard and swipe controls produce equivalent directions", () => {
|
||||||
|
const cases = [
|
||||||
|
["ArrowUp", [50, 80, 50, 20], "north"],
|
||||||
|
["KeyD", [20, 50, 80, 50], "east"],
|
||||||
|
["ArrowDown", [50, 20, 50, 80], "south"],
|
||||||
|
["KeyA", [80, 50, 20, 50], "west"]
|
||||||
|
];
|
||||||
|
for (const [key, swipe, expected] of cases) {
|
||||||
|
assert.equal(player.directionFromKey(key), expected);
|
||||||
|
assert.equal(player.directionFromSwipe(...swipe), expected);
|
||||||
|
}
|
||||||
|
assert.equal(player.directionFromSwipe(0, 0, 12, 4), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("avatar drag produces only cardinal, bounded movement intentions", () => {
|
||||||
|
assert.equal(player.avatarDirection(10, 10, null), null);
|
||||||
|
assert.equal(player.avatarDirection(44, 8, null), "east");
|
||||||
|
assert.equal(player.avatarDirection(-8, -44, null), "north");
|
||||||
|
assert.equal(player.avatarDirection(42, 38, "east"), "east");
|
||||||
|
assert.equal(player.avatarDirection(38, 42, "north"), "south");
|
||||||
|
const vector = player.avatarVector(300, 0, 48, null);
|
||||||
|
assert.equal(vector.direction, "east");
|
||||||
|
assert.equal(vector.x, player.AVATAR_MAX_DEFLECTION);
|
||||||
|
assert.equal(vector.y, 0);
|
||||||
|
assert.ok(vector.distance <= player.AVATAR_MAX_DEFLECTION);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("avatar cadence is monotonic and remains slower than cooldown", () => {
|
||||||
|
assert.equal(player.avatarRepeatMs(player.AVATAR_DEAD_ZONE, 48), null);
|
||||||
|
const near = player.avatarRepeatMs(18, 48);
|
||||||
|
const far = player.avatarRepeatMs(48, 48);
|
||||||
|
assert.ok(near > far);
|
||||||
|
assert.ok(far >= player.AVATAR_FAST_REPEAT_MS);
|
||||||
|
assert.ok(player.AVATAR_FAST_REPEAT_MS > 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("latest movement intent coalesces while one command is in flight", () => {
|
||||||
|
const dispatched = [];
|
||||||
|
const flow = new player.LatestIntentFlow(direction => {
|
||||||
|
const requestId = `move-${dispatched.length + 1}`;
|
||||||
|
dispatched.push({direction, requestId});
|
||||||
|
return requestId;
|
||||||
|
});
|
||||||
|
assert.equal(flow.request("east"), "move-1");
|
||||||
|
assert.equal(flow.request("north"), null);
|
||||||
|
assert.equal(flow.request("west"), null);
|
||||||
|
assert.deepEqual(dispatched.map(item => item.direction), ["east"]);
|
||||||
|
flow.complete("move-1");
|
||||||
|
assert.deepEqual(dispatched.map(item => item.direction), ["east", "west"]);
|
||||||
|
flow.release();
|
||||||
|
flow.complete("move-2");
|
||||||
|
assert.equal(flow.inFlight, null);
|
||||||
|
assert.equal(flow.latest, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("latency correlation is bounded and only completes matching authoritative views", () => {
|
||||||
|
let clock = 0;
|
||||||
|
const tracker = new player.LatencyTracker(() => clock);
|
||||||
|
tracker.sent("move-1");
|
||||||
|
clock = 12;
|
||||||
|
tracker.acknowledged("move-1", 41);
|
||||||
|
clock = 16;
|
||||||
|
tracker.painted(40);
|
||||||
|
assert.equal(tracker.snapshot().length, 0);
|
||||||
|
clock = 24;
|
||||||
|
tracker.painted(41);
|
||||||
|
assert.deepEqual(tracker.snapshot(), [{requestId: "move-1", commandToAckMs: 12,
|
||||||
|
commandToPaintMs: 24, ackToPaintMs: 12}]);
|
||||||
|
for (let index = 0; index < player.LATENCY_SAMPLE_LIMIT + 4; index++) {
|
||||||
|
tracker.sent(`move-${index + 2}`);
|
||||||
|
}
|
||||||
|
assert.ok(tracker.pending.size <= player.LATENCY_SAMPLE_LIMIT);
|
||||||
|
for (let index = 0; index < player.LATENCY_SAMPLE_LIMIT + 4; index++) {
|
||||||
|
clock++;
|
||||||
|
tracker.pointerPainted(0);
|
||||||
|
}
|
||||||
|
assert.equal(tracker.pointerSnapshot().length, player.LATENCY_SAMPLE_LIMIT);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("player map cells enlarge small projections without exceeding a readable bound", () => {
|
||||||
|
const sevenBySeven = player.playerCellSize(396, 730, 7, 7);
|
||||||
|
const fourByFour = player.playerCellSize(396, 730, 4, 4);
|
||||||
|
assert.ok(sevenBySeven >= 46);
|
||||||
|
assert.ok(fourByFour > sevenBySeven);
|
||||||
|
assert.ok(fourByFour <= 72);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("authoritative self cell remains below the centered avatar at map edges", () => {
|
||||||
|
assert.deepEqual(player.playerMapOffset(0, 7, 0, 0, 7, 7, 46), {x: 138, y: 138});
|
||||||
|
assert.deepEqual(player.playerMapOffset(48, 7, 0, 0, 7, 7, 46), {x: -138, y: -138});
|
||||||
|
assert.deepEqual(player.playerMapOffset(24, 7, 1, 1, 5, 5, 46), {x: 0, y: 0});
|
||||||
|
assert.deepEqual(player.playerMapOffset(-1, 7, 0, 0, 7, 7, 46), {x: 0, y: 0});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("onboarding has bounded, replayable steps", () => {
|
||||||
|
assert.equal(player.ONBOARDING_STEPS.length, 8);
|
||||||
|
assert.equal(player.nextOnboardingStep(0), 1);
|
||||||
|
assert.equal(player.nextOnboardingStep(6), 7);
|
||||||
|
assert.equal(player.nextOnboardingStep(7), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("avatar control resets safely and retains accessible alternatives", () => {
|
||||||
|
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
|
||||||
|
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
|
||||||
|
assert.match(script, /addEventListener\("pointerup", stopAvatarControl\)/);
|
||||||
|
assert.match(script, /addEventListener\("pointercancel", stopAvatarControl\)/);
|
||||||
|
assert.match(script, /addEventListener\("lostpointercapture", stopAvatarControl\)/);
|
||||||
|
assert.match(script, /addEventListener\("blur", resetAvatarControl\)/);
|
||||||
|
assert.match(script, /connectionState !== "online"\) resetAvatarControl\(\)/);
|
||||||
|
assert.match(script, /snapshot\.phase === "paused"\) \{ resetAvatarControl\(\)/);
|
||||||
|
assert.match(script, /state\.avatar\.startX = event\.clientX/);
|
||||||
|
assert.match(script, /event\.clientX - state\.avatar\.startX/);
|
||||||
|
assert.match(script, /setMuted\(!state\.muted\)/);
|
||||||
|
assert.match(script, /setReducedMotion\(!state\.reducedMotion\)/);
|
||||||
|
assert.match(html, /id="avatar-control"/);
|
||||||
|
assert.match(html, /aria-label="Тянуть героя для движения"/);
|
||||||
|
assert.match(script, /onboarding-replay/);
|
||||||
|
assert.match(html, /id="onboarding-dots"[\s\S]*?<span><\/span><span><\/span><span><\/span><span><\/span><span><\/span><span><\/span><span><\/span><span><\/span>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("frontend name suggestions randomize both words and remain bounded", () => {
|
||||||
|
const picks = [3, 4];
|
||||||
|
const suggestion = player.randomSuggestedName([], () => picks.shift());
|
||||||
|
assert.equal(suggestion, "Смелый-Кактус");
|
||||||
|
assert.match(suggestion, /^[^-]+-[^-]+$/u);
|
||||||
|
assert.ok(Buffer.byteLength(suggestion, "utf8") <= 32);
|
||||||
|
assert.equal(player.NAME_COMBINATION_COUNT, 144);
|
||||||
|
|
||||||
|
const occupied = "Бодрый-Барсук";
|
||||||
|
const skipped = player.randomSuggestedName([occupied], () => 0);
|
||||||
|
assert.notEqual(skipped, occupied);
|
||||||
|
assert.equal(skipped, "Бодрый-Крот");
|
||||||
|
const allNames = [];
|
||||||
|
for (let adjective = 0; adjective < 12; adjective++) {
|
||||||
|
for (let noun = 0; noun < 12; noun++) {
|
||||||
|
const pair = [adjective, noun];
|
||||||
|
allNames.push(player.randomSuggestedName([], () => pair.shift()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.equal(player.randomSuggestedName(allNames, () => 0), null);
|
||||||
|
assert.equal(player.randomIndex(12, () => 99), 0);
|
||||||
|
|
||||||
|
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
|
||||||
|
assert.doesNotMatch(script, /suggest_name|name_suggestion/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("login headline selection is randomizable, Russian, and has a fallback", () => {
|
||||||
|
assert.ok(player.LOGIN_HEADLINES.length >= 8);
|
||||||
|
const selected = player.randomLoginHeadline(() => 5);
|
||||||
|
assert.deepEqual(selected, {first: "Крот сказал", highlight: "что это срез"});
|
||||||
|
assert.equal(player.randomLoginHeadline(() => -1), player.LOGIN_HEADLINES[0]);
|
||||||
|
for (const headline of player.LOGIN_HEADLINES) {
|
||||||
|
assert.match(headline.first, /[А-Яа-яЁё]/u);
|
||||||
|
assert.match(headline.highlight, /[А-Яа-яЁё]/u);
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
|
||||||
|
assert.match(script, /renderWelcomeHeadline\(randomLoginHeadline\(\)\)/);
|
||||||
|
assert.match(script, /title\.replaceChildren/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("command envelopes contain intentions rather than client-owned state", () => {
|
||||||
|
const move = player.createEnvelope("move", 7, "move-7", {direction: "west"});
|
||||||
|
assert.deepEqual(move, {v: 1, type: "move", seq: 7, requestId: "move-7", payload: {direction: "west"}});
|
||||||
|
assert.equal("position" in move.payload, false);
|
||||||
|
assert.deepEqual(player.createEnvelope("drop_key", 8, "drop_key-8", {}), {v: 1, type: "drop_key", seq: 8, requestId: "drop_key-8", payload: {}});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chunk assembly replaces projection cells without explored-map memory", () => {
|
||||||
|
const assembler = new player.SnapshotAssembler();
|
||||||
|
const second = {v: 1, type: "view", seq: 20, payload: {role: "player1", width: 8, height: 8, chunk: 1, chunks: 2, cells: [[10, 15]], objects: []}};
|
||||||
|
const first = {v: 1, type: "view", seq: 20, payload: {role: "player1", phase: "active", result: "none", width: 8, height: 8, self: 9, inventory: null, visionMoves: 0, hint: "none", chunk: 0, chunks: 2, cells: [[9, 15]], objects: [["key", 0, 0, 10]]}};
|
||||||
|
assert.equal(assembler.accept(second), null);
|
||||||
|
const complete = assembler.accept(first);
|
||||||
|
assert.deepEqual(player.projectedCellIndices(complete), [9, 10]);
|
||||||
|
|
||||||
|
const replacement = assembler.accept({v: 1, type: "view", seq: 21, payload: {role: "player1", phase: "active", result: "none", width: 8, height: 8, self: 18, inventory: 0, visionMoves: 9, hint: "ne", chunk: 0, chunks: 1, cells: [[18, 15]], objects: []}});
|
||||||
|
assert.deepEqual(player.projectedCellIndices(replacement), [18]);
|
||||||
|
assert.equal(replacement.cells.some(cell => cell[0] === 9 || cell[0] === 10), false);
|
||||||
|
assert.equal(assembler.accept({v: 1, type: "view", seq: 22, payload: {chunk: 0, chunks: player.MAX_VIEW_CHUNKS + 1, cells: [], objects: []}}), null);
|
||||||
|
assert.equal(player.snapshotIsValid({width: 32, height: 32, cells: [[1023, 15]], objects: []}), true);
|
||||||
|
assert.equal(player.snapshotIsValid({width: 33, height: 32, cells: [], objects: []}), false);
|
||||||
|
assert.equal(player.snapshotIsValid({width: 32, height: 33, cells: [], objects: []}), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refresh contract restores only the token and waits for server projection", () => {
|
||||||
|
const restore = player.createEnvelope("restore", 1, "restore-1", {token: "0123456789abcdef0123456789abcdef"});
|
||||||
|
assert.deepEqual(Object.keys(restore.payload), ["token"]);
|
||||||
|
assert.equal("position" in restore.payload, false);
|
||||||
|
assert.equal("inventory" in restore.payload, false);
|
||||||
|
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
|
||||||
|
assert.equal((script.match(/localStorage\.setItem/g) || []).length, 1);
|
||||||
|
assert.match(script, /localStorage\.setItem\(TOKEN_KEY, message\.payload\.token\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("player assets are local, Russian, accessible, and responsive", () => {
|
||||||
|
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
|
||||||
|
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
|
||||||
|
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
|
||||||
|
const icons = fs.readFileSync(path.join(project, "web/icons.svg"), "utf8");
|
||||||
|
const all = `${html}\n${css}\n${script}\n${icons}`;
|
||||||
|
assert.doesNotMatch(all, /(?:src|href)=["']https?:|url\(["']?https?:/i);
|
||||||
|
assert.doesNotMatch(all, /@import|googleapis|analytics/i);
|
||||||
|
assert.match(html, /lang="ru"/);
|
||||||
|
assert.match(html, /aria-label="Сбросить ключ"/);
|
||||||
|
assert.match(html, /Кусты уже|Сбор заблудившихся/);
|
||||||
|
assert.match(css, /@media \(max-width: 720px\)/);
|
||||||
|
assert.match(css, /@media \(min-width: 721px\) and \(max-width: 980px\)/);
|
||||||
|
assert.match(css, /height: calc\(100dvh - 78px\)/);
|
||||||
|
assert.match(css, /\.dpad \{ display: none; \}/);
|
||||||
|
assert.match(html, /id="avatar-control"/);
|
||||||
|
assert.match(html, /id="onboarding"/);
|
||||||
|
assert.match(html, /id="mute-toggle"/);
|
||||||
|
assert.match(css, /\.avatar-control \{[^}]*width: 96px; height: 96px/);
|
||||||
|
assert.match(css, /\.avatar-handle \{[^}]*transition: transform/);
|
||||||
|
assert.match(script, /resetAvatarControl/);
|
||||||
|
assert.match(script, /playerCellSize/);
|
||||||
|
assert.match(css, /\(hover: none\) and \(pointer: coarse\) and \(max-height: 720px\)/);
|
||||||
|
assert.match(icons, /id="key"/);
|
||||||
|
assert.match(icons, /id="maze"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("observer view fits large screens and bounds zoom", () => {
|
||||||
|
assert.equal(player.observerFitScale(1024, 600, 32, 32), 2.25);
|
||||||
|
assert.equal(player.clampObserverZoom(.01), .25);
|
||||||
|
assert.equal(player.clampObserverZoom(20), 6);
|
||||||
|
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
|
||||||
|
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
|
||||||
|
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
|
||||||
|
assert.match(html, /Режим наблюдателя · только чтение/);
|
||||||
|
assert.match(html, /Нужен экран побольше/);
|
||||||
|
assert.match(html, /Зрительный зал открывается от 1024 × 768/);
|
||||||
|
assert.match(css, /@media \(max-width: 1023px\), \(max-height: 767px\)/);
|
||||||
|
assert.match(css, /\.observer-stage \{[^}]*overflow: hidden/);
|
||||||
|
assert.match(script, /snapshot\.role === "observer"/);
|
||||||
|
assert.match(script, /state\.session\.role === "observer" \? "observer" : "match"/);
|
||||||
|
assert.match(script, /!player\.connected \? "нет связи"/);
|
||||||
|
assert.match(script, /addEventListener\("wheel"/);
|
||||||
|
assert.match(script, /addEventListener\("pointermove"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("observer protocol exposes no mutation control path", () => {
|
||||||
|
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
|
||||||
|
const observerSection = html.match(/<section class="screen observer-screen"[\s\S]*?<\/section>/)[0];
|
||||||
|
assert.doesNotMatch(observerSection, /data-direction|drop-key|start-match/);
|
||||||
|
const observer = {v: 1, type: "view", seq: 30, payload: {role: "observer", phase: "active", result: "none", width: 2, height: 2, players: [{cell: 0, key: null, exited: 0, connected: 1}, {cell: 3, key: 1, exited: 0, connected: 1}], chunk: 0, chunks: 1, cells: [[0, 6], [1, 12], [2, 3], [3, 9]], objects: []}};
|
||||||
|
const snapshot = new player.SnapshotAssembler().accept(observer);
|
||||||
|
assert.equal(snapshot.role, "observer");
|
||||||
|
assert.deepEqual(snapshot.players.map(item => item.cell), [0, 3]);
|
||||||
|
assert.equal("self" in snapshot, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("finished matches expose only an owner restart intention", () => {
|
||||||
|
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
|
||||||
|
assert.match(script, /send\("restart", \{\}, byId\("modal-action"\)\)/);
|
||||||
|
assert.match(script, /message\.type === "reset"/);
|
||||||
|
assert.doesNotMatch(script, /restart[^\n]*(position|inventory|result):/);
|
||||||
|
});
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.6.0
|
0.14.0
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="key" viewBox="0 0 24 24"><circle cx="8" cy="9" r="4" fill="none" stroke="currentColor" stroke-width="3"/><path d="M11 12l9 9m-4-4 2-2m-5-1 2-2" fill="none" stroke="currentColor" stroke-width="3"/></symbol>
|
||||||
|
<symbol id="eye" viewBox="0 0 24 24"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12Z" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="3" fill="currentColor"/></symbol>
|
||||||
|
<symbol id="arrow" viewBox="0 0 24 24"><path d="M12 2l7 9h-4v11H9V11H5l7-9Z" fill="currentColor"/></symbol>
|
||||||
|
<symbol id="signal" viewBox="0 0 24 24"><path d="M3 9a14 14 0 0 1 18 0M6 13a9 9 0 0 1 12 0m-9 4a4 4 0 0 1 6 0" fill="none" stroke="currentColor" stroke-width="2.5"/><circle cx="12" cy="21" r="1.5" fill="currentColor"/></symbol>
|
||||||
|
<symbol id="radar" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 12 20 6" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="2" fill="currentColor"/></symbol>
|
||||||
|
<symbol id="hourglass" viewBox="0 0 24 24"><path d="M6 3h12M6 21h12M7 4c0 5 4 5 5 8-1 3-5 3-5 8m10-16c0 5-4 5-5 8 1 3 5 3 5 8" fill="none" stroke="currentColor" stroke-width="2"/></symbol>
|
||||||
|
<symbol id="sound" viewBox="0 0 24 24"><path d="M4 10h4l5-4v12l-5-4H4zM17 9c2 2 2 4 0 6M20 6c4 4 4 8 0 12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></symbol>
|
||||||
|
<symbol id="motion" viewBox="0 0 24 24"><path d="M4 12h6m4 0h6M8 7l5 5-5 5M16 7l-5 5 5 5" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></symbol>
|
||||||
|
<symbol id="help" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2.5"/><path d="M9.5 9a2.7 2.7 0 1 1 4.2 2.2c-1.4.9-1.7 1.4-1.7 2.8M12 17h.01" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></symbol>
|
||||||
|
<symbol id="hand" viewBox="0 0 24 24"><path d="M8 12V5a1.5 1.5 0 0 1 3 0v5V3.5a1.5 1.5 0 0 1 3 0V10V5a1.5 1.5 0 0 1 3 0v6V8a1.5 1.5 0 0 1 3 0v6c0 4-3 7-7 7-3 0-5-2-6-5l-1-3a1.5 1.5 0 0 1 2-1z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></symbol>
|
||||||
|
<symbol id="exit" viewBox="0 0 24 24"><path d="M4 3h12v18H4zM16 8h4v13H8" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linejoin="round"/><path d="m11 12 5-4m-5 4 5 4m-5-4H2" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></symbol>
|
||||||
|
<symbol id="friends" viewBox="0 0 24 24"><circle cx="8" cy="8" r="3" fill="none" stroke="currentColor" stroke-width="2.5"/><circle cx="17" cy="10" r="3" fill="none" stroke="currentColor" stroke-width="2.5"/><path d="M3 21c0-4 2-7 5-7s5 3 5 7m1-1c.5-3 1.7-5 4-5 2 0 3 2 3 5" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></symbol>
|
||||||
|
<symbol id="maze" viewBox="0 0 160 112"><path d="M8 8h144v96H8V8Zm20 0v24H8m44-24v20h28v28H52v24H28v24m52-48h28V32h24v48h20M80 80v24m28-48v48" fill="none" stroke="currentColor" stroke-width="8" stroke-linecap="square"/><circle cx="20" cy="92" r="7" fill="currentColor"/><path d="m135 89 9 7-9 7" fill="none" stroke="currentColor" stroke-width="6"/></symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
+79
-120
@@ -2,129 +2,88 @@
|
|||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||||
<title>maze32 — лобби</title>
|
<meta name="theme-color" content="#090711">
|
||||||
<style>
|
<title>maze32 — побег из кустов</title>
|
||||||
:root { color-scheme: dark; font-family: ui-monospace, "Cascadia Mono", monospace; --ink:#f5edff; --bg:#090711; --panel:#1d1230; --edge:#65488a; --aqua:#42ead2; --p1:#59a9ff; --p2:#ff73bf; --ok:#72e28b; --warn:#ffd75a; --bad:#ff657a; }
|
<link rel="stylesheet" href="/player.css">
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body { min-height:100vh; margin:0; padding:max(1rem,env(safe-area-inset-top)) max(1rem,env(safe-area-inset-right)) max(1rem,env(safe-area-inset-bottom)) max(1rem,env(safe-area-inset-left)); background:radial-gradient(circle at top,#24113a,var(--bg) 55%); color:var(--ink); }
|
|
||||||
main { width:min(70rem,100%); margin:auto; }
|
|
||||||
header { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:.75rem; margin-bottom:1rem; }
|
|
||||||
h1,h2 { margin:.2rem 0; text-transform:uppercase; letter-spacing:.09em; text-shadow:2px 2px #000; }
|
|
||||||
h1 { color:var(--aqua); }
|
|
||||||
.status { padding:.45rem .7rem; border:1px solid var(--edge); background:#10091d; }
|
|
||||||
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(17rem,1fr)); gap:1rem; }
|
|
||||||
section { min-height:13rem; padding:1rem; border:2px solid var(--edge); box-shadow:5px 5px 0 #050307; background:var(--panel); }
|
|
||||||
.player { display:grid; grid-template-columns:2.2rem 1fr auto; gap:.7rem; align-items:center; margin:.75rem 0; padding:.75rem; border:1px solid #493563; background:#130c20; }
|
|
||||||
.p1 { border-left:5px solid var(--p1); } .p2 { border-left:5px solid var(--p2); }
|
|
||||||
.lamp { color:var(--bad); } .online .lamp { color:var(--ok); }
|
|
||||||
label { display:block; margin:.8rem 0 .3rem; }
|
|
||||||
input,button { min-height:2.8rem; border:2px solid var(--aqua); border-radius:0; background:#0c1520; color:var(--ink); font:inherit; }
|
|
||||||
input { width:100%; padding:.55rem; }
|
|
||||||
button { padding:.5rem .8rem; cursor:pointer; box-shadow:3px 3px 0 #020506; }
|
|
||||||
button:hover:not(:disabled) { background:#123a40; } button:active:not(:disabled) { transform:translate(2px,2px); box-shadow:1px 1px 0 #020506; }
|
|
||||||
button:disabled { opacity:.45; cursor:not-allowed; }
|
|
||||||
.actions { display:flex; flex-wrap:wrap; gap:.6rem; margin-top:.7rem; }
|
|
||||||
#notice { min-height:2.8rem; margin-top:1rem; padding:.7rem; border-left:4px solid var(--warn); background:#17101f; }
|
|
||||||
#notice.error { border-color:var(--bad); color:#ffc0c8; }
|
|
||||||
#notice.good { border-color:var(--ok); color:#b5ffc4; }
|
|
||||||
dialog { width:min(28rem,calc(100% - 2rem)); border:3px solid var(--aqua); background:#0b0712ef; color:var(--ink); text-align:center; }
|
|
||||||
dialog::backdrop { background:#030205d9; }
|
|
||||||
#count { margin:1rem; color:var(--warn); font-size:clamp(5rem,30vw,12rem); animation:pulse .65s steps(2,end) infinite; }
|
|
||||||
.observer-warning { display:none; color:var(--warn); }
|
|
||||||
body.observer-mode .observer-warning { display:block; }
|
|
||||||
@media (min-width:1024px) and (min-height:768px) { body.observer-mode .observer-warning { display:none; } }
|
|
||||||
@keyframes pulse { 50% { transform:scale(1.08); } }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main id="app">
|
||||||
<header><h1>maze32</h1><span class="status" id="connection">Подключаем провода к воздуху…</span></header>
|
<header class="topbar">
|
||||||
<div class="grid">
|
<a class="brand" href="/" aria-label="maze32, на главную">MAZE<span>32</span></a>
|
||||||
<section>
|
<div class="connection-pill" id="connection" data-state="connecting" role="status"><span class="signal" aria-hidden="true"></span><span id="connection-text">Ловим сигнал ESP…</span></div>
|
||||||
<h2>Игроки</h2>
|
<button class="icon-button" id="details-toggle" type="button" aria-controls="details-panel" aria-expanded="false"><svg aria-hidden="true"><use href="/icons.svg#signal"></use></svg><span class="sr-only">Открыть сведения о соединении</span></button>
|
||||||
<div class="player p1" id="p1"><b>P1</b><span class="name">Свободно</span><span class="lamp">●</span></div>
|
</header>
|
||||||
<div class="player p2" id="p2"><b>P2</b><span class="name">Свободно</span><span class="lamp">●</span></div>
|
<aside class="details-panel" id="details-panel" hidden><strong>Связь с коробочкой</strong><span id="details-text">Подключение ещё не готово.</span><a href="/provision">Настроить Wi‑Fi</a></aside>
|
||||||
|
|
||||||
|
<section class="screen welcome-screen" id="welcome-screen" aria-labelledby="welcome-title">
|
||||||
|
<div class="hero-art" aria-hidden="true"><span class="pixel-bush bush-one"></span><span class="pixel-bush bush-two"></span><svg><use href="/icons.svg#maze"></use></svg></div>
|
||||||
|
<div class="panel welcome-panel">
|
||||||
|
<p class="eyebrow">Локальная гонка на два носа</p>
|
||||||
|
<h1 id="welcome-title">Кусты уже<br><span>потеряли выход</span></h1>
|
||||||
|
<p>Выбирай позывной. Сервер запомнит всё важное, а браузеру доверит только красивые пиксели.</p>
|
||||||
|
<label for="player-name">Позывной игрока</label>
|
||||||
|
<input id="player-name" maxlength="32" autocomplete="nickname" placeholder="Например, Байт-Беглец">
|
||||||
|
<div class="button-row"><button class="button secondary" id="suggest-name" type="button">Подкинуть имя</button><button class="button primary" id="join-player" type="button">Занять слот</button></div>
|
||||||
|
<button class="text-button" id="join-observer" type="button">Смотреть и не трогать</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="screen lobby-screen" id="lobby-screen" hidden aria-labelledby="lobby-title">
|
||||||
|
<div class="section-heading"><div><p class="eyebrow">Комната ожидания</p><h1 id="lobby-title">Сбор заблудившихся</h1></div><span class="phase-chip" id="lobby-phase">Ждём игроков</span></div>
|
||||||
|
<div class="lobby-grid">
|
||||||
|
<article class="panel roster-panel">
|
||||||
|
<h2>Игроки</h2>
|
||||||
|
<div class="player-card player-one" id="player-1"><span class="avatar">P1</span><span><strong class="player-name">Свободно</strong><small class="player-state">Слот грустит</small></span><span class="status-dot"></span></div>
|
||||||
|
<div class="player-card player-two" id="player-2"><span class="avatar">P2</span><span><strong class="player-name">Свободно</strong><small class="player-state">Слот грустит</small></span><span class="status-dot"></span></div>
|
||||||
|
</article>
|
||||||
|
<article class="panel observer-panel"><svg aria-hidden="true"><use href="/icons.svg#eye"></use></svg><h2>Зрительный зал</h2><p><strong id="observer-count">0</strong> / 4 эксперта по чужим ошибкам.</p></article>
|
||||||
|
<article class="panel launch-panel"><h2>Большая красная кнопка</h2><p id="launch-help">Старт доступен Игроку 1, когда оба слота на месте.</p><button class="button primary launch-button" id="start-match" type="button" disabled>Начать 3—2—1</button></article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="screen match-screen" id="match-screen" hidden aria-label="Игровой экран">
|
||||||
|
<div class="player-status" id="player-status">
|
||||||
|
<div class="identity" id="identity"><span class="avatar character-avatar" id="self-slot" aria-hidden="true">●</span><span><strong id="self-name">Неизвестный герой</strong><small id="match-state">Лабиринт думает</small></span></div>
|
||||||
|
<div class="stat" id="inventory-stat"><svg aria-hidden="true"><use href="/icons.svg#key"></use></svg><span><small>Ключ</small><strong id="inventory">Пусто</strong></span></div>
|
||||||
|
<div class="stat" id="vision-stat"><svg aria-hidden="true"><use href="/icons.svg#eye"></use></svg><span><small>Суперзрение</small><strong id="vision">Нет</strong></span></div>
|
||||||
|
<div class="stat compass-stat" id="compass-stat"><svg id="compass-arrow" aria-hidden="true"><use href="/icons.svg#arrow"></use></svg><span><small>Компас</small><strong id="compass">Молчит</strong></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="map-shell" id="map-shell" tabindex="0" aria-label="Лабиринт. Используйте стрелки, WASD или свайпы для движения">
|
||||||
|
<div class="unknown-message" id="map-placeholder"><svg aria-hidden="true"><use href="/icons.svg#radar"></use></svg><span>Ждём разрешённый сервером кусочек карты…</span></div>
|
||||||
|
<div class="maze" id="maze" role="img" aria-label="Видимая часть лабиринта"></div>
|
||||||
|
<button class="avatar-control" id="avatar-control" type="button" aria-label="Тянуть героя для движения"><span class="avatar-handle" id="avatar-handle" aria-hidden="true"><span id="avatar-handle-symbol">●</span><svg><use href="/icons.svg#arrow"></use></svg></span></button>
|
||||||
|
<div class="wall-bump" id="wall-bump" role="status" aria-live="polite"></div>
|
||||||
|
</div>
|
||||||
|
<div class="child-controls" aria-label="Игровые действия">
|
||||||
|
<button class="drop-key" id="drop-key" type="button" aria-label="Сбросить ключ" disabled><svg aria-hidden="true"><use href="/icons.svg#key"></use></svg><strong>OK</strong><span>бросить</span></button>
|
||||||
|
</div>
|
||||||
|
<div class="access-controls" aria-label="Настройки игры">
|
||||||
|
<button class="access-button" id="mute-toggle" type="button" aria-label="Выключить звук"><svg aria-hidden="true"><use href="/icons.svg#sound"></use></svg></button>
|
||||||
|
<button class="access-button" id="motion-toggle" type="button" aria-label="Уменьшить движение"><svg aria-hidden="true"><use href="/icons.svg#motion"></use></svg></button>
|
||||||
|
<button class="access-button" id="onboarding-replay" type="button" aria-label="Показать обучение"><svg aria-hidden="true"><use href="/icons.svg#help"></use></svg></button>
|
||||||
|
</div>
|
||||||
|
<nav class="dpad" aria-label="Управление движением"><button type="button" data-direction="north" aria-label="Двигаться вверх">▲</button><button type="button" data-direction="west" aria-label="Двигаться влево">◀</button><span aria-hidden="true">●</span><button type="button" data-direction="east" aria-label="Двигаться вправо">▶</button><button type="button" data-direction="south" aria-label="Двигаться вниз">▼</button></nav>
|
||||||
|
<section class="onboarding" id="onboarding" hidden aria-label="Обучение игре">
|
||||||
|
<div class="onboarding-card" id="onboarding-card"><svg class="onboarding-icon" aria-hidden="true"><use id="onboarding-icon-use" href="/icons.svg#hand"></use></svg><h2 id="onboarding-title">Потяни кружок</h2><p id="onboarding-text">Кружок ведёт героя по лабиринту.</p><div class="onboarding-dots" id="onboarding-dots" aria-hidden="true"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div><button id="onboarding-next" type="button" aria-label="Следующий шаг обучения">Дальше</button><button id="onboarding-skip" type="button" aria-label="Пропустить обучение">Пропустить</button></div>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
</section>
|
||||||
<h2>Наблюдатели</h2>
|
|
||||||
<p><strong id="observers">0</strong> / 4 смотрят очень внимательно.</p>
|
<section class="screen observer-screen" id="observer-screen" hidden aria-labelledby="observer-title">
|
||||||
<p class="observer-warning">Для режима наблюдателя нужен экран не меньше 1024 × 768.</p>
|
<div class="observer-toolbar">
|
||||||
<button id="observe">Смотреть лабиринт</button>
|
<div><p class="eyebrow">Режим наблюдателя · только чтение</p><h1 id="observer-title">Весь куст целиком</h1></div>
|
||||||
</section>
|
<div class="observer-players" aria-live="polite"><span class="player-one">P1 <strong id="observer-p1">готов</strong></span><span class="player-two">P2 <strong id="observer-p2">готов</strong></span><span id="observer-phase">Ждём старта</span></div>
|
||||||
<section>
|
<div class="zoom-controls" aria-label="Масштаб карты"><button id="zoom-out" type="button" aria-label="Уменьшить масштаб">−</button><button id="zoom-reset" type="button">Вся карта</button><button id="zoom-in" type="button" aria-label="Увеличить масштаб">+</button></div>
|
||||||
<h2>Пульт запуска</h2>
|
</div>
|
||||||
<div id="join">
|
<div class="observer-requirement" id="observer-requirement" role="status"><svg aria-hidden="true"><use href="/icons.svg#eye"></use></svg><strong>Нужен экран побольше</strong><span>Зрительный зал открывается от 1024 × 768 пикселей.</span></div>
|
||||||
<label for="name">Позывной игрока</label>
|
<div class="observer-stage" id="observer-stage" tabindex="0" aria-label="Полная карта лабиринта. Масштабируйте колёсиком или кнопками, перетаскивайте мышью.">
|
||||||
<input id="name" maxlength="32" autocomplete="nickname" placeholder="Например, Байт-Беглец">
|
<div class="observer-maze" id="observer-maze" role="img" aria-label="Полная карта лабиринта"></div>
|
||||||
<div class="actions"><button id="suggest">Подкинуть имя</button><button id="play">Занять слот</button></div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
<div class="actions"><button id="start" disabled>Начать 3-2-1</button><a href="/provision"><button>Wi‑Fi</button></a></div>
|
<div class="notice" id="notice" role="status" aria-live="polite">Сервер решает всё. Мы отвечаем за драматичное мерцание.</div>
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
<div id="notice" role="status">Сервер решает всё. Браузер только красиво моргает.</div>
|
|
||||||
</main>
|
</main>
|
||||||
<dialog id="overlay"><h2 id="overlay-title">Приготовились!</h2><div id="count">3</div><p id="overlay-text">Сервер синхронизирует старт.</p></dialog>
|
|
||||||
<script>
|
<div class="modal" id="modal" hidden role="dialog" aria-modal="true" aria-labelledby="modal-title"><div class="modal-card"><svg id="modal-icon" aria-hidden="true"><use href="/icons.svg#hourglass"></use></svg><p class="eyebrow" id="modal-kicker">Серверное объявление</p><h2 id="modal-title">Приготовились!</h2><div class="countdown" id="countdown" hidden>3</div><p id="modal-text">Один сервер — один честный момент.</p><button class="button primary" id="modal-action" type="button" hidden>Вернуться в лобби</button></div></div>
|
||||||
(() => {
|
<script src="/player.js" defer></script>
|
||||||
const $ = id => document.getElementById(id);
|
|
||||||
const state = { socket:null, clientSeq:0, serverSeq:0, session:null, lobby:null, reconnect:null };
|
|
||||||
const tokenKey = 'maze32.session.v1';
|
|
||||||
const notice = (text, kind='') => { $('notice').textContent=text; $('notice').className=kind; };
|
|
||||||
const send = (type,payload={},requestId=type) => {
|
|
||||||
if (!state.socket || state.socket.readyState !== WebSocket.OPEN) return;
|
|
||||||
state.socket.send(JSON.stringify({v:1,type,seq:++state.clientSeq,requestId,payload}));
|
|
||||||
};
|
|
||||||
const render = () => {
|
|
||||||
if (!state.lobby) return;
|
|
||||||
state.lobby.players.forEach(player => {
|
|
||||||
const row = $('p'+player.slot);
|
|
||||||
row.querySelector('.name').textContent = player.occupied ? player.name : 'Свободно';
|
|
||||||
row.classList.toggle('online',player.connected);
|
|
||||||
});
|
|
||||||
$('observers').textContent=state.lobby.observers;
|
|
||||||
$('join').hidden=!!state.session;
|
|
||||||
$('observe').hidden=!!state.session;
|
|
||||||
const owner=state.session?.role==='player' && state.session.slot===1;
|
|
||||||
$('start').disabled=!(owner && state.lobby.ready && state.lobby.phase==='waiting');
|
|
||||||
document.body.classList.toggle('observer-mode',state.session?.role==='observer');
|
|
||||||
};
|
|
||||||
const countdown = () => {
|
|
||||||
const dialog=$('overlay'); $('overlay-title').textContent='Приготовились!'; $('overlay-text').textContent='Один сервер — один момент старта.';
|
|
||||||
if (!dialog.open) dialog.showModal();
|
|
||||||
let value=3; $('count').textContent=value;
|
|
||||||
const timer=setInterval(() => { value--; if (value>0) $('count').textContent=value; else clearInterval(timer); },1000);
|
|
||||||
};
|
|
||||||
const handle = event => {
|
|
||||||
let message; try { message=JSON.parse(event.data); } catch { notice('Сервер прислал нечитаемый пакет.','error'); return; }
|
|
||||||
if (message.v!==1 || !Number.isInteger(message.seq) || message.seq<=state.serverSeq) return;
|
|
||||||
state.serverSeq=message.seq;
|
|
||||||
if (message.type==='session') {
|
|
||||||
state.session=message.payload; localStorage.setItem(tokenKey,message.payload.token);
|
|
||||||
notice(message.payload.role==='player' ? `Слот P${message.payload.slot} закреплён. Токен спрятан в этом браузере.` : 'Вы в зрительном зале. Руками не трогаем!','good');
|
|
||||||
} else if (message.type==='lobby') { state.lobby=message.payload; render();
|
|
||||||
} else if (message.type==='name_suggestion') { $('name').value=message.payload.name;
|
|
||||||
} else if (message.type==='countdown') { countdown();
|
|
||||||
} else if (message.type==='active') { $('count').textContent='GO'; $('overlay-title').textContent='Старт!'; $('overlay-text').textContent='Лобби активировано. Лабиринт появится в следующем этапе.'; setTimeout(()=>$('overlay').close(),1200);
|
|
||||||
} else if (message.type==='error') {
|
|
||||||
if (message.payload.code==='invalid_token') { localStorage.removeItem(tokenKey); state.session=null; render(); }
|
|
||||||
notice(message.payload.message || 'Команда отклонена сервером.','error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const connect = () => {
|
|
||||||
clearTimeout(state.reconnect); state.clientSeq=0; state.serverSeq=0;
|
|
||||||
const scheme=location.protocol==='https:'?'wss':'ws'; state.socket=new WebSocket(`${scheme}://${location.host}/ws`);
|
|
||||||
state.socket.onopen=() => { $('connection').textContent='Связь с ESP: есть'; const token=localStorage.getItem(tokenKey); if(token) send('restore',{token},'refresh'); };
|
|
||||||
state.socket.onmessage=handle;
|
|
||||||
state.socket.onclose=() => { $('connection').textContent='Связь потеряна, переподключаемся…'; state.reconnect=setTimeout(connect,1000); };
|
|
||||||
state.socket.onerror=() => notice('WebSocket споткнулся. Пробуем снова…','error');
|
|
||||||
};
|
|
||||||
$('suggest').onclick=()=>send('suggest_name',{},'suggest');
|
|
||||||
$('play').onclick=()=>send('join',{role:'player',name:$('name').value},'join-player');
|
|
||||||
$('observe').onclick=()=>send('join',{role:'observer'},'join-observer');
|
|
||||||
$('start').onclick=()=>send('start',{},'start');
|
|
||||||
connect();
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+290
@@ -0,0 +1,290 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: ui-monospace, "Cascadia Mono", "SFMono-Regular", Menlo, Consolas, monospace;
|
||||||
|
--bg: #090711;
|
||||||
|
--panel: #1d1230;
|
||||||
|
--panel-deep: #110a20;
|
||||||
|
--edge: #674990;
|
||||||
|
--ink: #f6efff;
|
||||||
|
--muted: #b7a8ca;
|
||||||
|
--aqua: #43ead2;
|
||||||
|
--p1: #58aaff;
|
||||||
|
--p2: #ff72bd;
|
||||||
|
--plant: #45b86b;
|
||||||
|
--plant-dark: #176638;
|
||||||
|
--success: #72e48c;
|
||||||
|
--warning: #ffd65a;
|
||||||
|
--error: #ff647a;
|
||||||
|
--pause: #5598ff;
|
||||||
|
--disconnect: #ff9f43;
|
||||||
|
--cell: 40px;
|
||||||
|
--shadow: #040207;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--ink); }
|
||||||
|
body { overflow-x: hidden; background-image: radial-gradient(circle at 50% -20%, #33194c 0, transparent 42%), linear-gradient(#090711dd, #090711f5), repeating-linear-gradient(0deg, transparent 0 3px, #ffffff05 3px 4px); }
|
||||||
|
button, input { font: inherit; }
|
||||||
|
button, a { -webkit-tap-highlight-color: transparent; }
|
||||||
|
button { color: inherit; }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||||
|
|
||||||
|
#app { min-height: 100dvh; width: min(1180px, 100%); margin: 0 auto; padding: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(12px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left)); }
|
||||||
|
.topbar { position: relative; z-index: 20; min-height: 54px; display: flex; align-items: center; gap: 12px; border-bottom: 2px solid #342347; }
|
||||||
|
.brand { color: var(--ink); text-decoration: none; font-weight: 1000; font-size: clamp(1.1rem, 3vw, 1.45rem); letter-spacing: .14em; text-shadow: 3px 3px 0 #000; }
|
||||||
|
.brand span { color: var(--aqua); }
|
||||||
|
.connection-pill { margin-left: auto; display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; }
|
||||||
|
.signal { width: 9px; height: 9px; border-radius: 50%; background: var(--warning); box-shadow: 0 0 10px var(--warning); }
|
||||||
|
.connection-pill[data-state="online"] .signal { background: var(--success); box-shadow: 0 0 10px var(--success); }
|
||||||
|
.connection-pill[data-state="offline"] .signal { background: var(--disconnect); box-shadow: 0 0 10px var(--disconnect); }
|
||||||
|
.icon-button { width: 38px; height: 38px; padding: 8px; border: 1px solid var(--edge); background: var(--panel-deep); cursor: pointer; }
|
||||||
|
.icon-button svg { width: 100%; height: 100%; color: var(--aqua); }
|
||||||
|
.details-panel { position: absolute; z-index: 30; top: 60px; right: max(12px, env(safe-area-inset-right)); display: grid; gap: 6px; width: min(300px, calc(100% - 24px)); padding: 14px; border: 2px solid var(--edge); background: #120b21f5; box-shadow: 6px 6px 0 var(--shadow); font-size: .8rem; }
|
||||||
|
.details-panel span { color: var(--muted); }
|
||||||
|
.details-panel a { color: var(--aqua); }
|
||||||
|
|
||||||
|
.screen { min-height: calc(100dvh - 90px); animation: screen-in .28s steps(5, end); }
|
||||||
|
.welcome-screen { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(320px, 500px); align-items: center; gap: clamp(24px, 6vw, 90px); padding: 5vh 4vw; }
|
||||||
|
.hero-art { position: relative; min-height: 330px; display: grid; place-items: center; color: var(--aqua); }
|
||||||
|
.hero-art svg { width: min(100%, 460px); filter: drop-shadow(10px 10px 0 #030204); animation: hover 2.4s steps(4, end) infinite; }
|
||||||
|
.pixel-bush { position: absolute; width: 76px; height: 56px; background: var(--plant); box-shadow: 18px -18px 0 var(--plant), 36px 0 0 var(--plant), 18px 18px 0 var(--plant-dark); image-rendering: pixelated; opacity: .8; }
|
||||||
|
.bush-one { left: 3%; bottom: 12%; transform: scale(.8); }
|
||||||
|
.bush-two { right: 4%; top: 12%; transform: scale(.55); }
|
||||||
|
.panel { border: 2px solid var(--edge); background: linear-gradient(145deg, #25163a, var(--panel)); box-shadow: 7px 7px 0 var(--shadow); }
|
||||||
|
.welcome-panel { padding: clamp(22px, 4vw, 42px); }
|
||||||
|
.eyebrow { margin: 0 0 8px; color: var(--aqua); text-transform: uppercase; letter-spacing: .12em; font-size: .72rem; }
|
||||||
|
h1, h2, p { text-wrap: balance; }
|
||||||
|
h1, h2 { margin: 0; text-transform: uppercase; line-height: 1.04; text-shadow: 3px 3px 0 #000; }
|
||||||
|
h1 { font-size: clamp(2rem, 6vw, 4.6rem); }
|
||||||
|
h1 span { color: var(--warning); }
|
||||||
|
h2 { font-size: 1rem; letter-spacing: .06em; }
|
||||||
|
p { color: var(--muted); line-height: 1.55; }
|
||||||
|
label { display: block; margin: 24px 0 7px; color: var(--ink); font-size: .8rem; font-weight: 800; text-transform: uppercase; }
|
||||||
|
input { width: 100%; min-height: 50px; border: 2px solid var(--edge); border-radius: 0; outline: 0; background: #0c0815; padding: 0 14px; color: var(--ink); }
|
||||||
|
input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
|
||||||
|
.button-row { display: flex; gap: 10px; margin-top: 12px; }
|
||||||
|
.button { min-height: 48px; padding: 0 17px; border: 2px solid var(--aqua); border-radius: 0; background: #0f2026; cursor: pointer; font-weight: 900; text-transform: uppercase; box-shadow: 4px 4px 0 var(--shadow); }
|
||||||
|
.button:hover:not(:disabled) { background: #164049; }
|
||||||
|
.button:active:not(:disabled) { transform: translate(3px, 3px); box-shadow: 1px 1px 0 var(--shadow); }
|
||||||
|
.button:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
.button.primary { background: var(--aqua); color: #051411; }
|
||||||
|
.button.primary:hover:not(:disabled) { background: #87f6e6; }
|
||||||
|
.button.pending, .dpad button.pending, .drop-key.pending { animation: pending .5s steps(2, end) infinite; }
|
||||||
|
.text-button { margin-top: 20px; border: 0; background: transparent; color: var(--muted); text-decoration: underline; cursor: pointer; }
|
||||||
|
|
||||||
|
.lobby-screen { padding: clamp(28px, 5vw, 70px) 2vw; }
|
||||||
|
.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 18px; margin-bottom: 24px; }
|
||||||
|
.section-heading h1 { font-size: clamp(1.8rem, 5vw, 3.8rem); }
|
||||||
|
.phase-chip { padding: 9px 12px; border: 1px solid var(--warning); color: var(--warning); background: #2b220b; font-size: .72rem; text-transform: uppercase; }
|
||||||
|
.lobby-grid { display: grid; grid-template-columns: 1.4fr .8fr; gap: 18px; }
|
||||||
|
.lobby-grid .panel { padding: 22px; }
|
||||||
|
.roster-panel { grid-row: span 2; }
|
||||||
|
.player-card { display: grid; grid-template-columns: 54px 1fr 12px; align-items: center; gap: 13px; margin-top: 16px; padding: 14px; border: 1px solid #51386e; background: #100a1b; }
|
||||||
|
.player-card.player-one { border-left: 5px solid var(--p1); }
|
||||||
|
.player-card.player-two { border-left: 5px solid var(--p2); }
|
||||||
|
.avatar { width: 46px; height: 46px; display: grid; place-items: center; border: 2px solid currentColor; background: #0b0712; font-weight: 1000; }
|
||||||
|
.player-one .avatar, .identity.player-one .avatar { color: var(--p1); }
|
||||||
|
.player-two .avatar, .identity.player-two .avatar { color: var(--p2); }
|
||||||
|
.player-card strong, .player-card small, .identity strong, .identity small, .stat small, .stat strong { display: block; }
|
||||||
|
.player-card small, .identity small, .stat small { margin-top: 4px; color: var(--muted); font-size: .66rem; }
|
||||||
|
.status-dot { width: 10px; height: 10px; background: var(--error); }
|
||||||
|
.player-card.online .status-dot { background: var(--success); box-shadow: 0 0 9px var(--success); }
|
||||||
|
.observer-panel svg { width: 46px; height: 46px; color: var(--p2); }
|
||||||
|
.observer-panel strong { color: var(--p2); font-size: 1.7rem; }
|
||||||
|
.launch-button { width: 100%; margin-top: 10px; }
|
||||||
|
|
||||||
|
.match-screen { position: relative; height: calc(100dvh - 78px); min-height: 520px; overflow: hidden; padding-top: 84px; }
|
||||||
|
.player-status { position: absolute; z-index: 8; inset: 10px 0 auto; display: grid; grid-template-columns: minmax(190px, 1.4fr) repeat(3, minmax(120px, .7fr)); gap: 7px; }
|
||||||
|
.identity, .stat { min-height: 68px; display: flex; align-items: center; gap: 10px; padding: 8px 12px; border: 1px solid #543a72; background: #171027eb; backdrop-filter: blur(8px); }
|
||||||
|
.identity .avatar { flex: 0 0 46px; }
|
||||||
|
.stat svg { width: 29px; height: 29px; flex: 0 0 29px; color: var(--aqua); }
|
||||||
|
.stat strong { font-size: .75rem; }
|
||||||
|
.vision-active { border-color: var(--warning); box-shadow: inset 0 0 18px #ffd65a22; }
|
||||||
|
.vision-active svg { color: var(--warning); animation: eye-pulse 1s steps(3, end) infinite; }
|
||||||
|
.compass-stat[data-direction]:not([data-direction="none"]) { border-color: var(--aqua); }
|
||||||
|
#compass-arrow { transition: transform .15s steps(3, end); }
|
||||||
|
.map-shell { position: absolute; inset: 84px 0 0; overflow: hidden; outline: 0; border: 2px solid #2f2041; background-color: #050408; background-image: radial-gradient(circle at center, #161024 0, #050408 72%); touch-action: none; user-select: none; }
|
||||||
|
.map-shell:focus-visible { border-color: var(--aqua); }
|
||||||
|
.unknown-message { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 12px; padding: 24px; color: var(--muted); text-align: center; }
|
||||||
|
.unknown-message svg { width: 54px; height: 54px; color: var(--aqua); animation: radar 1.8s linear infinite; }
|
||||||
|
.maze { position: absolute; left: 50%; top: 50%; display: grid; transform: translate(-50%, -50%); }
|
||||||
|
.cell { position: relative; width: var(--cell); height: var(--cell); background: #11131a; border: 4px solid transparent; }
|
||||||
|
.cell.wall-n { border-top-color: var(--plant); box-shadow: inset 0 3px 0 var(--plant-dark); }
|
||||||
|
.cell.wall-e { border-right-color: var(--plant); }
|
||||||
|
.cell.wall-s { border-bottom-color: var(--plant); }
|
||||||
|
.cell.wall-w { border-left-color: var(--plant); }
|
||||||
|
.cell::after { content: ""; position: absolute; inset: 7px; background: #1c1b27; opacity: .55; }
|
||||||
|
.object, .pawn { position: absolute; z-index: 3; inset: 16%; display: grid; place-items: center; }
|
||||||
|
.object svg { width: 100%; height: 100%; }
|
||||||
|
.object-key { color: var(--warning); }
|
||||||
|
.object-key::after { content: "●"; position: absolute; right: -5px; bottom: -6px; padding: 1px 3px; background: #090711; border: 1px solid currentColor; font-size: 8px; font-weight: 900; }
|
||||||
|
.object-key[data-owner="two"]::after { content: "◆"; }
|
||||||
|
.object-exit { inset: 8%; border: 3px double currentColor; background: #0b0911; }
|
||||||
|
.object-exit[data-owner="one"] { color: var(--p1); }
|
||||||
|
.object-exit[data-owner="two"] { color: var(--p2); transform: rotate(45deg); }
|
||||||
|
.object-exit[data-open="1"] { background: #72e48c35; animation: exit-open .8s steps(2, end) infinite; }
|
||||||
|
.object-compass { color: var(--aqua); border: 2px dashed currentColor; border-radius: 50%; }
|
||||||
|
.object-landmark { color: var(--plant); font-size: 1.15rem; text-shadow: 2px 2px #000; }
|
||||||
|
.object-vision { color: var(--warning); border-radius: 50%; background: #ffd65a30; box-shadow: 0 0 10px var(--warning); animation: artifact 1.2s steps(4, end) infinite; }
|
||||||
|
.pawn { inset: 12%; border: 3px solid currentColor; background: #0b0712; font-size: .62rem; font-weight: 1000; box-shadow: 3px 3px 0 #000; animation: pawn-in .16s steps(2, end); }
|
||||||
|
.pawn-self { color: var(--p1); }
|
||||||
|
.match-screen.player-two-mode .pawn-self { color: var(--p2); }
|
||||||
|
.pawn-opponent { color: var(--p2); transform: rotate(45deg); }
|
||||||
|
.match-screen.player-two-mode .pawn-opponent { color: var(--p1); }
|
||||||
|
.pawn-opponent span { transform: rotate(-45deg); }
|
||||||
|
.wall-bump { position: absolute; left: 50%; bottom: 18px; z-index: 6; min-height: 28px; transform: translateX(-50%); padding: 6px 10px; color: var(--error); background: #260b12dd; opacity: 0; }
|
||||||
|
.wall-bump.show { animation: bump .8s steps(3, end); }
|
||||||
|
.wall-bump::before { content: "▌"; display: inline-block; margin-right: 6px; font-size: 1.35em; font-weight: 1000; }
|
||||||
|
|
||||||
|
.avatar-control { position: absolute; z-index: 8; left: 50%; top: 50%; width: 96px; height: 96px; margin: -48px; padding: 0; border: 0; border-radius: 50%; background: transparent; cursor: grab; touch-action: none; user-select: none; }
|
||||||
|
.avatar-control:active, .avatar-control.active { cursor: grabbing; }
|
||||||
|
.avatar-handle { position: absolute; left: 50%; top: 50%; width: 52px; height: 52px; display: grid; place-items: center; border: 3px solid currentColor; border-radius: 50%; background: #0b0712; color: var(--p1); box-shadow: 4px 4px 0 #000, 0 0 0 3px #43ead236; font-size: 1.25rem; font-weight: 1000; transform: translate(-50%, -50%); transition: transform .12s steps(3, end); }
|
||||||
|
.player-two-mode .avatar-handle { color: var(--p2); }
|
||||||
|
.avatar-control.active .avatar-handle { transition: none; }
|
||||||
|
.avatar-handle svg { position: absolute; width: 19px; height: 19px; right: -13px; top: -13px; display: none; padding: 2px; border: 1px solid currentColor; background: #08050b; color: var(--aqua); }
|
||||||
|
.avatar-control[data-direction] .avatar-handle svg { display: block; }
|
||||||
|
.avatar-control[data-direction="north"] .avatar-handle svg { transform: rotate(0deg); }
|
||||||
|
.avatar-control[data-direction="east"] .avatar-handle svg { transform: rotate(90deg); }
|
||||||
|
.avatar-control[data-direction="south"] .avatar-handle svg { transform: rotate(180deg); }
|
||||||
|
.avatar-control[data-direction="west"] .avatar-handle svg { transform: rotate(270deg); }
|
||||||
|
.avatar-control[data-speed="fast"] .avatar-handle { box-shadow: 4px 4px 0 #000, 0 0 0 4px var(--aqua), 0 0 18px #43ead299; }
|
||||||
|
.avatar-control[data-speed="slow"] .avatar-handle { box-shadow: 4px 4px 0 #000, 0 0 0 3px #43ead270; }
|
||||||
|
.child-controls { position: absolute; z-index: 12; inset: auto 0 12px; display: flex; align-items: end; justify-content: space-between; padding: 0 16px; pointer-events: none; }
|
||||||
|
.child-controls > * { pointer-events: auto; }
|
||||||
|
.access-controls { position: absolute; z-index: 14; top: 92px; right: 8px; display: grid; gap: 6px; }
|
||||||
|
.access-button { width: 42px; height: 42px; display: grid; place-items: center; border: 2px solid var(--aqua); border-radius: 50%; background: #102a30e8; color: var(--aqua); box-shadow: 3px 3px 0 #020506; }
|
||||||
|
.access-button svg { width: 24px; height: 24px; }
|
||||||
|
.access-button[aria-pressed="true"] { color: var(--warning); border-color: var(--warning); }
|
||||||
|
.onboarding { position: absolute; z-index: 30; inset: 0; display: grid; place-items: center; padding: 18px; background: #08050bd9; }
|
||||||
|
.onboarding-card { width: min(340px, 92%); padding: 20px; border: 3px solid var(--aqua); background: var(--panel-deep); box-shadow: 7px 7px 0 #020103; text-align: center; }
|
||||||
|
.onboarding-icon { width: 104px; height: 104px; color: var(--aqua); animation: guide-hand 1.2s ease-in-out infinite; }
|
||||||
|
.onboarding-card h2 { margin: 12px 0 6px; font-size: 1.35rem; }
|
||||||
|
.onboarding-card p { min-height: 42px; margin: 0; color: var(--muted); }
|
||||||
|
.onboarding-dots { display: flex; justify-content: center; gap: 8px; margin: 10px; }
|
||||||
|
.onboarding-dots span { width: 10px; height: 10px; border: 2px solid var(--aqua); border-radius: 50%; }
|
||||||
|
.onboarding-dots .active { background: var(--aqua); }
|
||||||
|
#onboarding-next { min-width: 108px; min-height: 52px; border: 2px solid var(--aqua); background: var(--aqua); color: #05201c; }
|
||||||
|
#onboarding-skip { display: block; margin: 12px auto 0; border: 0; background: transparent; color: var(--muted); text-decoration: underline; }
|
||||||
|
[data-reduced-motion="true"] * { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: .01ms !important; }
|
||||||
|
@media (prefers-reduced-motion: reduce) { * { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: .01ms !important; } }
|
||||||
|
|
||||||
|
.drop-key { position: fixed; z-index: 12; right: max(22px, env(safe-area-inset-right)); top: max(166px, calc(env(safe-area-inset-top) + 150px)); width: 78px; height: 78px; display: grid; grid-template-columns: 26px 1fr; place-content: center; align-items: center; border: 3px solid var(--warning); background: #392c0d; color: var(--warning); box-shadow: 5px 5px 0 #050301; cursor: pointer; }
|
||||||
|
.drop-key svg { width: 25px; height: 25px; }
|
||||||
|
.drop-key strong { font-size: 1.2rem; }
|
||||||
|
.drop-key span { grid-column: 1 / -1; font-size: .56rem; text-transform: uppercase; }
|
||||||
|
.drop-key:disabled { opacity: .48; }
|
||||||
|
.dpad { position: fixed; z-index: 11; right: max(24px, env(safe-area-inset-right)); bottom: max(28px, env(safe-area-inset-bottom)); display: grid; grid-template-columns: repeat(3, 58px); grid-template-rows: repeat(2, 58px); gap: 5px; }
|
||||||
|
.dpad button { border: 2px solid var(--aqua); background: #102a30df; color: var(--aqua); box-shadow: 4px 4px 0 #020506; cursor: pointer; }
|
||||||
|
.dpad button:first-child { grid-column: 2; }
|
||||||
|
.dpad button:nth-child(2) { grid-column: 1; grid-row: 2; }
|
||||||
|
.dpad span { grid-column: 2; grid-row: 2; display: grid; place-items: center; color: #4b3a60; }
|
||||||
|
.dpad button:nth-child(4) { grid-column: 3; grid-row: 2; }
|
||||||
|
.dpad button:last-child { grid-column: 2; grid-row: 3; }
|
||||||
|
|
||||||
|
.notice { position: fixed; z-index: 40; left: 50%; bottom: max(12px, env(safe-area-inset-bottom)); max-width: calc(100% - 24px); transform: translate(-50%, 130%); padding: 10px 14px; border-left: 4px solid var(--warning); background: #191020f2; color: var(--ink); box-shadow: 4px 4px 0 #020104; font-size: .76rem; opacity: 0; transition: .18s steps(3, end); pointer-events: none; }
|
||||||
|
.notice.visible { opacity: 1; transform: translate(-50%, 0); }
|
||||||
|
.notice.error { border-color: var(--error); }
|
||||||
|
.notice.success { border-color: var(--success); }
|
||||||
|
.modal { position: fixed; z-index: 50; inset: 0; display: grid; place-items: center; padding: 18px; background: #050308dc; backdrop-filter: blur(5px); }
|
||||||
|
.modal-card { width: min(440px, 100%); padding: 30px; border: 3px solid var(--aqua); background: #130b22; text-align: center; box-shadow: 9px 9px 0 #020103; animation: modal-in .28s steps(4, end); }
|
||||||
|
.modal-card > svg { width: 54px; height: 54px; color: var(--aqua); }
|
||||||
|
.modal-card h2 { font-size: clamp(1.5rem, 7vw, 2.7rem); }
|
||||||
|
.countdown { color: var(--warning); font-size: clamp(5rem, 24vw, 10rem); font-weight: 1000; line-height: 1; text-shadow: 7px 7px 0 #000; animation: count .65s steps(2, end) infinite; }
|
||||||
|
|
||||||
|
.observer-screen { height: calc(100dvh - 78px); min-height: 678px; overflow: hidden; padding-top: 92px; }
|
||||||
|
.observer-toolbar { position: absolute; z-index: 8; inset: 8px 0 auto; min-height: 76px; display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 18px; padding: 9px 12px; border: 1px solid var(--edge); background: #171027eb; }
|
||||||
|
.observer-toolbar h1 { font-size: clamp(1.15rem, 2.8vw, 2.1rem); }
|
||||||
|
.observer-toolbar .eyebrow { margin-bottom: 3px; }
|
||||||
|
.observer-players { display: flex; align-items: center; gap: 12px; color: var(--muted); font-size: .72rem; }
|
||||||
|
.observer-players span { padding: 7px; border: 1px solid #51386e; }
|
||||||
|
.observer-players .player-one { border-color: var(--p1); }
|
||||||
|
.observer-players .player-two { border-color: var(--p2); }
|
||||||
|
.zoom-controls { display: flex; gap: 5px; }
|
||||||
|
.zoom-controls button { min-width: 42px; min-height: 38px; border: 1px solid var(--aqua); background: #102a30; color: var(--aqua); cursor: pointer; }
|
||||||
|
.observer-stage { position: absolute; inset: 92px 0 0; overflow: hidden; border: 2px solid #2f2041; background: #050408; cursor: grab; touch-action: none; user-select: none; }
|
||||||
|
.observer-stage.dragging { cursor: grabbing; }
|
||||||
|
.observer-maze { position: absolute; left: 50%; top: 50%; display: grid; transform-origin: center; will-change: transform; }
|
||||||
|
.observer-maze .cell { width: 8px; height: 8px; border-width: 1px; }
|
||||||
|
.observer-maze .cell::after { inset: 2px; }
|
||||||
|
.observer-maze .object { inset: 5%; }
|
||||||
|
.observer-maze .object-key::after { display: none; }
|
||||||
|
.observer-maze .object-landmark { font-size: 6px; }
|
||||||
|
.observer-maze .pawn { inset: -30%; z-index: 5; font-size: 5px; border-width: 1px; box-shadow: 1px 1px 0 #000; }
|
||||||
|
.observer-maze .pawn-player-one { color: var(--p1); }
|
||||||
|
.observer-maze .pawn-player-two { color: var(--p2); transform: rotate(45deg); }
|
||||||
|
.observer-maze .pawn-player-two span { transform: rotate(-45deg); }
|
||||||
|
.observer-requirement { display: none; position: absolute; inset: 92px 0 0; place-content: center; justify-items: center; gap: 12px; padding: 24px; color: var(--muted); text-align: center; border: 2px solid var(--edge); background: var(--panel-deep); }
|
||||||
|
.observer-requirement svg { width: 58px; height: 58px; color: var(--p2); }
|
||||||
|
.observer-requirement strong { color: var(--ink); font-size: 1.2rem; text-transform: uppercase; }
|
||||||
|
|
||||||
|
@media (max-width: 1023px), (max-height: 767px) {
|
||||||
|
.observer-toolbar { grid-template-columns: 1fr; }
|
||||||
|
.observer-toolbar > :not(:first-child) { display: none; }
|
||||||
|
.observer-stage { display: none; }
|
||||||
|
.observer-requirement { display: grid; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
:root { --cell: 46px; }
|
||||||
|
#app { padding-left: max(8px, env(safe-area-inset-left)); padding-right: max(8px, env(safe-area-inset-right)); }
|
||||||
|
.welcome-screen { grid-template-columns: 1fr; padding: 28px 2px; }
|
||||||
|
.hero-art { display: none; }
|
||||||
|
.welcome-panel { padding: 22px 18px; }
|
||||||
|
.button-row { flex-direction: column; }
|
||||||
|
.lobby-grid { grid-template-columns: 1fr; }
|
||||||
|
.lobby-screen { padding: 18px 2px; }
|
||||||
|
.lobby-grid { gap: 10px; }
|
||||||
|
.lobby-grid .panel { padding: 14px; }
|
||||||
|
.lobby-screen .section-heading { margin-bottom: 12px; }
|
||||||
|
.player-card { margin-top: 8px; padding: 8px; }
|
||||||
|
.roster-panel { grid-row: auto; }
|
||||||
|
.section-heading { align-items: start; flex-direction: column; }
|
||||||
|
.match-screen { height: calc(100dvh - 70px); padding-top: 82px; }
|
||||||
|
.player-status { grid-template-columns: 1.25fr repeat(3, .75fr); inset: 6px 0 auto; }
|
||||||
|
.identity, .stat { min-width: 0; min-height: 72px; padding: 5px; flex-direction: column; justify-content: center; text-align: center; gap: 1px; }
|
||||||
|
.identity .avatar { flex-basis: 34px; width: 34px; height: 34px; font-size: 1.4rem; }
|
||||||
|
.identity strong { max-width: 88px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .62rem; }
|
||||||
|
.identity small, .stat small { font-size: .48rem; }
|
||||||
|
.stat strong { font-size: .54rem; }
|
||||||
|
.stat svg { width: 27px; height: 27px; flex-basis: 27px; }
|
||||||
|
.map-shell { inset: 82px 0 0; }
|
||||||
|
.child-controls { bottom: max(14px, env(safe-area-inset-bottom)); padding: 0 10px; }
|
||||||
|
.drop-key { position: relative; top: auto; right: auto; bottom: auto; width: 78px; height: 78px; }
|
||||||
|
.access-controls { top: 88px; right: 8px; }
|
||||||
|
.dpad { display: none; }
|
||||||
|
.connection-pill { font-size: .58rem; }
|
||||||
|
.connection-pill #connection-text { max-width: 145px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 721px) and (max-width: 980px) {
|
||||||
|
:root { --cell: 34px; }
|
||||||
|
.player-status { grid-template-columns: 1.2fr repeat(3, .7fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (orientation: landscape) and (max-height: 620px) {
|
||||||
|
.topbar { min-height: 44px; }
|
||||||
|
.match-screen { height: calc(100dvh - 68px); padding-top: 64px; }
|
||||||
|
.player-status { inset: 5px 98px auto 0; }
|
||||||
|
.identity, .stat { min-height: 55px; }
|
||||||
|
.map-shell { inset: 64px 0 0; }
|
||||||
|
.drop-key { top: 76px; right: 14px; width: 70px; height: 70px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) and (pointer: coarse) and (max-height: 720px) {
|
||||||
|
.dpad { display: none; }
|
||||||
|
.drop-key { top: auto; bottom: max(14px, env(safe-area-inset-bottom)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes screen-in { from { opacity: 0; transform: translateY(10px); } }
|
||||||
|
@keyframes hover { 50% { transform: translateY(-8px); } }
|
||||||
|
@keyframes pending { 50% { filter: brightness(1.8); } }
|
||||||
|
@keyframes eye-pulse { 50% { transform: scale(1.15); } }
|
||||||
|
@keyframes radar { to { transform: rotate(360deg); } }
|
||||||
|
@keyframes artifact { 50% { transform: scale(.75); box-shadow: 0 0 18px var(--warning); } }
|
||||||
|
@keyframes exit-open { 50% { outline: 3px solid var(--success); } }
|
||||||
|
@keyframes pawn-in { from { transform: scale(.6); } }
|
||||||
|
@keyframes bump { 0%, 100% { opacity: 0; } 20%, 75% { opacity: 1; transform: translateX(-50%) translateX(4px); } 45% { transform: translateX(-50%) translateX(-4px); } }
|
||||||
|
@keyframes modal-in { from { opacity: 0; transform: scale(.82); } }
|
||||||
|
@keyframes count { 50% { transform: scale(1.08); } }
|
||||||
|
@keyframes guide-hand { 50% { transform: translate(18px, -12px); } }
|
||||||
+821
@@ -0,0 +1,821 @@
|
|||||||
|
(function (root) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const TOKEN_KEY = "maze32.session.v1";
|
||||||
|
const MAX_VIEW_CHUNKS = 90;
|
||||||
|
const MAX_VIEW_WIDTH = 32;
|
||||||
|
const MAX_VIEW_HEIGHT = 32;
|
||||||
|
const MAX_VIEW_CELLS = 1024;
|
||||||
|
const MAX_VIEW_OBJECTS = 12;
|
||||||
|
const OBSERVER_CELL_SIZE = 8;
|
||||||
|
const AVATAR_TOUCH_TARGET_SIZE = 96;
|
||||||
|
const AVATAR_MAX_DEFLECTION = 28;
|
||||||
|
const AVATAR_DEAD_ZONE = 12;
|
||||||
|
const AVATAR_DIRECTION_HYSTERESIS = .18;
|
||||||
|
const AVATAR_SLOW_REPEAT_MS = 360;
|
||||||
|
const AVATAR_FAST_REPEAT_MS = 170;
|
||||||
|
const LATENCY_SAMPLE_LIMIT = 256;
|
||||||
|
const ONBOARDING_STEPS = Object.freeze([
|
||||||
|
Object.freeze({icon: "hand", title: "Потяни героя", text: "Потяни героя — лабиринт поедет под ним."}),
|
||||||
|
Object.freeze({icon: "motion", title: "Отпусти — герой ждёт", text: "Рука остановилась — шаги тоже."}),
|
||||||
|
Object.freeze({icon: "maze", title: "Стена не пускает", text: "Упрись в стену — попробуй другую сторону."}),
|
||||||
|
Object.freeze({icon: "key", title: "Найди ключ", text: "Ключ открывает твой выход."}),
|
||||||
|
Object.freeze({icon: "eye", title: "Глаз расширяет карту", text: "Возьми глаз — увидишь дальше."}),
|
||||||
|
Object.freeze({icon: "arrow", title: "Стрелка знает путь", text: "Компас показывает сторону выхода."}),
|
||||||
|
Object.freeze({icon: "exit", title: "Выходи с ключом", text: "Коснись своего выхода с ключом."}),
|
||||||
|
Object.freeze({icon: "friends", title: "Встретил друга", text: "Другой герой может отдать найденный ключ."})
|
||||||
|
]);
|
||||||
|
const NAME_ADJECTIVES = Object.freeze(["Бодрый", "Хитрый", "Тихий", "Смелый", "Ловкий", "Сонный", "Шустрый", "Лунный", "Яркий", "Дикий", "Вечный", "Храбрый"]);
|
||||||
|
const NAME_NOUNS = Object.freeze(["Барсук", "Крот", "Енот", "Кабан", "Кактус", "Жук", "Тостер", "Тюлень", "Гриб", "Ежик", "Бублик", "Пингвин"]);
|
||||||
|
const NAME_COMBINATION_COUNT = NAME_ADJECTIVES.length * NAME_NOUNS.length;
|
||||||
|
const LOGIN_HEADLINES = Object.freeze([
|
||||||
|
Object.freeze({first: "Кусты уже", highlight: "потеряли выход"}),
|
||||||
|
Object.freeze({first: "Лабиринт снова", highlight: "подстригли криво"}),
|
||||||
|
Object.freeze({first: "Пиксели дружно", highlight: "ушли в кусты"}),
|
||||||
|
Object.freeze({first: "Карта молчит", highlight: "но смотрит косо"}),
|
||||||
|
Object.freeze({first: "Выход рядом", highlight: "в другой вселенной"}),
|
||||||
|
Object.freeze({first: "Крот сказал", highlight: "что это срез"}),
|
||||||
|
Object.freeze({first: "Тупик машет", highlight: "обеими стенами"}),
|
||||||
|
Object.freeze({first: "Компас уверен", highlight: "почти наверняка"})
|
||||||
|
]);
|
||||||
|
const KEY_DIRECTIONS = Object.freeze({
|
||||||
|
ArrowUp: "north", KeyW: "north",
|
||||||
|
ArrowRight: "east", KeyD: "east",
|
||||||
|
ArrowDown: "south", KeyS: "south",
|
||||||
|
ArrowLeft: "west", KeyA: "west"
|
||||||
|
});
|
||||||
|
const HINTS = Object.freeze({none: "Молчит", n: "Север", ne: "Северо-восток", e: "Восток", se: "Юго-восток", s: "Юг", sw: "Юго-запад", w: "Запад", nw: "Северо-запад"});
|
||||||
|
const HINT_ANGLES = Object.freeze({none: 0, n: 0, ne: 45, e: 90, se: 135, s: 180, sw: 225, w: 270, nw: 315});
|
||||||
|
const PHASES = Object.freeze({countdown: "Обратный отсчёт", waiting: "Ждём игроков", active: "Гонка идёт", finish_pending: "Фотофиниш", paused: "Пауза", finished: "Финиш"});
|
||||||
|
const ERRORS = Object.freeze({invalid_name: "Позывной должен быть длиной от 1 до 32 символов без пробелов по краям.", duplicate_name: "Этот позывной уже занят. Клоны сегодня не участвуют.", role_full: "Свободных мест больше нет.", invalid_token: "Старая сессия не найдена. Выбираем новый позывной.", session_connected: "Эта сессия уже открыта в другом окне.", not_owner: "Запустить гонку может только Игрок 1.", not_ready: "Для старта нужны оба игрока онлайн.", invalid_phase: "Сейчас эту команду выполнять нельзя.", rate_limited: "Слишком быстро! Сервер просит немного меньше турбо.", blocked: "Там стена. Очень убедительная.", no_key: "Карман пуст — бросать нечего.", player_exited: "Герой уже снаружи. Возвращаться за тапочками поздно.", match_finished: "Гонка уже закончилась.", stale_sequence: "Команда устарела и отправилась в музей.", unauthorized: "Сначала выберите роль или восстановите сессию.", text_required: "Сервер принимает только текстовые команды.", protocol_error: "Пакет не прошёл проверку протокола.", request_rejected: "Сервер отклонил команду.", no_suggestion: "Все смешные имена уже разобрали."});
|
||||||
|
|
||||||
|
function randomIndex(limit, randomSource) {
|
||||||
|
if (!Number.isInteger(limit) || limit < 1) return 0;
|
||||||
|
if (typeof randomSource === "function") {
|
||||||
|
const value = randomSource(limit);
|
||||||
|
return Number.isInteger(value) && value >= 0 && value < limit ? value : 0;
|
||||||
|
}
|
||||||
|
if (root.crypto && typeof root.crypto.getRandomValues === "function") {
|
||||||
|
const maximum = Math.floor(0x100000000 / limit) * limit;
|
||||||
|
const values = new Uint32Array(1);
|
||||||
|
for (let attempt = 0; attempt < 4; attempt++) {
|
||||||
|
root.crypto.getRandomValues(values);
|
||||||
|
if (values[0] < maximum) return values[0] % limit;
|
||||||
|
}
|
||||||
|
return values[0] % limit;
|
||||||
|
}
|
||||||
|
return Math.floor(Math.random() * limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomSuggestedName(occupiedNames, randomSource) {
|
||||||
|
const occupied = new Set(Array.isArray(occupiedNames) ? occupiedNames : []);
|
||||||
|
for (let attempt = 0; attempt < NAME_COMBINATION_COUNT; attempt++) {
|
||||||
|
const adjective = NAME_ADJECTIVES[randomIndex(NAME_ADJECTIVES.length, randomSource)];
|
||||||
|
const noun = NAME_NOUNS[randomIndex(NAME_NOUNS.length, randomSource)];
|
||||||
|
const name = `${adjective}-${noun}`;
|
||||||
|
if (!occupied.has(name)) return name;
|
||||||
|
}
|
||||||
|
for (let combination = 0; combination < NAME_COMBINATION_COUNT; combination++) {
|
||||||
|
const adjective = NAME_ADJECTIVES[Math.floor(combination / NAME_NOUNS.length)];
|
||||||
|
const noun = NAME_NOUNS[combination % NAME_NOUNS.length];
|
||||||
|
const name = `${adjective}-${noun}`;
|
||||||
|
if (!occupied.has(name)) return name;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomLoginHeadline(randomSource) {
|
||||||
|
return LOGIN_HEADLINES[randomIndex(LOGIN_HEADLINES.length, randomSource)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function directionFromKey(code) {
|
||||||
|
return KEY_DIRECTIONS[code] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function directionFromSwipe(startX, startY, endX, endY, minimumDistance) {
|
||||||
|
const dx = endX - startX;
|
||||||
|
const dy = endY - startY;
|
||||||
|
const threshold = minimumDistance == null ? 28 : minimumDistance;
|
||||||
|
if (Math.max(Math.abs(dx), Math.abs(dy)) < threshold) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (Math.abs(dx) > Math.abs(dy)) {
|
||||||
|
return dx > 0 ? "east" : "west";
|
||||||
|
}
|
||||||
|
return dy > 0 ? "south" : "north";
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value, minimum, maximum) {
|
||||||
|
return Math.min(maximum, Math.max(minimum, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function avatarDirection(dx, dy, previousDirection) {
|
||||||
|
const horizontal = Math.abs(dx);
|
||||||
|
const vertical = Math.abs(dy);
|
||||||
|
if (Math.max(horizontal, vertical) < AVATAR_DEAD_ZONE) return null;
|
||||||
|
const previousWasHorizontal = previousDirection === "east" || previousDirection === "west";
|
||||||
|
const previousWasVertical = previousDirection === "north" || previousDirection === "south";
|
||||||
|
if (previousWasHorizontal && horizontal >= vertical * (1 - AVATAR_DIRECTION_HYSTERESIS)) return dx >= 0 ? "east" : "west";
|
||||||
|
if (previousWasVertical && vertical >= horizontal * (1 - AVATAR_DIRECTION_HYSTERESIS)) return dy >= 0 ? "south" : "north";
|
||||||
|
return horizontal >= vertical ? (dx >= 0 ? "east" : "west") : (dy >= 0 ? "south" : "north");
|
||||||
|
}
|
||||||
|
|
||||||
|
function avatarRepeatMs(distance, targetRadius) {
|
||||||
|
if (!Number.isFinite(distance) || !Number.isFinite(targetRadius) || distance <= AVATAR_DEAD_ZONE || targetRadius <= AVATAR_DEAD_ZONE) return null;
|
||||||
|
const strength = clamp((distance - AVATAR_DEAD_ZONE) / (targetRadius - AVATAR_DEAD_ZONE), 0, 1);
|
||||||
|
return Math.round(AVATAR_SLOW_REPEAT_MS - (AVATAR_SLOW_REPEAT_MS - AVATAR_FAST_REPEAT_MS) * strength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function avatarVector(dx, dy, targetRadius, previousDirection) {
|
||||||
|
const distance = Math.hypot(dx, dy);
|
||||||
|
const inputDistance = Math.min(distance, targetRadius);
|
||||||
|
const deflection = Math.min(inputDistance, AVATAR_MAX_DEFLECTION);
|
||||||
|
const ratio = distance > 0 ? deflection / distance : 0;
|
||||||
|
return {
|
||||||
|
x: dx * ratio,
|
||||||
|
y: dy * ratio,
|
||||||
|
distance: deflection,
|
||||||
|
direction: avatarDirection(dx, dy, previousDirection),
|
||||||
|
repeatMs: avatarRepeatMs(inputDistance, targetRadius)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class LatestIntentFlow {
|
||||||
|
constructor(dispatch) {
|
||||||
|
this.dispatch = dispatch;
|
||||||
|
this.inFlight = null;
|
||||||
|
this.latest = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
request(direction) {
|
||||||
|
if (!direction) return null;
|
||||||
|
if (this.inFlight) {
|
||||||
|
this.latest = direction;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const requestId = this.dispatch(direction);
|
||||||
|
if (requestId) this.inFlight = requestId;
|
||||||
|
return requestId;
|
||||||
|
}
|
||||||
|
|
||||||
|
complete(requestId) {
|
||||||
|
if (!requestId || requestId !== this.inFlight) return null;
|
||||||
|
this.inFlight = null;
|
||||||
|
const direction = this.latest;
|
||||||
|
this.latest = null;
|
||||||
|
return direction ? this.request(direction) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
release() { this.latest = null; }
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.inFlight = null;
|
||||||
|
this.latest = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function playerCellSize(stageWidth, stageHeight, columns, rows) {
|
||||||
|
if (![stageWidth, stageHeight, columns, rows].every(value => Number.isFinite(value) && value > 0)) return 46;
|
||||||
|
return Math.round(clamp(Math.min(stageWidth / (columns + 1), stageHeight / (rows + 1)), 32, 72));
|
||||||
|
}
|
||||||
|
|
||||||
|
function playerMapOffset(selfIndex, width, minX, minY, columns, rows, cellSize) {
|
||||||
|
if (![selfIndex, width, minX, minY, columns, rows, cellSize].every(Number.isFinite) || !Number.isInteger(selfIndex) || selfIndex < 0 || width < 1 || columns < 1 || rows < 1 || cellSize < 1) return {x: 0, y: 0};
|
||||||
|
const selfX = selfIndex % width - minX;
|
||||||
|
const selfY = Math.floor(selfIndex / width) - minY;
|
||||||
|
return {
|
||||||
|
x: (columns / 2 - selfX - .5) * cellSize,
|
||||||
|
y: (rows / 2 - selfY - .5) * cellSize
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextOnboardingStep(currentStep) {
|
||||||
|
return currentStep >= ONBOARDING_STEPS.length - 1 ? null : currentStep + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LatencyTracker {
|
||||||
|
constructor(now) {
|
||||||
|
this.now = typeof now === "function" ? now : () => root.performance.now();
|
||||||
|
this.pending = new Map();
|
||||||
|
this.samples = [];
|
||||||
|
this.pointerSamples = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
sent(requestId) {
|
||||||
|
if (!requestId) return;
|
||||||
|
this.pending.set(requestId, {sent: this.now(), acknowledged: null, stateSequence: null});
|
||||||
|
if (this.pending.size > LATENCY_SAMPLE_LIMIT) this.pending.delete(this.pending.keys().next().value);
|
||||||
|
}
|
||||||
|
|
||||||
|
acknowledged(requestId, stateSequence) {
|
||||||
|
const sample = this.pending.get(requestId);
|
||||||
|
if (!sample) return;
|
||||||
|
sample.acknowledged = this.now();
|
||||||
|
sample.stateSequence = Number.isInteger(stateSequence) ? stateSequence : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
painted(stateSequence) {
|
||||||
|
for (const [requestId, sample] of this.pending) {
|
||||||
|
if (sample.acknowledged == null || sample.stateSequence !== stateSequence) continue;
|
||||||
|
const painted = this.now();
|
||||||
|
this.samples.push({requestId, commandToAckMs: sample.acknowledged - sample.sent,
|
||||||
|
commandToPaintMs: painted - sample.sent, ackToPaintMs: painted - sample.acknowledged});
|
||||||
|
if (this.samples.length > LATENCY_SAMPLE_LIMIT) this.samples.shift();
|
||||||
|
this.pending.delete(requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot() { return this.samples.map(sample => Object.assign({}, sample)); }
|
||||||
|
|
||||||
|
pointerPainted(startedAt) {
|
||||||
|
if (!Number.isFinite(startedAt)) return;
|
||||||
|
this.pointerSamples.push(this.now() - startedAt);
|
||||||
|
if (this.pointerSamples.length > LATENCY_SAMPLE_LIMIT) this.pointerSamples.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
pointerSnapshot() { return this.pointerSamples.slice(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEnvelope(type, sequence, requestId, payload) {
|
||||||
|
return {v: 1, type, seq: sequence, requestId, payload: payload || {}};
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotIsValid(snapshot) {
|
||||||
|
if (!Number.isInteger(snapshot.width) || snapshot.width < 1 || snapshot.width > MAX_VIEW_WIDTH || !Number.isInteger(snapshot.height) || snapshot.height < 1 || snapshot.height > MAX_VIEW_HEIGHT || snapshot.width * snapshot.height > MAX_VIEW_CELLS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const cellLimit = snapshot.width * snapshot.height;
|
||||||
|
return snapshot.cells.every(cell => Array.isArray(cell) && cell.length === 2 && Number.isInteger(cell[0]) && cell[0] >= 0 && cell[0] < cellLimit && Number.isInteger(cell[1]) && cell[1] >= 0 && cell[1] <= 15) && snapshot.objects.every(object => Array.isArray(object) && object.length === 4 && Number.isInteger(object[3]) && object[3] >= 0 && object[3] < cellLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
class SnapshotAssembler {
|
||||||
|
constructor() {
|
||||||
|
this.pending = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
accept(message) {
|
||||||
|
if (!message || message.v !== 1 || message.type !== "view" || !Number.isInteger(message.seq)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const payload = message.payload;
|
||||||
|
if (!payload || !Number.isInteger(payload.chunk) || !Number.isInteger(payload.chunks) || payload.chunks < 1 || payload.chunks > MAX_VIEW_CHUNKS || payload.chunk < 0 || payload.chunk >= payload.chunks || !Array.isArray(payload.cells) || payload.cells.length > 12 || !Array.isArray(payload.objects) || payload.objects.length > 6) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!this.pending || this.pending.seq !== message.seq || this.pending.count !== payload.chunks) {
|
||||||
|
this.pending = {seq: message.seq, count: payload.chunks, parts: new Array(payload.chunks)};
|
||||||
|
}
|
||||||
|
this.pending.parts[payload.chunk] = payload;
|
||||||
|
if (this.pending.parts.filter(Boolean).length !== this.pending.count) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const first = this.pending.parts[0];
|
||||||
|
const snapshot = Object.assign({}, first, {
|
||||||
|
stateSequence: message.seq,
|
||||||
|
cells: this.pending.parts.flatMap(part => part.cells),
|
||||||
|
objects: this.pending.parts.flatMap(part => part.objects)
|
||||||
|
});
|
||||||
|
if (snapshot.cells.length > MAX_VIEW_CELLS || snapshot.objects.length > MAX_VIEW_OBJECTS || !snapshotIsValid(snapshot)) {
|
||||||
|
this.pending = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
delete snapshot.chunk;
|
||||||
|
delete snapshot.chunks;
|
||||||
|
this.pending = null;
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectedCellIndices(snapshot) {
|
||||||
|
return snapshot && Array.isArray(snapshot.cells) ? snapshot.cells.map(cell => cell[0]) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampObserverZoom(value) {
|
||||||
|
return Math.min(6, Math.max(.25, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function observerFitScale(stageWidth, stageHeight, columns, rows) {
|
||||||
|
if (![stageWidth, stageHeight, columns, rows].every(value => Number.isFinite(value) && value > 0)) return 1;
|
||||||
|
return clampObserverZoom(Math.min(stageWidth / (columns * OBSERVER_CELL_SIZE), stageHeight / (rows * OBSERVER_CELL_SIZE)) * .96);
|
||||||
|
}
|
||||||
|
|
||||||
|
function boot(documentObject, windowObject) {
|
||||||
|
const byId = id => documentObject.getElementById(id);
|
||||||
|
const state = {socket: null, clientSequence: 0, serverSequence: 0, viewSequence: -1, session: null, lobby: null, snapshot: null, reconnectTimer: null, noticeTimer: null, touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), latency: new LatencyTracker(() => windowObject.performance.now()), audio: null, feedbackReady: false, muted: false, reducedMotion: false, onboardingSeen: false, onboardingStep: 0, avatar: {pointerId: null, startX: null, startY: null, direction: null, repeatTimer: null, repeatMs: null}, observer: {scale: 1, x: 0, y: 0, dragging: null}};
|
||||||
|
state.moveFlow = new LatestIntentFlow(direction => sendMoveNow(direction));
|
||||||
|
|
||||||
|
function showNotice(text, kind) {
|
||||||
|
const element = byId("notice");
|
||||||
|
element.textContent = text;
|
||||||
|
element.className = `notice visible ${kind || ""}`;
|
||||||
|
windowObject.clearTimeout(state.noticeTimer);
|
||||||
|
state.noticeTimer = windowObject.setTimeout(() => element.classList.remove("visible"), 2600);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setScreen(name) {
|
||||||
|
byId("welcome-screen").hidden = name !== "welcome";
|
||||||
|
byId("lobby-screen").hidden = name !== "lobby";
|
||||||
|
byId("match-screen").hidden = name !== "match";
|
||||||
|
byId("observer-screen").hidden = name !== "observer";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWelcomeHeadline(headline) {
|
||||||
|
const title = byId("welcome-title");
|
||||||
|
const highlight = documentObject.createElement("span");
|
||||||
|
highlight.textContent = headline.highlight;
|
||||||
|
title.replaceChildren(documentObject.createTextNode(headline.first), documentObject.createElement("br"), highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConnection(connectionState, text) {
|
||||||
|
byId("connection").dataset.state = connectionState;
|
||||||
|
byId("connection-text").textContent = text;
|
||||||
|
byId("details-text").textContent = connectionState === "online" ? "WebSocket подключён к локальному ESP." : "Команды временно не отправляются. Повторяем подключение автоматически.";
|
||||||
|
if (connectionState !== "online") resetAvatarControl();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderWelcomeHeadline(randomLoginHeadline());
|
||||||
|
|
||||||
|
function beginFeedback() {
|
||||||
|
if (state.feedbackReady) return;
|
||||||
|
state.feedbackReady = true;
|
||||||
|
const AudioContext = windowObject.AudioContext || windowObject.webkitAudioContext;
|
||||||
|
if (AudioContext) {
|
||||||
|
try { state.audio = new AudioContext(); } catch (_) { state.audio = null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function feedback(success) {
|
||||||
|
if (state.muted) return;
|
||||||
|
if (windowObject.navigator && typeof windowObject.navigator.vibrate === "function") {
|
||||||
|
windowObject.navigator.vibrate(success ? 18 : [24, 35, 24]);
|
||||||
|
}
|
||||||
|
if (!state.audio || state.audio.state === "suspended") return;
|
||||||
|
const oscillator = state.audio.createOscillator();
|
||||||
|
const gain = state.audio.createGain();
|
||||||
|
oscillator.frequency.value = success ? 560 : 150;
|
||||||
|
oscillator.type = "square";
|
||||||
|
gain.gain.setValueAtTime(.035, state.audio.currentTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(.001, state.audio.currentTime + .06);
|
||||||
|
oscillator.connect(gain).connect(state.audio.destination);
|
||||||
|
oscillator.start(); oscillator.stop(state.audio.currentTime + .065);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPending(requestId, element) {
|
||||||
|
if (!requestId || !element) return;
|
||||||
|
state.pending.set(requestId, element);
|
||||||
|
element.classList.add("pending");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPending(requestId) {
|
||||||
|
const element = state.pending.get(requestId);
|
||||||
|
if (element) element.classList.remove("pending");
|
||||||
|
state.pending.delete(requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(type, payload, element) {
|
||||||
|
if (!state.socket || state.socket.readyState !== windowObject.WebSocket.OPEN) {
|
||||||
|
showNotice("Нет связи с ESP. Команда осталась дома.", "error");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const sequence = ++state.clientSequence;
|
||||||
|
const requestId = `${type}-${sequence}`;
|
||||||
|
state.socket.send(JSON.stringify(createEnvelope(type, sequence, requestId, payload)));
|
||||||
|
setPending(requestId, element);
|
||||||
|
if (type === "move") state.latency.sent(requestId);
|
||||||
|
return requestId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function playerName() {
|
||||||
|
if (!state.session || !state.lobby || state.session.role !== "player") return "Неизвестный герой";
|
||||||
|
const player = state.lobby.players.find(item => item.slot === state.session.slot);
|
||||||
|
return player && player.name ? player.name : `Игрок ${state.session.slot}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLobby() {
|
||||||
|
if (!state.lobby) return;
|
||||||
|
state.lobby.players.forEach(player => {
|
||||||
|
const card = byId(`player-${player.slot}`);
|
||||||
|
card.querySelector(".player-name").textContent = player.occupied ? player.name : "Свободно";
|
||||||
|
card.querySelector(".player-state").textContent = player.connected ? "На связи" : player.occupied ? "Отошёл искать Wi‑Fi" : "Слот грустит";
|
||||||
|
card.classList.toggle("online", !!player.connected);
|
||||||
|
});
|
||||||
|
byId("observer-count").textContent = state.lobby.observers;
|
||||||
|
byId("lobby-phase").textContent = PHASES[state.lobby.phase] || "Сервер размышляет";
|
||||||
|
const owner = state.session && state.session.role === "player" && state.session.slot === 1;
|
||||||
|
byId("start-match").disabled = !(owner && state.lobby.ready && state.lobby.phase === "waiting");
|
||||||
|
byId("launch-help").textContent = owner ? (state.lobby.ready ? "Оба героя здесь. Можно будить лабиринт." : "Ждём второго героя. Первый пока разминает стрелки.") : "Старт доступен Игроку 1, когда оба слота на месте.";
|
||||||
|
if (state.session && state.lobby.phase !== "active") setScreen("lobby");
|
||||||
|
}
|
||||||
|
|
||||||
|
function icon(name, className) {
|
||||||
|
const svg = documentObject.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
|
const use = documentObject.createElementNS("http://www.w3.org/2000/svg", "use");
|
||||||
|
use.setAttribute("href", `/icons.svg#${name}`);
|
||||||
|
svg.appendChild(use);
|
||||||
|
const wrapper = documentObject.createElement("span");
|
||||||
|
wrapper.className = className;
|
||||||
|
wrapper.appendChild(svg);
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addObject(cell, object) {
|
||||||
|
const [kind, owner, variant] = object;
|
||||||
|
let marker;
|
||||||
|
if (kind === "key") {
|
||||||
|
marker = icon("key", "object object-key"); marker.dataset.owner = owner === 1 ? "two" : "one";
|
||||||
|
} else if (kind === "exit") {
|
||||||
|
marker = documentObject.createElement("span"); marker.className = "object object-exit"; marker.dataset.owner = owner === 1 ? "two" : "one"; marker.dataset.open = String(variant);
|
||||||
|
} else if (kind === "compass") {
|
||||||
|
marker = icon("arrow", "object object-compass");
|
||||||
|
} else if (kind === "vision") {
|
||||||
|
marker = icon("eye", "object object-vision");
|
||||||
|
} else {
|
||||||
|
marker = documentObject.createElement("span"); marker.className = "object object-landmark"; marker.textContent = ["♣", "✿", "♠", "⌂"][variant % 4] || "♣";
|
||||||
|
}
|
||||||
|
cell.appendChild(marker);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPawn(cell, label, opponent) {
|
||||||
|
const pawn = documentObject.createElement("span");
|
||||||
|
pawn.className = opponent ? "pawn pawn-opponent" : "pawn pawn-self";
|
||||||
|
const text = documentObject.createElement("span"); text.textContent = label;
|
||||||
|
pawn.appendChild(text); cell.appendChild(pawn);
|
||||||
|
}
|
||||||
|
|
||||||
|
function characterForSlot(slot) {
|
||||||
|
return slot === 2 ? {symbol: "◆", name: "Мышонок"} : {symbol: "●", name: "Лисёнок"};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMap(snapshot) {
|
||||||
|
const maze = byId("maze");
|
||||||
|
maze.replaceChildren();
|
||||||
|
if (!Number.isInteger(snapshot.width) || snapshot.width < 1 || !Number.isInteger(snapshot.height) || snapshot.height < 1 || !snapshot.cells.length) {
|
||||||
|
byId("map-placeholder").hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const coordinates = snapshot.cells.map(cell => ({index: cell[0], x: cell[0] % snapshot.width, y: Math.floor(cell[0] / snapshot.width), passages: cell[1]}));
|
||||||
|
const minX = Math.min(...coordinates.map(cell => cell.x)); const maxX = Math.max(...coordinates.map(cell => cell.x));
|
||||||
|
const minY = Math.min(...coordinates.map(cell => cell.y)); const maxY = Math.max(...coordinates.map(cell => cell.y));
|
||||||
|
const columns = maxX - minX + 1;
|
||||||
|
const rows = maxY - minY + 1;
|
||||||
|
const mapShell = byId("map-shell");
|
||||||
|
const cellSize = playerCellSize(mapShell.clientWidth, mapShell.clientHeight, columns, rows);
|
||||||
|
const offset = playerMapOffset(snapshot.self, snapshot.width, minX, minY, columns, rows, cellSize);
|
||||||
|
maze.style.setProperty("--cell", `${cellSize}px`);
|
||||||
|
maze.style.gridTemplateColumns = `repeat(${columns}, var(--cell))`;
|
||||||
|
maze.style.gridTemplateRows = `repeat(${rows}, var(--cell))`;
|
||||||
|
maze.style.transform = `translate(calc(-50% + ${offset.x}px), calc(-50% + ${offset.y}px))`;
|
||||||
|
const objects = new Map();
|
||||||
|
snapshot.objects.forEach(object => { const list = objects.get(object[3]) || []; list.push(object); objects.set(object[3], list); });
|
||||||
|
coordinates.forEach(data => {
|
||||||
|
const cell = documentObject.createElement("span");
|
||||||
|
cell.className = "cell";
|
||||||
|
cell.style.gridColumn = String(data.x - minX + 1); cell.style.gridRow = String(data.y - minY + 1);
|
||||||
|
if (!(data.passages & 1)) cell.classList.add("wall-n");
|
||||||
|
if (!(data.passages & 2)) cell.classList.add("wall-e");
|
||||||
|
if (!(data.passages & 4)) cell.classList.add("wall-s");
|
||||||
|
if (!(data.passages & 8)) cell.classList.add("wall-w");
|
||||||
|
(objects.get(data.index) || []).forEach(object => addObject(cell, object));
|
||||||
|
const slot = state.session ? state.session.slot : 1;
|
||||||
|
if (data.index === snapshot.opponent) addPawn(cell, characterForSlot(slot === 1 ? 2 : 1).symbol, true);
|
||||||
|
maze.appendChild(cell);
|
||||||
|
});
|
||||||
|
byId("map-placeholder").hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSnapshot(snapshot) {
|
||||||
|
if (snapshot.role === "observer") {
|
||||||
|
renderObserverSnapshot(snapshot);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.snapshot = snapshot;
|
||||||
|
setScreen("match");
|
||||||
|
const slot = state.session && state.session.slot ? state.session.slot : 1;
|
||||||
|
const character = characterForSlot(slot);
|
||||||
|
byId("self-slot").textContent = character.symbol;
|
||||||
|
byId("self-slot").setAttribute("aria-label", character.name);
|
||||||
|
byId("avatar-handle-symbol").textContent = character.symbol;
|
||||||
|
byId("avatar-control").setAttribute("aria-label", `Тянуть героя ${character.name} для движения`);
|
||||||
|
byId("self-name").textContent = playerName();
|
||||||
|
byId("identity").className = `identity player-${slot === 1 ? "one" : "two"}`;
|
||||||
|
byId("match-screen").classList.toggle("player-two-mode", slot === 2);
|
||||||
|
byId("match-state").textContent = PHASES[snapshot.phase] || "Гонка идёт";
|
||||||
|
byId("inventory").textContent = snapshot.inventory == null ? "Пусто" : `Ключ ${characterForSlot(snapshot.inventory + 1).symbol}`;
|
||||||
|
byId("inventory-stat").dataset.filled = String(snapshot.inventory != null);
|
||||||
|
byId("drop-key").disabled = snapshot.inventory == null;
|
||||||
|
byId("vision").textContent = snapshot.visionMoves ? `${snapshot.visionMoves} ход.` : "Нет";
|
||||||
|
byId("vision-stat").classList.toggle("vision-active", snapshot.visionMoves > 0);
|
||||||
|
const hint = HINTS[snapshot.hint] ? snapshot.hint : "none";
|
||||||
|
byId("compass").textContent = HINTS[hint];
|
||||||
|
byId("compass-stat").dataset.direction = hint;
|
||||||
|
byId("compass-arrow").style.transform = `rotate(${HINT_ANGLES[hint]}deg)`;
|
||||||
|
renderMap(snapshot);
|
||||||
|
if (Number.isInteger(snapshot.stateSequence)) {
|
||||||
|
windowObject.requestAnimationFrame(() => state.latency.painted(snapshot.stateSequence));
|
||||||
|
}
|
||||||
|
if (snapshot.phase === "paused") { resetAvatarControl(); showModal("Пауза", "Один герой потерялся между Wi‑Fi пакетами. Ждём возвращения.", "ПЕРЕРЫВ", false); }
|
||||||
|
else if (snapshot.phase === "finished" || snapshot.result && snapshot.result !== "none") renderResult(snapshot.result);
|
||||||
|
else {
|
||||||
|
hideModal();
|
||||||
|
if (!state.onboardingSeen) showOnboarding(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyObserverTransform() {
|
||||||
|
const transform = state.observer;
|
||||||
|
byId("observer-maze").style.transform = `translate(-50%, -50%) translate(${transform.x}px, ${transform.y}px) scale(${transform.scale})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetObserverView(snapshot) {
|
||||||
|
const stage = byId("observer-stage");
|
||||||
|
state.observer.scale = observerFitScale(stage.clientWidth, stage.clientHeight, snapshot.width, snapshot.height);
|
||||||
|
state.observer.x = 0;
|
||||||
|
state.observer.y = 0;
|
||||||
|
applyObserverTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderObserverSnapshot(snapshot) {
|
||||||
|
if (!snapshot.cells.length && state.snapshot && state.snapshot.role === "observer" && state.snapshot.width === snapshot.width && state.snapshot.height === snapshot.height) {
|
||||||
|
snapshot.cells = state.snapshot.cells;
|
||||||
|
}
|
||||||
|
state.snapshot = snapshot;
|
||||||
|
setScreen("observer");
|
||||||
|
const maze = byId("observer-maze");
|
||||||
|
maze.replaceChildren();
|
||||||
|
maze.style.gridTemplateColumns = `repeat(${snapshot.width}, ${OBSERVER_CELL_SIZE}px)`;
|
||||||
|
maze.style.gridTemplateRows = `repeat(${snapshot.height}, ${OBSERVER_CELL_SIZE}px)`;
|
||||||
|
const objects = new Map();
|
||||||
|
snapshot.objects.forEach(object => { const list = objects.get(object[3]) || []; list.push(object); objects.set(object[3], list); });
|
||||||
|
const players = Array.isArray(snapshot.players) ? snapshot.players : [];
|
||||||
|
snapshot.cells.forEach(data => {
|
||||||
|
const index = data[0]; const passages = data[1];
|
||||||
|
const cell = documentObject.createElement("span");
|
||||||
|
cell.className = "cell";
|
||||||
|
cell.style.gridColumn = String(index % snapshot.width + 1);
|
||||||
|
cell.style.gridRow = String(Math.floor(index / snapshot.width) + 1);
|
||||||
|
if (!(passages & 1)) cell.classList.add("wall-n");
|
||||||
|
if (!(passages & 2)) cell.classList.add("wall-e");
|
||||||
|
if (!(passages & 4)) cell.classList.add("wall-s");
|
||||||
|
if (!(passages & 8)) cell.classList.add("wall-w");
|
||||||
|
(objects.get(index) || []).forEach(object => addObject(cell, object));
|
||||||
|
players.forEach((player, playerIndex) => {
|
||||||
|
if (player.cell === index && !player.exited) {
|
||||||
|
const pawn = documentObject.createElement("span");
|
||||||
|
pawn.className = `pawn pawn-player-${playerIndex === 0 ? "one" : "two"}`;
|
||||||
|
const label = documentObject.createElement("span"); label.textContent = `P${playerIndex + 1}`;
|
||||||
|
pawn.appendChild(label); cell.appendChild(pawn);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
maze.appendChild(cell);
|
||||||
|
});
|
||||||
|
players.forEach((player, index) => {
|
||||||
|
const key = player.key == null ? "без ключа" : `ключ P${player.key + 1}`;
|
||||||
|
byId(`observer-p${index + 1}`).textContent = !player.connected ? "нет связи" : player.exited ? "снаружи" : key;
|
||||||
|
});
|
||||||
|
byId("observer-phase").textContent = PHASES[snapshot.phase] || "Сервер размышляет";
|
||||||
|
const dimensions = `${snapshot.width}x${snapshot.height}`;
|
||||||
|
if (!state.observer.initialized || state.observer.dimensions !== dimensions) {
|
||||||
|
resetObserverView(snapshot); state.observer.initialized = true; state.observer.dimensions = dimensions;
|
||||||
|
} else applyObserverTransform();
|
||||||
|
if (snapshot.phase === "finished" || snapshot.result && snapshot.result !== "none") renderResult(snapshot.result);
|
||||||
|
else if (snapshot.phase !== "paused") hideModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showModal(title, text, kicker, countdown) {
|
||||||
|
byId("modal-title").textContent = title; byId("modal-text").textContent = text; byId("modal-kicker").textContent = kicker;
|
||||||
|
byId("countdown").hidden = !countdown; byId("modal").hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideModal() { byId("modal").hidden = true; byId("countdown").hidden = true; }
|
||||||
|
|
||||||
|
function renderOnboardingStep(step) {
|
||||||
|
const scene = ONBOARDING_STEPS[step] || ONBOARDING_STEPS[0];
|
||||||
|
byId("onboarding-icon-use").setAttribute("href", `/icons.svg#${scene.icon}`);
|
||||||
|
byId("onboarding-title").textContent = scene.title;
|
||||||
|
byId("onboarding-text").textContent = scene.text;
|
||||||
|
documentObject.querySelectorAll("#onboarding-dots span").forEach((dot, index) => dot.classList.toggle("active", index === step));
|
||||||
|
byId("onboarding-card").dataset.step = String(step);
|
||||||
|
byId("onboarding-next").textContent = step === ONBOARDING_STEPS.length - 1 ? "В лабиринт" : "Дальше";
|
||||||
|
}
|
||||||
|
|
||||||
|
function showOnboarding(step) {
|
||||||
|
state.onboardingSeen = true;
|
||||||
|
state.onboardingStep = clamp(step, 0, ONBOARDING_STEPS.length - 1);
|
||||||
|
renderOnboardingStep(state.onboardingStep);
|
||||||
|
byId("onboarding").hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideOnboarding() { byId("onboarding").hidden = true; }
|
||||||
|
|
||||||
|
function renderResult(result) {
|
||||||
|
const slot = state.session && state.session.slot;
|
||||||
|
let title = "Ничья!"; let text = "Фотофиниш пожал плечами: оба выбрались почти одновременно.";
|
||||||
|
if (result === "player1" || result === "player2") {
|
||||||
|
const winner = result === "player1" ? 1 : 2;
|
||||||
|
title = slot ? (winner === slot ? "Победа!" : "Победил соперник") : `Победил Игрок ${winner}`;
|
||||||
|
text = slot ? (winner === slot ? "Вы официально быстрее очень большого куста." : "Соперник нашёл выход. Лабиринт требует реванш.") : "Зрительный зал фиксирует героический побег без вмешательства в результат.";
|
||||||
|
}
|
||||||
|
showModal(title, text, "ГОНКА ЗАВЕРШЕНА", false);
|
||||||
|
resetAvatarControl();
|
||||||
|
byId("modal-action").textContent = state.session && state.session.role === "player" && state.session.slot === 1 ? "Новая гонка" : "Вернуться в лобби";
|
||||||
|
byId("modal-action").hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCountdown(payload) {
|
||||||
|
showModal("Приготовились!", "Один сервер — один честный момент старта.", "СИНХРОННЫЙ СТАРТ", true);
|
||||||
|
const steps = payload && Array.isArray(payload.steps) ? payload.steps : [3, 2, 1];
|
||||||
|
let index = 0; byId("countdown").textContent = steps[index];
|
||||||
|
const timer = windowObject.setInterval(() => { index++; if (index < steps.length) byId("countdown").textContent = steps[index]; else windowObject.clearInterval(timer); }, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function localError(code) { return ERRORS[code] || "Сервер отклонил команду. Подробности остались у него."; }
|
||||||
|
|
||||||
|
function handleMessage(message) {
|
||||||
|
if (!message || message.v !== 1 || !Number.isInteger(message.seq)) return;
|
||||||
|
if (message.type === "view") {
|
||||||
|
if (message.seq < state.viewSequence) return;
|
||||||
|
state.viewSequence = Math.max(state.viewSequence, message.seq);
|
||||||
|
const snapshot = state.assembler.accept(message);
|
||||||
|
if (snapshot) renderSnapshot(snapshot);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.seq <= state.serverSequence) return;
|
||||||
|
state.serverSequence = message.seq;
|
||||||
|
clearPending(message.requestId);
|
||||||
|
if (message.type === "session") {
|
||||||
|
state.session = message.payload; windowObject.localStorage.setItem(TOKEN_KEY, message.payload.token);
|
||||||
|
showNotice(message.payload.role === "player" ? `Слот P${message.payload.slot} закреплён за этим браузером.` : "Вы в зрительном зале. Руками не трогаем!", "success");
|
||||||
|
if (state.lobby) renderLobby();
|
||||||
|
} else if (message.type === "lobby") { state.lobby = message.payload; renderLobby();
|
||||||
|
} else if (message.type === "countdown") { showCountdown(message.payload);
|
||||||
|
} else if (message.type === "active") { setScreen(state.session && state.session.role === "observer" ? "observer" : "match"); byId("countdown").textContent = "ВПЕРЁД"; windowObject.setTimeout(hideModal, 750);
|
||||||
|
} else if (message.type === "pause") { showModal("Пауза", `Игрок ${message.payload.slot || "?"} переподключается. Ноги пока не считаются.`, "ЖДЁМ СВЯЗЬ", false);
|
||||||
|
} else if (message.type === "resume") { hideModal(); showNotice("Оба героя снова на связи. Гонка продолжается.", "success");
|
||||||
|
} else if (message.type === "match_result") { renderResult(message.payload.result);
|
||||||
|
} else if (message.type === "reset") { state.snapshot = null; state.viewSequence = -1; state.assembler = new SnapshotAssembler(); resetAvatarControl(); hideModal(); setScreen("lobby"); showNotice("Лабиринт пересажен. Можно начинать новую гонку.", "success");
|
||||||
|
} else if (message.type === "ack") { state.latency.acknowledged(message.requestId, message.payload && message.payload.stateSequence); state.moveFlow.complete(message.requestId); feedback(true);
|
||||||
|
} else if (message.type === "error") {
|
||||||
|
state.moveFlow.complete(message.requestId);
|
||||||
|
if (message.payload.code === "invalid_token") { windowObject.localStorage.removeItem(TOKEN_KEY); state.session = null; setScreen("welcome"); }
|
||||||
|
if (message.payload.code === "blocked") { const bump = byId("wall-bump"); bump.textContent = localError("blocked"); bump.classList.remove("show"); void bump.offsetWidth; bump.classList.add("show"); }
|
||||||
|
showNotice(localError(message.payload.code), "error"); feedback(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
windowObject.clearTimeout(state.reconnectTimer); state.clientSequence = 0; state.serverSequence = 0; state.viewSequence = -1; state.assembler = new SnapshotAssembler();
|
||||||
|
const scheme = windowObject.location.protocol === "https:" ? "wss" : "ws";
|
||||||
|
state.socket = new windowObject.WebSocket(`${scheme}://${windowObject.location.host}/ws`);
|
||||||
|
state.socket.onopen = () => { setConnection("online", "ESP на связи"); const token = windowObject.localStorage.getItem(TOKEN_KEY); if (token) send("restore", {token}, null); };
|
||||||
|
state.socket.onmessage = event => { let message; try { message = JSON.parse(event.data); } catch (_) { showNotice("Сервер прислал нечитаемый пакет.", "error"); return; } handleMessage(message); };
|
||||||
|
state.socket.onclose = () => { state.moveFlow.disconnect(); setConnection("offline", "Связь потеряна"); state.reconnectTimer = windowObject.setTimeout(connect, 1000); };
|
||||||
|
state.socket.onerror = () => showNotice("WebSocket споткнулся. Пробуем снова…", "error");
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(direction, element) {
|
||||||
|
if (direction) state.moveFlow.request(direction, element);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendMoveNow(direction) {
|
||||||
|
return send("move", {direction}, byId("avatar-control"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAvatarControl() {
|
||||||
|
windowObject.clearTimeout(state.avatar.repeatTimer);
|
||||||
|
state.avatar.pointerId = null;
|
||||||
|
state.avatar.startX = null;
|
||||||
|
state.avatar.startY = null;
|
||||||
|
state.avatar.direction = null;
|
||||||
|
state.avatar.repeatTimer = null;
|
||||||
|
state.avatar.repeatMs = null;
|
||||||
|
state.moveFlow.release();
|
||||||
|
const control = byId("avatar-control");
|
||||||
|
const handle = byId("avatar-handle");
|
||||||
|
if (control) {
|
||||||
|
control.classList.remove("active");
|
||||||
|
control.removeAttribute("data-direction");
|
||||||
|
control.dataset.speed = "stop";
|
||||||
|
}
|
||||||
|
if (handle) handle.style.transform = "translate(-50%, -50%)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function repeatAvatarMove() {
|
||||||
|
const avatar = state.avatar;
|
||||||
|
if (!avatar.direction || avatar.pointerId == null || !avatar.repeatMs) return;
|
||||||
|
move(avatar.direction);
|
||||||
|
avatar.repeatTimer = windowObject.setTimeout(repeatAvatarMove, avatar.repeatMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAvatarControl(event) {
|
||||||
|
const pointerStartedAt = windowObject.performance.now();
|
||||||
|
const control = byId("avatar-control");
|
||||||
|
const rect = control.getBoundingClientRect();
|
||||||
|
const radius = Math.max(AVATAR_DEAD_ZONE + 1, Math.min(rect.width, rect.height) / 2);
|
||||||
|
const vector = avatarVector(event.clientX - state.avatar.startX, event.clientY - state.avatar.startY, radius, state.avatar.direction);
|
||||||
|
const handle = byId("avatar-handle");
|
||||||
|
handle.style.transform = `translate(calc(-50% + ${vector.x}px), calc(-50% + ${vector.y}px))`;
|
||||||
|
const directionChanged = vector.direction !== state.avatar.direction;
|
||||||
|
state.avatar.direction = vector.direction;
|
||||||
|
state.avatar.repeatMs = vector.repeatMs;
|
||||||
|
control.dataset.speed = vector.repeatMs == null ? "stop" : vector.repeatMs <= 220 ? "fast" : "slow";
|
||||||
|
if (vector.direction) control.dataset.direction = vector.direction;
|
||||||
|
else control.removeAttribute("data-direction");
|
||||||
|
windowObject.clearTimeout(state.avatar.repeatTimer);
|
||||||
|
state.avatar.repeatTimer = null;
|
||||||
|
if (vector.direction) {
|
||||||
|
if (directionChanged) move(vector.direction);
|
||||||
|
state.avatar.repeatTimer = windowObject.setTimeout(repeatAvatarMove, vector.repeatMs);
|
||||||
|
}
|
||||||
|
windowObject.requestAnimationFrame(() => state.latency.pointerPainted(pointerStartedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMuted(muted) {
|
||||||
|
state.muted = muted;
|
||||||
|
const button = byId("mute-toggle");
|
||||||
|
button.setAttribute("aria-pressed", String(muted));
|
||||||
|
button.setAttribute("aria-label", muted ? "Включить звук" : "Выключить звук");
|
||||||
|
button.title = muted ? "Включить звук" : "Выключить звук";
|
||||||
|
button.querySelector("use").setAttribute("href", muted ? "/icons.svg#sound" : "/icons.svg#sound");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setReducedMotion(reduced) {
|
||||||
|
state.reducedMotion = reduced;
|
||||||
|
documentObject.documentElement.dataset.reducedMotion = String(reduced);
|
||||||
|
byId("motion-toggle").setAttribute("aria-pressed", String(reduced));
|
||||||
|
byId("motion-toggle").setAttribute("aria-label", reduced ? "Включить движение" : "Уменьшить движение");
|
||||||
|
}
|
||||||
|
|
||||||
|
documentObject.addEventListener("pointerdown", beginFeedback, {once: true});
|
||||||
|
documentObject.addEventListener("keydown", event => {
|
||||||
|
if (event.target instanceof windowObject.HTMLInputElement || byId("match-screen").hidden) return;
|
||||||
|
beginFeedback();
|
||||||
|
const direction = directionFromKey(event.code); if (direction) { event.preventDefault(); move(direction, null); }
|
||||||
|
});
|
||||||
|
byId("map-shell").addEventListener("touchstart", event => { if (event.target.closest("#avatar-control")) return; const touch = event.changedTouches[0]; state.touchStart = {x: touch.clientX, y: touch.clientY}; }, {passive: true});
|
||||||
|
byId("map-shell").addEventListener("touchend", event => { if (event.target.closest("#avatar-control") || !state.touchStart) return; const touch = event.changedTouches[0]; move(directionFromSwipe(state.touchStart.x, state.touchStart.y, touch.clientX, touch.clientY), null); state.touchStart = null; }, {passive: true});
|
||||||
|
byId("avatar-control").addEventListener("pointerdown", event => {
|
||||||
|
event.preventDefault();
|
||||||
|
beginFeedback();
|
||||||
|
state.avatar.pointerId = event.pointerId;
|
||||||
|
state.avatar.startX = event.clientX;
|
||||||
|
state.avatar.startY = event.clientY;
|
||||||
|
byId("avatar-control").classList.add("active");
|
||||||
|
if (byId("avatar-control").setPointerCapture) byId("avatar-control").setPointerCapture(event.pointerId);
|
||||||
|
updateAvatarControl(event);
|
||||||
|
});
|
||||||
|
byId("avatar-control").addEventListener("pointermove", event => {
|
||||||
|
if (event.pointerId === state.avatar.pointerId) updateAvatarControl(event);
|
||||||
|
});
|
||||||
|
const stopAvatarControl = event => {
|
||||||
|
if (event.pointerId === state.avatar.pointerId) resetAvatarControl();
|
||||||
|
};
|
||||||
|
byId("avatar-control").addEventListener("pointerup", stopAvatarControl);
|
||||||
|
byId("avatar-control").addEventListener("pointercancel", stopAvatarControl);
|
||||||
|
byId("avatar-control").addEventListener("lostpointercapture", stopAvatarControl);
|
||||||
|
windowObject.addEventListener("blur", resetAvatarControl);
|
||||||
|
documentObject.addEventListener("visibilitychange", () => { if (documentObject.hidden) resetAvatarControl(); });
|
||||||
|
documentObject.querySelectorAll("[data-direction]").forEach(button => button.addEventListener("click", () => move(button.dataset.direction, button)));
|
||||||
|
byId("drop-key").addEventListener("click", () => send("drop_key", {}, byId("drop-key")));
|
||||||
|
byId("mute-toggle").addEventListener("click", () => setMuted(!state.muted));
|
||||||
|
byId("motion-toggle").addEventListener("click", () => setReducedMotion(!state.reducedMotion));
|
||||||
|
byId("onboarding-replay").addEventListener("click", () => showOnboarding(0));
|
||||||
|
byId("onboarding-next").addEventListener("click", () => {
|
||||||
|
const next = nextOnboardingStep(state.onboardingStep);
|
||||||
|
if (next == null) hideOnboarding();
|
||||||
|
else { state.onboardingStep = next; renderOnboardingStep(next); }
|
||||||
|
});
|
||||||
|
byId("onboarding-skip").addEventListener("click", hideOnboarding);
|
||||||
|
byId("suggest-name").addEventListener("click", () => {
|
||||||
|
const occupiedNames = state.lobby && Array.isArray(state.lobby.players) ? state.lobby.players.filter(player => player.occupied && typeof player.name === "string").map(player => player.name) : [];
|
||||||
|
const suggestion = randomSuggestedName(occupiedNames);
|
||||||
|
if (suggestion) byId("player-name").value = suggestion;
|
||||||
|
else showNotice(localError("no_suggestion"), "error");
|
||||||
|
});
|
||||||
|
byId("join-player").addEventListener("click", () => send("join", {role: "player", name: byId("player-name").value}, byId("join-player")));
|
||||||
|
byId("join-observer").addEventListener("click", () => send("join", {role: "observer"}, byId("join-observer")));
|
||||||
|
byId("start-match").addEventListener("click", () => send("start", {}, byId("start-match")));
|
||||||
|
byId("zoom-in").addEventListener("click", () => { state.observer.scale = clampObserverZoom(state.observer.scale * 1.25); applyObserverTransform(); });
|
||||||
|
byId("zoom-out").addEventListener("click", () => { state.observer.scale = clampObserverZoom(state.observer.scale / 1.25); applyObserverTransform(); });
|
||||||
|
byId("zoom-reset").addEventListener("click", () => { if (state.snapshot) resetObserverView(state.snapshot); });
|
||||||
|
byId("observer-stage").addEventListener("wheel", event => { event.preventDefault(); state.observer.scale = clampObserverZoom(state.observer.scale * (event.deltaY < 0 ? 1.12 : .89)); applyObserverTransform(); }, {passive: false});
|
||||||
|
byId("observer-stage").addEventListener("pointerdown", event => { state.observer.dragging = {id: event.pointerId, x: event.clientX, y: event.clientY, originX: state.observer.x, originY: state.observer.y}; byId("observer-stage").classList.add("dragging"); if (byId("observer-stage").setPointerCapture) byId("observer-stage").setPointerCapture(event.pointerId); });
|
||||||
|
byId("observer-stage").addEventListener("pointermove", event => { const drag = state.observer.dragging; if (!drag || drag.id !== event.pointerId) return; state.observer.x = drag.originX + event.clientX - drag.x; state.observer.y = drag.originY + event.clientY - drag.y; applyObserverTransform(); });
|
||||||
|
const stopObserverDrag = event => { if (state.observer.dragging && state.observer.dragging.id === event.pointerId) { state.observer.dragging = null; byId("observer-stage").classList.remove("dragging"); } };
|
||||||
|
byId("observer-stage").addEventListener("pointerup", stopObserverDrag);
|
||||||
|
byId("observer-stage").addEventListener("pointercancel", stopObserverDrag);
|
||||||
|
byId("details-toggle").addEventListener("click", () => { const panel = byId("details-panel"); panel.hidden = !panel.hidden; byId("details-toggle").setAttribute("aria-expanded", String(!panel.hidden)); });
|
||||||
|
byId("modal-action").addEventListener("click", () => {
|
||||||
|
if (state.session && state.session.role === "player" && state.session.slot === 1 && state.snapshot && state.snapshot.phase === "finished") {
|
||||||
|
send("restart", {}, byId("modal-action"));
|
||||||
|
} else {
|
||||||
|
hideModal(); setScreen("lobby");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
connect();
|
||||||
|
return {state, handleMessage, move, resetAvatarControl, latencySamples: () => state.latency.snapshot(), pointerFeedbackSamples: () => state.latency.pointerSnapshot()};
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, NAME_COMBINATION_COUNT, LOGIN_HEADLINES, ONBOARDING_STEPS, LATENCY_SAMPLE_LIMIT, AVATAR_TOUCH_TARGET_SIZE, AVATAR_MAX_DEFLECTION, AVATAR_DEAD_ZONE, AVATAR_DIRECTION_HYSTERESIS, AVATAR_SLOW_REPEAT_MS, AVATAR_FAST_REPEAT_MS, LatestIntentFlow, LatencyTracker, directionFromKey, directionFromSwipe, avatarDirection, avatarRepeatMs, avatarVector, playerCellSize, playerMapOffset, nextOnboardingStep, randomIndex, randomSuggestedName, randomLoginHeadline, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, clampObserverZoom, observerFitScale, boot});
|
||||||
|
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||||
|
root.Maze32Player = api;
|
||||||
|
if (root.document) root.document.addEventListener("DOMContentLoaded", () => { root.maze32PlayerApp = boot(root.document, root); });
|
||||||
|
})(typeof globalThis !== "undefined" ? globalThis : this);
|
||||||
Reference in New Issue
Block a user