Compare commits

...

29 Commits

Author SHA1 Message Date
sasa 3518f8a152 feat(trails): render authoritative player route trails 2026-09-13 23:31:26 +03:00
sasa 8ad856b931 fix(session): reset lobby when player leaves finished match 2026-09-13 23:14:09 +03:00
sasa b6d484dbc9 feat(session): add explicit leave and immediate slot release 2026-09-13 22:53:18 +03:00
sasa 44433aee9c feat(ui): consolidate secondary controls in settings menu 2026-09-13 22:16:11 +03:00
sasa 8a37412416 feat(player): animate authoritative hedge breakthroughs 2026-09-12 23:38:01 +03:00
sasa 720cd18b6e fix(lobby): restore mobile scrolling outside matches 2026-09-12 22:53:26 +03:00
sasa 07eaed9c93 feat(web): render canonical living hedge maze walls 2026-09-11 23:46:02 +03:00
sasa 9ce44cdef7 feat(art): add living hedge SVG review library 2026-09-10 23:25:08 +03:00
sasa e10ad3acb8 feat(game): add persistent bush-wall breakthroughs 2026-09-10 23:12:42 +03:00
sasa bb5bd73ebd fix(player): make avatar handle transparent 2026-09-10 22:38:48 +03:00
sasa 8949891a6b feat(player): reposition mobile player focal point 2026-09-10 22:26:55 +03:00
sasa 2501345740 docs(plans): add playful visual feedback milestone 2026-09-10 22:08:02 +03:00
sasa abb3ff27b7 feat(ui): replace gameplay icons with local SVGs 2026-09-10 21:47:15 +03:00
sasa 8214e7f7ed feat(player): add mobile directional pad controls 2026-09-10 21:23:38 +03:00
sasa 5a806fb5b8 fix(player): align edge projections with avatar drag 2026-09-10 01:02:43 +03:00
sasa 1396bad76d feat(player): add avatar-centered drag steering 2026-09-09 23:15:00 +03:00
sasa 381eacf403 docs(plan): record M016 evidence deviation and complete milestone 2026-09-09 23:03:17 +03:00
sasa 37704028c5 feat(diagnostics): correlate movement acknowledgements with painted views 2026-09-09 22:12:12 +03:00
sasa 3827ba7ec7 feat(player-ui): add pre-reader controls and onboarding 2026-09-09 20:56:38 +03:00
sasa f043b63732 feat(maze): shrink map and rebalance compact gameplay 2026-09-08 22:14:13 +03:00
sasa 5346f9296f feat(login): randomize Russian welcome headlines 2026-09-08 21:36:26 +03:00
sasa 4b1ac6ca5a docs(plans): add combinatorial name suggestions milestone 2026-09-08 21:32:22 +03:00
sasa a2a8f4a59d docs(m011): record approved MVP playtest evidence 2026-09-08 21:15:16 +03:00
sasa cf187bdd1c docs(m011): record MVP balance and acceptance evidence 2026-09-08 21:02:20 +03:00
sasa 7902800ff7 fix(gateway): harden websocket streaming under client backpressure 2026-09-08 00:18:41 +03:00
sasa c730cb818d feat(runtime): add integration hardening and diagnostics 2026-09-05 21:56:09 +03:00
sasa ffdaa299bc feat(observer): add observer mode and disconnect recovery 2026-09-05 21:19:52 +03:00
sasa d042c3ce90 feat(ui): implement responsive player browser experience 2026-09-04 21:57:17 +03:00
sasa 9339578dc9 feat(game): complete authoritative gameplay mechanics 2026-09-04 21:07:22 +03:00
57 changed files with 17129 additions and 528 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
.pio/ .pio/
.vscode/ .vscode/
.obsidian/ .obsidian/
data/*.gz
+2 -2
View File
@@ -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.
+54 -6
View File
@@ -195,6 +195,16 @@ stateDiagram-v2
During `Paused`, movement commands are rejected and move-counted effects do not During `Paused`, movement commands are rejected and move-counted effects do not
advance. During `FinishPending`, the first exit is retained while the server advance. During `FinishPending`, the first exit is retained while the server
waits long enough to determine whether the second exit creates a draw. waits long enough to determine whether the second exit creates a draw.
An authenticated explicit player leave from any non-waiting phase, including
`Finished`, stops the current lifecycle and returns remaining sessions to the
waiting lobby. This is distinct from an unexpected disconnect, which preserves
the player's reconnect reservation for 60 seconds; observer leave and player
leave from `Lobby` revoke only the departing session.
The game stores a fixed two-bit owner mask for each canonical traversable
edge. View construction exposes those masks only for authorized visible edges,
and the browser replaces its bounded trail drawing layer from each complete
projection.
## 6. Authoritative command processing ## 6. Authoritative command processing
@@ -219,7 +229,8 @@ conceptual records include:
- `MazeConfig`: width, height, movement cooldown, object-density settings, and - `MazeConfig`: width, height, movement cooldown, object-density settings, and
balance thresholds; balance thresholds;
- `Maze`: compact cell topology plus landmark identifiers; - `Maze`: compact generated cell topology plus landmark identifiers;
- `MatchTopology`: fixed-size server-opened passage overlay for one match;
- `PlayerState`: slot, display name, session token reference, position, - `PlayerState`: slot, display name, session token reference, position,
inventory, vision effect, connection state, and last accepted move time; inventory, vision effect, connection state, and last accepted move time;
- `KeyState`: owner and either a ground position or carrier; - `KeyState`: owner and either a ground position or carrier;
@@ -231,7 +242,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
@@ -315,10 +326,19 @@ The browser application has four presentation modes:
3. player match view; 3. player match view;
4. observer full-map view. 4. observer full-map view.
Player controls support keyboard movement, touch swipes, and an explicit Player controls support keyboard movement, avatar drag, touch swipes, an
drop-key action. The UI renders only server-provided visible state. The observer explicit four-button directional pad, and an explicit drop-key action. Every
view supports fitting, panning, or zooming the complete `128 x 64` map without movement input uses the same bounded latest-intent path and sends only a
changing server authority. cardinal intention; the browser does not predict a movement result. The UI
renders only server-provided visible state. The observer view supports fitting,
panning, or zooming the complete `32 x 32` map without changing server
authority.
The browser renders Player 1 as yellow and Player 2 as blue. The matching key
and exit use the owner's color as a secondary identity signal, while distinct
SVG silhouettes and open/closed treatments identify object type and state. This
presentation mapping is local only and does not alter authoritative game or
projection data.
`docs/UI_STYLE.md` is the canonical browser presentation contract. The player `docs/UI_STYLE.md` is the canonical browser presentation contract. The player
layout supports a minimum `412 x 916` CSS-pixel viewport. Observer mode requires layout supports a minimum `412 x 916` CSS-pixel viewport. Observer mode requires
@@ -326,6 +346,34 @@ at least `1024 x 768` CSS pixels and presents a large-screen requirement message
below that threshold. Responsive layout changes presentation only; they never below that threshold. Responsive layout changes presentation only; they never
change projection content, command validation, or server authority. change projection content, command validation, or server authority.
The ESP stores each player's bounded perseverance streak and a fixed-size
opened-passage overlay. It alone expires streaks at 3,000 ms, opens both passage
bits, and projects changed passages only in cells already visible to that role.
The browser may correlate a matching server-approved impact stage with an
already visible bush wall; it never derives or opens topology locally.
Browser hedge rendering derives a bounded canonical edge list solely from the
current role projection. Its deterministic local SVG selection is presentation
only: it cannot create maze edges, alter hit testing, retain hidden cells, or
change authoritative movement and visibility. A player-only breakthrough
animation may begin only when a newer projection contains both a newly opened
visible edge and an adjacent server-approved self-cell move across that exact
edge; it is bounded, replaceable by a newer projection, and never delays the
received state.
The browser may keep one low-priority heartbeat in flight after a session is
established. It measures complete round trip with its own monotonic clock; the
matching acknowledgement contains only a bounded ESP-monotonic handling
interval. The displayed communication remainder is `max(0, round trip - ESP
handling)` and is never represented as a synchronized-clock measurement or
pure Wi-Fi diagnosis.
Player projections are translated only within their received cells so the
authoritative `self` cell aligns with the responsive local focal point. That
point is centered horizontally and 30% down the usable portrait map region;
compact landscape uses a left-biased unobstructed region. It changes no server
projection, visibility, or hidden-state handling.
All application assets are local and precompressed. Browser startup and play All application assets are local and precompressed. Browser startup and play
must not depend on internet access, third-party CSS, remote fonts, icons, must not depend on internet access, third-party CSS, remote fonts, icons,
analytics, or content services. The complete compressed web payload is limited analytics, or content services. The complete compressed web payload is limited
+35 -10
View File
@@ -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.
@@ -109,8 +109,10 @@ approximately five minutes. There is no match time limit.
simultaneous turns. simultaneous turns.
- A small server-enforced minimum interval exists between accepted moves. Its - A small server-enforced minimum interval exists between accepted moves. Its
value is configurable for playtesting. value is configurable for playtesting.
- Rejected movement into a wall does not change position and is not a - A server-accepted impact against the same closed interior wall from the same
successful move. cell and direction is tracked per player. The first four impacts do not move
the player; the fifth within three seconds of each previous impact opens the
passage in both cells and completes that move. Perimeter walls never open.
- Players do not block each other and may occupy the same cell. - Players do not block each other and may occupy the same cell.
- A meeting occurs when both players occupy the same cell. - A meeting occurs when both players occupy the same cell.
- There are no attacks, health, damage, or other combat mechanics in MVP. - There are no attacks, health, damage, or other combat mechanics in MVP.
@@ -214,6 +216,18 @@ Browser controls must support:
state. state.
- If the player does not reconnect within 60 seconds, the match ends in a draw. - If the player does not reconnect within 60 seconds, the match ends in a draw.
- Reloading a page is treated as a reconnect, not as leaving the match. - Reloading a page is treated as a reconnect, not as leaving the match.
- A confirmed explicit player exit during any non-waiting phase, including a
finished result, revokes only that session and authoritatively returns all
remaining participants to a fresh waiting lobby. Observer exits and player
exits from an already waiting lobby affect only the departing session.
### Player trails
- Every successful in-maze player move is recorded authoritatively as a
persistent passage trail for the current match. Players receive only trail
segments inside their current fog-of-war projection; observers receive both
complete trails. Trails clear with the match and never come from browser
history.
## 11. Browser presentation ## 11. Browser presentation
@@ -222,15 +236,26 @@ Browser controls must support:
`docs/UI_STYLE.md`. `docs/UI_STYLE.md`.
- All player-facing UI copy, game messages, and browser errors are in Russian. - All player-facing UI copy, game messages, and browser errors are in Russian.
- The UI uses a near-black background, purple panels, turquoise interactive - The UI uses a near-black background, purple panels, turquoise interactive
accents, blue for Player 1, pink for Player 2, and plant-like green maze accents, yellow for Player 1, blue for Player 2, and plant-like green maze
walls. Important state is distinguished by icons, labels, outlines, or walls. Each player's matching key and exit use the same ownership color.
shapes in addition to color. Important state is distinguished by icons, labels, outlines, or shapes in
addition to color; amber warnings and blue pause states use distinct
treatments so they cannot be confused with player identity.
- The player map consumes all space not required by fixed status and control - The player map consumes all space not required by fixed status and control
panels. The player view supports a minimum `412 x 916` CSS-pixel viewport panels. The player view supports a minimum `412 x 916` CSS-pixel viewport
without page scrolling. without page scrolling.
- Computers support keyboard movement and on-screen arcade controls. Tablets - On portrait phones, the authoritative player focal point is horizontally
support swipes and on-screen controls. Phones use directional swipes without centered at approximately 30% of the usable map height, above the operating
on-screen directional buttons. hand zone. Compact landscape uses a stable left-biased focal point in the
largest unobstructed map area.
- Textual system notices use one Russian live message rail above the top bar.
A connection-loss notice remains there until recovery; movement blocked by a
wall and rate limiting use maze-only visual feedback and never show text.
- Computers and tablets support keyboard, swipes, and on-screen arcade
controls. Phones show an always-visible four-button directional pad as the
primary touch control while retaining avatar drag and directional swipes as
optional alternatives. The pad sends only existing orthogonal movement
intentions through the authoritative server path.
- An always-visible `OK` control with a key icon drops the carried key without - An always-visible `OK` control with a key icon drops the carried key without
confirmation and exposes the Russian accessible label `Сбросить ключ`. confirmation and exposes the Russian accessible label `Сбросить ключ`.
- Observer mode requires at least a `1024 x 768` CSS-pixel viewport, initially - Observer mode requires at least a `1024 x 768` CSS-pixel viewport, initially
@@ -274,7 +299,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
+3098 -23
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -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
+122 -3
View File
@@ -10,18 +10,32 @@ extern "C" {
#endif #endif
#define MAZE32_GAME_PLAYER_COUNT 2U #define MAZE32_GAME_PLAYER_COUNT 2U
#define MAZE32_GAME_BREAKTHROUGH_HITS 5U
#define MAZE32_GAME_BREAKTHROUGH_TIMEOUT_MS 3000U
#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 5U
#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_MAX_TRAIL_EDGES (2U * MAZE32_MAZE_MAX_CELLS)
#define MAZE32_GAME_TRAIL_BYTES ((MAZE32_GAME_MAX_TRAIL_EDGES + 3U) / 4U)
#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
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 +51,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 +85,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 +98,37 @@ 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_cell_t perseverance_cell;
maze32_game_direction_t perseverance_direction;
uint64_t perseverance_last_impact_ms;
uint8_t perseverance_hits;
} 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 +140,20 @@ 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 breakthrough_hits;
uint8_t breakthrough_opened;
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 +161,23 @@ 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];
uint8_t opened_passages[MAZE32_MAZE_MAX_CELLS];
uint8_t trail_owners[MAZE32_GAME_TRAIL_BYTES];
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 {
@@ -99,6 +191,9 @@ typedef enum {
typedef struct { typedef struct {
uint16_t cell_index; uint16_t cell_index;
uint8_t passages; uint8_t passages;
uint8_t opened_passages;
uint8_t trail_east;
uint8_t trail_south;
} maze32_view_cell_t; } maze32_view_cell_t;
typedef struct { typedef struct {
@@ -113,10 +208,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 +242,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,
+659 -83
View File
@@ -15,15 +15,24 @@ 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];
} }
static maze32_passage_t opposite_passage(maze32_passage_t passage) {
if (passage == MAZE32_PASSAGE_NORTH) {
return MAZE32_PASSAGE_SOUTH;
}
if (passage == MAZE32_PASSAGE_EAST) {
return MAZE32_PASSAGE_WEST;
}
if (passage == MAZE32_PASSAGE_SOUTH) {
return MAZE32_PASSAGE_NORTH;
}
return MAZE32_PASSAGE_EAST;
}
static maze32_cell_t moved_cell(maze32_cell_t cell, maze32_game_direction_t direction) { static maze32_cell_t moved_cell(maze32_cell_t cell, maze32_game_direction_t direction) {
if (direction == MAZE32_GAME_DIRECTION_NORTH) { if (direction == MAZE32_GAME_DIRECTION_NORTH) {
cell.y--; cell.y--;
@@ -45,12 +54,91 @@ 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 uint8_t game_passages(const maze32_game_t* game, maze32_cell_t cell) {
return (uint8_t)(game->maze->passages[cell_index(game->maze, cell)] |
game->opened_passages[cell_index(game->maze, cell)]);
}
static int game_has_passage(const maze32_game_t* game, maze32_cell_t cell,
maze32_passage_t passage) {
return (game_passages(game, cell) & passage) != 0U;
}
static uint16_t trail_edge_index(const maze32_maze_t* maze, maze32_cell_t cell,
maze32_passage_t passage) {
uint16_t horizontal_count = (uint16_t)(maze->config.height * (maze->config.width - 1U));
if (passage == MAZE32_PASSAGE_EAST) {
return (uint16_t)(cell.y * (maze->config.width - 1U) + cell.x);
}
return (uint16_t)(horizontal_count + cell.y * maze->config.width + cell.x);
}
static uint8_t trail_owner_mask(const maze32_game_t* game, uint16_t edge_index) {
uint8_t byte = game->trail_owners[edge_index / 4U];
return (uint8_t)((byte >> ((edge_index % 4U) * 2U)) & 0x03U);
}
static void mark_trail_edge(maze32_game_t* game, uint8_t player_index, maze32_cell_t origin,
maze32_game_direction_t direction) {
maze32_cell_t canonical = origin;
maze32_passage_t passage = direction_passage(direction);
if (direction == MAZE32_GAME_DIRECTION_NORTH) {
canonical.y--;
passage = MAZE32_PASSAGE_SOUTH;
} else if (direction == MAZE32_GAME_DIRECTION_WEST) {
canonical.x--;
passage = MAZE32_PASSAGE_EAST;
}
uint16_t edge_index = trail_edge_index(game->maze, canonical, passage);
uint8_t shift = (uint8_t)((edge_index % 4U) * 2U);
game->trail_owners[edge_index / 4U] |= (uint8_t)((1U << player_index) << shift);
}
static void reset_perseverance(maze32_game_player_t* player) {
player->perseverance_hits = 0U;
player->perseverance_last_impact_ms = 0U;
}
static uint8_t record_wall_impact(maze32_game_player_t* player, maze32_cell_t cell,
maze32_game_direction_t direction, uint64_t submitted_ms) {
if (player->perseverance_hits == 0U || !cells_equal(player->perseverance_cell, cell) ||
player->perseverance_direction != direction ||
submitted_ms < player->perseverance_last_impact_ms ||
submitted_ms - player->perseverance_last_impact_ms >= MAZE32_GAME_BREAKTHROUGH_TIMEOUT_MS) {
player->perseverance_cell = cell;
player->perseverance_direction = direction;
player->perseverance_hits = 1U;
} else {
player->perseverance_hits++;
}
player->perseverance_last_impact_ms = submitted_ms;
return player->perseverance_hits;
}
static void reset_all_perseverance(maze32_game_t* game) {
for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) {
reset_perseverance(&game->players[player]);
}
}
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 +158,42 @@ 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) {
reset_all_perseverance(game);
}
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 +206,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 +215,386 @@ 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;
reset_all_perseverance(game);
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;
reset_all_perseverance(game);
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;
reset_perseverance(player);
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;
reset_all_perseverance(game);
}
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;
reset_all_perseverance(game);
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); reset_perseverance(player);
if (!maze32_maze_has_passage(game->maze, player->position, passage)) { if (!cells_equal(player->position, game->maze->exits[player_index]) ||
processed.result = MAZE32_GAME_RESULT_BLOCKED; !game->exits[player_index].open) {
return processed; processed.result = MAZE32_GAME_RESULT_BLOCKED;
return processed;
}
finish_outward_move(game, player_index, command.submitted_ms, &processed);
} else {
maze32_cell_t origin = player->position;
maze32_passage_t passage = direction_passage(command.direction);
if (!game_has_passage(game, player->position, passage)) {
processed.breakthrough_hits = record_wall_impact(
player, player->position, command.direction, command.submitted_ms);
if (processed.breakthrough_hits < MAZE32_GAME_BREAKTHROUGH_HITS) {
processed.result = MAZE32_GAME_RESULT_BLOCKED;
return processed;
}
maze32_cell_t destination = moved_cell(player->position, command.direction);
if (!valid_cell(game->maze, destination)) {
reset_perseverance(player);
processed.result = MAZE32_GAME_RESULT_BLOCKED;
return processed;
}
game->opened_passages[cell_index(game->maze, player->position)] |= passage;
game->opened_passages[cell_index(game->maze, destination)] |= opposite_passage(passage);
processed.breakthrough_opened = 1U;
reset_perseverance(player);
}
if (game_has_passage(game, player->position, passage)) {
reset_perseverance(player);
} else {
processed.result = MAZE32_GAME_RESULT_BLOCKED;
return processed;
}
player->position = moved_cell(player->position, command.direction);
mark_trail_edge(game, player_index, origin, 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 +612,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 +630,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 +651,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) {
@@ -236,7 +700,22 @@ maze32_game_result_t maze32_game_project_view(const maze32_game_t* game, maze32_
if (reveal_all || is_visible(center, cell, radius)) { if (reveal_all || is_visible(center, cell, radius)) {
uint16_t index = cell_index(game->maze, cell); uint16_t index = cell_index(game->maze, cell);
view->cells[view->cell_count].cell_index = index; view->cells[view->cell_count].cell_index = index;
view->cells[view->cell_count].passages = game->maze->passages[index]; view->cells[view->cell_count].passages = game_passages(game, cell);
view->cells[view->cell_count].opened_passages = game->opened_passages[index];
if (x + 1U < game->maze->config.width) {
maze32_cell_t east = {x + 1U, y};
if (reveal_all || is_visible(center, east, radius)) {
view->cells[view->cell_count].trail_east = trail_owner_mask(
game, trail_edge_index(game->maze, cell, MAZE32_PASSAGE_EAST));
}
}
if (y + 1U < game->maze->config.height) {
maze32_cell_t south = {x, y + 1U};
if (reveal_all || is_visible(center, south, radius)) {
view->cells[view->cell_count].trail_south = trail_owner_mask(
game, trail_edge_index(game->maze, cell, MAZE32_PASSAGE_SOUTH));
}
}
view->cell_count++; view->cell_count++;
} }
} }
@@ -245,12 +724,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 +775,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,43 +871,45 @@ 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;
} }
for (size_t index = first; index < end; ++index) { for (size_t index = first; index < end; ++index) {
const maze32_view_cell_t* cell = &view->cells[index]; const maze32_view_cell_t* cell = &view->cells[index];
if (!append_text(output, output_size, &offset, "%s[%u,%u]", index == first ? "" : ",", if (!append_text(output, output_size, &offset, "%s[%u,%u,%u,%u,%u]",
cell->cell_index, cell->passages)) { index == first ? "" : ",", cell->cell_index, cell->passages,
cell->opened_passages, cell->trail_east, cell->trail_south)) {
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL; return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
} }
} }
@@ -345,7 +917,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 +926,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 +944,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];
@@ -66,8 +66,11 @@ maze32_lobby_result_t maze32_lobby_join_observer(maze32_lobby_t* lobby, int conn
maze32_lobby_result_t maze32_lobby_restore(maze32_lobby_t* lobby, int connection_id, maze32_lobby_result_t maze32_lobby_restore(maze32_lobby_t* lobby, int connection_id,
const char* token, maze32_session_t** session); const char* token, maze32_session_t** session);
void maze32_lobby_disconnect(maze32_lobby_t* lobby, int connection_id); void maze32_lobby_disconnect(maze32_lobby_t* lobby, int connection_id);
int maze32_lobby_leave(maze32_lobby_t* lobby, int connection_id, maze32_role_t* role,
unsigned int* player_slot);
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,11 @@ 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_LEAVE,
MAZE32_MESSAGE_HEARTBEAT, MAZE32_MESSAGE_HEARTBEAT,
MAZE32_MESSAGE_SUGGEST_NAME,
} maze32_message_type_t; } maze32_message_type_t;
typedef enum { typedef enum {
@@ -39,6 +42,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,
+34
View File
@@ -167,6 +167,30 @@ void maze32_lobby_disconnect(maze32_lobby_t* lobby, int connection_id) {
} }
} }
int maze32_lobby_leave(maze32_lobby_t* lobby, int connection_id, maze32_role_t* role,
unsigned int* player_slot) {
if (lobby == NULL) {
return 0;
}
for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) {
maze32_session_t* session = &lobby->sessions[index];
if (session->role != MAZE32_ROLE_NONE && session->connected &&
session->connection_id == connection_id) {
if (role != NULL) {
*role = session->role;
}
if (player_slot != NULL) {
*player_slot = session->player_slot;
}
memset(session, 0, sizeof(*session));
session->connection_id = -1;
lobby->revision++;
return 1;
}
}
return 0;
}
maze32_lobby_result_t maze32_lobby_start(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) { uint64_t now_ms) {
if (lobby == NULL || lobby->phase != MAZE32_LOBBY_WAITING) { if (lobby == NULL || lobby->phase != MAZE32_LOBBY_WAITING) {
@@ -195,6 +219,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) {
+24 -3
View File
@@ -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,21 @@ 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, "leave") == 0) {
return MAZE32_MESSAGE_LEAVE;
}
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;
} }
+4 -4
View File
@@ -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)
+5 -5
View File
@@ -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
} }
+82 -80
View File
@@ -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 -1
View File
@@ -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
) )
+35 -1
View File
@@ -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
} }
+711 -73
View File
@@ -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,191 @@ 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 send_breakthrough_impact(maze32_gateway_t* gateway, int socket_fd,
const char* request_id, uint8_t hits) {
char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = "";
int written = snprintf(response, sizeof(response),
"{\"v\":1,\"type\":\"error\",\"seq\":%" PRIu32
",\"requestId\":\"%s\",\"payload\":{\"code\":\"blocked\","
"\"message\":\"Там стена. Очень убедительная\","
"\"breakthroughHits\":%u}}",
next_server_sequence(gateway), request_id, (unsigned int)hits);
if (written >= 0 && (size_t)written < sizeof(response)) {
enqueue(gateway, socket_fd, response);
}
}
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);
}
if (result == MAZE32_GAME_RESULT_BLOCKED && processed.breakthrough_hits > 0U) {
send_breakthrough_impact(gateway, session->connection_id, message->request_id,
processed.breakthrough_hits);
} else {
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 +895,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 +914,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;
} }
@@ -491,12 +948,50 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_
"Сначала выберите роль или восстановите сессию"); "Сначала выберите роль или восстановите сессию");
return; return;
} }
if (message->type == MAZE32_MESSAGE_LEAVE) {
maze32_role_t leaving_role = session->role;
int cancel_match =
leaving_role == MAZE32_ROLE_PLAYER && gateway->lobby.phase != MAZE32_LOBBY_WAITING;
char acknowledgement[192] = "";
size_t length = 0U;
char prefix[96];
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\":\"leave\"}}")) {
enqueue(gateway, socket_fd, acknowledgement);
}
if (cancel_match) {
esp_timer_stop(gateway->countdown_timer);
esp_timer_stop(gateway->reconnect_timer);
esp_timer_stop(gateway->finish_timer);
initialize_game(gateway);
maze32_lobby_reset_match(&gateway->lobby);
}
maze32_lobby_leave(&gateway->lobby, socket_fd, NULL, NULL);
if (cancel_match) {
broadcast_match_event(gateway, "reset", "{}");
}
broadcast_snapshot(gateway);
drain_queue(gateway);
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,24 +1001,43 @@ 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) {
int64_t heartbeat_started_us = esp_timer_get_time();
char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = ""; char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = "";
size_t length = 0U; size_t length = 0U;
char prefix[128]; char prefix[128];
char handling_text[32];
int written = snprintf(prefix, sizeof(prefix), "{\"v\":1,\"type\":\"ack\",\"seq\":%" PRIu32, int written = snprintf(prefix, sizeof(prefix), "{\"v\":1,\"type\":\"ack\",\"seq\":%" PRIu32,
next_server_sequence(gateway)); next_server_sequence(gateway));
if (written >= 0 && (size_t)written < sizeof(prefix) && uint64_t handling_ms = (uint64_t)(esp_timer_get_time() - heartbeat_started_us) / 1000U;
if (handling_ms > 1000U) {
handling_ms = 1000U;
}
int handling_written =
snprintf(handling_text, sizeof(handling_text), "%" PRIu64, handling_ms);
if (written >= 0 && (size_t)written < sizeof(prefix) && handling_written >= 0 &&
(size_t)handling_written < sizeof(handling_text) &&
append_text(response, sizeof(response), &length, prefix) && append_text(response, sizeof(response), &length, prefix) &&
append_text(response, sizeof(response), &length, ",\"requestId\":") && append_text(response, sizeof(response), &length, ",\"requestId\":") &&
append_json_string(response, sizeof(response), &length, message->request_id) && append_json_string(response, sizeof(response), &length, message->request_id) &&
append_text(response, sizeof(response), &length, append_text(response, sizeof(response), &length,
",\"payload\":{\"command\":\"heartbeat\"}}")) { ",\"payload\":{\"command\":\"heartbeat\",\"handlingMs\":") &&
enqueue(gateway, socket_fd, response); append_text(response, sizeof(response), &length, handling_text) &&
append_text(response, sizeof(response), &length, "}}")) {
enqueue_with_policy(gateway, socket_fd, response, 1);
} }
} }
} }
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 +1054,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 +1078,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 +1092,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 +1110,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 +1140,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 +1150,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 +1217,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 +1260,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;
}
+88 -12
View File
@@ -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,38 @@
#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 const maze32_static_asset_t HEDGE_ASSET = {"/littlefs/hedges.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 +54,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 +84,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 +105,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 = 14;
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 +122,38 @@ 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 hedges = {
.uri = "/hedges.svg",
.method = HTTP_GET,
.handler = send_static_asset,
.user_ctx = (void*)&HEDGE_ASSET,
}; };
const httpd_uri_t health_uri = { const httpd_uri_t health_uri = {
.uri = "/health", .uri = "/health",
@@ -106,14 +161,35 @@ 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) {
result = httpd_register_uri_handler(*server, &hedges);
}
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.
+72
View File
@@ -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
View File
@@ -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.
+38
View File
@@ -0,0 +1,38 @@
# Hedge sample library manifest
`hedge-sample-library.svg` is a review-only, hand-editable SVG source. It is
not a web, firmware, or generated LittleFS asset.
## Symbols
- Intact straight variants: `hedge-intact-1` through `hedge-intact-6`.
- Topology examples: `hedge-base`, `hedge-corner`, `hedge-crossing`, and
`hedge-end`.
- Perseverance frames: `hedge-impact-1` through `hedge-impact-4` and
`hedge-breakthrough`.
- Permanent openings: `hedge-breach-1` through `hedge-breach-3`.
- Reusable primitives: `leaf-cluster-a`, `leaf-cluster-b`, and `twig-fork`.
All edge art is authored left-to-right. Later integration may rotate it by 90,
180, or 270 degrees and mirror it only when the shared-edge centre remains
aligned. Highlights are distributed rather than directional, so rotation does
not create a lighting contradiction.
## Accessibility and review
The dark outline, branch forks, irregular leaf silhouette, and clear central
gap distinguish the symbols without relying only on hue. The comparison sheet
contains neutral and dark-cell previews at 34, 40, and 46 CSS-pixel targets.
Impact frames are static review frames; no animation, particle system, or
runtime event is implied.
## Size and dependency record
| Source | Raw bytes | gzip -9 bytes | Dependencies |
| --- | ---: | ---: | --- |
| `hedge-sample-library.svg` | 6,495 | 1,884 | None; inline SVG only |
The validator rejects scripts, raster payloads, external URLs, editor metadata,
and missing stable identifiers. Reused `<use>` primitives avoid duplicating
leaf and twig paths. The current sheet is deliberately outside the 1.5 MiB
runtime budget until a future approved integration milestone selects a family.
+76
View File
@@ -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.
+54
View File
@@ -0,0 +1,54 @@
# 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 |
| ESP heartbeat handling | ESP | bounded acknowledgement preparation interval |
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.
7. For the compact quality indicator, record the rounded `Отклик`, `Связь`,
and `ESP` values after the five-second heartbeat cadence has collected a
fresh sample. `Связь` is the non-negative round-trip remainder, not a pure
Wi-Fi measurement.
## 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.
+55
View File
@@ -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.
+6 -6
View File
@@ -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
+54
View File
@@ -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. |
+37
View File
@@ -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.
+145
View File
@@ -0,0 +1,145 @@
# 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.
## Gameplay icon mapping
`icons.svg` contains editable, local SVG symbols with a shared `64 x 64` view
box for the reference-derived gameplay set: `player`, `key`, `exit-closed`,
`crystal`, `exit-open`, and `spikes`. Player 1 is yellow and Player 2 is blue;
their matching keys and exits use the same owner color. The player, key, and
exit silhouettes remain distinct, and Player 2 uses a dashed secondary
treatment so ownership does not depend on color alone.
The closed and opened arched exits show the existing locked and opened exit
states. The crystal represents the existing vision artifact. The spike symbol
is kept as a local catalog asset but is not shown for blocked movement; it does
not add a trap, damage, protocol, or server-state feature.
## 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 `Сбросить ключ`.
On phones, an always-visible four-button directional pad is the primary touch
control. Its inactive centre prevents diagonal input; each button sends one of
the same four authoritative movement intentions and a held button repeats no
faster than every 170 ms. The centered player avatar is also an optional 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 responsive avatar focal point. On a
portrait phone this is horizontally centered at 30% of the map height; compact
landscape uses a stable left-biased unobstructed point away from the directional
pad. This also keeps the avatar off a wall when the visible projection reaches
a maze boundary, and is recalculated on resize and rotation.
## Feedback and message rail
## Compact settings
The persistent gear button beside the connection indicator opens one compact
popover. It shows live connection details and Wi-Fi settings on every screen.
During an active player match it additionally provides sound, reduced-motion,
and optional-help controls with visible Russian on/off labels. Opening or
closing this presentation-only menu never sends a command or changes server
state. Escape and outside activation dismiss it; player actions also close it
before opening their existing destination.
Authenticated sessions also see `Выйти из игры`. Its confirmation states that
a player exit from any non-waiting phase, including the result screen, returns
the remaining participants to the waiting lobby; observer exits and waiting
lobby player exits release only the departing browser's place. The browser
removes its token only after the correlated server acknowledgement.
The header response indicator takes at most one heartbeat sample every five
seconds, holds one in-flight request, and uses a five-sample median. Good is
at most 150 ms, delayed is 151--500 ms, and slow is above 500 ms. Hysteresis
holds good until above 175 ms and slow until at most 450 ms; a sample is stale
after 15 seconds. Hidden documents and closed sockets do not probe.
The browser stores the direction for each outstanding move request. A matching
server `blocked` rejection may include only that player's approved breakthrough
stage, which bends the already rendered bush wall for at most 340 ms without
blocked-move text. A received opened-passage projection paired with an adjacent
authoritative player move may show one 480 ms hedge-parting dash and four
bounded decorative accents before settling at the server-approved destination.
Opened-passage bits render a permanent trampled bush gap with a 60% clear
centre. Effects have restartable timers and clear on a newer projection, pause,
result, reset, disconnect, screen change, and control reset. Reduced motion is
static and settles directly at the received destination.
The map draws only a bounded canonical hedge-edge layer generated from current
received cells. It hashes each visible edge deterministically to choose the
approved local SVG variant, so the browser neither retains unseen geometry nor
decides topology. The layer is decorative and cannot receive input.
Each complete projection also replaces one bounded SVG trail layer. It draws
only server-authorized visible east/south passage masks: yellow and blue use
different dotted rhythms, and a shared passage retains both patterns.
All remaining Russian system messages use one `role=status` live message rail
above the application top bar. One connection-loss message has highest priority
and remains until a successful recovery; other messages deduplicate, replace
lower-priority content, and dismiss after 2.6 seconds.
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`.
+48
View File
@@ -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.
+38
View File
@@ -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.
+44 -9
View File
@@ -30,27 +30,62 @@ 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.
- `leave`: `{}`. Revokes only the authenticated sending session. A player
leaving any non-waiting match phase, including a finished result, cancels
that match and returns remaining sessions to the waiting lobby; observer and
waiting-lobby departures release only that session.
- `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.
- `ack`: successful heartbeat correlation. - `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. Each
projected cell carries compact Player 1 and Player 2 trail ownership masks
for its east and south edges only when both edge endpoints are authorized.
- `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. A heartbeat acknowledgement has
`payload.command` set to `heartbeat` and a bounded integer
`payload.handlingMs` measured only by the ESP monotonic clock while it
prepares that acknowledgement.
- `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.
+45
View File
@@ -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.
+151 -22
View File
@@ -30,10 +30,11 @@ roles must remain stable:
- black or near-black for the application and map background; - black or near-black for the application and map background;
- purple for panels and elevated surfaces; - purple for panels and elevated surfaces;
- turquoise for interactive accents; - turquoise for interactive accents;
- blue for Player 1 and pink for Player 2 across every screen, map object, and - yellow for Player 1 and blue for Player 2 across every screen, map object,
message; and message; each player's matching key and exit use the same ownership
- green for success, yellow for warnings, red for errors, blue for pause, and color;
orange for disconnection; - green for success, amber for warnings, red for errors, periwinkle for pause,
and orange for disconnection;
- green vegetation, bushes, and plants for maze walls. - green vegetation, bushes, and plants for maze walls.
When two semantic roles use related colors, icons, labels, outlines, or shapes When two semantic roles use related colors, icons, labels, outlines, or shapes
@@ -41,6 +42,12 @@ must keep them unambiguous. Important player and object information must not be
communicated by color alone. Key objects use an additional label, outline, or communicated by color alone. Key objects use an additional label, outline, or
marker. marker.
The gameplay icon family is locally authored SVG artwork derived from the
approved running-player, key, arched-exit, crystal, opened-exit, and spike
reference silhouettes. The spike icon remains a catalog asset only; blocked
movement uses the maze feedback defined below and never represents a trap or a
new gameplay mechanic.
WCAG AA contrast certification and dedicated color-vision-deficiency palettes WCAG AA contrast certification and dedicated color-vision-deficiency palettes
are not MVP requirements. are not MVP requirements.
@@ -49,11 +56,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 +71,20 @@ 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, an always-visible four-button directional pad in the lower
lower-right corner for right-handed use. Touch swipes remain supported. right is the primary movement control. Its centre is inactive, each button
- On phones, movement uses directional swipes and no on-screen directional is at least 48 by 48 CSS pixels, and it sends only one cardinal intention.
buttons are shown. The centered player avatar remains an optional movement handle. Its
transparent touch target is at least 96 by 96 CSS pixels and its visible
character remains unobscured. During a drag, it deflects no more than 28 CSS
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. The pad's hold repeat is 170 ms. Touch
swipes remain supported. The pad, avatar drag, swipe, and keyboard inputs
share one bounded latest-intent path.
- 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,29 +97,120 @@ 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.
- Connection details and the Wi-Fi settings link belong in a secondary panel, - The player identity is represented by a distinct character shape, not `P1`
not in the persistent status row. or `P2`. Object, blocked-move, pause, and result feedback must pair a shape
or icon with its color.
- A single gear button beside the persistent Wi-Fi connection indicator opens
the secondary settings popover. It contains connection details and Wi-Fi
settings on every screen, plus the player-only sound, reduced-motion, and
help actions during an active player match. The popover is constrained to
safe areas, closes on outside activation or Escape, and never contains
primary game, lobby, observer, or result actions.
- The settings popover shows `Выйти из игры` only for an authenticated session.
It opens a confirmation modal; a player match, including a finished result,
warns that it returns everyone to the waiting lobby, while observer and
waiting-lobby departures explain that only this browser's place is released.
- Trails are dotted passage-center lines beneath maze objects and hedges: Player
1 uses the established yellow dot rhythm, Player 2 uses a distinct blue
rhythm, and shared passages draw both rhythms on one centerline.
- A compact response indicator between the connection state and gear shows a
rounded total response time or an explicit unknown, stale, or offline state.
Its accessible description and settings details expose `Отклик`, `Связь`,
and `ESP`; `Связь` is a transport remainder and does not assign blame to
Wi-Fi, the ESP, or a player.
- Countdown, pause, and result states appear as modal overlays above the map. - Countdown, pause, and result states appear as modal overlays above the map.
- Errors and rejected actions appear as contextual tooltips or notices near - Textual system notices use one live message rail above the top bar and player
the relevant control or status area. status area. It shows one message at a time: connection loss has highest
priority and persists until recovery; all other messages replace lower
priority content, deduplicate by event, and dismiss after 2.6 seconds.
- The lobby uses separate blocks for players, observers, and match actions. - The lobby uses separate blocks for players, observers, and match actions.
- Network provisioning uses the same colors, type, controls, and humor as the - Network provisioning uses the same colors, type, controls, and humor as the
game application. game application.
## 7. Player map presentation ## 7. Player map presentation
- Keep the player near the center of the map viewport except where maze - Keep the portrait player horizontally centered at approximately 30% of the
boundaries prevent it. usable map height, above the directional-pad hand zone. In compact landscape,
use the stable left-biased focal point in the largest unobstructed map area.
Recalculate it after resize and rotation without moving the authoritative
player away from its received `self` cell.
- 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.
- Give immediate visual feedback when movement is rejected by a wall without - Server-approved breakthrough impacts one through four bend the visible bush
revealing hidden state. wall with four increasing restrained stages and no text or spike notice. A
fifth accepted move is reflected by a permanent textured trampled-bush gap.
Only a received opened-edge projection paired with an adjacent authoritative
player position may start one 480 ms hedge-parting, player dash, and fixed
four-accent leaf-and-sparkle sequence. It uses one restartable timer and no
unbounded generated particles.
`rate_limited` still darkens and blurs only the maze for at most 520 ms.
- Reduced-motion mode uses static deformation and the same trampled-gap state;
it never shows travel or moving accents.
### Hedge art review contract
- Hedge samples are direct top-down vectors for review only; they are not
runtime assets in this milestone. A segment occupies one cell edge and uses
a centered, 10 CSS-pixel-wide foliage band at 40 CSS pixels per cell, scaling
proportionally at 34 and 46 pixels.
- The family uses deep shadow `#173d2a`, branch `#765033`, foliage dark
`#296443`, foliage mid `#4f9b55`, foliage light `#8cc968`, and pale leaf
highlight `#c7e68a`. A dark outline and branch fork preserve silhouette in
grayscale; color is supplementary depth, not the only boundary cue.
- Each intact symbol combines three or more named leaf clusters with visible
twig forks, a restrained shadow, and an irregular outer silhouette. Straight
symbols tile end-to-end without double borders; corner, crossing, and end
symbols share their edge centre and may rotate or mirror without directional
lighting.
- Impact stages one through four retain the same wall centre while increasingly
compressing foliage, bending twigs, and shedding a fixed small number of
leaves. The breakthrough and trampled symbols leave a clear centred passage
at least 24 CSS pixels wide at 40-pixel scale, with two separated foliage
clusters, bent branches, and restrained ground debris on both sides. They
never imply movement beyond that one authoritative edge.
- Review sheets show the source symbols on neutral and dark-cell backgrounds
at 34, 40, and 46 CSS pixels. They contain no coordinates, objects,
ownership, hidden topology, animation, or runtime dependencies.
### Runtime hedge rendering
- The local `/hedges.svg` sprite is derived from the approved review source.
It contains only reusable runtime symbols and no scripts, raster data, or
external dependencies. The review sheet in `docs/` is never requested by a
browser page.
- A map uses one non-interactive hedge-edge element for each canonical closed
or server-opened edge in its current projection. Closed shared edges are
owned by their north or west cell; east and south edges are rendered only at
the current projection boundary. This bounds a full 32 by 32 map to 2,112
hedge elements and avoids duplicated foliage.
- A stable integer hash of the canonical edge index and orientation selects one
of the six approved intact variants. Opened edges select one of the three
approved breach variants. The same projection edge always receives the same
art without random state, retained exploration, or a protocol field.
- Gameplay readability takes priority over foliage density. Hedge foliage is
at most 20% of one cell, and the center line of every horizontal or vertical
symbol must coincide with its authoritative cell edge. Rotation must preserve
that center line instead of shifting a vertical hedge through cell centers.
Every runtime SVG instance uses the approved `160 x 40` view box scaled to
exactly one cell edge; source-coordinate artwork must never overflow into
unrelated cells. Cell interiors remain visually quiet and continuous so
passages read as open floor rather than as a grid of highlighted rectangles.
- Hedge edges are decorative, have `pointer-events: none`, sit below map
objects and players, and may overhang an edge by at most 15% of a cell. A
projection replacement removes all obsolete edge nodes. One restartable
breakthrough timer may briefly show the approved breakthrough frame; reduced
motion settles immediately on the permanent breach. A player-only transient
runner replaces the focal avatar for at most 480 ms after a matching opened
edge and adjacent authoritative move; it has exactly four non-interactive
accents and is cleared on any newer projection, pause, disconnect, result,
reset, screen change, or reduced-motion mode.
## 8. Responsive behavior ## 8. Responsive behavior
@@ -108,6 +218,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 +233,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 +256,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.
+48
View File
@@ -0,0 +1,48 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 760" role="img" aria-labelledby="title desc">
<title id="title">Top-down living hedge sample library</title>
<desc id="desc">Review-only editable vector symbols for intact hedges, joins, impact stages, and breached passages.</desc>
<defs>
<g id="leaf-cluster-a" stroke="#173d2a" stroke-width="1.5" stroke-linejoin="round">
<path fill="#296443" d="M2 13C0 6 7 0 13 3c2-7 12-5 14 2 8-1 11 8 5 13 3 8-6 13-12 8-6 5-14 1-13-6-7 0-9-5-5-7Z"/>
<path fill="#4f9b55" d="M7 13c2-5 7-8 12-7 5 0 8 4 9 8-3 5-8 8-14 7-5 0-8-3-7-8Z"/>
<path fill="#c7e68a" d="M12 9c3-3 8-3 10 0-2 3-7 4-10 0Z"/>
</g>
<g id="leaf-cluster-b" stroke="#173d2a" stroke-width="1.5" stroke-linejoin="round">
<path fill="#296443" d="M1 16C-1 8 5 2 11 4c1-7 11-7 14-1 7-3 13 5 8 11 5 6-2 14-9 11-4 7-15 4-15-3-8 2-12-4-8-6Z"/>
<path fill="#8cc968" d="M8 14c2-5 8-7 13-5 4 2 6 7 4 11-5 4-13 3-17-2Z"/>
<path fill="#4f9b55" d="M4 18c5 1 10 4 15 1-1 5-8 8-13 5Z"/>
</g>
<g id="twig-fork" fill="none" stroke="#765033" stroke-width="2" stroke-linecap="round">
<path d="M3 20 16 8 29 3M16 8l2 12M16 8 7 4"/>
</g>
<g id="hedge-base">
<path d="M0 11C9 2 20 4 30 9c12-8 23-7 34 1 10-5 22-5 32 1 11-7 23-6 34 1 9-5 22-4 30 4v17c-10 5-22 5-32 0-11 7-23 6-34 0-10 6-22 6-32 0-11 6-23 6-34 0-10 5-20 4-30-3Z" fill="#173d2a"/>
<use href="#twig-fork" x="5" y="9"/><use href="#twig-fork" x="54" y="8"/><use href="#twig-fork" x="105" y="9"/>
<use href="#leaf-cluster-a" x="1" y="5"/><use href="#leaf-cluster-b" x="27" y="4"/><use href="#leaf-cluster-a" x="54" y="6"/><use href="#leaf-cluster-b" x="80" y="4"/><use href="#leaf-cluster-a" x="107" y="5"/><use href="#leaf-cluster-b" x="132" y="5"/>
</g>
<g id="hedge-intact-1"><use href="#hedge-base"/></g>
<g id="hedge-intact-2"><use href="#hedge-base"/><path fill="#8cc968" d="M20 7l8-3 4 4-8 4Zm61 3 8-4 4 4-7 4Zm48 12 7-3 3 5-7 3Z"/></g>
<g id="hedge-intact-3"><use href="#hedge-base"/><path fill="none" stroke="#765033" stroke-width="2" d="M38 27 49 9m36 18L73 7m47 20 13-17"/></g>
<g id="hedge-intact-4"><use href="#hedge-base"/><path fill="#4f9b55" d="M6 27c12-5 20 6 31 0 11-6 20 6 32 0 11-6 21 5 33-1 10-5 19 3 29 0v8H6Z"/></g>
<g id="hedge-intact-5"><use href="#hedge-base"/><path fill="#c7e68a" d="m10 17 5-4 4 3-5 4Zm42-8 5-4 4 3-5 4Zm46 11 5-4 4 3-5 4Zm38-8 5-4 4 3-5 4Z"/></g>
<g id="hedge-intact-6"><use href="#hedge-base"/><path fill="none" stroke="#173d2a" stroke-width="2" d="M19 30 32 14l14 13m33 3 12-15 13 14m17 1 11-12 12 10"/></g>
<g id="hedge-corner"><use href="#hedge-base" transform="translate(0 80) rotate(-90)"/><use href="#hedge-base"/></g>
<g id="hedge-crossing"><use href="#hedge-base"/><use href="#hedge-base" transform="translate(80 -40) rotate(90)"/></g>
<g id="hedge-end"><use href="#hedge-base"/><path fill="#173d2a" stroke="#765033" stroke-width="2" d="M150 7c18 1 19 29 0 31-11-2-11-28 0-31Z"/></g>
<g id="hedge-impact-1"><use href="#hedge-intact-1"/><path fill="#8cc968" d="m70 2 8 3-3 6-8-3Zm12 28 7 2-3 5-7-2Z"/></g>
<g id="hedge-impact-2"><use href="#hedge-intact-1"/><path fill="#173d2a" d="M64 0h30v40H64Z" opacity=".18"/><path fill="#8cc968" d="m66 1 8 3-3 6-8-3Zm15 7 8 3-3 6-8-3Zm4 20 7 2-3 5-7-2Z"/></g>
<g id="hedge-impact-3"><use href="#hedge-intact-1" transform="skewX(-4)"/><path fill="#173d2a" d="M58 0h42v40H58Z" opacity=".25"/><path fill="none" stroke="#765033" stroke-width="3" d="m71 28 11-18m1 16 11-13"/></g>
<g id="hedge-impact-4"><use href="#hedge-intact-1" transform="translate(0 4) scale(1 .86)"/><path fill="#173d2a" d="M53 0h54v40H53Z" opacity=".32"/><path fill="#c7e68a" d="m61 2 8 3-3 6-8-3Zm13 7 8 3-3 6-8-3Zm14-4 8 3-3 6-8-3Zm4 20 7 2-3 5-7-2Z"/></g>
<g id="hedge-breakthrough"><use href="#hedge-intact-1"/><path fill="#11131a" stroke="#173d2a" stroke-width="2" d="M62 0h36v40H62Z"/><path fill="#765033" d="m60 6 10 8-4 5-11-8Zm40 12 10-8 5 5-10 9Z"/><path fill="#8cc968" d="m52 5 8 4-4 6-8-4Zm55 19 8-4 4 6-8 4Z"/></g>
<g id="hedge-breach-1"><use href="#hedge-breakthrough"/><path fill="#4f9b55" d="M44 28c8-7 14-5 18 0v8H42Zm56 0c7-6 13-5 18 0v8H98Z"/></g>
<g id="hedge-breach-2"><use href="#hedge-breakthrough"/><path fill="#296443" d="M35 25c11-8 18-4 27 3v10H33Zm65 3c10-7 18-11 27-3v13H98Z"/></g>
<g id="hedge-breach-3"><use href="#hedge-breakthrough"/><path fill="none" stroke="#765033" stroke-width="3" d="m38 33 17-12m50 12 17-12"/><path fill="#c7e68a" d="m49 29 7-3 3 5-7 3Zm61 0 7-3 3 5-7 3Z"/></g>
</defs>
<rect width="960" height="760" fill="#e7e3d8"/>
<g fill="#173d2a" font-family="monospace" font-size="16"><text x="32" y="34">INTACT STRAIGHT HEDGE FAMILIES</text><text x="32" y="178">JOINS AND BOUNDARIES</text><text x="32" y="322">PERSEVERANCE PROGRESSION</text><text x="32" y="466">PERMANENT BREACHES</text><text x="32" y="610">DARK CELL SCALE PREVIEWS: 34 / 40 / 46 CSS px</text></g>
<g transform="translate(32 48)"><use href="#hedge-intact-1"/><use href="#hedge-intact-2" x="155"/><use href="#hedge-intact-3" x="310"/><use href="#hedge-intact-4" x="465"/><use href="#hedge-intact-5" x="620"/><use href="#hedge-intact-6" x="775"/></g>
<g transform="translate(32 190)"><use href="#hedge-base"/><use href="#hedge-corner" x="190"/><use href="#hedge-crossing" x="380"/><use href="#hedge-end" x="570"/></g>
<g transform="translate(32 335)"><use href="#hedge-intact-1"/><use href="#hedge-impact-1" x="155"/><use href="#hedge-impact-2" x="310"/><use href="#hedge-impact-3" x="465"/><use href="#hedge-impact-4" x="620"/><use href="#hedge-breakthrough" x="775"/></g>
<g transform="translate(32 480)"><use href="#hedge-breach-1"/><use href="#hedge-breach-2" x="220"/><use href="#hedge-breach-3" x="440"/></g>
<g transform="translate(32 630)"><g transform="scale(.85)"><rect width="170" height="85" fill="#11131a"/><use href="#hedge-intact-2" transform="translate(4 21)"/><use href="#hedge-breach-1" transform="translate(4 51)"/></g><g transform="translate(240 0)"><rect width="200" height="100" fill="#11131a"/><use href="#hedge-intact-3" transform="translate(8 28) scale(1.15)"/><use href="#hedge-breach-2" transform="translate(8 62) scale(1.15)"/></g><g transform="translate(510 0)"><rect width="230" height="115" fill="#11131a"/><use href="#hedge-intact-5" transform="translate(10 33) scale(1.3)"/><use href="#hedge-breach-3" transform="translate(10 73) scale(1.3)"/></g></g>
</svg>

After

Width:  |  Height:  |  Size: 6.3 KiB

+6
View File
@@ -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
+24
View File
@@ -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"
+18 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+96
View File
@@ -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;
}
+62 -9
View File
@@ -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;
} }
@@ -307,12 +324,48 @@ static void test_default_map_chunk_bounds(void) {
(unsigned int)chunks, (unsigned int)largest_chunk); (unsigned int)chunks, (unsigned int)largest_chunk);
} }
static void test_authoritative_trails_and_visibility(void) {
maze32_maze_t maze;
open_test_grid(&maze);
maze.starts[1] = (maze32_cell_t){2U, 1U};
maze32_game_t game = initialized_game(&maze);
maze32_game_set_phase(&game, MAZE32_GAME_PHASE_ACTIVE);
uint32_t sequence = 0U;
assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST,
200U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED);
assert(maze32_game_process_next(&game).result == MAZE32_GAME_RESULT_ACCEPTED);
assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_TWO, MAZE32_GAME_DIRECTION_WEST,
200U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED);
assert(maze32_game_process_next(&game).result == MAZE32_GAME_RESULT_ACCEPTED);
maze32_view_t observer;
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, &observer) ==
MAZE32_GAME_RESULT_ACCEPTED);
uint16_t origin = index_of(1U, 1U);
int found = 0;
for (uint16_t index = 0U; index < observer.cell_count; ++index) {
if (observer.cells[index].cell_index == origin) {
assert(observer.cells[index].trail_east == 3U);
found = 1;
}
}
assert(found);
maze32_view_t player;
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, &player) ==
MAZE32_GAME_RESULT_ACCEPTED);
for (uint16_t index = 0U; index < player.cell_count; ++index) {
assert(player.cells[index].trail_east <= 3U && player.cells[index].trail_south <= 3U);
}
puts("authoritative trail ownership and visibility: PASS");
}
int main(void) { int main(void) {
test_movement_cooldown_and_ordering(); test_movement_cooldown_and_ordering();
test_bounded_queue(); test_bounded_queue();
test_shared_cell_meeting(); test_shared_cell_meeting();
test_visibility_and_serialization(); test_visibility_and_serialization();
test_default_map_chunk_bounds(); test_default_map_chunk_bounds();
test_authoritative_trails_and_visibility();
puts("game core tests: PASS"); puts("game core tests: PASS");
return 0; return 0;
} }
+390
View File
@@ -0,0 +1,390 @@
#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");
}
static void test_persistent_bush_wall_breakthrough(void) {
maze32_maze_t maze;
open_test_grid(&maze);
maze.passages[index_of(2U, 2U)] &= (uint8_t)~MAZE32_PASSAGE_EAST;
maze.passages[index_of(3U, 2U)] &= (uint8_t)~MAZE32_PASSAGE_WEST;
maze32_game_t game = active_game(&maze);
game.players[0].position = (maze32_cell_t){2U, 2U};
for (uint8_t hit = 1U; hit < MAZE32_GAME_BREAKTHROUGH_HITS; ++hit) {
maze32_game_command_result_t result =
move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, hit * 500U);
assert(result.result == MAZE32_GAME_RESULT_BLOCKED);
assert(result.breakthrough_hits == hit);
assert(!result.breakthrough_opened);
assert(game.players[0].position.x == 2U);
}
maze32_game_command_result_t result =
move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, 2500U);
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED && result.breakthrough_opened);
assert(result.breakthrough_hits == MAZE32_GAME_BREAKTHROUGH_HITS);
assert(game.players[0].position.x == 3U);
assert(game.opened_passages[index_of(2U, 2U)] & MAZE32_PASSAGE_EAST);
assert(game.opened_passages[index_of(3U, 2U)] & MAZE32_PASSAGE_WEST);
game.players[1].position = (maze32_cell_t){3U, 2U};
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_TWO, MAZE32_GAME_DIRECTION_WEST, 2600U);
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED);
assert(game.players[1].position.x == 2U);
printf("game persistent bush-wall breakthrough: PASS (five impacts, shared passage)\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();
test_persistent_bush_wall_breakthrough();
puts("game mechanics tests: PASS");
return 0;
}
+82 -1
View File
@@ -29,6 +29,24 @@ 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* leave = "{\"v\":1,\"type\":\"leave\",\"seq\":6,\"payload\":{}}";
assert(maze32_protocol_parse(leave, strlen(leave), &message) == MAZE32_PROTOCOL_OK);
assert(message.type == MAZE32_MESSAGE_LEAVE);
const char* diagonal =
"{\"v\":1,\"type\":\"move\",\"seq\":7,\"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 +70,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 +90,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,11 +115,69 @@ 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)");
}
static void test_explicit_leave_releases_only_departing_session(void) {
maze32_lobby_t lobby;
maze32_lobby_init(&lobby);
maze32_session_t* player_1 = NULL;
maze32_session_t* player_2 = NULL;
maze32_session_t* observer = NULL;
assert(maze32_lobby_join_player(&lobby, 10, "Первый", TOKEN_1, &player_1) ==
MAZE32_LOBBY_OK);
assert(maze32_lobby_join_player(&lobby, 11, "Второй", TOKEN_2, &player_2) ==
MAZE32_LOBBY_OK);
assert(maze32_lobby_join_observer(&lobby, 20, OBSERVER_TOKENS[0], &observer) ==
MAZE32_LOBBY_OK);
maze32_role_t role = MAZE32_ROLE_NONE;
unsigned int player_slot = 0U;
assert(maze32_lobby_leave(&lobby, 10, &role, &player_slot));
assert(role == MAZE32_ROLE_PLAYER && player_slot == 1U);
assert(maze32_lobby_session_for_connection(&lobby, 10) == NULL);
assert(maze32_lobby_session_for_connection(&lobby, 11) == player_2);
assert(maze32_lobby_session_for_connection(&lobby, 20) == observer);
assert(maze32_lobby_restore(&lobby, 30, TOKEN_1, &player_1) == MAZE32_LOBBY_INVALID_TOKEN);
assert(maze32_lobby_join_player(&lobby, 30, "Новый первый", OBSERVER_TOKENS[1], &player_1) ==
MAZE32_LOBBY_OK);
assert(player_1->player_slot == 1U);
assert(!maze32_lobby_leave(&lobby, 10, NULL, NULL));
assert(maze32_lobby_leave(&lobby, 11, &role, &player_slot));
assert(role == MAZE32_ROLE_PLAYER && player_slot == 2U);
assert(maze32_lobby_session_for_connection(&lobby, 30) == player_1);
assert(maze32_lobby_session_for_connection(&lobby, 20) == observer);
assert(maze32_lobby_restore(&lobby, 31, TOKEN_2, &player_2) == MAZE32_LOBBY_INVALID_TOKEN);
assert(maze32_lobby_join_player(&lobby, 31, "Новый второй", OBSERVER_TOKENS[2], &player_2) ==
MAZE32_LOBBY_OK);
assert(player_2->player_slot == 2U);
assert(maze32_lobby_leave(&lobby, 20, &role, &player_slot));
assert(role == MAZE32_ROLE_OBSERVER && player_slot == 0U);
assert(maze32_lobby_session_for_connection(&lobby, 30) == player_1);
assert(maze32_lobby_session_for_connection(&lobby, 31) == player_2);
puts("explicit leave session revocation and slot reuse: PASS");
} }
int main(void) { int main(void) {
test_protocol_schema(); test_protocol_schema();
test_lobby_limits_restoration_and_authority(); test_lobby_limits_restoration_and_authority();
test_explicit_leave_releases_only_departing_session();
puts("lobby and protocol tests: PASS"); puts("lobby and protocol tests: PASS");
return 0; return 0;
} }
+5
View File
@@ -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;
+52
View File
@@ -0,0 +1,52 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const zlib = require("node:zlib");
const samplePath = path.join(__dirname, "..", "..", "docs", "hedge-sample-library.svg");
const sample = fs.readFileSync(samplePath, "utf8");
const runtimePath = path.join(__dirname, "..", "..", "web", "hedges.svg");
const runtime = fs.readFileSync(runtimePath, "utf8");
test("hedge review sheet is local editable SVG with complete stable symbols", () => {
assert.match(sample, /^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg"/);
assert.doesNotMatch(sample, /<(?:script|image|foreignObject)\b/i);
assert.doesNotMatch(sample, /(?:href="https?:|url\(|data:|@import|<style\b)/i);
assert.doesNotMatch(sample, /(?:sodipodi:|inkscape:|metadata\b)/i);
for (const identifier of [
"leaf-cluster-a", "leaf-cluster-b", "twig-fork", "hedge-base",
"hedge-intact-1", "hedge-intact-2", "hedge-intact-3", "hedge-intact-4",
"hedge-intact-5", "hedge-intact-6", "hedge-corner", "hedge-crossing",
"hedge-end", "hedge-impact-1", "hedge-impact-2", "hedge-impact-3",
"hedge-impact-4", "hedge-breakthrough", "hedge-breach-1",
"hedge-breach-2", "hedge-breach-3"
]) {
assert.match(sample, new RegExp(`id="${identifier}"`));
}
assert.ok((sample.match(/<use href=/g) || []).length >= 20);
assert.ok(zlib.gzipSync(sample, {level: 9}).length < sample.length);
});
test("runtime hedge sprite remains local and contains approved runtime symbols", () => {
assert.match(runtime, /^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg"/);
assert.doesNotMatch(runtime, /<(?:script|image|foreignObject|metadata)\b/i);
assert.doesNotMatch(runtime, /(?:href="https?:|url\(|data:|@import|<style\b)/i);
for (const identifier of [
"hedge-intact-1", "hedge-intact-2", "hedge-intact-3", "hedge-intact-4",
"hedge-intact-5", "hedge-intact-6", "hedge-impact-1", "hedge-impact-2",
"hedge-impact-3", "hedge-impact-4", "hedge-breakthrough", "hedge-breach-1",
"hedge-breach-2", "hedge-breach-3"
]) {
assert.match(runtime, new RegExp(`id="${identifier}"`));
assert.match(sample, new RegExp(`id="${identifier}"`));
}
assert.ok(zlib.gzipSync(runtime, {level: 9}).length < 4096);
});
test("permanent hedge breaches leave a 60 percent clear centre", () => {
for (const identifier of ["hedge-breach-1", "hedge-breach-2", "hedge-breach-3"]) {
const symbol = runtime.match(new RegExp(`<g id="${identifier}">([\\s\\S]*?)</g>`))[1];
assert.match(symbol, /fill="#11131a" d="M32 0h96v40H32Z"/);
}
});
+523
View File
@@ -0,0 +1,523 @@
"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("directional pad repeat remains slower than the authoritative cooldown", () => {
assert.ok(player.DIRECTIONAL_PAD_REPEAT_MS > 150);
const dispatched = [];
const flow = new player.LatestIntentFlow((direction, element) => {
dispatched.push({direction, element});
return "move-1";
});
const button = {id: "north"};
flow.request("north", button);
flow.request("east", {id: "east"});
assert.deepEqual(flow.latest, {direction: "east", element: {id: "east"}});
flow.release();
assert.equal(flow.latest, null);
assert.deepEqual(dispatched, [{direction: "north", element: button}]);
});
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("connection quality uses one correlated heartbeat and bounded local smoothing", () => {
let clock = 0;
const quality = new player.ConnectionQuality(() => clock);
assert.equal(quality.begin("heartbeat-1"), true);
assert.equal(quality.begin("heartbeat-2"), false);
clock = 85;
assert.equal(quality.acknowledge("wrong", 2), false);
assert.equal(quality.acknowledge("heartbeat-1", 2), true);
assert.deepEqual(quality.latest, {totalMs: 85, communicationMs: 83, handlingMs: 2, received: 85});
for (let index = 0; index < player.QUALITY_SAMPLE_LIMIT + 2; index++) {
clock += 10;
assert.equal(quality.begin(`heartbeat-${index + 2}`), true);
clock += 20;
assert.equal(quality.acknowledge(`heartbeat-${index + 2}`, 1), true);
}
assert.equal(quality.samples.length, player.QUALITY_SAMPLE_LIMIT);
assert.equal(quality.acknowledge("heartbeat-99", 1001), false);
clock += player.HEARTBEAT_STALE_MS + 1;
assert.equal(quality.status(), "stale");
quality.reset();
assert.equal(quality.status(), "unknown");
});
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("responsive player focal point stays above phone controls and composes with map offsets", () => {
assert.deepEqual(player.playerFocalPoint(400, 800), {x: 200, y: 240});
assert.deepEqual(player.playerFocalPoint(900, 400), {x: 378, y: 180});
assert.deepEqual(player.playerFocalMapOffset(24, 7, 1, 1, 5, 5, 46, 400, 800), {x: 0, y: -160});
assert.deepEqual(player.playerFocalMapOffset(0, 7, 0, 0, 7, 7, 46, 400, 800), {x: 138, y: -22});
});
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("countdown completion leaves help closed until the player requests it", () => {
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
const activeView = script.match(/function renderSnapshot\(snapshot\) \{([\s\S]*?)\n \}/)[1];
assert.match(activeView, /setScreen\("match"\)/);
assert.match(activeView, /setDirectionalPadEnabled\(snapshot\.phase === "active"/);
assert.match(activeView, /else hideModal\(\);/);
assert.doesNotMatch(activeView, /showOnboarding\(0\)/);
assert.match(script, /byId\("settings-help"\)\.addEventListener\("click", \(\) => \{ closeSettings\(false\); showOnboarding\(0\); \}\)/);
assert.match(script, /byId\("onboarding-skip"\)\.addEventListener\("click", hideOnboarding\)/);
assert.match(script, /if \(next == null\) hideOnboarding\(\)/);
});
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", resetPlayerControls\)/);
assert.match(script, /connectionState === "offline"\) \{ resetPlayerControls\(\);/);
assert.match(script, /snapshot\.phase === "paused"\) \{ resetPlayerControls\(\)/);
assert.match(script, /state\.avatar\.startX = event\.clientX/);
assert.match(script, /event\.clientX - state\.avatar\.startX/);
assert.match(script, /windowObject\.addEventListener\("resize", refreshPlayerFocalPoint\)/);
assert.match(script, /windowObject\.addEventListener\("orientationchange", refreshPlayerFocalPoint\)/);
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, /settings-help/);
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("playful feedback uses bounded maze-only effects and a top message rail", () => {
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, /<main id="app">\s*<div class="message-rail" id="notice" role="status" aria-live="polite" aria-atomic="true" hidden><\/div>\s*<header/);
assert.doesNotMatch(html, /wall-bump-text/);
assert.match(css, /\.maze\.impact-north/);
assert.match(css, /\.maze\.rate-limited \{ filter: brightness\(\.48\) blur\(2px\)/);
assert.match(css, /\[data-reduced-motion="true"\] \.maze\[class\*="impact-"\] \{ animation: none;/);
assert.match(css, /\[data-reduced-motion="true"\] \.maze\.rate-limited \{ transition: none;/);
assert.match(css, /\.message-rail \{ position: fixed; top:/);
assert.match(script, /moveDirections: new Map\(\)/);
assert.match(script, /if \(message\.payload\.code === "blocked"\) \{ showImpact\(direction, message\.payload\.breakthroughHits\); feedback\(false\); return; \}/);
assert.match(css, /\.cell\.bush-impact/);
assert.match(css, /\.hedge-layer/);
assert.match(css, /\.hedge-edge \{[^}]*height: calc\(var\(--cell\) \* \.2\)/);
assert.match(css, /\.hedge-edge\.hedge-vertical \{[^}]*left: calc\(var\(--hedge-x\) \* var\(--cell\) - var\(--cell\) \* \.5\);[^}]*top: calc\(var\(--hedge-y\) \* var\(--cell\) \+ var\(--cell\) \* \.4\)/);
assert.match(css, /\.observer-maze \.hedge-edge\.hedge-vertical \{[^}]*top: calc\(var\(--hedge-y\) \* var\(--cell\) \+ var\(--cell\) \* \.5 - 1px\)/);
assert.match(css, /\.cell::after \{[^}]*background: #1c1b27;[^}]*opacity: \.55/);
assert.match(script, /projectedHedgeEdges/);
assert.match(script, /svg\.setAttribute\("viewBox", "0 0 160 40"\)/);
assert.match(script, /svg\.setAttribute\("preserveAspectRatio", "none"\)/);
assert.match(script, /\/hedges\.svg#/);
assert.match(script, /if \(message\.payload\.code === "rate_limited"\) \{ showRateLimit\(\); feedback\(false\); return; \}/);
assert.doesNotMatch(script, /Там стена\. Очень убедительная\.|Слишком быстро! Сервер просит немного меньше турбо\./);
});
test("mobile directional pad is explicit, bounded, and resettable", () => {
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");
for (const label of ["Двигаться вверх", "Двигаться вниз", "Двигаться влево", "Двигаться вправо"]) {
assert.match(html, new RegExp(`aria-label="${label}"`));
}
assert.match(html, /class="dpad-center" aria-hidden="true"/);
assert.match(html, /data-direction="north"[^>]*disabled/);
assert.match(css, /\.dpad \{[^}]*grid-template-rows: repeat\(3, 58px\)/);
assert.match(css, /\.dpad button:disabled/);
assert.match(css, /\.dpad button\.active/);
assert.match(css, /\.dpad button\.pending/);
assert.match(css, /\.dpad \{[^}]*touch-action: none/);
assert.match(css, /\.dpad button \{[^}]*touch-action: none/);
assert.match(css, /grid-template-columns: repeat\(3, 62px\)/);
assert.match(script, /function resetDirectionalPad\(\)/);
assert.match(script, /addEventListener\("pointercancel", stopDirectionalPad\)/);
assert.match(script, /addEventListener\("lostpointercapture", stopDirectionalPad\)/);
assert.match(script, /windowObject\.addEventListener\("blur", resetPlayerControls\)/);
assert.match(script, /state\.moveFlow\.request\(direction, element\)/);
});
test("mobile welcome and lobby screens scroll while an active match remains fixed", () => {
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
const mobileRules = css.slice(
css.indexOf("@media (max-width: 720px)"),
css.indexOf("@media (min-width: 721px)")
);
assert.doesNotMatch(mobileRules, /html, body \{[^}]*overflow: hidden/);
assert.doesNotMatch(mobileRules, /^ #app \{[^}]*height: 100dvh[^}]*overflow: hidden/m);
assert.match(mobileRules, /body\.match-active \{ height: 100%; overflow: hidden; \}/);
assert.match(mobileRules, /body\.match-active #app \{ height: 100dvh; min-height: 0; overflow: hidden; \}/);
assert.match(mobileRules, /#app \{ padding: max\(8px, env\(safe-area-inset-top\)\).*max\(8px, env\(safe-area-inset-bottom\)\)/);
assert.match(css, /\.match-screen \{[^}]*overflow: hidden/);
assert.match(css, /\.map-shell \{[^}]*touch-action: none/);
assert.match(script, /documentObject\.body\.classList\.toggle\("match-active", name === "match"\)/);
for (const viewport of [[412, 915], [412, 916], [320, 568], [568, 320]]) {
assert.ok(viewport[0] > 0 && viewport[1] > 0);
}
});
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, 0]], 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, 2]], 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, 0]], 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, 0]], 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("hedge edges are canonical, bounded, and deterministic", () => {
const cells = [[0, 0, 0], [1, 0, 0], [2, 0, 0], [3, 0, 0]];
const first = player.projectedHedgeEdges(cells, 2);
const second = player.projectedHedgeEdges(cells, 2);
assert.deepEqual(first, second);
assert.equal(new Set(first.map(edge => edge.key)).size, first.length);
assert.ok(first.length <= cells.length * 2 + 2 * 2);
assert.equal(player.hedgeVariant(first[0], 6), player.hedgeVariant(first[0], 6));
const opened = player.projectedHedgeEdges([[0, 0, 2], [1, 0, 8]], 2);
assert.equal(opened.filter(edge => edge.opened).length, 1);
assert.equal(player.hedgeKeyForMove(0, 2, "east"), "v:1:0");
});
test("breakthrough travel is bounded and starts only from an opened authoritative edge", () => {
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
assert.match(script, /function breakthroughDirection\(previous, snapshot\)/);
assert.match(script, /projectedHedgeEdges\(snapshot\.cells, snapshot\.width\)\.some\(edge => edge\.key === key && edge\.opened\)/);
assert.match(script, /if \(direction\) showBreakthroughTravel\(direction\);\n else stopBreakthroughTravel\(\);/);
assert.match(script, /for \(let index = 0; index < 4; index\+\+\)/);
assert.match(script, /setTimeout\(stopBreakthroughTravel, 480\)/);
assert.match(script, /if \(reduced\) stopBreakthroughTravel\(\);/);
assert.match(script, /if \(name !== "match"\) stopBreakthroughTravel\(\);/);
assert.match(css, /\.breakthrough-runner \{[^}]*pointer-events: none[^}]*480ms/);
assert.match(css, /@keyframes breakthrough-run/);
assert.match(css, /\[data-reduced-motion="true"\] \.breakthrough-runner/);
});
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, 2);
assert.match(script, /localStorage\.setItem\(TOKEN_KEY, message\.payload\.token\)/);
assert.match(script, /localStorage\.setItem\(NAME_KEY, byId\("player-name"\)\.value\.slice\(0, 32\)\)/);
});
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 \{[^}]*grid-template-columns: repeat\(3, 58px\)/);
assert.match(html, /id="avatar-control"/);
assert.match(html, /id="onboarding"/);
assert.match(html, /id="settings-mute"/);
assert.match(css, /\.avatar-control \{[^}]*width: 96px; height: 96px/);
assert.match(css, /\.avatar-handle \{[^}]*transition: transform/);
assert.match(css, /\.avatar-handle \{[^}]*background: transparent/);
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("one compact settings popover replaces standalone secondary controls", () => {
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, /id="settings-toggle"[^>]*aria-controls="settings-menu"[^>]*aria-expanded="false"[^>]*aria-label="Открыть настройки"/);
for (const identifier of ["settings-menu", "details-text", "settings-mute", "settings-motion", "settings-help", "settings-wifi"]) {
assert.match(html, new RegExp(`id="${identifier}"`));
}
assert.doesNotMatch(html, /id="details-toggle"|id="details-panel"|id="mute-toggle"|id="motion-toggle"|id="onboarding-replay"/);
assert.doesNotMatch(html, /access-controls|access-button/);
assert.match(css, /\.settings-menu \{[^}]*max-height: calc\(100dvh/);
assert.match(css, /\.settings-item\[aria-pressed="true"\]/);
assert.match(script, /function openSettings\(\)/);
assert.match(script, /function closeSettings\(restoreFocus\)/);
assert.match(script, /event\.key === "Escape" && !byId\("settings-menu"\)\.hidden/);
assert.match(script, /!menu\.hidden && !menu\.contains\(event\.target\) && !trigger\.contains\(event\.target\)/);
assert.match(script, /\["settings-mute", "settings-motion", "settings-help"\]\.forEach\(id => \{ byId\(id\)\.hidden = name !== "match"; \}\)/);
assert.match(script, /button\.textContent = muted \? "Звук: выкл" : "Звук: вкл"/);
assert.match(script, /button\.textContent = reduced \? "Движение: выкл" : "Движение: вкл"/);
});
test("connection quality stays compact and exposes an honest response split", () => {
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, /id="quality"[^>]*aria-label="Отклик неизвестен"/);
assert.match(html, /id="quality-details"/);
assert.match(css, /\.quality-pill \{[^}]*min-width: 48px/);
assert.match(script, /send\("heartbeat", \{\}, null\)/);
assert.match(script, /communicationMs: Math\.max\(0, smoothedMs - handlingMs\)/);
assert.match(script, /message\.payload\.command === "heartbeat"/);
assert.match(script, /documentObject\.hidden/);
});
test("reference-derived SVG gameplay symbols preserve local yellow and blue ownership", () => {
const icons = fs.readFileSync(path.join(project, "web/icons.svg"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
const playerUi = fs.readFileSync(path.join(project, "docs/PLAYER_UI.md"), "utf8");
for (const iconId of ["player", "key", "exit-closed", "crystal", "exit-open", "spikes"]) {
assert.match(icons, new RegExp(`<symbol id="${iconId}" viewBox="0 0 64 64">`));
}
assert.doesNotMatch(icons, /<(?:image|foreignObject)\b/i);
assert.doesNotMatch(icons, /data:image\//i);
assert.match(script, /icon\(variant \? "exit-open" : "exit-closed", "object object-exit"\)/);
assert.match(script, /icon\("crystal", "object object-vision"\)/);
assert.match(script, /icon\("player", `pawn/);
assert.match(html, /\/icons\.svg#spikes/);
assert.match(css, /--p1: #ffd33d/);
assert.match(css, /--p2: #41b6ff/);
assert.match(css, /--warning: #ffad38/);
assert.match(css, /--pause: #9b8cff/);
assert.match(css, /\.pawn-owner-two \{ border-style: dashed; \}/);
assert.match(html, /Жёлтый/);
assert.match(html, /Синий/);
assert.match(playerUi, /`player`, `key`, `exit-closed`,\n`crystal`, `exit-open`, and `spikes`/);
});
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, 0], [1, 12, 0], [2, 3, 0], [3, 9, 0]], 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):/);
});
test("finished-player exit returns remaining browsers to the waiting lobby", () => {
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
const gateway = fs.readFileSync(path.join(project, "components/maze32_web/maze32_gateway.c"), "utf8");
const leaveHandler = gateway.match(/if \(message->type == MAZE32_MESSAGE_LEAVE\) \{[\s\S]*?if \(message->type == MAZE32_MESSAGE_START\)/)[0];
assert.match(html, /id="settings-leave"/);
assert.match(html, /id="leave-confirmation"/);
assert.match(script, /\["countdown", "active", "finish_pending", "paused", "finished"\]\.includes\(phase\)/);
assert.match(script, /message\.type === "reset"[\s\S]*?hideModal\(\); setScreen\("lobby"\)/);
assert.match(script, /message\.payload\.command === "leave" && message\.requestId === state\.leaveRequest/);
assert.match(script, /windowObject\.localStorage\.removeItem\(TOKEN_KEY\)/);
assert.match(script, /setScreen\("welcome"\)/);
assert.match(leaveHandler, /leaving_role == MAZE32_ROLE_PLAYER &&\s*gateway->lobby\.phase != MAZE32_LOBBY_WAITING;/);
assert.doesNotMatch(leaveHandler, /gateway->game\.phase != MAZE32_GAME_PHASE_FINISHED/);
});
test("authoritative trail masks render bounded yellow, blue, and shared paths", () => {
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
assert.equal(player.snapshotIsValid({width: 2, height: 2, cells: [[0, 6, 0, 3, 0]], objects: []}), true);
assert.equal(player.snapshotIsValid({width: 2, height: 2, cells: [[0, 6, 0, 4, 0]], objects: []}), false);
assert.match(script, /function renderTrails\(/);
assert.match(script, /if \(owners & 1\) paths\[0\]\.push/);
assert.match(script, /if \(owners & 2\) paths\[1\]\.push/);
assert.match(script, /renderTrails\(maze, snapshot\.cells/);
assert.match(css, /\.trail-layer \{[^}]*pointer-events: none/);
assert.match(css, /\.trail-player-one \{ stroke: var\(--p1\); stroke-dasharray/);
assert.match(css, /\.trail-player-two \{ stroke: var\(--p2\); stroke-dasharray/);
});
+1 -1
View File
@@ -1 +1 @@
0.6.0 0.14.0
+22
View File
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 40" aria-hidden="true">
<defs>
<g id="leaf-cluster-a" stroke="#173d2a" stroke-width="1.5" stroke-linejoin="round"><path fill="#296443" d="M2 13C0 6 7 0 13 3c2-7 12-5 14 2 8-1 11 8 5 13 3 8-6 13-12 8-6 5-14 1-13-6-7 0-9-5-5-7Z"/><path fill="#4f9b55" d="M7 13c2-5 7-8 12-7 5 0 8 4 9 8-3 5-8 8-14 7-5 0-8-3-7-8Z"/><path fill="#c7e68a" d="M12 9c3-3 8-3 10 0-2 3-7 4-10 0Z"/></g>
<g id="leaf-cluster-b" stroke="#173d2a" stroke-width="1.5" stroke-linejoin="round"><path fill="#296443" d="M1 16C-1 8 5 2 11 4c1-7 11-7 14-1 7-3 13 5 8 11 5 6-2 14-9 11-4 7-15 4-15-3-8 2-12-4-8-6Z"/><path fill="#8cc968" d="M8 14c2-5 8-7 13-5 4 2 6 7 4 11-5 4-13 3-17-2Z"/></g>
<g id="twig-fork" fill="none" stroke="#765033" stroke-width="2" stroke-linecap="round"><path d="M3 20 16 8 29 3M16 8l2 12M16 8 7 4"/></g>
<g id="hedge-base"><path d="M0 11C9 2 20 4 30 9c12-8 23-7 34 1 10-5 22-5 32 1 11-7 23-6 34 1 9-5 22-4 30 4v17c-10 5-22 5-32 0-11 7-23 6-34 0-10 6-22 6-32 0-11 6-23 6-34 0-10 5-20 4-30-3Z" fill="#173d2a"/><use href="#twig-fork" x="5" y="9"/><use href="#twig-fork" x="54" y="8"/><use href="#twig-fork" x="105" y="9"/><use href="#leaf-cluster-a" x="1" y="5"/><use href="#leaf-cluster-b" x="27" y="4"/><use href="#leaf-cluster-a" x="54" y="6"/><use href="#leaf-cluster-b" x="80" y="4"/><use href="#leaf-cluster-a" x="107" y="5"/><use href="#leaf-cluster-b" x="132" y="5"/></g>
<g id="hedge-intact-1"><use href="#hedge-base"/></g>
<g id="hedge-intact-2"><use href="#hedge-base"/><path fill="#8cc968" d="M20 7l8-3 4 4-8 4Zm61 3 8-4 4 4-7 4Zm48 12 7-3 3 5-7 3Z"/></g>
<g id="hedge-intact-3"><use href="#hedge-base"/><path fill="none" stroke="#765033" stroke-width="2" d="M38 27 49 9m36 18L73 7m47 20 13-17"/></g>
<g id="hedge-intact-4"><use href="#hedge-base"/><path fill="#4f9b55" d="M6 27c12-5 20 6 31 0 11-6 20 6 32 0 11-6 21 5 33-1 10-5 19 3 29 0v8H6Z"/></g>
<g id="hedge-intact-5"><use href="#hedge-base"/><path fill="#c7e68a" d="m10 17 5-4 4 3-5 4Zm42-8 5-4 4 3-5 4Zm46 11 5-4 4 3-5 4Zm38-8 5-4 4 3-5 4Z"/></g>
<g id="hedge-intact-6"><use href="#hedge-base"/><path fill="none" stroke="#173d2a" stroke-width="2" d="M19 30 32 14l14 13m33 3 12-15 13 14m17 1 11-12 12 10"/></g>
<g id="hedge-impact-1"><use href="#hedge-intact-1"/><path fill="#8cc968" d="m70 2 8 3-3 6-8-3Zm12 28 7 2-3 5-7-2Z"/></g>
<g id="hedge-impact-2"><use href="#hedge-intact-1"/><path fill="#173d2a" d="M64 0h30v40H64Z" opacity=".18"/></g>
<g id="hedge-impact-3"><use href="#hedge-intact-1" transform="skewX(-4)"/><path fill="none" stroke="#765033" stroke-width="3" d="m71 28 11-18m1 16 11-13"/></g>
<g id="hedge-impact-4"><use href="#hedge-intact-1" transform="translate(0 4) scale(1 .86)"/><path fill="#c7e68a" d="m61 2 8 3-3 6-8-3Zm13 7 8 3-3 6-8-3Zm14-4 8 3-3 6-8-3Z"/></g>
<g id="hedge-breakthrough"><use href="#hedge-intact-1"/><path fill="#11131a" stroke="#173d2a" stroke-width="2" d="M62 0h36v40H62Z"/><path fill="#765033" d="m60 6 10 8-4 5-11-8Zm40 12 10-8 5 5-10 9Z"/></g>
<g id="hedge-breach-1"><use href="#hedge-intact-1"/><path fill="#11131a" d="M32 0h96v40H32Z"/><path fill="#296443" d="M0 8c12-9 23-6 32 2v22c-11 6-22 4-32-3Zm128 2c11-8 22-11 32-2v21c-10 7-21 9-32 3Z"/><path fill="none" stroke="#765033" stroke-width="3" d="m8 30 17-15m127 15-17-15"/><path fill="#4f9b55" d="m27 30 6-5 5 7-7 4Zm99 2 7-7 6 5-5 6Z"/></g>
<g id="hedge-breach-2"><use href="#hedge-intact-3"/><path fill="#11131a" d="M32 0h96v40H32Z"/><path fill="#173d2a" d="M0 5c14-7 25-5 32 5v22c-10 5-22 4-32-2Zm128 5c8-10 19-12 32-5v25c-11 6-22 7-32 2Z"/><path fill="none" stroke="#765033" stroke-width="4" d="M4 32 21 9m7 25L15 19m141 13-17-23m-7 25 13-15"/><path fill="#8cc968" d="m25 28 7-6 5 7-8 5Zm98 1 7-7 6 6-7 5Z"/></g>
<g id="hedge-breach-3"><use href="#hedge-intact-5"/><path fill="#11131a" d="M32 0h96v40H32Z"/><path fill="#4f9b55" d="M0 10c10-10 22-8 32 0v21c-12 6-23 3-32-4Zm128 0c10-8 22-10 32 0v17c-10 9-21 10-32 4Z"/><path fill="none" stroke="#765033" stroke-width="3" d="M5 31 23 12m-9 22 14-13m132-10-18-19m9 22-14-13"/><path fill="#296443" d="m29 30 7-5 4 7-7 3Zm94 3 6-8 7 5-5 7Z"/></g>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg">
<!-- Reference-derived gameplay symbols use a shared 64 by 64 view box and currentColor. -->
<symbol id="player" viewBox="0 0 64 64"><path fill="currentColor" d="M37 5a9 9 0 1 1 0 18 9 9 0 0 1 0-18Zm-8 20 10-2 10 7 8-5a6 6 0 1 1 6 10l-12 8a6 6 0 0 1-7 0l-7-5-3 11 12 8a6 6 0 0 1-7 10L24 57a6 6 0 0 1-2-7l4-15-7 4-4 10a6 6 0 1 1-11-4l5-14a6 6 0 0 1 3-3l13-7a6 6 0 0 1 4 4Z"/></symbol>
<symbol id="key" viewBox="0 0 64 64"><path fill="currentColor" fill-rule="evenodd" d="M43 5a17 17 0 0 0-14 27L5 56l3 3 5-2 3 4 5-2 3 4 6-6-4-4 7-7A17 17 0 1 0 43 5Zm0 11a6 6 0 1 1 0 12 6 6 0 0 1 0-12Z" clip-rule="evenodd"/></symbol>
<symbol id="exit-closed" viewBox="0 0 64 64"><path fill="currentColor" d="M15 59V29C15 10 49 10 49 29v30H15Zm10-30v30h14V29c0-8-14-8-14 0Zm-17-8 6 2-2 10-7-2 3-10Zm42 2 6-2 3 10-7 2-2-10ZM7 38h7v11H7V38Zm43 0h7v11h-7V38ZM14 9l7-5 4 9-7 3-4-7Zm32-5 7 5-4 7-7-3 4-9Z"/></symbol>
<symbol id="exit-open" viewBox="0 0 64 64"><path fill="currentColor" d="M15 59V29C15 10 49 10 49 29v30H15Zm10-30v30h14V29c0-8-14-8-14 0ZM7 38h7v11H7V38Zm43 0h7v11h-7V38ZM14 9l7-5 4 9-7 3-4-7Zm32-5 7 5-4 7-7-3 4-9Z"/><path fill="currentColor" d="M25 36h10v-7l14 12-14 12v-7H25V36Z"/></symbol>
<symbol id="crystal" viewBox="0 0 64 64"><path fill="currentColor" fill-rule="evenodd" d="m32 5 19 20-19 34L13 25 32 5Zm0 12-8 9h16l-8-9Zm-11 17 11 17 11-17H21Z" clip-rule="evenodd"/><path fill="currentColor" d="M32 1h4v8h-4V1ZM5 29h9v5H5v-5Zm45 0h9v5h-9v-5ZM10 11l6-6 4 4-6 6-4-4Zm34-2 4-4 6 6-4 4-6-6ZM10 53l4-4 6 6-4 4-6-6Zm34 2 6-6 4 4-6 6-4-4Z"/></symbol>
<symbol id="spikes" viewBox="0 0 64 64"><path fill="currentColor" d="m9 47 9-32 9 32H9Zm18 0 9-32 9 32H27Zm18 0 9-32 9 32H45ZM5 51h54v9H5v-9Z"/></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="gear" viewBox="0 0 24 24"><path d="M10 2h4l.7 3 2.6 1.1 2.7-1.5 2 3.5-2.2 2 .1 3 2.1 1.9-2 3.5-2.7-1.5-2.6 1.1-.7 3h-4l-.7-3-2.6-1.1-2.7 1.5-2-3.5 2.1-1.9.1-3-2.2-2 2-3.5 2.7 1.5L9.3 5Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="1.8"/></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="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: 4.8 KiB

+85 -120
View File
@@ -2,129 +2,94 @@
<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> <div class="message-rail" id="notice" role="status" aria-live="polite" aria-atomic="true" hidden></div>
<div class="grid"> <header class="topbar">
<section> <a class="brand" href="/" aria-label="maze32, на главную">MAZE<span>32</span></a>
<h2>Игроки</h2> <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>
<div class="player p1" id="p1"><b>P1</b><span class="name">Свободно</span><span class="lamp">●</span></div> <span class="quality-pill" id="quality" data-state="unknown" role="status" aria-live="polite" aria-label="Отклик неизвестен"><svg aria-hidden="true"><use href="/icons.svg#signal"></use></svg><span id="quality-text">—</span></span>
<div class="player p2" id="p2"><b>P2</b><span class="name">Свободно</span><span class="lamp">●</span></div> <button class="icon-button" id="settings-toggle" type="button" aria-controls="settings-menu" aria-expanded="false" aria-label="Открыть настройки"><svg aria-hidden="true"><use href="/icons.svg#gear"></use></svg></button>
</header>
<aside class="settings-menu" id="settings-menu" hidden aria-label="Настройки">
<strong>Настройки</strong>
<span class="settings-details" id="details-text">Подключение ещё не готово.</span>
<span class="settings-quality" id="quality-details">Отклик: неизвестен</span>
<button class="settings-item" id="settings-mute" type="button" aria-pressed="false" hidden>Звук: вкл</button>
<button class="settings-item" id="settings-motion" type="button" aria-pressed="false" hidden>Движение: вкл</button>
<button class="settings-item" id="settings-help" type="button" hidden>Показать обучение</button>
<a class="settings-item" id="settings-wifi" href="/provision">Настроить Wi‑Fi</a>
<button class="settings-item settings-exit" id="settings-leave" type="button" hidden>Выйти из игры</button>
</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" aria-label="Жёлтый игрок"><svg aria-hidden="true"><use href="/icons.svg#player"></use></svg></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" aria-label="Синий игрок"><svg aria-hidden="true"><use href="/icons.svg#player"></use></svg></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"><svg><use href="/icons.svg#player"></use></svg></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"><svg class="avatar-player-icon"><use href="/icons.svg#player"></use></svg><svg class="avatar-direction-icon"><use href="/icons.svg#arrow"></use></svg></span></button>
<div class="wall-bump" id="wall-bump" aria-hidden="true"><svg hidden><use href="/icons.svg#spikes"></use></svg></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>
<nav class="dpad" aria-label="Управление движением"><button type="button" data-direction="north" aria-label="Двигаться вверх" disabled>▲</button><button type="button" data-direction="west" aria-label="Двигаться влево" disabled>◀</button><span class="dpad-center" aria-hidden="true">●</span><button type="button" data-direction="east" aria-label="Двигаться вправо" disabled>▶</button><button type="button" data-direction="south" aria-label="Двигаться вниз" disabled>▼</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"><svg aria-hidden="true"><use href="/icons.svg#player"></use></svg> Жёлтый <strong id="observer-p1">готов</strong></span><span class="player-two"><svg aria-hidden="true"><use href="/icons.svg#player"></use></svg> Синий <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>
</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>
(() => { <section class="modal" id="leave-confirmation" hidden role="dialog" aria-modal="true" aria-labelledby="leave-title"><div class="modal-card"><p class="eyebrow">Подтверждение</p><h2 id="leave-title">Выйти из игры?</h2><p id="leave-text"></p><div class="button-row"><button class="button primary" id="leave-accept" type="button">Выйти</button><button class="button" id="leave-cancel" type="button">Остаться</button></div></div></section>
const $ = id => document.getElementById(id); <script src="/player.js" defer></script>
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>
+346
View File
@@ -0,0 +1,346 @@
: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: #ffd33d;
--p2: #41b6ff;
--plant: #45b86b;
--plant-dark: #176638;
--success: #72e48c;
--warning: #ffad38;
--vision: #37c9ff;
--error: #ff647a;
--pause: #9b8cff;
--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; }
.quality-pill { display: flex; align-items: center; gap: 4px; min-width: 48px; color: var(--muted); font-size: .68rem; font-weight: 800; white-space: nowrap; }
.quality-pill svg { width: 17px; height: 17px; }
.quality-pill[data-state="good"] { color: var(--success); }
.quality-pill[data-state="delayed"] { color: var(--warning); }
.quality-pill[data-state="slow"], .quality-pill[data-state="offline"] { color: var(--error); }
.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); }
.settings-menu { position: fixed; z-index: 40; top: max(58px, calc(env(safe-area-inset-top) + 58px)); right: max(12px, env(safe-area-inset-right)); display: grid; gap: 8px; width: min(300px, calc(100% - 24px)); max-height: calc(100dvh - max(70px, env(safe-area-inset-top)) - max(12px, env(safe-area-inset-bottom))); overflow-y: auto; padding: 14px; border: 2px solid var(--edge); background: #120b21f5; box-shadow: 6px 6px 0 var(--shadow); font-size: .8rem; }
.settings-details { color: var(--muted); line-height: 1.45; }
.settings-quality { color: var(--muted); line-height: 1.45; }
.settings-item { min-height: 42px; display: flex; align-items: center; justify-content: space-between; border: 1px solid var(--edge); background: #0c0815; padding: 8px 10px; color: var(--ink); font: inherit; text-align: left; text-decoration: none; cursor: pointer; }
.settings-item:hover, .settings-item:focus-visible { border-color: var(--aqua); background: #164049; }
.settings-item[aria-pressed="true"] { border-color: var(--warning); color: var(--warning); }
.settings-exit { border-color: var(--error); color: var(--error); }
.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; }
.avatar svg { width: 82%; height: 82%; }
.player-one .avatar, .identity.player-one .avatar { color: var(--p1); }
.player-two .avatar, .identity.player-two .avatar { color: var(--p2); }
.player-two .avatar { border-style: dashed; }
.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(calc(-50% + var(--map-offset-x, 0px)), calc(-50% + var(--map-offset-y, 0px))); }
.cell { position: relative; width: var(--cell); height: var(--cell); background: #11131a; }
.cell::after { content: ""; position: absolute; inset: 7px; background: #1c1b27; opacity: .55; }
.trail-layer { position: absolute; z-index: 0; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
.trail-path { fill: none; stroke-width: .065; stroke-linecap: round; opacity: .58; }
.trail-player-one { stroke: var(--p1); stroke-dasharray: .06 .20; }
.trail-player-two { stroke: var(--p2); stroke-dasharray: .03 .16; stroke-dashoffset: .08; }
.hedge-layer { position: absolute; z-index: 1; inset: 0; pointer-events: none; overflow: visible; }
.hedge-edge { position: absolute; width: var(--cell); height: calc(var(--cell) * .2); left: calc(var(--hedge-x) * var(--cell)); top: calc(var(--hedge-y) * var(--cell) - var(--cell) * .1); transform-origin: 50% 50%; pointer-events: none; }
.hedge-edge svg { display: block; width: 100%; height: 100%; overflow: visible; }
.hedge-edge.hedge-vertical { left: calc(var(--hedge-x) * var(--cell) - var(--cell) * .5); top: calc(var(--hedge-y) * var(--cell) + var(--cell) * .4); transform: rotate(90deg); }
.cell.bush-impact { z-index: 5; animation: bush-resist .34s steps(3, end); }
.cell.bush-stage-2 { filter: saturate(1.25); }
.cell.bush-stage-3 { filter: brightness(1.2) saturate(1.4); }
.cell.bush-stage-4 { filter: brightness(1.35) saturate(1.65); }
.object, .pawn { position: absolute; z-index: 3; inset: 16%; display: grid; place-items: center; }
.object svg, .pawn svg { width: 100%; height: 100%; }
.object-key { color: var(--p1); }
.object-key[data-owner="two"] { color: var(--p2); }
.object-key::after { content: ""; position: absolute; right: -4px; bottom: -4px; width: 8px; height: 8px; border: 2px solid currentColor; border-radius: 50%; background: #090711; }
.object-key[data-owner="two"]::after { border-radius: 0; transform: rotate(45deg); }
.object-exit { inset: 8%; background: #0b0911; }
.object-exit[data-owner="one"] { color: var(--p1); }
.object-exit[data-owner="two"] { color: var(--p2); }
.object-exit[data-owner="two"]::after { content: ""; position: absolute; inset: 6%; border: 2px dashed currentColor; }
.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(--vision); border-radius: 50%; background: #37c9ff30; box-shadow: 0 0 10px var(--vision); animation: artifact 1.2s steps(4, end) infinite; }
.pawn { inset: 12%; padding: 5%; border: 3px solid currentColor; background: #0b0712; 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); }
.match-screen.player-two-mode .pawn-opponent { color: var(--p1); }
.pawn-owner-two { border-style: dashed; }
.wall-bump { position: absolute; inset: 0; z-index: 6; border: 0 solid transparent; pointer-events: none; }
.wall-bump.show { border-color: var(--error); }
.maze.impact-north { animation: impact-north .28s steps(3, end); }
.maze.impact-east { animation: impact-east .28s steps(3, end); }
.maze.impact-south { animation: impact-south .28s steps(3, end); }
.maze.impact-west { animation: impact-west .28s steps(3, end); }
.maze.rate-limited { filter: brightness(.48) blur(2px); transition: filter .12s linear; }
[data-reduced-motion="true"] .maze[class*="impact-"] { animation: none; }
[data-reduced-motion="true"] .maze.rate-limited { transition: none; }
[data-reduced-motion="true"] .breakthrough-runner, [data-reduced-motion="true"] .breakthrough-accent { display: none; }
[data-reduced-motion="true"] .wall-bump.show { border-width: 4px; }
[data-reduced-motion="true"] .wall-bump.impact-north { border-top-color: var(--error); }
[data-reduced-motion="true"] .wall-bump.impact-east { border-right-color: var(--error); }
[data-reduced-motion="true"] .wall-bump.impact-south { border-bottom-color: var(--error); }
[data-reduced-motion="true"] .wall-bump.impact-west { border-left-color: var(--error); }
.avatar-control { position: absolute; z-index: 8; left: var(--player-focal-x, 50%); top: var(--player-focal-y, 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: transparent; 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-control.breakthrough-travelling { opacity: 0; }
.breakthrough-runner { position: absolute; z-index: 9; left: var(--player-focal-x, 50%); top: var(--player-focal-y, 50%); width: 52px; height: 52px; display: grid; place-items: center; color: var(--p1); pointer-events: none; animation: breakthrough-run 480ms steps(6, end) forwards; }
.breakthrough-runner.player-two { color: var(--p2); }
.breakthrough-runner svg { width: 100%; height: 100%; padding: 5%; border: 3px solid currentColor; background: #0b0712; box-shadow: 3px 3px 0 #000; }
.breakthrough-accent { position: absolute; width: 8px; height: 8px; border: 2px solid #c7e68a; background: var(--plant); box-shadow: 1px 1px 0 #173d2a; animation: breakthrough-accent 480ms steps(5, end) forwards; }
.accent-1 { left: 4px; top: 6px; }
.accent-2 { right: 2px; top: 12px; background: var(--aqua); }
.accent-3 { left: 12px; bottom: 1px; background: #8cc968; }
.accent-4 { right: 9px; bottom: 5px; background: var(--warning); }
.avatar-player-icon { width: 74%; height: 74%; }
.avatar-direction-icon { 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-direction-icon { display: block; }
.avatar-control[data-direction="north"] .avatar-direction-icon { transform: rotate(0deg); }
.avatar-control[data-direction="east"] .avatar-direction-icon { transform: rotate(90deg); }
.avatar-control[data-direction="south"] .avatar-direction-icon { transform: rotate(180deg); }
.avatar-control[data-direction="west"] .avatar-direction-icon { 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; }
.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: 16; right: max(18px, env(safe-area-inset-right)); bottom: max(18px, env(safe-area-inset-bottom)); display: grid; grid-template-columns: repeat(3, 58px); grid-template-rows: repeat(3, 58px); gap: 5px; touch-action: none; user-select: none; -webkit-user-select: none; }
.dpad button { min-width: 58px; min-height: 58px; border: 2px solid var(--aqua); background: #102a30df; color: var(--aqua); box-shadow: 4px 4px 0 #020506; cursor: pointer; touch-action: none; user-select: none; -webkit-user-select: none; }
.dpad button.active, .dpad button:active { transform: translate(3px, 3px); border-style: dashed; box-shadow: 1px 1px 0 #020506; }
.dpad button:disabled { border-color: var(--edge); background: #171027c9; color: var(--muted); opacity: .55; cursor: not-allowed; }
.dpad button.pending { outline: 3px solid var(--warning); outline-offset: -5px; }
.dpad button:first-child { grid-column: 2; }
.dpad button:nth-child(2) { grid-column: 1; grid-row: 2; }
.dpad-center { grid-column: 2; grid-row: 2; display: grid; place-items: center; border: 2px solid #3b2951; background: #0d0915d9; color: #5a4174; pointer-events: none; }
.dpad button:nth-child(4) { grid-column: 3; grid-row: 2; }
.dpad button:last-child { grid-column: 2; grid-row: 3; }
.message-rail { position: fixed; top: max(4px, env(safe-area-inset-top)); z-index: 45; left: 50%; width: min(620px, calc(100% - 20px)); transform: translateX(-50%); padding: 8px 14px; border: 2px solid var(--warning); background: #191020f2; color: var(--ink); box-shadow: 4px 4px 0 #020104; font-size: .76rem; text-align: center; pointer-events: none; }
.message-rail.error { border-color: var(--error); }
.message-rail.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); border-style: dashed; }
.observer-players svg { width: 16px; height: 16px; vertical-align: middle; }
.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; }
.observer-maze .cell::after { inset: 2px; }
.observer-maze .hedge-edge { height: 2px; top: calc(var(--hedge-y) * var(--cell) - 1px); }
.observer-maze .hedge-edge.hedge-vertical { left: calc(var(--hedge-x) * var(--cell) - var(--cell) * .5); top: calc(var(--hedge-y) * var(--cell) + var(--cell) * .5 - 1px); }
.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; padding: 1px; 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); }
.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; }
body.match-active { height: 100%; overflow: hidden; }
#app { padding: max(8px, env(safe-area-inset-top)) max(8px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left)); }
body.match-active #app { height: 100dvh; min-height: 0; overflow: hidden; }
.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); min-height: 0; 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; }
.settings-menu { top: max(52px, calc(env(safe-area-inset-top) + 52px)); }
.dpad { right: max(12px, env(safe-area-inset-right)); bottom: max(12px, env(safe-area-inset-bottom)); grid-template-columns: repeat(3, 62px); grid-template-rows: repeat(3, 62px); gap: 5px; }
.dpad button { min-width: 62px; min-height: 62px; font-size: 1.3rem; }
.connection-pill { font-size: .58rem; }
.quality-pill { min-width: 40px; font-size: .6rem; }
.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; }
.dpad { right: max(12px, env(safe-area-inset-right)); bottom: max(8px, env(safe-area-inset-bottom)); grid-template-columns: repeat(3, 52px); grid-template-rows: repeat(3, 52px); }
.dpad button { min-width: 52px; min-height: 52px; }
}
@media (hover: none) and (pointer: coarse) and (max-height: 720px) {
.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 bush-resist { 50% { transform: translateX(3px) rotate(1deg); } }
@keyframes impact-north { 50% { transform: translate(calc(-50% + var(--map-offset-x, 0px)), calc(-50% + var(--map-offset-y, 0px) - 10px)); } }
@keyframes impact-east { 50% { transform: translate(calc(-50% + var(--map-offset-x, 0px) + 10px), calc(-50% + var(--map-offset-y, 0px))); } }
@keyframes impact-south { 50% { transform: translate(calc(-50% + var(--map-offset-x, 0px)), calc(-50% + var(--map-offset-y, 0px) + 10px)); } }
@keyframes impact-west { 50% { transform: translate(calc(-50% + var(--map-offset-x, 0px) - 10px), calc(-50% + var(--map-offset-y, 0px))); } }
@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); } }
@keyframes breakthrough-run { 0% { transform: translate(calc(-50% + var(--breakthrough-start-x)), calc(-50% + var(--breakthrough-start-y))) scale(.78, 1.16); } 42% { transform: translate(calc(-50% + var(--breakthrough-mid-x)), calc(-50% + var(--breakthrough-mid-y))) scale(1.16, .78); } 100% { transform: translate(-50%, -50%) scale(1); } }
@keyframes breakthrough-accent { 0% { opacity: 0; transform: translate(0, 0) scale(.4); } 25% { opacity: 1; } 100% { opacity: 0; transform: translate(var(--breakthrough-start-x, 12px), -18px) scale(1.35); } }
+1289
View File
File diff suppressed because it is too large Load Diff