Compare commits
29 Commits
115a3bd0a1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3518f8a152 | |||
| 8ad856b931 | |||
| b6d484dbc9 | |||
| 44433aee9c | |||
| 8a37412416 | |||
| 720cd18b6e | |||
| 07eaed9c93 | |||
| 9ce44cdef7 | |||
| e10ad3acb8 | |||
| bb5bd73ebd | |||
| 8949891a6b | |||
| 2501345740 | |||
| abb3ff27b7 | |||
| 8214e7f7ed | |||
| 5a806fb5b8 | |||
| 1396bad76d | |||
| 381eacf403 | |||
| 37704028c5 | |||
| 3827ba7ec7 | |||
| f043b63732 | |||
| 5346f9296f | |||
| 4b1ac6ca5a | |||
| a2a8f4a59d | |||
| cf187bdd1c | |||
| 7902800ff7 | |||
| c730cb818d | |||
| ffdaa299bc | |||
| d042c3ce90 | |||
| 9339578dc9 |
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
.pio/
|
.pio/
|
||||||
.vscode/
|
.vscode/
|
||||||
.obsidian/
|
.obsidian/
|
||||||
|
data/*.gz
|
||||||
|
|||||||
@@ -98,9 +98,9 @@ Do not silently choose one requirement over another.
|
|||||||
### Build and verification
|
### Build and verification
|
||||||
|
|
||||||
- Parse the project configuration with
|
- Parse the project configuration with
|
||||||
`/home/sasa/.platformio/penv/bin/pio project config` after changing
|
`pio project config` after changing
|
||||||
`platformio.ini`.
|
`platformio.ini`.
|
||||||
- Build with `/home/sasa/.platformio/penv/bin/pio run` once firmware sources
|
- Build with `pio run` once firmware sources
|
||||||
exist.
|
exist.
|
||||||
- Add host-side deterministic tests for portable game logic before relying only
|
- Add host-side deterministic tests for portable game logic before relying only
|
||||||
on device tests.
|
on device tests.
|
||||||
|
|||||||
+54
-6
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,11 @@
|
|||||||
extern "C" {
|
extern "C" {
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#define MAZE32_MAZE_MAX_WIDTH 128U
|
#define MAZE32_MAZE_MAX_WIDTH 32U
|
||||||
#define MAZE32_MAZE_MAX_HEIGHT 64U
|
#define MAZE32_MAZE_MAX_HEIGHT 32U
|
||||||
#define MAZE32_MAZE_MAX_CELLS (MAZE32_MAZE_MAX_WIDTH * MAZE32_MAZE_MAX_HEIGHT)
|
#define MAZE32_MAZE_MAX_CELLS (MAZE32_MAZE_MAX_WIDTH * MAZE32_MAZE_MAX_HEIGHT)
|
||||||
#define MAZE32_MAZE_DEFAULT_WIDTH 128U
|
#define MAZE32_MAZE_DEFAULT_WIDTH 32U
|
||||||
#define MAZE32_MAZE_DEFAULT_HEIGHT 64U
|
#define MAZE32_MAZE_DEFAULT_HEIGHT 32U
|
||||||
#define MAZE32_MAZE_MAX_LANDMARK_PAIRS 16U
|
#define MAZE32_MAZE_MAX_LANDMARK_PAIRS 16U
|
||||||
#define MAZE32_MAZE_MAX_ARTIFACT_PAIRS 16U
|
#define MAZE32_MAZE_MAX_ARTIFACT_PAIRS 16U
|
||||||
#define MAZE32_MAZE_RAM_BUDGET_BYTES (64U * 1024U)
|
#define MAZE32_MAZE_RAM_BUDGET_BYTES (64U * 1024U)
|
||||||
|
|||||||
@@ -571,11 +571,11 @@ maze32_maze_config_t maze32_maze_default_config(void) {
|
|||||||
.width = MAZE32_MAZE_DEFAULT_WIDTH,
|
.width = MAZE32_MAZE_DEFAULT_WIDTH,
|
||||||
.height = MAZE32_MAZE_DEFAULT_HEIGHT,
|
.height = MAZE32_MAZE_DEFAULT_HEIGHT,
|
||||||
.initial_view_radius = 3U,
|
.initial_view_radius = 3U,
|
||||||
.cross_half_pairs = 3U,
|
.cross_half_pairs = 2U,
|
||||||
.extra_cycle_pairs = 64U,
|
.extra_cycle_pairs = 8U,
|
||||||
.minimum_exit_separation = 64U,
|
.minimum_exit_separation = 22U,
|
||||||
.landmark_pairs = 8U,
|
.landmark_pairs = 2U,
|
||||||
.artifact_pairs = 4U,
|
.artifact_pairs = 1U,
|
||||||
.maximum_attempts = 8U,
|
.maximum_attempts = 8U,
|
||||||
};
|
};
|
||||||
return config;
|
return config;
|
||||||
|
|||||||
@@ -35,36 +35,33 @@ typedef struct {
|
|||||||
typedef struct {
|
typedef struct {
|
||||||
EventGroupHandle_t events;
|
EventGroupHandle_t events;
|
||||||
SemaphoreHandle_t operation_mutex;
|
SemaphoreHandle_t operation_mutex;
|
||||||
esp_netif_t *station_netif;
|
esp_netif_t* station_netif;
|
||||||
esp_netif_t *access_point_netif;
|
esp_netif_t* access_point_netif;
|
||||||
volatile maze32_network_state_t state;
|
volatile maze32_network_state_t state;
|
||||||
|
volatile int station_wait_active;
|
||||||
unsigned int retry_count;
|
unsigned int retry_count;
|
||||||
char access_point_ssid[24];
|
char access_point_ssid[24];
|
||||||
maze32_reconfiguration_guard_fn reconfiguration_guard;
|
maze32_reconfiguration_guard_fn reconfiguration_guard;
|
||||||
void *guard_context;
|
void* guard_context;
|
||||||
httpd_handle_t server;
|
httpd_handle_t server;
|
||||||
} maze32_network_t;
|
} maze32_network_t;
|
||||||
|
|
||||||
esp_err_t maze32_network_init(maze32_network_t *network,
|
esp_err_t maze32_network_init(maze32_network_t* network, maze32_reconfiguration_guard_fn guard,
|
||||||
maze32_reconfiguration_guard_fn guard,
|
void* guard_context);
|
||||||
void *guard_context);
|
esp_err_t maze32_network_start(maze32_network_t* network);
|
||||||
esp_err_t maze32_network_start(maze32_network_t *network);
|
esp_err_t maze32_network_apply_credentials(maze32_network_t* network,
|
||||||
esp_err_t maze32_network_apply_credentials(maze32_network_t *network,
|
const maze32_wifi_credentials_t* credentials);
|
||||||
const maze32_wifi_credentials_t *credentials);
|
esp_err_t maze32_network_reset_credentials(maze32_network_t* network);
|
||||||
esp_err_t maze32_network_reset_credentials(maze32_network_t *network);
|
void maze32_network_retry_saved_credentials(void* context);
|
||||||
void maze32_network_retry_saved_credentials(void *context);
|
void maze32_network_return_to_provisioning(void* context);
|
||||||
void maze32_network_return_to_provisioning(void *context);
|
esp_err_t maze32_network_scan(maze32_network_t* network, maze32_wifi_scan_result_t* results,
|
||||||
esp_err_t maze32_network_scan(maze32_network_t *network,
|
size_t capacity, size_t* count);
|
||||||
maze32_wifi_scan_result_t *results,
|
maze32_network_state_t maze32_network_state(const maze32_network_t* network);
|
||||||
size_t capacity,
|
const char* maze32_network_state_name(maze32_network_state_t state);
|
||||||
size_t *count);
|
const char* maze32_network_access_point_ssid(const maze32_network_t* network);
|
||||||
maze32_network_state_t maze32_network_state(const maze32_network_t *network);
|
esp_err_t maze32_network_ip_address(const maze32_network_t* network, char* output,
|
||||||
const char *maze32_network_state_name(maze32_network_state_t state);
|
|
||||||
const char *maze32_network_access_point_ssid(const maze32_network_t *network);
|
|
||||||
esp_err_t maze32_network_ip_address(const maze32_network_t *network,
|
|
||||||
char *output,
|
|
||||||
size_t output_size);
|
size_t output_size);
|
||||||
esp_err_t maze32_provisioning_register(httpd_handle_t server, maze32_network_t *network);
|
esp_err_t maze32_provisioning_register(httpd_handle_t server, maze32_network_t* network);
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,42 +13,61 @@
|
|||||||
#define MAZE32_STATION_RETRY_LIMIT 3U
|
#define MAZE32_STATION_RETRY_LIMIT 3U
|
||||||
#define MAZE32_STATION_TIMEOUT_MS 12000U
|
#define MAZE32_STATION_TIMEOUT_MS 12000U
|
||||||
|
|
||||||
static const char *TAG = "maze32_network";
|
static const char* TAG = "maze32_network";
|
||||||
static maze32_network_t *active_network;
|
static maze32_network_t* active_network;
|
||||||
|
|
||||||
static void set_state(maze32_network_t *network, maze32_network_state_t state)
|
static void set_state(maze32_network_t* network, maze32_network_state_t state) {
|
||||||
{
|
|
||||||
network->state = state;
|
network->state = state;
|
||||||
ESP_LOGI(TAG, "Network state: %s", maze32_network_state_name(state));
|
ESP_LOGI(TAG, "Network state: %s", maze32_network_state_name(state));
|
||||||
}
|
}
|
||||||
|
|
||||||
static void network_event(void *argument,
|
static void network_event(void* argument, esp_event_base_t event_base, int32_t event_id,
|
||||||
esp_event_base_t event_base,
|
void* event_data) {
|
||||||
int32_t event_id,
|
|
||||||
void *event_data)
|
|
||||||
{
|
|
||||||
(void)event_data;
|
(void)event_data;
|
||||||
maze32_network_t *network = argument;
|
maze32_network_t* network = argument;
|
||||||
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START &&
|
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START &&
|
||||||
network->state == MAZE32_NETWORK_STATION_CONNECTING) {
|
network->state == MAZE32_NETWORK_STATION_CONNECTING) {
|
||||||
esp_wifi_connect();
|
esp_wifi_connect();
|
||||||
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED &&
|
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED &&
|
||||||
network->state == MAZE32_NETWORK_STATION_CONNECTING) {
|
(network->state == MAZE32_NETWORK_STATION_CONNECTING ||
|
||||||
|
network->state == MAZE32_NETWORK_STATION_CONNECTED)) {
|
||||||
|
if (network->state == MAZE32_NETWORK_STATION_CONNECTED) {
|
||||||
|
set_state(network, MAZE32_NETWORK_STATION_CONNECTING);
|
||||||
|
network->retry_count = 0U;
|
||||||
|
}
|
||||||
if (network->retry_count < MAZE32_STATION_RETRY_LIMIT) {
|
if (network->retry_count < MAZE32_STATION_RETRY_LIMIT) {
|
||||||
++network->retry_count;
|
++network->retry_count;
|
||||||
ESP_LOGW(TAG, "Station connection failed; retry %u/%u",
|
ESP_LOGW(TAG, "Station connection failed; retry %u/%u", network->retry_count,
|
||||||
network->retry_count, MAZE32_STATION_RETRY_LIMIT);
|
MAZE32_STATION_RETRY_LIMIT);
|
||||||
esp_wifi_connect();
|
esp_wifi_connect();
|
||||||
} else {
|
} else if (network->station_wait_active) {
|
||||||
xEventGroupSetBits(network->events, MAZE32_FAILED_BIT);
|
xEventGroupSetBits(network->events, MAZE32_FAILED_BIT);
|
||||||
|
} else if (network->server != NULL) {
|
||||||
|
set_state(network, MAZE32_NETWORK_PROVISIONING);
|
||||||
|
esp_err_t result =
|
||||||
|
httpd_queue_work(network->server, maze32_network_return_to_provisioning, network);
|
||||||
|
if (result != ESP_OK) {
|
||||||
|
ESP_LOGE(TAG, "Could not schedule runtime fallback: %s", esp_err_to_name(result));
|
||||||
|
set_state(network, MAZE32_NETWORK_ERROR);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
set_state(network, MAZE32_NETWORK_ERROR);
|
||||||
}
|
}
|
||||||
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
|
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
|
||||||
xEventGroupSetBits(network->events, MAZE32_CONNECTED_BIT);
|
if (network->station_wait_active) {
|
||||||
|
xEventGroupSetBits(network->events, MAZE32_CONNECTED_BIT);
|
||||||
|
} else if (network->state == MAZE32_NETWORK_STATION_CONNECTING) {
|
||||||
|
network->retry_count = 0U;
|
||||||
|
set_state(network, MAZE32_NETWORK_STATION_CONNECTED);
|
||||||
|
char address[16];
|
||||||
|
if (maze32_network_ip_address(network, address, sizeof(address)) == ESP_OK) {
|
||||||
|
ESP_LOGI(TAG, "Runtime station reconnect succeeded: IP=%s", address);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static esp_err_t stop_wifi(void)
|
static esp_err_t stop_wifi(void) {
|
||||||
{
|
|
||||||
esp_err_t result = esp_wifi_stop();
|
esp_err_t result = esp_wifi_stop();
|
||||||
if (result == ESP_ERR_WIFI_NOT_STARTED || result == ESP_ERR_WIFI_NOT_INIT) {
|
if (result == ESP_ERR_WIFI_NOT_STARTED || result == ESP_ERR_WIFI_NOT_INIT) {
|
||||||
return ESP_OK;
|
return ESP_OK;
|
||||||
@@ -56,8 +75,7 @@ static esp_err_t stop_wifi(void)
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
static esp_err_t start_provisioning(maze32_network_t *network)
|
static esp_err_t start_provisioning(maze32_network_t* network) {
|
||||||
{
|
|
||||||
set_state(network, MAZE32_NETWORK_PROVISIONING);
|
set_state(network, MAZE32_NETWORK_PROVISIONING);
|
||||||
esp_err_t result = stop_wifi();
|
esp_err_t result = stop_wifi();
|
||||||
if (result != ESP_OK) {
|
if (result != ESP_OK) {
|
||||||
@@ -89,9 +107,8 @@ static esp_err_t start_provisioning(maze32_network_t *network)
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
static esp_err_t connect_station(maze32_network_t *network,
|
static esp_err_t connect_station(maze32_network_t* network,
|
||||||
const maze32_wifi_credentials_t *credentials)
|
const maze32_wifi_credentials_t* credentials) {
|
||||||
{
|
|
||||||
esp_err_t result = stop_wifi();
|
esp_err_t result = stop_wifi();
|
||||||
if (result != ESP_OK) {
|
if (result != ESP_OK) {
|
||||||
return result;
|
return result;
|
||||||
@@ -113,19 +130,19 @@ static esp_err_t connect_station(maze32_network_t *network,
|
|||||||
result = esp_wifi_set_config(WIFI_IF_STA, &station);
|
result = esp_wifi_set_config(WIFI_IF_STA, &station);
|
||||||
}
|
}
|
||||||
if (result == ESP_OK) {
|
if (result == ESP_OK) {
|
||||||
|
network->station_wait_active = 1;
|
||||||
result = esp_wifi_start();
|
result = esp_wifi_start();
|
||||||
}
|
}
|
||||||
if (result != ESP_OK) {
|
if (result != ESP_OK) {
|
||||||
|
network->station_wait_active = 0;
|
||||||
ESP_LOGE(TAG, "Station startup failed: %s", esp_err_to_name(result));
|
ESP_LOGE(TAG, "Station startup failed: %s", esp_err_to_name(result));
|
||||||
return start_provisioning(network);
|
return start_provisioning(network);
|
||||||
}
|
}
|
||||||
|
|
||||||
EventBits_t bits = xEventGroupWaitBits(
|
EventBits_t bits =
|
||||||
network->events,
|
xEventGroupWaitBits(network->events, MAZE32_CONNECTED_BIT | MAZE32_FAILED_BIT, pdTRUE,
|
||||||
MAZE32_CONNECTED_BIT | MAZE32_FAILED_BIT,
|
pdFALSE, pdMS_TO_TICKS(MAZE32_STATION_TIMEOUT_MS));
|
||||||
pdTRUE,
|
network->station_wait_active = 0;
|
||||||
pdFALSE,
|
|
||||||
pdMS_TO_TICKS(MAZE32_STATION_TIMEOUT_MS));
|
|
||||||
if ((bits & MAZE32_CONNECTED_BIT) != 0U) {
|
if ((bits & MAZE32_CONNECTED_BIT) != 0U) {
|
||||||
set_state(network, MAZE32_NETWORK_STATION_CONNECTED);
|
set_state(network, MAZE32_NETWORK_STATION_CONNECTED);
|
||||||
char address[16];
|
char address[16];
|
||||||
@@ -139,22 +156,18 @@ static esp_err_t connect_station(maze32_network_t *network,
|
|||||||
return start_provisioning(network);
|
return start_provisioning(network);
|
||||||
}
|
}
|
||||||
|
|
||||||
static int save_credentials(const maze32_wifi_credentials_t *credentials, void *context)
|
static int save_credentials(const maze32_wifi_credentials_t* credentials, void* context) {
|
||||||
{
|
|
||||||
(void)context;
|
(void)context;
|
||||||
return maze32_config_store_save(credentials) == ESP_OK ? 0 : -1;
|
return maze32_config_store_save(credentials) == ESP_OK ? 0 : -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
static int clear_credentials(void *context)
|
static int clear_credentials(void* context) {
|
||||||
{
|
|
||||||
(void)context;
|
(void)context;
|
||||||
return maze32_config_store_clear() == ESP_OK ? 0 : -1;
|
return maze32_config_store_clear() == ESP_OK ? 0 : -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_err_t maze32_network_init(maze32_network_t *network,
|
esp_err_t maze32_network_init(maze32_network_t* network, maze32_reconfiguration_guard_fn guard,
|
||||||
maze32_reconfiguration_guard_fn guard,
|
void* guard_context) {
|
||||||
void *guard_context)
|
|
||||||
{
|
|
||||||
if (network == NULL || guard == NULL || active_network != NULL) {
|
if (network == NULL || guard == NULL || active_network != NULL) {
|
||||||
return ESP_ERR_INVALID_ARG;
|
return ESP_ERR_INVALID_ARG;
|
||||||
}
|
}
|
||||||
@@ -173,8 +186,8 @@ esp_err_t maze32_network_init(maze32_network_t *network,
|
|||||||
if (result != ESP_OK) {
|
if (result != ESP_OK) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
snprintf(network->access_point_ssid, sizeof(network->access_point_ssid),
|
snprintf(network->access_point_ssid, sizeof(network->access_point_ssid), "maze32-%02X%02X",
|
||||||
"maze32-%02X%02X", mac[4], mac[5]);
|
mac[4], mac[5]);
|
||||||
|
|
||||||
wifi_init_config_t wifi_configuration = WIFI_INIT_CONFIG_DEFAULT();
|
wifi_init_config_t wifi_configuration = WIFI_INIT_CONFIG_DEFAULT();
|
||||||
result = esp_wifi_init(&wifi_configuration);
|
result = esp_wifi_init(&wifi_configuration);
|
||||||
@@ -196,8 +209,7 @@ esp_err_t maze32_network_init(maze32_network_t *network,
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_err_t maze32_network_start(maze32_network_t *network)
|
esp_err_t maze32_network_start(maze32_network_t* network) {
|
||||||
{
|
|
||||||
if (network == NULL || xSemaphoreTake(network->operation_mutex, portMAX_DELAY) != pdTRUE) {
|
if (network == NULL || xSemaphoreTake(network->operation_mutex, portMAX_DELAY) != pdTRUE) {
|
||||||
return ESP_ERR_INVALID_ARG;
|
return ESP_ERR_INVALID_ARG;
|
||||||
}
|
}
|
||||||
@@ -214,16 +226,13 @@ esp_err_t maze32_network_start(maze32_network_t *network)
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_err_t maze32_network_apply_credentials(maze32_network_t *network,
|
esp_err_t maze32_network_apply_credentials(maze32_network_t* network,
|
||||||
const maze32_wifi_credentials_t *credentials)
|
const maze32_wifi_credentials_t* credentials) {
|
||||||
{
|
|
||||||
if (network == NULL) {
|
if (network == NULL) {
|
||||||
return ESP_ERR_INVALID_ARG;
|
return ESP_ERR_INVALID_ARG;
|
||||||
}
|
}
|
||||||
int result = maze32_wifi_config_apply(credentials,
|
int result = maze32_wifi_config_apply(credentials, network->reconfiguration_guard,
|
||||||
network->reconfiguration_guard,
|
save_credentials, network->guard_context);
|
||||||
save_credentials,
|
|
||||||
network->guard_context);
|
|
||||||
if (result == -1) {
|
if (result == -1) {
|
||||||
return ESP_ERR_INVALID_ARG;
|
return ESP_ERR_INVALID_ARG;
|
||||||
}
|
}
|
||||||
@@ -233,13 +242,11 @@ esp_err_t maze32_network_apply_credentials(maze32_network_t *network,
|
|||||||
return result == 0 ? ESP_OK : ESP_FAIL;
|
return result == 0 ? ESP_OK : ESP_FAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_err_t maze32_network_reset_credentials(maze32_network_t *network)
|
esp_err_t maze32_network_reset_credentials(maze32_network_t* network) {
|
||||||
{
|
|
||||||
if (network == NULL) {
|
if (network == NULL) {
|
||||||
return ESP_ERR_INVALID_ARG;
|
return ESP_ERR_INVALID_ARG;
|
||||||
}
|
}
|
||||||
int result = maze32_wifi_config_reset(network->reconfiguration_guard,
|
int result = maze32_wifi_config_reset(network->reconfiguration_guard, clear_credentials,
|
||||||
clear_credentials,
|
|
||||||
network->guard_context);
|
network->guard_context);
|
||||||
if (result == -2) {
|
if (result == -2) {
|
||||||
return ESP_ERR_INVALID_STATE;
|
return ESP_ERR_INVALID_STATE;
|
||||||
@@ -247,9 +254,8 @@ esp_err_t maze32_network_reset_credentials(maze32_network_t *network)
|
|||||||
return result == 0 ? ESP_OK : ESP_FAIL;
|
return result == 0 ? ESP_OK : ESP_FAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
void maze32_network_retry_saved_credentials(void *context)
|
void maze32_network_retry_saved_credentials(void* context) {
|
||||||
{
|
maze32_network_t* network = context;
|
||||||
maze32_network_t *network = context;
|
|
||||||
if (network == NULL || xSemaphoreTake(network->operation_mutex, portMAX_DELAY) != pdTRUE) {
|
if (network == NULL || xSemaphoreTake(network->operation_mutex, portMAX_DELAY) != pdTRUE) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -265,9 +271,8 @@ void maze32_network_retry_saved_credentials(void *context)
|
|||||||
xSemaphoreGive(network->operation_mutex);
|
xSemaphoreGive(network->operation_mutex);
|
||||||
}
|
}
|
||||||
|
|
||||||
void maze32_network_return_to_provisioning(void *context)
|
void maze32_network_return_to_provisioning(void* context) {
|
||||||
{
|
maze32_network_t* network = context;
|
||||||
maze32_network_t *network = context;
|
|
||||||
if (network == NULL || xSemaphoreTake(network->operation_mutex, portMAX_DELAY) != pdTRUE) {
|
if (network == NULL || xSemaphoreTake(network->operation_mutex, portMAX_DELAY) != pdTRUE) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -278,11 +283,8 @@ void maze32_network_return_to_provisioning(void *context)
|
|||||||
xSemaphoreGive(network->operation_mutex);
|
xSemaphoreGive(network->operation_mutex);
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_err_t maze32_network_scan(maze32_network_t *network,
|
esp_err_t maze32_network_scan(maze32_network_t* network, maze32_wifi_scan_result_t* results,
|
||||||
maze32_wifi_scan_result_t *results,
|
size_t capacity, size_t* count) {
|
||||||
size_t capacity,
|
|
||||||
size_t *count)
|
|
||||||
{
|
|
||||||
if (network == NULL || results == NULL || count == NULL || capacity == 0U ||
|
if (network == NULL || results == NULL || count == NULL || capacity == 0U ||
|
||||||
capacity > MAZE32_WIFI_SCAN_LIMIT ||
|
capacity > MAZE32_WIFI_SCAN_LIMIT ||
|
||||||
xSemaphoreTake(network->operation_mutex, pdMS_TO_TICKS(1000U)) != pdTRUE) {
|
xSemaphoreTake(network->operation_mutex, pdMS_TO_TICKS(1000U)) != pdTRUE) {
|
||||||
@@ -313,34 +315,34 @@ esp_err_t maze32_network_scan(maze32_network_t *network,
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
maze32_network_state_t maze32_network_state(const maze32_network_t *network)
|
maze32_network_state_t maze32_network_state(const maze32_network_t* network) {
|
||||||
{
|
|
||||||
return network == NULL ? MAZE32_NETWORK_ERROR : network->state;
|
return network == NULL ? MAZE32_NETWORK_ERROR : network->state;
|
||||||
}
|
}
|
||||||
|
|
||||||
const char *maze32_network_state_name(maze32_network_state_t state)
|
const char* maze32_network_state_name(maze32_network_state_t state) {
|
||||||
{
|
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case MAZE32_NETWORK_BOOTING: return "booting";
|
case MAZE32_NETWORK_BOOTING:
|
||||||
case MAZE32_NETWORK_STATION_CONNECTING: return "station_connecting";
|
return "booting";
|
||||||
case MAZE32_NETWORK_STATION_CONNECTED: return "station_connected";
|
case MAZE32_NETWORK_STATION_CONNECTING:
|
||||||
case MAZE32_NETWORK_PROVISIONING: return "provisioning";
|
return "station_connecting";
|
||||||
case MAZE32_NETWORK_ERROR: return "error";
|
case MAZE32_NETWORK_STATION_CONNECTED:
|
||||||
default: return "unknown";
|
return "station_connected";
|
||||||
|
case MAZE32_NETWORK_PROVISIONING:
|
||||||
|
return "provisioning";
|
||||||
|
case MAZE32_NETWORK_ERROR:
|
||||||
|
return "error";
|
||||||
|
default:
|
||||||
|
return "unknown";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const char *maze32_network_access_point_ssid(const maze32_network_t *network)
|
const char* maze32_network_access_point_ssid(const maze32_network_t* network) {
|
||||||
{
|
|
||||||
return network == NULL ? "" : network->access_point_ssid;
|
return network == NULL ? "" : network->access_point_ssid;
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_err_t maze32_network_ip_address(const maze32_network_t *network,
|
esp_err_t maze32_network_ip_address(const maze32_network_t* network, char* output,
|
||||||
char *output,
|
size_t output_size) {
|
||||||
size_t output_size)
|
if (network == NULL || output == NULL || output_size < 16U || network->station_netif == NULL) {
|
||||||
{
|
|
||||||
if (network == NULL || output == NULL || output_size < 16U ||
|
|
||||||
network->station_netif == NULL) {
|
|
||||||
return ESP_ERR_INVALID_ARG;
|
return ESP_ERR_INVALID_ARG;
|
||||||
}
|
}
|
||||||
esp_netif_ip_info_t information;
|
esp_netif_ip_info_t information;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
idf_component_register(
|
idf_component_register(
|
||||||
SRCS "maze32_web.c" "maze32_gateway.c"
|
SRCS "maze32_web.c" "maze32_gateway.c"
|
||||||
INCLUDE_DIRS "include"
|
INCLUDE_DIRS "include"
|
||||||
REQUIRES esp_http_server esp_timer heap maze32_lobby maze32_platform
|
REQUIRES esp_http_server esp_timer heap maze32_game maze32_lobby maze32_maze maze32_platform
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
#include "esp_err.h"
|
#include "esp_err.h"
|
||||||
#include "esp_http_server.h"
|
#include "esp_http_server.h"
|
||||||
#include "esp_timer.h"
|
#include "esp_timer.h"
|
||||||
|
#include "maze32_game.h"
|
||||||
#include "maze32_lobby.h"
|
#include "maze32_lobby.h"
|
||||||
|
#include "maze32_maze.h"
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
extern "C" {
|
extern "C" {
|
||||||
@@ -17,6 +19,8 @@ extern "C" {
|
|||||||
#define MAZE32_GATEWAY_QUEUE_LIMIT 12U
|
#define MAZE32_GATEWAY_QUEUE_LIMIT 12U
|
||||||
#define MAZE32_GATEWAY_OUTGOING_LIMIT 768U
|
#define MAZE32_GATEWAY_OUTGOING_LIMIT 768U
|
||||||
#define MAZE32_GATEWAY_RATE_LIMIT_PER_SECOND 20U
|
#define MAZE32_GATEWAY_RATE_LIMIT_PER_SECOND 20U
|
||||||
|
#define MAZE32_GATEWAY_RATE_LIMIT_CLOSE_THRESHOLD 40U
|
||||||
|
#define MAZE32_GATEWAY_PLAYER_LATENCY_BUDGET_MS 500U
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
int active;
|
int active;
|
||||||
@@ -24,14 +28,36 @@ typedef struct {
|
|||||||
uint32_t last_client_sequence;
|
uint32_t last_client_sequence;
|
||||||
uint64_t rate_window_started_ms;
|
uint64_t rate_window_started_ms;
|
||||||
unsigned int messages_in_window;
|
unsigned int messages_in_window;
|
||||||
|
int closing;
|
||||||
} maze32_gateway_connection_t;
|
} maze32_gateway_connection_t;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
int socket_fd;
|
int socket_fd;
|
||||||
size_t length;
|
size_t length;
|
||||||
|
int drop_if_busy;
|
||||||
char payload[MAZE32_GATEWAY_OUTGOING_LIMIT];
|
char payload[MAZE32_GATEWAY_OUTGOING_LIMIT];
|
||||||
} maze32_gateway_outgoing_t;
|
} maze32_gateway_outgoing_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint32_t connection_high_water;
|
||||||
|
uint32_t queue_high_water;
|
||||||
|
uint32_t messages_received;
|
||||||
|
uint32_t messages_sent;
|
||||||
|
uint32_t messages_dropped;
|
||||||
|
uint32_t send_failures;
|
||||||
|
uint32_t malformed_messages;
|
||||||
|
uint32_t oversized_messages;
|
||||||
|
uint32_t rate_limited_messages;
|
||||||
|
uint32_t accepted_game_commands;
|
||||||
|
uint32_t matches_started;
|
||||||
|
uint32_t matches_completed;
|
||||||
|
uint32_t matches_reset;
|
||||||
|
uint64_t maximum_handler_us;
|
||||||
|
uint64_t maximum_send_us;
|
||||||
|
size_t minimum_internal_free_bytes;
|
||||||
|
size_t minimum_internal_largest_block_bytes;
|
||||||
|
} maze32_gateway_metrics_t;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
httpd_handle_t server;
|
httpd_handle_t server;
|
||||||
maze32_lobby_t lobby;
|
maze32_lobby_t lobby;
|
||||||
@@ -40,8 +66,14 @@ typedef struct {
|
|||||||
size_t outgoing_head;
|
size_t outgoing_head;
|
||||||
size_t outgoing_count;
|
size_t outgoing_count;
|
||||||
uint32_t server_sequence;
|
uint32_t server_sequence;
|
||||||
unsigned int suggestion_index;
|
|
||||||
esp_timer_handle_t countdown_timer;
|
esp_timer_handle_t countdown_timer;
|
||||||
|
esp_timer_handle_t reconnect_timer;
|
||||||
|
esp_timer_handle_t finish_timer;
|
||||||
|
maze32_gateway_metrics_t metrics;
|
||||||
|
maze32_maze_t maze;
|
||||||
|
maze32_maze_workspace_t maze_workspace;
|
||||||
|
maze32_game_t game;
|
||||||
|
maze32_view_t view;
|
||||||
} maze32_gateway_t;
|
} maze32_gateway_t;
|
||||||
|
|
||||||
esp_err_t maze32_gateway_init(maze32_gateway_t* gateway);
|
esp_err_t maze32_gateway_init(maze32_gateway_t* gateway);
|
||||||
@@ -49,6 +81,8 @@ void maze32_gateway_set_server(maze32_gateway_t* gateway, httpd_handle_t server)
|
|||||||
esp_err_t maze32_gateway_register(httpd_handle_t server, maze32_gateway_t* gateway);
|
esp_err_t maze32_gateway_register(httpd_handle_t server, maze32_gateway_t* gateway);
|
||||||
void maze32_gateway_close_socket(httpd_handle_t server, int socket_fd);
|
void maze32_gateway_close_socket(httpd_handle_t server, int socket_fd);
|
||||||
int maze32_gateway_can_reconfigure(const maze32_gateway_t* gateway);
|
int maze32_gateway_can_reconfigure(const maze32_gateway_t* gateway);
|
||||||
|
size_t maze32_gateway_format_diagnostics(maze32_gateway_t* gateway, char* output,
|
||||||
|
size_t output_size);
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
#include "maze32_gateway.h"
|
#include "maze32_gateway.h"
|
||||||
|
|
||||||
|
#include <errno.h>
|
||||||
|
#include <fcntl.h>
|
||||||
#include <inttypes.h>
|
#include <inttypes.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
#include <sys/select.h>
|
||||||
#include <sys/socket.h>
|
#include <sys/socket.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
|
|
||||||
@@ -12,14 +15,75 @@
|
|||||||
#include "maze32_protocol.h"
|
#include "maze32_protocol.h"
|
||||||
|
|
||||||
static const char* TAG = "maze32_gateway";
|
static const char* TAG = "maze32_gateway";
|
||||||
static const char* NAME_SUGGESTIONS[] = {
|
|
||||||
"Байт-Беглец", "Кот-Навигатор", "Сэр Тупичок", "Пиксельный Ёж", "Лорд Лабиринт",
|
|
||||||
};
|
|
||||||
|
|
||||||
static uint64_t now_ms(void) {
|
static uint64_t now_ms(void) {
|
||||||
return (uint64_t)(esp_timer_get_time() / 1000);
|
return (uint64_t)(esp_timer_get_time() / 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static size_t internal_free_bytes(void) {
|
||||||
|
return heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
static size_t internal_largest_block_bytes(void) {
|
||||||
|
return heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
static unsigned int active_connection_count(const maze32_gateway_t* gateway) {
|
||||||
|
unsigned int count = 0U;
|
||||||
|
for (size_t index = 0U; index < MAZE32_GATEWAY_CONNECTION_LIMIT; ++index) {
|
||||||
|
if (gateway->connections[index].active) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int gateway_send_all(httpd_handle_t server, int socket_fd, const char* buffer, size_t length,
|
||||||
|
int flags) {
|
||||||
|
(void)server;
|
||||||
|
if (buffer == NULL) {
|
||||||
|
return HTTPD_SOCK_ERR_INVALID;
|
||||||
|
}
|
||||||
|
size_t offset = 0U;
|
||||||
|
while (offset < length) {
|
||||||
|
int sent = send(socket_fd, buffer + offset, length - offset, flags);
|
||||||
|
if (sent > 0) {
|
||||||
|
offset += (size_t)sent;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (sent < 0 && errno == EINTR) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (sent < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
|
||||||
|
return HTTPD_SOCK_ERR_TIMEOUT;
|
||||||
|
}
|
||||||
|
return HTTPD_SOCK_ERR_FAIL;
|
||||||
|
}
|
||||||
|
return (int)offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void sample_resources(maze32_gateway_t* gateway, const char* label) {
|
||||||
|
size_t free_bytes = internal_free_bytes();
|
||||||
|
size_t largest_block = internal_largest_block_bytes();
|
||||||
|
if (gateway->metrics.minimum_internal_free_bytes == 0U ||
|
||||||
|
free_bytes < gateway->metrics.minimum_internal_free_bytes) {
|
||||||
|
gateway->metrics.minimum_internal_free_bytes = free_bytes;
|
||||||
|
}
|
||||||
|
if (gateway->metrics.minimum_internal_largest_block_bytes == 0U ||
|
||||||
|
largest_block < gateway->metrics.minimum_internal_largest_block_bytes) {
|
||||||
|
gateway->metrics.minimum_internal_largest_block_bytes = largest_block;
|
||||||
|
}
|
||||||
|
if (label != NULL) {
|
||||||
|
ESP_LOGI(TAG,
|
||||||
|
"M010 resources: label=%s active=%u queue=%u free=%u largest=%u min_free=%u "
|
||||||
|
"min_largest=%u",
|
||||||
|
label, active_connection_count(gateway), (unsigned int)gateway->outgoing_count,
|
||||||
|
(unsigned int)free_bytes, (unsigned int)largest_block,
|
||||||
|
(unsigned int)gateway->metrics.minimum_internal_free_bytes,
|
||||||
|
(unsigned int)gateway->metrics.minimum_internal_largest_block_bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static maze32_gateway_connection_t* find_connection(maze32_gateway_t* gateway, int socket_fd) {
|
static maze32_gateway_connection_t* find_connection(maze32_gateway_t* gateway, int socket_fd) {
|
||||||
for (size_t index = 0U; index < MAZE32_GATEWAY_CONNECTION_LIMIT; ++index) {
|
for (size_t index = 0U; index < MAZE32_GATEWAY_CONNECTION_LIMIT; ++index) {
|
||||||
if (gateway->connections[index].active &&
|
if (gateway->connections[index].active &&
|
||||||
@@ -41,20 +105,19 @@ static maze32_gateway_connection_t* open_connection(maze32_gateway_t* gateway, i
|
|||||||
memset(connection, 0, sizeof(*connection));
|
memset(connection, 0, sizeof(*connection));
|
||||||
connection->active = 1;
|
connection->active = 1;
|
||||||
connection->socket_fd = socket_fd;
|
connection->socket_fd = socket_fd;
|
||||||
|
if (httpd_sess_set_send_override(gateway->server, socket_fd, gateway_send_all) !=
|
||||||
|
ESP_OK) {
|
||||||
|
ESP_LOGW(TAG, "Could not install bounded send callback for fd=%d", socket_fd);
|
||||||
|
}
|
||||||
connection->rate_window_started_ms = now_ms();
|
connection->rate_window_started_ms = now_ms();
|
||||||
unsigned int active_count = 0U;
|
unsigned int active_count = active_connection_count(gateway);
|
||||||
for (size_t count_index = 0U; count_index < MAZE32_GATEWAY_CONNECTION_LIMIT;
|
if (active_count > gateway->metrics.connection_high_water) {
|
||||||
++count_index) {
|
gateway->metrics.connection_high_water = active_count;
|
||||||
if (gateway->connections[count_index].active) {
|
|
||||||
active_count++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ESP_LOGI(TAG, "WebSocket connected: fd=%d, active=%u/%u", socket_fd, active_count,
|
ESP_LOGI(TAG, "WebSocket connected: fd=%d, active=%u/%u", socket_fd, active_count,
|
||||||
(unsigned int)MAZE32_GATEWAY_CONNECTION_LIMIT);
|
(unsigned int)MAZE32_GATEWAY_CONNECTION_LIMIT);
|
||||||
if (active_count == MAZE32_GATEWAY_CONNECTION_LIMIT) {
|
if (active_count == MAZE32_GATEWAY_CONNECTION_LIMIT) {
|
||||||
ESP_LOGI(TAG, "Six-client heap: free=%u bytes, largest=%u bytes",
|
sample_resources(gateway, "six_clients");
|
||||||
(unsigned int)heap_caps_get_free_size(MALLOC_CAP_8BIT),
|
|
||||||
(unsigned int)heap_caps_get_largest_free_block(MALLOC_CAP_8BIT));
|
|
||||||
}
|
}
|
||||||
return connection;
|
return connection;
|
||||||
}
|
}
|
||||||
@@ -112,10 +175,16 @@ static uint32_t next_server_sequence(maze32_gateway_t* gateway) {
|
|||||||
return gateway->server_sequence;
|
return gateway->server_sequence;
|
||||||
}
|
}
|
||||||
|
|
||||||
static int enqueue(maze32_gateway_t* gateway, int socket_fd, const char* payload) {
|
static int enqueue_with_policy(maze32_gateway_t* gateway, int socket_fd, const char* payload,
|
||||||
|
int drop_if_busy) {
|
||||||
size_t length = strlen(payload);
|
size_t length = strlen(payload);
|
||||||
if (gateway->outgoing_count >= MAZE32_GATEWAY_QUEUE_LIMIT ||
|
if (gateway->outgoing_count >= MAZE32_GATEWAY_QUEUE_LIMIT ||
|
||||||
length >= MAZE32_GATEWAY_OUTGOING_LIMIT) {
|
length >= MAZE32_GATEWAY_OUTGOING_LIMIT) {
|
||||||
|
if (drop_if_busy) {
|
||||||
|
gateway->metrics.messages_dropped++;
|
||||||
|
} else {
|
||||||
|
gateway->metrics.send_failures++;
|
||||||
|
}
|
||||||
ESP_LOGW(TAG, "Bounded outgoing queue rejected a message for fd=%d", socket_fd);
|
ESP_LOGW(TAG, "Bounded outgoing queue rejected a message for fd=%d", socket_fd);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -123,12 +192,33 @@ static int enqueue(maze32_gateway_t* gateway, int socket_fd, const char* payload
|
|||||||
maze32_gateway_outgoing_t* entry = &gateway->outgoing[index];
|
maze32_gateway_outgoing_t* entry = &gateway->outgoing[index];
|
||||||
entry->socket_fd = socket_fd;
|
entry->socket_fd = socket_fd;
|
||||||
entry->length = length;
|
entry->length = length;
|
||||||
|
entry->drop_if_busy = drop_if_busy;
|
||||||
memcpy(entry->payload, payload, length + 1U);
|
memcpy(entry->payload, payload, length + 1U);
|
||||||
gateway->outgoing_count++;
|
gateway->outgoing_count++;
|
||||||
|
if (gateway->outgoing_count > gateway->metrics.queue_high_water) {
|
||||||
|
gateway->metrics.queue_high_water = (uint32_t)gateway->outgoing_count;
|
||||||
|
}
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void drain_queue(maze32_gateway_t* gateway) {
|
static int enqueue(maze32_gateway_t* gateway, int socket_fd, const char* payload) {
|
||||||
|
return enqueue_with_policy(gateway, socket_fd, payload, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void close_slow_connection(maze32_gateway_t* gateway, int socket_fd) {
|
||||||
|
maze32_gateway_connection_t* connection = find_connection(gateway, socket_fd);
|
||||||
|
if (connection == NULL || connection->closing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connection->closing = 1;
|
||||||
|
ESP_LOGW(TAG, "Closing an unresponsive WebSocket: fd=%d", socket_fd);
|
||||||
|
if (httpd_sess_trigger_close(gateway->server, socket_fd) != ESP_OK) {
|
||||||
|
ESP_LOGW(TAG, "Could not schedule close for fd=%d", socket_fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int drain_queue(maze32_gateway_t* gateway) {
|
||||||
|
int success = 1;
|
||||||
while (gateway->outgoing_count > 0U) {
|
while (gateway->outgoing_count > 0U) {
|
||||||
maze32_gateway_outgoing_t* entry = &gateway->outgoing[gateway->outgoing_head];
|
maze32_gateway_outgoing_t* entry = &gateway->outgoing[gateway->outgoing_head];
|
||||||
httpd_ws_frame_t frame = {
|
httpd_ws_frame_t frame = {
|
||||||
@@ -138,14 +228,109 @@ static void drain_queue(maze32_gateway_t* gateway) {
|
|||||||
.payload = (uint8_t*)entry->payload,
|
.payload = (uint8_t*)entry->payload,
|
||||||
.len = entry->length,
|
.len = entry->length,
|
||||||
};
|
};
|
||||||
esp_err_t result = httpd_ws_send_frame_async(gateway->server, entry->socket_fd, &frame);
|
int ready = 1;
|
||||||
if (result != ESP_OK) {
|
int original_flags = -1;
|
||||||
ESP_LOGW(TAG, "WebSocket send failed for fd=%d: %s", entry->socket_fd,
|
if (entry->drop_if_busy) {
|
||||||
esp_err_to_name(result));
|
fd_set write_set;
|
||||||
|
FD_ZERO(&write_set);
|
||||||
|
FD_SET(entry->socket_fd, &write_set);
|
||||||
|
struct timeval timeout = {0};
|
||||||
|
ready = select(entry->socket_fd + 1, NULL, &write_set, NULL, &timeout) > 0 &&
|
||||||
|
FD_ISSET(entry->socket_fd, &write_set);
|
||||||
|
if (ready) {
|
||||||
|
original_flags = fcntl(entry->socket_fd, F_GETFL, 0);
|
||||||
|
if (original_flags >= 0) {
|
||||||
|
(void)fcntl(entry->socket_fd, F_SETFL, original_flags | O_NONBLOCK);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ready) {
|
||||||
|
gateway->metrics.messages_dropped++;
|
||||||
|
} else {
|
||||||
|
int64_t started_us = esp_timer_get_time();
|
||||||
|
esp_err_t result = httpd_ws_send_frame_async(gateway->server, entry->socket_fd, &frame);
|
||||||
|
uint64_t elapsed_us = (uint64_t)(esp_timer_get_time() - started_us);
|
||||||
|
if (elapsed_us > gateway->metrics.maximum_send_us) {
|
||||||
|
gateway->metrics.maximum_send_us = elapsed_us;
|
||||||
|
}
|
||||||
|
if (result == ESP_OK) {
|
||||||
|
gateway->metrics.messages_sent++;
|
||||||
|
} else {
|
||||||
|
gateway->metrics.send_failures++;
|
||||||
|
success = 0;
|
||||||
|
ESP_LOGW(TAG, "WebSocket send failed for fd=%d: %s", entry->socket_fd,
|
||||||
|
esp_err_to_name(result));
|
||||||
|
close_slow_connection(gateway, entry->socket_fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (original_flags >= 0) {
|
||||||
|
(void)fcntl(entry->socket_fd, F_SETFL, original_flags);
|
||||||
}
|
}
|
||||||
gateway->outgoing_head = (gateway->outgoing_head + 1U) % MAZE32_GATEWAY_QUEUE_LIMIT;
|
gateway->outgoing_head = (gateway->outgoing_head + 1U) % MAZE32_GATEWAY_QUEUE_LIMIT;
|
||||||
gateway->outgoing_count--;
|
gateway->outgoing_count--;
|
||||||
}
|
}
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
static maze32_game_role_t game_role_for_session(const maze32_session_t* session) {
|
||||||
|
if (session->role == MAZE32_ROLE_OBSERVER) {
|
||||||
|
return MAZE32_GAME_ROLE_OBSERVER;
|
||||||
|
}
|
||||||
|
return session->player_slot == 1U ? MAZE32_GAME_ROLE_PLAYER_ONE : MAZE32_GAME_ROLE_PLAYER_TWO;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void send_view(maze32_gateway_t* gateway, const maze32_session_t* session,
|
||||||
|
int full_observer_view) {
|
||||||
|
if (session == NULL || !session->connected ||
|
||||||
|
maze32_game_project_view(&gateway->game, game_role_for_session(session), &gateway->view) !=
|
||||||
|
MAZE32_GAME_RESULT_ACCEPTED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (session->role == MAZE32_ROLE_OBSERVER && !full_observer_view) {
|
||||||
|
gateway->view.cell_count = 0U;
|
||||||
|
}
|
||||||
|
size_t chunks = maze32_game_view_chunk_count(&gateway->view);
|
||||||
|
for (size_t chunk = 0U; chunk < chunks; ++chunk) {
|
||||||
|
char payload[MAZE32_GAME_VIEW_CHUNK_BYTES];
|
||||||
|
size_t written = 0U;
|
||||||
|
if (maze32_game_serialize_view_chunk(&gateway->view, chunk, payload, sizeof(payload),
|
||||||
|
&written) != MAZE32_GAME_RESULT_ACCEPTED) {
|
||||||
|
ESP_LOGE(TAG, "View serialization failed for fd=%d, chunk=%u", session->connection_id,
|
||||||
|
(unsigned int)chunk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(void)written;
|
||||||
|
int drop_if_busy = session->role == MAZE32_ROLE_OBSERVER && !full_observer_view;
|
||||||
|
if (!enqueue_with_policy(gateway, session->connection_id, payload, drop_if_busy)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!drain_queue(gateway)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void broadcast_views(maze32_gateway_t* gateway, int full_observer_view) {
|
||||||
|
for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) {
|
||||||
|
send_view(gateway, &gateway->lobby.sessions[index], full_observer_view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void broadcast_match_event(maze32_gateway_t* gateway, const char* type,
|
||||||
|
const char* payload) {
|
||||||
|
char response[MAZE32_GATEWAY_OUTGOING_LIMIT];
|
||||||
|
int written = snprintf(response, sizeof(response),
|
||||||
|
"{\"v\":1,\"type\":\"%s\",\"seq\":%" PRIu32 ",\"payload\":%s}", type,
|
||||||
|
next_server_sequence(gateway), payload);
|
||||||
|
if (written < 0 || (size_t)written >= sizeof(response)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) {
|
||||||
|
const maze32_session_t* session = &gateway->lobby.sessions[index];
|
||||||
|
if (session->role != MAZE32_ROLE_NONE && session->connected) {
|
||||||
|
enqueue(gateway, session->connection_id, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void send_error(maze32_gateway_t* gateway, int socket_fd, const char* request_id,
|
static void send_error(maze32_gateway_t* gateway, int socket_fd, const char* request_id,
|
||||||
@@ -329,52 +514,6 @@ static void send_lobby_error(maze32_gateway_t* gateway, int socket_fd, const cha
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void send_suggestion(maze32_gateway_t* gateway, int socket_fd, const char* request_id) {
|
|
||||||
const char* suggestion = NULL;
|
|
||||||
size_t suggestion_count = sizeof(NAME_SUGGESTIONS) / sizeof(NAME_SUGGESTIONS[0]);
|
|
||||||
for (size_t attempt = 0U; attempt < suggestion_count; ++attempt) {
|
|
||||||
size_t index = gateway->suggestion_index++ % suggestion_count;
|
|
||||||
int occupied = 0;
|
|
||||||
for (size_t session_index = 0U; session_index < MAZE32_SESSION_LIMIT; ++session_index) {
|
|
||||||
const maze32_session_t* session = &gateway->lobby.sessions[session_index];
|
|
||||||
if (session->role == MAZE32_ROLE_PLAYER &&
|
|
||||||
strcmp(session->name, NAME_SUGGESTIONS[index]) == 0) {
|
|
||||||
occupied = 1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!occupied) {
|
|
||||||
suggestion = NAME_SUGGESTIONS[index];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (suggestion == NULL) {
|
|
||||||
send_error(gateway, socket_fd, request_id, "no_suggestion",
|
|
||||||
"Все смешные имена уже разобрали");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = "";
|
|
||||||
size_t length = 0U;
|
|
||||||
char prefix[128];
|
|
||||||
int written =
|
|
||||||
snprintf(prefix, sizeof(prefix), "{\"v\":1,\"type\":\"name_suggestion\",\"seq\":%" PRIu32,
|
|
||||||
next_server_sequence(gateway));
|
|
||||||
if (written < 0 || (size_t)written >= sizeof(prefix) ||
|
|
||||||
!append_text(response, sizeof(response), &length, prefix)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (request_id[0] != '\0' &&
|
|
||||||
(!append_text(response, sizeof(response), &length, ",\"requestId\":") ||
|
|
||||||
!append_json_string(response, sizeof(response), &length, request_id))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (append_text(response, sizeof(response), &length, ",\"payload\":{\"name\":") &&
|
|
||||||
append_json_string(response, sizeof(response), &length, suggestion) &&
|
|
||||||
append_text(response, sizeof(response), &length, "}}")) {
|
|
||||||
enqueue(gateway, socket_fd, response);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static void broadcast_countdown(maze32_gateway_t* gateway) {
|
static void broadcast_countdown(maze32_gateway_t* gateway) {
|
||||||
char response[MAZE32_GATEWAY_OUTGOING_LIMIT];
|
char response[MAZE32_GATEWAY_OUTGOING_LIMIT];
|
||||||
int written = snprintf(response, sizeof(response),
|
int written = snprintf(response, sizeof(response),
|
||||||
@@ -399,6 +538,7 @@ static void countdown_finished_work(void* context) {
|
|||||||
if (!maze32_lobby_activate_if_due(&gateway->lobby, now_ms())) {
|
if (!maze32_lobby_activate_if_due(&gateway->lobby, now_ms())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
maze32_game_set_phase(&gateway->game, MAZE32_GAME_PHASE_ACTIVE);
|
||||||
char response[MAZE32_GATEWAY_OUTGOING_LIMIT];
|
char response[MAZE32_GATEWAY_OUTGOING_LIMIT];
|
||||||
int written = snprintf(response, sizeof(response),
|
int written = snprintf(response, sizeof(response),
|
||||||
"{\"v\":1,\"type\":\"active\",\"seq\":%" PRIu32
|
"{\"v\":1,\"type\":\"active\",\"seq\":%" PRIu32
|
||||||
@@ -412,8 +552,11 @@ static void countdown_finished_work(void* context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
drain_queue(gateway);
|
||||||
broadcast_snapshot(gateway);
|
broadcast_snapshot(gateway);
|
||||||
drain_queue(gateway);
|
drain_queue(gateway);
|
||||||
|
broadcast_views(gateway, 1);
|
||||||
|
drain_queue(gateway);
|
||||||
ESP_LOGI(TAG, "Lobby became active at server time %" PRIu64 " ms",
|
ESP_LOGI(TAG, "Lobby became active at server time %" PRIu64 " ms",
|
||||||
gateway->lobby.activation_ms);
|
gateway->lobby.activation_ms);
|
||||||
}
|
}
|
||||||
@@ -428,6 +571,116 @@ static void countdown_timer_callback(void* context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void schedule_reconnect_deadline(maze32_gateway_t* gateway);
|
||||||
|
static void schedule_finish_deadline(maze32_gateway_t* gateway);
|
||||||
|
|
||||||
|
static const char* final_result_name(maze32_game_final_result_t result) {
|
||||||
|
switch (result) {
|
||||||
|
case MAZE32_GAME_FINAL_PLAYER_ONE:
|
||||||
|
return "player1";
|
||||||
|
case MAZE32_GAME_FINAL_PLAYER_TWO:
|
||||||
|
return "player2";
|
||||||
|
case MAZE32_GAME_FINAL_DRAW:
|
||||||
|
return "draw";
|
||||||
|
default:
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void complete_match(maze32_gateway_t* gateway) {
|
||||||
|
char payload[48];
|
||||||
|
int written = snprintf(payload, sizeof(payload), "{\"result\":\"%s\"}",
|
||||||
|
final_result_name(gateway->game.final_result));
|
||||||
|
esp_timer_stop(gateway->finish_timer);
|
||||||
|
if (written >= 0 && (size_t)written < sizeof(payload)) {
|
||||||
|
broadcast_match_event(gateway, "match_result", payload);
|
||||||
|
}
|
||||||
|
broadcast_views(gateway, 0);
|
||||||
|
drain_queue(gateway);
|
||||||
|
gateway->metrics.matches_completed++;
|
||||||
|
sample_resources(gateway, "match_complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void reconnect_deadline_work(void* context) {
|
||||||
|
maze32_gateway_t* gateway = context;
|
||||||
|
if (maze32_game_advance_time(&gateway->game, now_ms())) {
|
||||||
|
complete_match(gateway);
|
||||||
|
ESP_LOGI(TAG, "Player reconnect deadline expired; match ended in a draw");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
schedule_reconnect_deadline(gateway);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void finish_deadline_work(void* context) {
|
||||||
|
maze32_gateway_t* gateway = context;
|
||||||
|
if (maze32_game_advance_time(&gateway->game, now_ms())) {
|
||||||
|
complete_match(gateway);
|
||||||
|
ESP_LOGI(TAG, "Authoritative finish window closed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
schedule_finish_deadline(gateway);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void finish_timer_callback(void* context) {
|
||||||
|
maze32_gateway_t* gateway = context;
|
||||||
|
if (gateway->server != NULL) {
|
||||||
|
esp_err_t result = httpd_queue_work(gateway->server, finish_deadline_work, gateway);
|
||||||
|
if (result != ESP_OK) {
|
||||||
|
ESP_LOGE(TAG, "Could not queue finish deadline: %s", esp_err_to_name(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void schedule_finish_deadline(maze32_gateway_t* gateway) {
|
||||||
|
esp_timer_stop(gateway->finish_timer);
|
||||||
|
if (gateway->game.phase != MAZE32_GAME_PHASE_FINISH_PENDING ||
|
||||||
|
gateway->game.first_exited_player >= MAZE32_GAME_PLAYER_COUNT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uint64_t exit_ms = gateway->game.exit_times_ms[gateway->game.first_exited_player];
|
||||||
|
uint64_t window_ms = MAZE32_GAME_DRAW_WINDOW_MS + 1U;
|
||||||
|
uint64_t deadline = exit_ms > UINT64_MAX - window_ms ? UINT64_MAX : exit_ms + window_ms;
|
||||||
|
uint64_t current_ms = now_ms();
|
||||||
|
uint64_t delay_ms = deadline > current_ms ? deadline - current_ms : 1U;
|
||||||
|
esp_err_t result = esp_timer_start_once(gateway->finish_timer, delay_ms * 1000U);
|
||||||
|
if (result != ESP_OK) {
|
||||||
|
ESP_LOGE(TAG, "Finish timer could not start: %s", esp_err_to_name(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void reconnect_timer_callback(void* context) {
|
||||||
|
maze32_gateway_t* gateway = context;
|
||||||
|
if (gateway->server != NULL) {
|
||||||
|
esp_err_t result = httpd_queue_work(gateway->server, reconnect_deadline_work, gateway);
|
||||||
|
if (result != ESP_OK) {
|
||||||
|
ESP_LOGE(TAG, "Could not queue reconnect deadline: %s", esp_err_to_name(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void schedule_reconnect_deadline(maze32_gateway_t* gateway) {
|
||||||
|
esp_timer_stop(gateway->reconnect_timer);
|
||||||
|
if (gateway->game.phase != MAZE32_GAME_PHASE_PAUSED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uint64_t deadline = UINT64_MAX;
|
||||||
|
for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) {
|
||||||
|
if (!gateway->game.players[player].connected &&
|
||||||
|
gateway->game.players[player].reconnect_deadline_ms < deadline) {
|
||||||
|
deadline = gateway->game.players[player].reconnect_deadline_ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deadline == UINT64_MAX) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uint64_t current_ms = now_ms();
|
||||||
|
uint64_t delay_ms = deadline > current_ms ? deadline - current_ms : 1U;
|
||||||
|
esp_err_t result = esp_timer_start_once(gateway->reconnect_timer, delay_ms * 1000U);
|
||||||
|
if (result != ESP_OK) {
|
||||||
|
ESP_LOGE(TAG, "Reconnect timer could not start: %s", esp_err_to_name(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static int rate_limit_allows(maze32_gateway_connection_t* connection, uint64_t current_ms) {
|
static int rate_limit_allows(maze32_gateway_connection_t* connection, uint64_t current_ms) {
|
||||||
if (current_ms - connection->rate_window_started_ms >= 1000U) {
|
if (current_ms - connection->rate_window_started_ms >= 1000U) {
|
||||||
connection->rate_window_started_ms = current_ms;
|
connection->rate_window_started_ms = current_ms;
|
||||||
@@ -437,13 +690,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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
@@ -0,0 +1,72 @@
|
|||||||
|
# MVP gameplay balance
|
||||||
|
|
||||||
|
This document locks the compact gameplay configuration evaluated for Milestone
|
||||||
|
014. Human confirmation of the revised map belongs in
|
||||||
|
`docs/PLAYTEST_REPORT.md`.
|
||||||
|
|
||||||
|
## Locked configuration
|
||||||
|
|
||||||
|
| Parameter | Locked value | Purpose |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| Maze dimensions | 32 x 32 cells | Square map with one eighth of the former area |
|
||||||
|
| Initial vision radius | 3 cells | Current-view-only exploration |
|
||||||
|
| Cross-half connection pairs | 2 | Four seam crossings between conceptual halves |
|
||||||
|
| Extra cycle pairs | 8 | Proportional route density without removing dead ends |
|
||||||
|
| Minimum start-to-exit separation | 22 cells | Linear scaling by approximately the square root of eight |
|
||||||
|
| Landmark pairs | 2 | Four spatial-memory cues in symmetric positions |
|
||||||
|
| Vision-artifact pairs | 1 | Two one-use artifacts; preserves the MVP minimum |
|
||||||
|
| Compass pairs | 1 | One compass for each player's own exit |
|
||||||
|
| Movement cooldown | 150 ms | Server-enforced minimum accepted-move interval |
|
||||||
|
| Enhanced-vision duration | 10 successful moves | MVP artifact duration |
|
||||||
|
| Draw window | 200 ms | Authoritative simultaneous-exit boundary |
|
||||||
|
| Match timer | None | Matches end only by defined result conditions |
|
||||||
|
|
||||||
|
The values come from `maze32_maze_default_config()` and
|
||||||
|
`maze32_game_default_config()`. They are server-owned defaults; browser
|
||||||
|
clients cannot alter them.
|
||||||
|
|
||||||
|
## Deterministic generation evidence
|
||||||
|
|
||||||
|
`test/host/test_maze_generation.c` generates and validates seeds 0 through
|
||||||
|
1,023 with this configuration. For every generated map it proves reachability,
|
||||||
|
central symmetry, positive cycles and dead ends, at least four cross-half
|
||||||
|
connections, paired player-relative route distances, and this balance
|
||||||
|
invariant:
|
||||||
|
|
||||||
|
```
|
||||||
|
start_to_key_distance >= key_to_exit_distance
|
||||||
|
```
|
||||||
|
|
||||||
|
The test measures shortest traversable routes, not straight-line distance. It
|
||||||
|
therefore establishes that finding a key is never made easier by a shorter
|
||||||
|
required route than the later exit search. It does not substitute for human
|
||||||
|
search behaviour in a maze with fog of war, landmarks, compasses, and
|
||||||
|
artifacts.
|
||||||
|
|
||||||
|
The most recent recorded sweep (2026-09-08) reported:
|
||||||
|
|
||||||
|
| Metric | Observed range across 1,024 seeds |
|
||||||
|
| --- | ---: |
|
||||||
|
| Cycles | 19..19 |
|
||||||
|
| Dead ends | 74..128 |
|
||||||
|
| Start to key | 93..422 cells |
|
||||||
|
| Key to exit | 84..352 cells |
|
||||||
|
| Start to exit | 26..242 cells |
|
||||||
|
| Generation attempts | 1..2 |
|
||||||
|
|
||||||
|
At the 150 ms cooldown, an uninterrupted shortest 93-cell key route takes at
|
||||||
|
least 13.95 seconds of accepted-move time. Actual match duration also includes
|
||||||
|
route discovery, current-view-only navigation, the exit search, detours,
|
||||||
|
interactions, and player decisions. The approximately five-minute target must
|
||||||
|
be confirmed by the representative human-playtest procedure; no hard timer is
|
||||||
|
introduced to force that outcome.
|
||||||
|
|
||||||
|
## Tuning decision
|
||||||
|
|
||||||
|
The topology and object counts were reduced with the map area while retaining
|
||||||
|
the three-cell base view, ten-move artifact effect, 150 ms cooldown, and 200 ms
|
||||||
|
draw window. These interaction values remain unchanged until representative
|
||||||
|
playtesting of the compact map supplies evidence for a further adjustment. If
|
||||||
|
playtesting identifies a material duration or navigation problem, change only
|
||||||
|
the smallest relevant locked value, rerun the 1,024-seed suite, record the new
|
||||||
|
range here, and repeat the affected playtests.
|
||||||
+73
-13
@@ -1,9 +1,8 @@
|
|||||||
# Authoritative game core
|
# Authoritative game core
|
||||||
|
|
||||||
Milestone 006 provides the portable movement engine and view projector. The
|
Milestones 006, 007, and 009 provide the portable movement engine, gameplay
|
||||||
ESP remains the only owner of mutable game state. Object interaction, item
|
object systems, terminal results, view projector, and disconnect recovery. The
|
||||||
effects, exits, results, reconnect behavior, and browser controls remain out of
|
ESP remains the only owner of mutable game state.
|
||||||
scope until their later milestones.
|
|
||||||
|
|
||||||
## Command processing
|
## Command processing
|
||||||
|
|
||||||
@@ -22,11 +21,12 @@ Observers cannot enqueue commands.
|
|||||||
## Projection boundary
|
## Projection boundary
|
||||||
|
|
||||||
Player projections contain only cells whose Chebyshev distance from the
|
Player projections contain only cells whose Chebyshev distance from the
|
||||||
player is at most the active radius:
|
player is at most the server-owned active radius:
|
||||||
|
|
||||||
`max(abs(x - player_x), abs(y - player_y)) <= radius`
|
`max(abs(x - player_x), abs(y - player_y)) <= radius`
|
||||||
|
|
||||||
The projector supports the normal radius three and vision-effect radius six.
|
The projector selects the normal radius three or vision-effect radius six from
|
||||||
|
authoritative player state; clients cannot request a radius.
|
||||||
Map boundaries clip the square; walls do not occlude it. Each projection is
|
Map boundaries clip the square; walls do not occlude it. Each projection is
|
||||||
rebuilt from zero, so an opponent or object that leaves the current square is
|
rebuilt from zero, so an opponent or object that leaves the current square is
|
||||||
omitted instead of surviving as stale state. Observer projections contain all
|
omitted instead of surviving as stale state. Observer projections contain all
|
||||||
@@ -35,11 +35,71 @@ maze cells, static objects, and both current player positions.
|
|||||||
The serialized view is split into bounded JSON records of at most 512 bytes,
|
The serialized view is split into bounded JSON records of at most 512 bytes,
|
||||||
matching the WebSocket gateway's message bound.
|
matching the WebSocket gateway's message bound.
|
||||||
Every record uses the architecture's versioned envelope and carries the
|
Every record uses the architecture's versioned envelope and carries the
|
||||||
authoritative state sequence, role, match phase, meeting sequence, chunk index,
|
authoritative state sequence, role, match phase, maze width and height,
|
||||||
and chunk count. Cell records contain only the cell index and current passage
|
meeting sequence, chunk index, and chunk count. Role status appears once in chunk zero instead of being
|
||||||
flags. Object records contain kind, owner, variant, and cell index. Player
|
duplicated across the snapshot. Cell records contain only the cell index and
|
||||||
records omit the `opponent` member when that player is not currently visible;
|
current passage flags. Object records contain kind, owner, variant, and cell
|
||||||
observer records instead carry both positions in `players`.
|
index. Player records omit the `opponent` member when that player is not
|
||||||
|
currently visible; observer records instead carry both positions in `players`.
|
||||||
|
|
||||||
Static maze objects are projected for visibility testing, but Milestone 006
|
## Gameplay resolution
|
||||||
does not pick up, consume, transfer, or otherwise mutate them.
|
|
||||||
|
Every accepted in-maze move resolves in this order:
|
||||||
|
|
||||||
|
1. Decrement an existing vision effect.
|
||||||
|
2. Consume and activate an artifact on the destination cell.
|
||||||
|
3. Pick up the lowest-owner-numbered ground key when the inventory is empty.
|
||||||
|
4. Detect a meeting and atomically return all carried keys to their owners.
|
||||||
|
5. Activate the moving player's own compass.
|
||||||
|
6. Open the moving player's own exit when they carry their own key.
|
||||||
|
|
||||||
|
The deterministic key priority matters only if voluntary drops place both keys
|
||||||
|
on one cell. Each key always has exactly one authoritative location: a ground
|
||||||
|
cell or one carrier. Inventory has one slot, dropping places the key on the
|
||||||
|
current cell, and carrying an opponent's key is allowed. A meeting clears both
|
||||||
|
inventory slots before assigning every carried key to its owner, so crossed
|
||||||
|
keys swap atomically without duplication or loss.
|
||||||
|
|
||||||
|
An unconsumed vision artifact is removed on entry and sets the player's effect
|
||||||
|
counter to ten. Only later successful moves decrement the counter; failed
|
||||||
|
moves and key drops do not. Entering another unconsumed artifact resets the
|
||||||
|
counter to ten rather than stacking effects.
|
||||||
|
|
||||||
|
Entering the player's associated compass produces one of eight direct compass
|
||||||
|
bearings toward that player's own exit. It provides neither a coordinate nor a
|
||||||
|
route. The hint remains through rejected commands and is cleared by the next
|
||||||
|
successful move unless that move activates the compass again.
|
||||||
|
|
||||||
|
## Exits and results
|
||||||
|
|
||||||
|
Entering an owner's exit while carrying the matching key opens it but does not
|
||||||
|
finish the match. The player must issue another movement command pointing out
|
||||||
|
of the map from that open exit. The first accepted outward move starts a
|
||||||
|
200-millisecond finish window. A different player exiting at the same timestamp
|
||||||
|
or up to and including 200 milliseconds later produces a draw. Once time is
|
||||||
|
strictly more than 200 milliseconds later, advancing the authoritative clock
|
||||||
|
finalizes the first player as winner. Opponent exits never open or permit an
|
||||||
|
outward move for the wrong player.
|
||||||
|
|
||||||
|
Player snapshots always contain their own inventory, remaining vision moves,
|
||||||
|
compass hint, and own-exit status. An opponent's carried key is included only
|
||||||
|
while that opponent is currently visible. Observer snapshots contain both
|
||||||
|
inventories and effect counters, both exit states and authoritative exit
|
||||||
|
timestamps, every remaining ground object, and the public match result.
|
||||||
|
|
||||||
|
## Disconnect recovery
|
||||||
|
|
||||||
|
Both players start connected. Disconnecting either player during `active` or
|
||||||
|
`finish_pending` stores that exact phase, clears queued commands, enters
|
||||||
|
`paused`, and assigns the disconnected player a monotonic deadline 60,000 ms
|
||||||
|
after detection. Commands processed during the pause are rejected before any
|
||||||
|
position, inventory, effect counter, or result can change.
|
||||||
|
If the disconnect occurs during `finish_pending`, the remaining 200 ms result
|
||||||
|
window is preserved and continues only after both players reconnect.
|
||||||
|
|
||||||
|
Restoring the reserved player session before the deadline reconnects the same
|
||||||
|
player record. The stored phase resumes only when both player records are
|
||||||
|
connected, so a second disconnected player cannot be bypassed. Reaching a
|
||||||
|
disconnected player's deadline ends the match with the authoritative `draw`
|
||||||
|
result and clears queued commands. Observer connection state never enters this
|
||||||
|
state machine.
|
||||||
|
|||||||
@@ -0,0 +1,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.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# On-device integration testing
|
||||||
|
|
||||||
|
This procedure verifies Milestone 010 on the ESP32-C6FH4 without PSRAM or any
|
||||||
|
assumed GPIO. Run it against production firmware and the production LittleFS
|
||||||
|
image. Start from a freshly booted device with no occupied lobby sessions.
|
||||||
|
|
||||||
|
## Build and delivery
|
||||||
|
|
||||||
|
```sh
|
||||||
|
/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1
|
||||||
|
/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1 --target buildfs
|
||||||
|
/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1 --target upload --upload-port /dev/ttyACM0
|
||||||
|
/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1 --target uploadfs --upload-port /dev/ttyACM0
|
||||||
|
```
|
||||||
|
|
||||||
|
Monitor the USB serial log at 115200 baud. Confirm application version
|
||||||
|
`0.14.0`, the 4 MB partition table, successful LittleFS mount, station or
|
||||||
|
fallback network startup, registration of all HTTP/WebSocket routes, and
|
||||||
|
health state `serving`.
|
||||||
|
|
||||||
|
## Resource and load scenario
|
||||||
|
|
||||||
|
Keep the development host connected to `Mazda7`; do not switch it to the
|
||||||
|
device fallback access point. When the board has station address
|
||||||
|
`192.168.1.50`, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 scripts/ws_protocol_client.py 192.168.1.50 \
|
||||||
|
--verify-m010 --stress-moves 80 --repeat-matches 2
|
||||||
|
```
|
||||||
|
|
||||||
|
The scenario intentionally takes more than two minutes because both repeated
|
||||||
|
matches use the real 60-second reconnect deadline. It performs these checks:
|
||||||
|
|
||||||
|
1. Connect two players and four observers and assemble all four complete
|
||||||
|
1,024-cell observer projections.
|
||||||
|
2. Verify heartbeat round-trip latency for all six sessions.
|
||||||
|
3. Stop consuming one observer stream while Player 1 performs accepted moves;
|
||||||
|
intermediate observer updates may be dropped, but player acknowledgements
|
||||||
|
must remain within the reported 500 ms budget.
|
||||||
|
4. Flood a second observer with malformed messages. The fixed rate limiter
|
||||||
|
closes sustained abuse, and the players remain responsive.
|
||||||
|
5. Complete and authoritatively reset two matches through the real disconnect
|
||||||
|
timeout. Each reset generates a new server-owned maze while preserving the
|
||||||
|
connected sessions.
|
||||||
|
6. Fetch `/diagnostics` before, during, and after the run. Assert the connection
|
||||||
|
and queue high-water marks remain within six and twelve, internal free heap
|
||||||
|
stays above 32 KiB, the largest internal block stays above 16 KiB, and the
|
||||||
|
post-reset free-heap samples remain within a 32 KiB range.
|
||||||
|
|
||||||
|
Capture the command output, the final diagnostics JSON, and serial lines that
|
||||||
|
begin with `M010 resources:`. The JSON counters are monotonic since boot and
|
||||||
|
include connections, queue use, dropped messages, failures, malformed/rate-
|
||||||
|
limited input, commands, match cycles, maximum handler/send time, and current
|
||||||
|
and minimum internal heap samples.
|
||||||
|
|
||||||
|
## Station, fallback, and browser recovery
|
||||||
|
|
||||||
|
These checks involve network state and must be performed from a separate phone
|
||||||
|
or computer so the development host retains its Mazda7 internet connection.
|
||||||
|
|
||||||
|
1. Reboot with the saved Mazda7 configuration and confirm
|
||||||
|
`/api/wifi/status` reports `station_connected`. Reload both player pages
|
||||||
|
during an active match and verify their tokens restore the same slots and
|
||||||
|
authoritative state.
|
||||||
|
2. From the separate client, exercise the established provisioning procedure
|
||||||
|
with an intentionally unavailable test network. Confirm the bounded station
|
||||||
|
attempts return the device to `maze32-XXXX` at
|
||||||
|
`http://192.168.4.1/provision` and that the Russian page remains usable.
|
||||||
|
3. Restore Mazda7 through the provisioning page without recording its
|
||||||
|
password. Reboot and confirm station connection, browser recovery, `/health`,
|
||||||
|
and `/diagnostics` again.
|
||||||
|
|
||||||
|
Do not record an acceptance result unless the physical logs and samples are
|
||||||
|
available. Do not use an LED, a board pin, or a PSRAM capability as a test
|
||||||
|
oracle.
|
||||||
@@ -0,0 +1,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.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Milestone 017 latency remediation report
|
||||||
|
|
||||||
|
## Baseline
|
||||||
|
|
||||||
|
Milestone 016 recorded an unsuitable local Wi-Fi tail: 100 HTTP requests had
|
||||||
|
7.5 ms minimum, 186.7 ms average, 508.0 ms p95, 713.3 ms p99, and 985.4 ms
|
||||||
|
maximum round-trip time. The available browser samples ranged from 19 to 548
|
||||||
|
ms for command acknowledgement and from 30 to 551 ms for an authoritative
|
||||||
|
painted view. ESP handler and send measurements were materially smaller during
|
||||||
|
the partial runs. The M016 project-owner-approved deviation records that the
|
||||||
|
remaining controlled runs could not be completed on that path.
|
||||||
|
|
||||||
|
## Browser remediation
|
||||||
|
|
||||||
|
The player now uses the centered avatar as a 96 by 96 CSS-pixel transparent
|
||||||
|
touch target. Pointer movement changes only a local avatar deflection capped at
|
||||||
|
28 CSS pixels. A 12-pixel dead zone, diagonal hysteresis, and a 360-to-170 ms
|
||||||
|
monotonic cadence resolve a cardinal movement intention without exceeding the
|
||||||
|
150 ms server cooldown.
|
||||||
|
|
||||||
|
The browser sends at most one move at a time. While that command awaits an
|
||||||
|
acknowledgement or error, changing drag direction replaces one local latest
|
||||||
|
intention. Release removes the unsent intention. Consequently a slow socket
|
||||||
|
cannot accumulate a client-side movement backlog. No browser code predicts a
|
||||||
|
maze position, collision result, inventory, visibility, timer, or outcome.
|
||||||
|
|
||||||
|
Each received projection is translated so its authoritative `self` cell is
|
||||||
|
directly below the fixed avatar. This replaces rectangle-centering, which could
|
||||||
|
put the fixed avatar over a wall when a visible projection reached a maze edge.
|
||||||
|
Drag displacement is measured from the pointer-down position rather than from
|
||||||
|
the edge of the transparent target, preventing an unintended move on touch.
|
||||||
|
|
||||||
|
## Required deployment-network remediation and retest
|
||||||
|
|
||||||
|
Do not use the M016 Wi-Fi path for latency acceptance. Place the ESP and both
|
||||||
|
test clients on the same dedicated 5 GHz access point or the ESP fallback AP,
|
||||||
|
with no WAN routing, captive portal, VPN, power-saving transition, or active
|
||||||
|
bulk traffic. Record access-point model/band/channel, client hardware,
|
||||||
|
browser/version, RSSI, device address, build identity, and viewport.
|
||||||
|
|
||||||
|
Repeat the M016 procedure for two players with zero observers and with four
|
||||||
|
observers. For each player, collect at least 200 accepted moves and export:
|
||||||
|
|
||||||
|
```js
|
||||||
|
window.maze32PlayerApp.pointerFeedbackSamples()
|
||||||
|
window.maze32PlayerApp.latencySamples()
|
||||||
|
```
|
||||||
|
|
||||||
|
Record p50, p95, p99, and maximum values separately for pointer feedback,
|
||||||
|
command acknowledgement, complete authoritative view, and painted
|
||||||
|
authoritative view. Capture `/diagnostics` before and after each run and 100
|
||||||
|
ICMP plus application/WebSocket round trips. The acceptance target remains
|
||||||
|
painted-update p95 at or below 100 ms, maximum at or below 150 ms, and no
|
||||||
|
accepted sample at or above 500 ms. Do not mark this report as a passing
|
||||||
|
device measurement until those controlled results exist.
|
||||||
@@ -7,13 +7,13 @@ later milestones.
|
|||||||
## Representation and limits
|
## Representation and limits
|
||||||
|
|
||||||
- Dimensions are runtime configuration values. Supported maps are even-width,
|
- Dimensions are runtime configuration values. Supported maps are even-width,
|
||||||
even-height rectangles from `8 x 8` through `128 x 64` and contain at most
|
even-height rectangles from `8 x 8` through `32 x 32` and contain at most
|
||||||
8,192 cells.
|
1,024 cells.
|
||||||
- Each cell uses one byte of four-direction passage flags. A fixed bit set,
|
- Each cell uses one byte of four-direction passage flags. A fixed bit set,
|
||||||
traversal queue, and distance array form the reusable generation workspace.
|
traversal queue, and distance array form the reusable generation workspace.
|
||||||
- Starts, keys, boundary exits, compass cells, landmarks, and vision artifacts
|
- Starts, keys, boundary exits, compass cells, landmarks, and vision artifacts
|
||||||
use bounded fixed arrays. No generation or validation path calls a heap API.
|
use bounded fixed arrays. No generation or validation path calls a heap API.
|
||||||
- The maze plus workspace budget is 64 KiB. The default `128 x 64`
|
- The maze plus workspace budget is 64 KiB. The default `32 x 32`
|
||||||
configuration targets generation within 250 ms on the ESP32-C6.
|
configuration targets generation within 250 ms on the ESP32-C6.
|
||||||
- Candidate regeneration is capped by `maximum_attempts`; failure returns a
|
- Candidate regeneration is capped by `maximum_attempts`; failure returns a
|
||||||
diagnostic result instead of retrying indefinitely.
|
diagnostic result instead of retrying indefinitely.
|
||||||
@@ -35,10 +35,10 @@ topology, placements, metrics, and hash.
|
|||||||
|
|
||||||
Player 2's start and every required object are the exact rotational counterpart
|
Player 2's start and every required object are the exact rotational counterpart
|
||||||
of Player 1's placement. Starts and exits are boundary cells, and the default
|
of Player 1's placement. Starts and exits are boundary cells, and the default
|
||||||
requires at least 64 cells of Manhattan separation between each start and its
|
requires at least 22 cells of Manhattan separation between each start and its
|
||||||
exit. Keys are selected outside the owners' initial Chebyshev-radius-three
|
exit. Keys are selected outside the owners' initial Chebyshev-radius-three
|
||||||
views. The default places eight landmark pairs and four vision-artifact pairs,
|
views. The default places two landmark pairs and one vision-artifact pair, with
|
||||||
with no placement collisions.
|
no placement collisions.
|
||||||
|
|
||||||
Validation independently checks reciprocal passages, rotational symmetry,
|
Validation independently checks reciprocal passages, rotational symmetry,
|
||||||
full reachability, edge and cycle counts, dead ends, half crossings, placement
|
full reachability, edge and cycle counts, dead ends, half crossings, placement
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# MVP acceptance evidence register
|
||||||
|
|
||||||
|
This register maps the MVP acceptance criteria to existing evidence and the
|
||||||
|
remaining Milestone 011 evidence. It prevents release approval from relying on
|
||||||
|
unrecorded assumptions.
|
||||||
|
|
||||||
|
| MVP criterion | Evidence | Status |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1. Firmware and LittleFS fit | Milestone 010 target builds: application 1,028,956 / 2,097,152 bytes; LittleFS 2,031,616 / 2,031,616 bytes. | Passing; repeat for release candidate. |
|
||||||
|
| 2. Persistent station and fallback provisioning | Milestone 010 owner confirmation: persisted station connection and bounded retry; fallback provisioning page worked. | Passing. |
|
||||||
|
| 3. Two-player lobby, countdown, and refresh | Host lobby/protocol and browser tests; Milestone 010 owner confirmation that both player tokens restored their slots. | Passing. |
|
||||||
|
| 4. Four read-only observers | Milestone 010 six-client target run assembled four complete observer maps; Milestone 009 authorization tests. | Passing. |
|
||||||
|
| 5. Fair repeated 32 x 32 maps | Milestone 014 1,024-seed host sweep and maze validation; locked metrics in `docs/BALANCE_REPORT.md`. | Passing at host level; target playtest pending. |
|
||||||
|
| 6. Projection security | Host game visibility and payload-security tests; player and observer browser-contract tests. | Passing. |
|
||||||
|
| 7. Controls and cooldown | Host game-core and browser keyboard/swipe tests; 150 ms locked cooldown. | Passing. |
|
||||||
|
| 8. Key and inventory mechanics | Host game-mechanics tests covering pickup, drop, sabotage, meeting return, and exits. | Passing. |
|
||||||
|
| 9. Artifact and compass rules | Host game-mechanics tests covering ten successful moves and role-specific compass projections. | Passing. |
|
||||||
|
| 10. Victory and draw window | Host game-mechanics test covering outward exit and 200 ms draw boundary. | Passing. |
|
||||||
|
| 11. Disconnect recovery | Host disconnect-recovery tests and Milestone 009 physical acceptance evidence. | Passing. |
|
||||||
|
| 12. Approximately five-minute normal match | The Milestone 011 evidence applies to the superseded `128 x 64` configuration. Repeat representative testing for the compact map or approve a new deviation. | Pending Milestone 014 confirmation. |
|
||||||
|
| 13. Russian offline UI and browser coverage | Milestone 008 owner checks in Chrome 152, Firefox 155, Safari 26.6, and Edge 152.0.4191.53; asset verification enforces the offline and 1.5 MiB budget. | Passing; repeat asset check for release candidate. |
|
||||||
|
|
||||||
|
## Release-candidate record
|
||||||
|
|
||||||
|
Before marking Milestone 011 complete, add the exact date, application version,
|
||||||
|
firmware and LittleFS build commands, sizes, and the web-asset reproducibility
|
||||||
|
digest from the final candidate below. This record must match the tested image.
|
||||||
|
|
||||||
|
| Field | Release-candidate value |
|
||||||
|
| --- | --- |
|
||||||
|
| Date | 2026-09-08 |
|
||||||
|
| Application version | 0.10.0 |
|
||||||
|
| Firmware build command and size | `pio run -e esp32-c6-devkitm-1`; 1,028,956 / 2,097,152 application bytes; `firmware.bin` 1,029,584 bytes, SHA-256 `e654643cb9f16ec03c8d54d981da32ab8e64616f99d23d4a6bb14c8ac7f69c17` |
|
||||||
|
| LittleFS build command and size | `pio run -e esp32-c6-devkitm-1 --target buildfs`; 2,031,616 / 2,031,616 bytes; SHA-256 `526e2d03ca4473adb0fe075f4b075aaaeec85ef982240f6578e8cbbb4bec7f35` |
|
||||||
|
| Web asset digest and compressed size | `8f3207a208c26aa9f0faff0b0fd8db8e8f8e44537bd6db2c0f914ca5f352920a`; 19,253 / 1,572,864 bytes |
|
||||||
|
| Approved deviations | Criterion 12: omit playtest count and timing statistics; project owner confirmed representative physical playtests passed without issues. |
|
||||||
|
|
||||||
|
No pending criterion is treated as passing evidence. The only approved
|
||||||
|
deviation is recorded above.
|
||||||
|
|
||||||
|
## Milestone 014 compact-map candidate
|
||||||
|
|
||||||
|
The following host-verified candidate supersedes the map configuration in the
|
||||||
|
Milestone 011 release record. It is not the final accepted release until the
|
||||||
|
representative compact-map playtest is confirmed.
|
||||||
|
|
||||||
|
| Field | Compact-map candidate value |
|
||||||
|
| --- | --- |
|
||||||
|
| Date | 2026-09-08 |
|
||||||
|
| Application version | 0.14.0 |
|
||||||
|
| Firmware build command and size | `pio run -e esp32-c6-devkitm-1`; 1,027,934 / 2,097,152 application bytes; `firmware.bin` 1,028,560 bytes, SHA-256 `428e7690bed50d30caf61aca174d838f00d7de1a603f14ea669e996e3d167123` |
|
||||||
|
| LittleFS build command and size | `pio run -e esp32-c6-devkitm-1 --target buildfs`; 2,031,616 / 2,031,616 bytes; SHA-256 `467f0a07413c83bff5ea246b5063289eb61f8bf40b95d16d639b3241ceda8a9d` |
|
||||||
|
| Web asset digest and compressed size | `3461d20742e1999c1489465d7f3a68317d56b3acfc5ac5003341e1cf2d478eed`; 20,314 / 1,572,864 bytes |
|
||||||
|
| Pending evidence | Representative two-player confirmation for the `32 x 32` configuration. |
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Observer browser mode
|
||||||
|
|
||||||
|
Milestone 009 adds a password-free observer role to the same local browser
|
||||||
|
application. The role is still authenticated by its temporary session token,
|
||||||
|
but joining it does not request a player name or consume either player slot.
|
||||||
|
At most four observer sessions are connected at once.
|
||||||
|
|
||||||
|
## Projection and authorization
|
||||||
|
|
||||||
|
An observer renders only the server's `observer` view projection. That
|
||||||
|
projection contains all maze cells, remaining objects, both player positions,
|
||||||
|
inventories, effects, exit state, phase, and result. The browser does not
|
||||||
|
simulate movements or infer updates. Each player status also reflects the
|
||||||
|
server-owned WebSocket connection state. A complete projection establishes the
|
||||||
|
server-approved static geometry on join, restoration, and match start.
|
||||||
|
Subsequent bounded projections replace all dynamic player and object state
|
||||||
|
while reusing only that authoritative geometry, avoiding a 1,024-cell
|
||||||
|
retransmission after every move.
|
||||||
|
|
||||||
|
The observer screen contains no movement, start, or drop-key control. This is
|
||||||
|
only presentation-level defense: the gateway also rejects and logs observer
|
||||||
|
`start`, `move`, and `drop_key` attempts before they reach authoritative
|
||||||
|
mutation. Disconnecting an observer changes only lobby presence and never
|
||||||
|
pauses the game.
|
||||||
|
|
||||||
|
## Viewport and navigation
|
||||||
|
|
||||||
|
At `1024 x 768` CSS pixels and above, the complete `32 x 32` default map is
|
||||||
|
initially scaled to fit the available stage. The observer can zoom with the
|
||||||
|
labelled buttons or mouse wheel and pan with pointer dragging. Zoom is bounded
|
||||||
|
between 0.25 and 6 times the eight-pixel base cell size; the **Вся карта**
|
||||||
|
control restores the calculated fit and centered position.
|
||||||
|
|
||||||
|
When either viewport dimension is below the supported minimum, the map and
|
||||||
|
controls are hidden and the Russian message **Нужен экран побольше** explains
|
||||||
|
the `1024 x 768` requirement. All assets remain local and use the shared
|
||||||
|
retro-arcade visual language.
|
||||||
@@ -0,0 +1,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`.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Milestone 011 playtest report
|
||||||
|
|
||||||
|
## Protocol
|
||||||
|
|
||||||
|
Run representative completed two-player matches on the target ESP32-C6 with
|
||||||
|
the locked configuration in `docs/BALANCE_REPORT.md`. Use two active players
|
||||||
|
and, where practical, one observer. Do not impose a time limit or coach a
|
||||||
|
route. Start timing when the authoritative countdown completes and stop at the
|
||||||
|
authoritative result. A draw, reconnect timeout, abandoned match, or technical
|
||||||
|
failure is recorded separately and does not count as a completed match.
|
||||||
|
|
||||||
|
For each completed match, record the maze seed or map hash, both players'
|
||||||
|
experience level, winner or draw, elapsed time, whether either player used a
|
||||||
|
vision artifact or compass, and any gameplay defect. Do not record session
|
||||||
|
tokens, Wi-Fi passwords, or other credentials.
|
||||||
|
|
||||||
|
Use at least five completed matches involving more than one pair of players.
|
||||||
|
The target is that ordinary completed matches cluster around five minutes;
|
||||||
|
there is deliberately no hard timer. If a result materially misses that target
|
||||||
|
or a participant reports a balance issue, describe it and repeat the affected
|
||||||
|
tests after any parameter change.
|
||||||
|
|
||||||
|
## Results
|
||||||
|
|
||||||
|
On 2026-09-08, the project owner confirmed that the representative physical
|
||||||
|
two-player playtests covered by this protocol worked as expected, with no
|
||||||
|
gameplay defects or balance issues observed. The owner explicitly approved a
|
||||||
|
deviation from the protocol's count and elapsed-time statistics: individual
|
||||||
|
match timing, seed or hash, and per-run fields are not required for MVP
|
||||||
|
acceptance. No hard match timer was introduced.
|
||||||
|
|
||||||
|
## Assessment
|
||||||
|
|
||||||
|
The project owner accepted the approximately five-minute target based on the
|
||||||
|
representative playtests above and approved the absence of a quantified timing
|
||||||
|
sample. This is an approved evidence deviation, not a claim of statistical
|
||||||
|
measurement. The locked configuration remains unchanged because no issue was
|
||||||
|
reported.
|
||||||
|
|
||||||
|
## Milestone 014 compact-map revalidation
|
||||||
|
|
||||||
|
The earlier results apply to the superseded `128 x 64` configuration. Test the
|
||||||
|
new `32 x 32` configuration with two players after the Milestone 014 firmware
|
||||||
|
and web assets are installed. Confirm that navigation remains understandable,
|
||||||
|
both players consider the symmetric race fair, no gameplay-blocking defect is
|
||||||
|
observed, and the match duration remains acceptable without a hard timer.
|
||||||
|
|
||||||
|
Representative compact-map results have not yet been recorded.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Pre-reader usability observation guide
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Use this short, consent-based observation to verify the player interface with
|
||||||
|
a child who does not rely on reading. It is an acceptance activity for
|
||||||
|
Milestone 015, not a source of analytics. Do not record names, images, audio,
|
||||||
|
contact details, or any other identifying information.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
- Use the player screen at a `412 x 916` CSS-pixel viewport with sound enabled.
|
||||||
|
- Start a normal two-player match with the observer screen hidden.
|
||||||
|
- An adult may read a prompt once but must not point to the answer or operate
|
||||||
|
the child's device.
|
||||||
|
|
||||||
|
## Prompts
|
||||||
|
|
||||||
|
Ask the child to demonstrate each task. Mark it as completed only when the
|
||||||
|
child succeeds without reading the Russian support text.
|
||||||
|
|
||||||
|
| Task | Expected non-reading cue |
|
||||||
|
| --- | --- |
|
||||||
|
| Move the hero | centered character drag, direction arrow, and map movement |
|
||||||
|
| Find and pick up a key | key shape and pickup feedback |
|
||||||
|
| Recognize farther vision | eye symbol and map expansion |
|
||||||
|
| Follow the compass | arrow direction and compass icon |
|
||||||
|
| Find the exit | outlined exit shape and owner character shape |
|
||||||
|
| Recognize a wall | wall shape plus rejection feedback |
|
||||||
|
| Recognize the result | result icon/overlay plus local feedback |
|
||||||
|
|
||||||
|
## Record
|
||||||
|
|
||||||
|
Record only the date, viewport, browser, whether Russian labels were read,
|
||||||
|
and the seven task outcomes. Summarize unclear cues and any adult assistance.
|
||||||
|
If no representative child observation is feasible, the project owner may
|
||||||
|
approve a documented deviation instead; include the reason and the remaining
|
||||||
|
risk in the active milestone evidence.
|
||||||
+44
-9
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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 |
@@ -54,3 +54,9 @@ extends = env:esp32-c6-devkitm-1
|
|||||||
build_flags =
|
build_flags =
|
||||||
${env:esp32-c6-devkitm-1.build_flags}
|
${env:esp32-c6-devkitm-1.build_flags}
|
||||||
-DMAZE32_MILESTONE_006_DEVICE_TEST=1
|
-DMAZE32_MILESTONE_006_DEVICE_TEST=1
|
||||||
|
|
||||||
|
[env:milestone-007-device-test]
|
||||||
|
extends = env:esp32-c6-devkitm-1
|
||||||
|
build_flags =
|
||||||
|
${env:esp32-c6-devkitm-1.build_flags}
|
||||||
|
-DMAZE32_MILESTONE_007_DEVICE_TEST=1
|
||||||
|
|||||||
@@ -68,4 +68,28 @@ cc -std=c11 -Wall -Wextra -Werror -pedantic \
|
|||||||
-o "$test_directory/test_game_core"
|
-o "$test_directory/test_game_core"
|
||||||
|
|
||||||
"$test_directory/test_game_core"
|
"$test_directory/test_game_core"
|
||||||
|
|
||||||
|
cc -std=c11 -Wall -Wextra -Werror -pedantic \
|
||||||
|
-I"$project_directory/components/maze32_maze/include" \
|
||||||
|
-I"$project_directory/components/maze32_game/include" \
|
||||||
|
"$project_directory/components/maze32_maze/maze32_maze.c" \
|
||||||
|
"$project_directory/components/maze32_game/maze32_game.c" \
|
||||||
|
"$project_directory/test/host/test_game_mechanics.c" \
|
||||||
|
-o "$test_directory/test_game_mechanics"
|
||||||
|
|
||||||
|
"$test_directory/test_game_mechanics"
|
||||||
|
|
||||||
|
cc -std=c11 -Wall -Wextra -Werror -pedantic \
|
||||||
|
-I"$project_directory/components/maze32_maze/include" \
|
||||||
|
-I"$project_directory/components/maze32_game/include" \
|
||||||
|
"$project_directory/components/maze32_maze/maze32_maze.c" \
|
||||||
|
"$project_directory/components/maze32_game/maze32_game.c" \
|
||||||
|
"$project_directory/test/host/test_disconnect_recovery.c" \
|
||||||
|
-o "$test_directory/test_disconnect_recovery"
|
||||||
|
|
||||||
|
"$test_directory/test_disconnect_recovery"
|
||||||
|
node "$project_directory/test/web/test_player_app.js"
|
||||||
|
python3 "$project_directory/scripts/generate_web_assets.py" \
|
||||||
|
--source "$project_directory/web" \
|
||||||
|
--output "$project_directory/data" >/dev/null
|
||||||
python3 "$project_directory/scripts/verify_web_assets.py"
|
python3 "$project_directory/scripts/verify_web_assets.py"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from generate_web_assets import generate_assets
|
|||||||
|
|
||||||
|
|
||||||
PROJECT_DIRECTORY = Path(__file__).resolve().parent.parent
|
PROJECT_DIRECTORY = Path(__file__).resolve().parent.parent
|
||||||
|
COMPRESSED_PAYLOAD_LIMIT = 1572864
|
||||||
|
|
||||||
|
|
||||||
def tree_digest(directory: Path) -> str:
|
def tree_digest(directory: Path) -> str:
|
||||||
@@ -41,17 +42,25 @@ def main() -> int:
|
|||||||
if first_digest != checked_in_digest:
|
if first_digest != checked_in_digest:
|
||||||
raise AssertionError("Generated web assets are stale; regenerate the data directory")
|
raise AssertionError("Generated web assets are stale; regenerate the data directory")
|
||||||
|
|
||||||
index_source = (source_directory / "index.html").read_bytes()
|
for compressed_path in sorted(path for path in first.rglob("*.gz") if path.is_file()):
|
||||||
index_compressed = (first / "index.html.gz").read_bytes()
|
relative_path = compressed_path.relative_to(first)
|
||||||
if gzip.decompress(index_compressed) != index_source:
|
source_relative = Path(str(relative_path)[:-3])
|
||||||
raise AssertionError("Compressed index does not reproduce its source")
|
if gzip.decompress(compressed_path.read_bytes()) != (
|
||||||
|
source_directory / source_relative
|
||||||
|
).read_bytes():
|
||||||
|
raise AssertionError(f"Compressed asset does not reproduce {source_relative}")
|
||||||
|
|
||||||
provision_source = (source_directory / "provision.html").read_bytes()
|
compressed_size = sum(path.stat().st_size for path in first.rglob("*.gz"))
|
||||||
provision_compressed = (first / "provision.html.gz").read_bytes()
|
if compressed_size > COMPRESSED_PAYLOAD_LIMIT:
|
||||||
if gzip.decompress(provision_compressed) != provision_source:
|
raise AssertionError(
|
||||||
raise AssertionError("Compressed provisioning page does not reproduce its source")
|
f"Compressed web payload is {compressed_size} bytes and exceeds "
|
||||||
|
f"the {COMPRESSED_PAYLOAD_LIMIT}-byte limit"
|
||||||
|
)
|
||||||
|
|
||||||
print(f"web asset reproducibility: PASS ({first_digest})")
|
print(
|
||||||
|
f"web asset reproducibility: PASS ({first_digest}, "
|
||||||
|
f"{compressed_size}/{COMPRESSED_PAYLOAD_LIMIT} bytes)"
|
||||||
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Regular → Executable
+372
-5
@@ -6,13 +6,17 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import http.client
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
import struct
|
import struct
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
MALFORMED_STRESS_MESSAGES = 80
|
||||||
|
|
||||||
|
|
||||||
class WebSocket:
|
class WebSocket:
|
||||||
def __init__(self, host: str, port: int = 80, timeout: float = 8.0):
|
def __init__(self, host: str, port: int = 80, timeout: float = 8.0):
|
||||||
@@ -120,6 +124,360 @@ def join(client: WebSocket, role: str, name: str | None = None) -> dict[str, Any
|
|||||||
return client.receive_type("session")["payload"]
|
return client.receive_type("session")["payload"]
|
||||||
|
|
||||||
|
|
||||||
|
def receive_view(client: WebSocket, maximum_messages: int = 800) -> dict[str, Any]:
|
||||||
|
parts: dict[int, dict[str, Any]] = {}
|
||||||
|
sequence: int | None = None
|
||||||
|
expected = 0
|
||||||
|
for _ in range(maximum_messages):
|
||||||
|
message = client.receive()
|
||||||
|
if message.get("type") != "view":
|
||||||
|
continue
|
||||||
|
payload = message["payload"]
|
||||||
|
if sequence != message["seq"]:
|
||||||
|
sequence = message["seq"]
|
||||||
|
expected = payload["chunks"]
|
||||||
|
parts = {}
|
||||||
|
parts[payload["chunk"]] = payload
|
||||||
|
if len(parts) == expected:
|
||||||
|
first = dict(parts[0])
|
||||||
|
first["cells"] = [cell for index in range(expected) for cell in parts[index]["cells"]]
|
||||||
|
first["objects"] = [
|
||||||
|
item for index in range(expected) for item in parts[index]["objects"]
|
||||||
|
]
|
||||||
|
return first
|
||||||
|
raise RuntimeError("did not receive a complete view")
|
||||||
|
|
||||||
|
|
||||||
|
def open_direction(view: dict[str, Any]) -> str:
|
||||||
|
passages = next(cell[1] for cell in view["cells"] if cell[0] == view["self"])
|
||||||
|
for bit, direction in ((1, "north"), (2, "east"), (4, "south"), (8, "west")):
|
||||||
|
if passages & bit:
|
||||||
|
return direction
|
||||||
|
raise AssertionError("player cell has no open passage")
|
||||||
|
|
||||||
|
|
||||||
|
def diagnostics(host: str) -> dict[str, Any]:
|
||||||
|
connection = http.client.HTTPConnection(host, 80, timeout=8.0)
|
||||||
|
try:
|
||||||
|
connection.request("GET", "/diagnostics")
|
||||||
|
response = connection.getresponse()
|
||||||
|
payload = response.read()
|
||||||
|
if response.status != 200:
|
||||||
|
raise RuntimeError(f"diagnostics returned HTTP {response.status}")
|
||||||
|
return json.loads(payload.decode("utf-8"))
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_m010(host: str, stress_moves: int, repeat_matches: int) -> None:
|
||||||
|
clients: list[WebSocket] = []
|
||||||
|
try:
|
||||||
|
baseline = diagnostics(host)
|
||||||
|
player_1 = WebSocket(host, timeout=30.0)
|
||||||
|
player_2 = WebSocket(host, timeout=30.0)
|
||||||
|
clients.extend((player_1, player_2))
|
||||||
|
join(player_1, "player", "Нагрузочный Один")
|
||||||
|
session_2 = join(player_2, "player", "Нагрузочный Два")
|
||||||
|
observers = [WebSocket(host, timeout=120.0) for _ in range(4)]
|
||||||
|
clients.extend(observers)
|
||||||
|
for observer in observers:
|
||||||
|
join(observer, "observer")
|
||||||
|
|
||||||
|
observer_views: list[dict[str, Any] | None] = [None] * len(observers)
|
||||||
|
failures: list[BaseException] = []
|
||||||
|
|
||||||
|
def read_observer(index: int) -> None:
|
||||||
|
try:
|
||||||
|
observers[index].receive_type("active", attempts=50)
|
||||||
|
observer_views[index] = receive_view(observers[index])
|
||||||
|
except BaseException as error: # Preserve worker failure for the main thread.
|
||||||
|
failures.append(error)
|
||||||
|
|
||||||
|
readers = [threading.Thread(target=read_observer, args=(index,))
|
||||||
|
for index in range(len(observers))]
|
||||||
|
for reader in readers:
|
||||||
|
reader.start()
|
||||||
|
player_views: list[dict[str, Any] | None] = [None, None]
|
||||||
|
player_failures: list[BaseException] = []
|
||||||
|
|
||||||
|
def read_player(index: int, client: WebSocket) -> None:
|
||||||
|
try:
|
||||||
|
client.receive_type("active", attempts=50)
|
||||||
|
player_views[index] = receive_view(client)
|
||||||
|
except BaseException as error: # Preserve worker failure for the main thread.
|
||||||
|
player_failures.append(error)
|
||||||
|
|
||||||
|
player_readers = [
|
||||||
|
threading.Thread(target=read_player, args=(0, player_1)),
|
||||||
|
threading.Thread(target=read_player, args=(1, player_2)),
|
||||||
|
]
|
||||||
|
for reader in player_readers:
|
||||||
|
reader.start()
|
||||||
|
player_1.send("start", {}, "m010-start")
|
||||||
|
for reader in player_readers:
|
||||||
|
reader.join(timeout=180.0)
|
||||||
|
for reader in readers:
|
||||||
|
reader.join(timeout=180.0)
|
||||||
|
assert not player_failures and all(not reader.is_alive() for reader in player_readers), (
|
||||||
|
f"player read failures: {player_failures!r}"
|
||||||
|
)
|
||||||
|
assert not failures and all(not reader.is_alive() for reader in readers), (
|
||||||
|
f"observer read failures: {failures!r}"
|
||||||
|
)
|
||||||
|
player_1_view = player_views[0]
|
||||||
|
assert player_1_view is not None and player_views[1] is not None
|
||||||
|
assert all(view is not None and len(view["cells"]) == 1024
|
||||||
|
for view in observer_views)
|
||||||
|
|
||||||
|
six_client_sample = diagnostics(host)
|
||||||
|
limits = six_client_sample["limits"]
|
||||||
|
assert six_client_sample["activeConnections"] == 6
|
||||||
|
assert six_client_sample["connectionHighWater"] == 6
|
||||||
|
assert six_client_sample["queueHighWater"] <= limits["queueEntries"]
|
||||||
|
print("M010 six-client load: PASS (2 players + 4 complete observers)")
|
||||||
|
|
||||||
|
observers[0].send("restart", {}, "m010-observer-restart")
|
||||||
|
observer_error = observers[0].receive_type("error", attempts=30)
|
||||||
|
assert observer_error["payload"]["code"] == "read_only"
|
||||||
|
|
||||||
|
heartbeat_latencies_ms: list[float] = []
|
||||||
|
for index, client in enumerate(clients):
|
||||||
|
started = time.monotonic()
|
||||||
|
client.send("heartbeat", {}, f"m010-heartbeat-{index}")
|
||||||
|
client.receive_type("ack", attempts=30)
|
||||||
|
heartbeat_latencies_ms.append((time.monotonic() - started) * 1000.0)
|
||||||
|
assert max(heartbeat_latencies_ms) <= limits["playerLatencyBudgetMs"]
|
||||||
|
print(f"M010 six-client responsiveness: PASS (max {max(heartbeat_latencies_ms):.1f} ms)")
|
||||||
|
|
||||||
|
slow_observer = observers[0]
|
||||||
|
slow_observer.socket.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1024)
|
||||||
|
drain_stop = threading.Event()
|
||||||
|
drain_failures: list[BaseException] = []
|
||||||
|
|
||||||
|
def drain_fast_observer(observer: WebSocket) -> None:
|
||||||
|
observer.socket.settimeout(1.0)
|
||||||
|
while not drain_stop.is_set():
|
||||||
|
try:
|
||||||
|
observer.receive()
|
||||||
|
except socket.timeout:
|
||||||
|
continue
|
||||||
|
except BaseException as error: # Preserve an unexpected disconnect.
|
||||||
|
drain_failures.append(error)
|
||||||
|
return
|
||||||
|
|
||||||
|
drainers = [threading.Thread(target=drain_fast_observer, args=(observer,))
|
||||||
|
for observer in observers[1:]]
|
||||||
|
for drainer in drainers:
|
||||||
|
drainer.start()
|
||||||
|
|
||||||
|
opposite = {"north": "south", "east": "west", "south": "north", "west": "east"}
|
||||||
|
direction = open_direction(player_1_view)
|
||||||
|
command_latencies_ms: list[float] = []
|
||||||
|
for move_index in range(stress_moves):
|
||||||
|
time.sleep(0.16)
|
||||||
|
started = time.monotonic()
|
||||||
|
player_1.send("move", {"direction": direction}, f"m010-move-{move_index}")
|
||||||
|
player_1.receive_type("ack", attempts=50)
|
||||||
|
command_latencies_ms.append((time.monotonic() - started) * 1000.0)
|
||||||
|
player_1_view = receive_view(player_1)
|
||||||
|
receive_view(player_2)
|
||||||
|
direction = opposite[direction]
|
||||||
|
|
||||||
|
drain_stop.set()
|
||||||
|
for drainer in drainers:
|
||||||
|
drainer.join(timeout=3.0)
|
||||||
|
assert not drain_failures and all(not drainer.is_alive() for drainer in drainers), (
|
||||||
|
f"observer drain failures: {drain_failures!r}"
|
||||||
|
)
|
||||||
|
for observer in observers[1:]:
|
||||||
|
observer.socket.settimeout(30.0)
|
||||||
|
|
||||||
|
abusive_observer = observers[1]
|
||||||
|
|
||||||
|
def malformed_flood() -> None:
|
||||||
|
try:
|
||||||
|
for _ in range(MALFORMED_STRESS_MESSAGES):
|
||||||
|
abusive_observer.send_raw(b"{")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
flood = threading.Thread(target=malformed_flood)
|
||||||
|
flood.start()
|
||||||
|
stress_heartbeat_latencies_ms: list[float] = []
|
||||||
|
for index in range(10):
|
||||||
|
started = time.monotonic()
|
||||||
|
player_1.send("heartbeat", {}, f"m010-stress-heartbeat-{index}")
|
||||||
|
player_1.receive_type("ack", attempts=50)
|
||||||
|
stress_heartbeat_latencies_ms.append((time.monotonic() - started) * 1000.0)
|
||||||
|
time.sleep(0.02)
|
||||||
|
flood.join(timeout=10.0)
|
||||||
|
assert not flood.is_alive()
|
||||||
|
stressed = diagnostics(host)
|
||||||
|
maximum_latency_ms = max(command_latencies_ms + stress_heartbeat_latencies_ms)
|
||||||
|
assert maximum_latency_ms <= limits["playerLatencyBudgetMs"], (
|
||||||
|
f"maximum player latency {maximum_latency_ms:.1f} ms"
|
||||||
|
)
|
||||||
|
assert stressed["malformedMessages"] > baseline["malformedMessages"]
|
||||||
|
assert stressed["rateLimitedMessages"] > baseline["rateLimitedMessages"]
|
||||||
|
assert stressed["queueHighWater"] <= limits["queueEntries"]
|
||||||
|
assert stressed["minimumInternalFreeBytes"] > 32768
|
||||||
|
assert stressed["minimumInternalLargestBlockBytes"] > 16384
|
||||||
|
print(
|
||||||
|
"M010 slow/malformed stress: PASS "
|
||||||
|
f"(max player latency {maximum_latency_ms:.1f} ms, "
|
||||||
|
f"dropped {stressed['messagesDropped'] - baseline['messagesDropped']})"
|
||||||
|
)
|
||||||
|
|
||||||
|
for observer in observers:
|
||||||
|
observer.close()
|
||||||
|
if observer in clients:
|
||||||
|
clients.remove(observer)
|
||||||
|
observers.clear()
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
reset_heap_samples: list[int] = []
|
||||||
|
for match_index in range(repeat_matches):
|
||||||
|
token_2 = session_2["token"]
|
||||||
|
player_2.close()
|
||||||
|
clients.remove(player_2)
|
||||||
|
player_1.receive_type("pause", attempts=100)
|
||||||
|
player_1.socket.settimeout(70.0)
|
||||||
|
result = player_1.receive_type("match_result", attempts=200)["payload"]
|
||||||
|
assert result["result"] == "draw"
|
||||||
|
|
||||||
|
player_2 = WebSocket(host, timeout=30.0)
|
||||||
|
clients.append(player_2)
|
||||||
|
player_2.send("restore", {"token": token_2}, f"m010-restore-{match_index}")
|
||||||
|
session_2 = player_2.receive_type("session", attempts=50)["payload"]
|
||||||
|
player_1.send("restart", {}, f"m010-restart-{match_index}")
|
||||||
|
player_1.receive_type("ack", attempts=100)
|
||||||
|
time.sleep(0.2)
|
||||||
|
sample = diagnostics(host)
|
||||||
|
reset_heap_samples.append(sample["internalFreeBytes"])
|
||||||
|
if match_index + 1 < repeat_matches:
|
||||||
|
player_views = [None, None]
|
||||||
|
player_failures = []
|
||||||
|
player_readers = [
|
||||||
|
threading.Thread(target=read_player, args=(0, player_1)),
|
||||||
|
threading.Thread(target=read_player, args=(1, player_2)),
|
||||||
|
]
|
||||||
|
for reader in player_readers:
|
||||||
|
reader.start()
|
||||||
|
player_1.send("start", {}, f"m010-start-{match_index + 1}")
|
||||||
|
for reader in player_readers:
|
||||||
|
reader.join(timeout=40.0)
|
||||||
|
assert not player_failures and all(
|
||||||
|
not reader.is_alive() for reader in player_readers
|
||||||
|
)
|
||||||
|
|
||||||
|
final_sample = diagnostics(host)
|
||||||
|
assert final_sample["matchesCompleted"] - baseline["matchesCompleted"] >= repeat_matches
|
||||||
|
assert final_sample["matchesReset"] - baseline["matchesReset"] >= repeat_matches
|
||||||
|
if len(reset_heap_samples) > 1:
|
||||||
|
assert max(reset_heap_samples) - min(reset_heap_samples) <= 32768
|
||||||
|
print(
|
||||||
|
f"M010 repeated-match stability: PASS ({repeat_matches} matches, "
|
||||||
|
f"heap range {max(reset_heap_samples) - min(reset_heap_samples)} bytes)"
|
||||||
|
)
|
||||||
|
print(json.dumps(final_sample, ensure_ascii=False, sort_keys=True))
|
||||||
|
finally:
|
||||||
|
for client in clients:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_m009(host: str) -> None:
|
||||||
|
clients: list[WebSocket] = []
|
||||||
|
try:
|
||||||
|
player_1 = WebSocket(host)
|
||||||
|
player_2 = WebSocket(host)
|
||||||
|
clients.extend((player_1, player_2))
|
||||||
|
join(player_1, "player", "Наблюдаемый Один")
|
||||||
|
session_2 = join(player_2, "player", "Наблюдаемый Два")
|
||||||
|
observers = [WebSocket(host) for _ in range(4)]
|
||||||
|
clients.extend(observers)
|
||||||
|
for observer in observers:
|
||||||
|
join(observer, "observer")
|
||||||
|
observer.socket.settimeout(30.0)
|
||||||
|
|
||||||
|
observer_views: list[dict[str, Any] | None] = [None] * 4
|
||||||
|
failures: list[BaseException] = []
|
||||||
|
|
||||||
|
def read_observer(index: int) -> None:
|
||||||
|
try:
|
||||||
|
observers[index].receive_type("active", attempts=40)
|
||||||
|
observer_views[index] = receive_view(observers[index])
|
||||||
|
except BaseException as error: # Preserve worker failure for the main thread.
|
||||||
|
failures.append(error)
|
||||||
|
|
||||||
|
readers = [threading.Thread(target=read_observer, args=(index,)) for index in range(4)]
|
||||||
|
for reader in readers:
|
||||||
|
reader.start()
|
||||||
|
player_1.send("start", {}, "m009-start")
|
||||||
|
player_1.receive_type("countdown")
|
||||||
|
player_2.receive_type("countdown")
|
||||||
|
player_1.receive_type("active")
|
||||||
|
player_2.receive_type("active")
|
||||||
|
player_1_view = receive_view(player_1)
|
||||||
|
player_2_view = receive_view(player_2)
|
||||||
|
for reader in readers:
|
||||||
|
reader.join(timeout=35.0)
|
||||||
|
assert not failures and all(not reader.is_alive() for reader in readers)
|
||||||
|
assert all(view is not None and len(view["cells"]) == 1024 for view in observer_views)
|
||||||
|
print("four-observer full-map delivery: PASS (1024 cells each)")
|
||||||
|
|
||||||
|
observers[0].send("move", {"direction": "north"}, "observer-move")
|
||||||
|
error = observers[0].receive_type("error")
|
||||||
|
assert error["payload"]["code"] == "read_only"
|
||||||
|
print("observer mutation authorization: PASS (read_only)")
|
||||||
|
|
||||||
|
direction = open_direction(player_1_view)
|
||||||
|
player_1.send("move", {"direction": direction}, "before-observer-close")
|
||||||
|
player_1.receive_type("ack")
|
||||||
|
player_1_view = receive_view(player_1)
|
||||||
|
observers[-1].close()
|
||||||
|
clients.remove(observers[-1])
|
||||||
|
observers.pop()
|
||||||
|
time.sleep(0.2)
|
||||||
|
player_1.send("move", {"direction": open_direction(player_1_view)},
|
||||||
|
"after-observer-close")
|
||||||
|
player_1.receive_type("ack")
|
||||||
|
print("observer disconnect continuity: PASS")
|
||||||
|
|
||||||
|
preserved_cell = player_2_view["self"]
|
||||||
|
preserved_inventory = player_2_view.get("inventory")
|
||||||
|
token_2 = session_2["token"]
|
||||||
|
player_2.close()
|
||||||
|
clients.remove(player_2)
|
||||||
|
pause = player_1.receive_type("pause", attempts=30)["payload"]
|
||||||
|
assert pause["slot"] == 2
|
||||||
|
player_1.send("move", {"direction": "north"}, "paused-move")
|
||||||
|
error = player_1.receive_type("error", attempts=30)
|
||||||
|
assert error["payload"]["code"] == "premature"
|
||||||
|
|
||||||
|
restored = WebSocket(host)
|
||||||
|
clients.append(restored)
|
||||||
|
restored.send("restore", {"token": token_2}, "restore-player-2")
|
||||||
|
restored_session = restored.receive_type("session")["payload"]
|
||||||
|
assert restored_session["slot"] == 2 and restored_session["token"] == token_2
|
||||||
|
player_1.receive_type("resume", attempts=30)
|
||||||
|
restored_view = receive_view(restored)
|
||||||
|
assert restored_view["self"] == preserved_cell
|
||||||
|
assert restored_view.get("inventory") == preserved_inventory
|
||||||
|
print("player pause and reconnect: PASS (same position and inventory)")
|
||||||
|
|
||||||
|
restored.close()
|
||||||
|
clients.remove(restored)
|
||||||
|
player_1.socket.settimeout(70.0)
|
||||||
|
started = time.monotonic()
|
||||||
|
result = player_1.receive_type("match_result", attempts=60)["payload"]
|
||||||
|
elapsed = time.monotonic() - started
|
||||||
|
assert result["result"] == "draw" and elapsed >= 59.0
|
||||||
|
print(f"reconnect timeout draw: PASS ({elapsed:.1f} seconds)")
|
||||||
|
finally:
|
||||||
|
for client in clients:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
def verify(host: str) -> None:
|
def verify(host: str) -> None:
|
||||||
clients: list[WebSocket] = []
|
clients: list[WebSocket] = []
|
||||||
try:
|
try:
|
||||||
@@ -223,14 +581,23 @@ def main() -> None:
|
|||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("host", help="maze32 IP address or hostname")
|
parser.add_argument("host", help="maze32 IP address or hostname")
|
||||||
parser.add_argument("--verify", action="store_true", help="run the Milestone 004 scenario")
|
parser.add_argument("--verify", action="store_true", help="run the Milestone 004 scenario")
|
||||||
|
parser.add_argument("--verify-m009", action="store_true", help="run the Milestone 009 scenario")
|
||||||
|
parser.add_argument("--verify-m010", action="store_true", help="run the Milestone 010 load and stability scenario")
|
||||||
|
parser.add_argument("--stress-moves", type=int, default=80,
|
||||||
|
help="accepted moves sent while one observer is not reading")
|
||||||
|
parser.add_argument("--repeat-matches", type=int, default=2,
|
||||||
|
help="real 60-second timeout matches completed and reset")
|
||||||
arguments = parser.parse_args()
|
arguments = parser.parse_args()
|
||||||
if arguments.verify:
|
if arguments.stress_moves < 1 or arguments.repeat_matches < 1:
|
||||||
|
parser.error("--stress-moves and --repeat-matches must be positive")
|
||||||
|
if arguments.verify_m010:
|
||||||
|
verify_m010(arguments.host, arguments.stress_moves, arguments.repeat_matches)
|
||||||
|
elif arguments.verify_m009:
|
||||||
|
verify_m009(arguments.host)
|
||||||
|
elif arguments.verify:
|
||||||
verify(arguments.host)
|
verify(arguments.host)
|
||||||
else:
|
else:
|
||||||
client = WebSocket(arguments.host)
|
parser.error("choose --verify, --verify-m009, or --verify-m010")
|
||||||
client.send("suggest_name", {}, "manual")
|
|
||||||
print(json.dumps(client.receive(), ensure_ascii=False, indent=2))
|
|
||||||
client.close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+171
-3
@@ -26,6 +26,167 @@ static httpd_handle_t web_server;
|
|||||||
static maze32_network_t network;
|
static maze32_network_t network;
|
||||||
static maze32_gateway_t gateway;
|
static maze32_gateway_t gateway;
|
||||||
|
|
||||||
|
#ifdef MAZE32_MILESTONE_007_DEVICE_TEST
|
||||||
|
static maze32_maze_t mechanics_test_maze;
|
||||||
|
static maze32_maze_workspace_t mechanics_test_workspace;
|
||||||
|
static maze32_game_t mechanics_test_game;
|
||||||
|
static maze32_view_t mechanics_test_view;
|
||||||
|
static char mechanics_test_payload[MAZE32_GAME_VIEW_CHUNK_BYTES];
|
||||||
|
|
||||||
|
static int entry_move_for(const maze32_maze_t* maze, maze32_cell_t target,
|
||||||
|
maze32_cell_t* starting_position, maze32_game_direction_t* direction) {
|
||||||
|
if (target.y > 0U && maze32_maze_has_passage(maze, target, MAZE32_PASSAGE_NORTH)) {
|
||||||
|
*starting_position = (maze32_cell_t){target.x, (uint16_t)(target.y - 1U)};
|
||||||
|
*direction = MAZE32_GAME_DIRECTION_SOUTH;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (target.x + 1U < maze->config.width &&
|
||||||
|
maze32_maze_has_passage(maze, target, MAZE32_PASSAGE_EAST)) {
|
||||||
|
*starting_position = (maze32_cell_t){(uint16_t)(target.x + 1U), target.y};
|
||||||
|
*direction = MAZE32_GAME_DIRECTION_WEST;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (target.y + 1U < maze->config.height &&
|
||||||
|
maze32_maze_has_passage(maze, target, MAZE32_PASSAGE_SOUTH)) {
|
||||||
|
*starting_position = (maze32_cell_t){target.x, (uint16_t)(target.y + 1U)};
|
||||||
|
*direction = MAZE32_GAME_DIRECTION_NORTH;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (target.x > 0U && maze32_maze_has_passage(maze, target, MAZE32_PASSAGE_WEST)) {
|
||||||
|
*starting_position = (maze32_cell_t){(uint16_t)(target.x - 1U), target.y};
|
||||||
|
*direction = MAZE32_GAME_DIRECTION_EAST;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static maze32_game_direction_t outward_direction(const maze32_maze_t* maze,
|
||||||
|
maze32_cell_t exit_cell) {
|
||||||
|
if (exit_cell.x == 0U) {
|
||||||
|
return MAZE32_GAME_DIRECTION_WEST;
|
||||||
|
}
|
||||||
|
if (exit_cell.x + 1U == maze->config.width) {
|
||||||
|
return MAZE32_GAME_DIRECTION_EAST;
|
||||||
|
}
|
||||||
|
if (exit_cell.y == 0U) {
|
||||||
|
return MAZE32_GAME_DIRECTION_NORTH;
|
||||||
|
}
|
||||||
|
return MAZE32_GAME_DIRECTION_SOUTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t run_mechanics_device_test(void) {
|
||||||
|
maze32_maze_config_t maze_config = maze32_maze_default_config();
|
||||||
|
maze32_maze_report_t maze_report;
|
||||||
|
if (maze32_maze_generate(&mechanics_test_maze, &mechanics_test_workspace, &maze_config,
|
||||||
|
UINT32_C(0x5a320007), &maze_report) != MAZE32_MAZE_OK) {
|
||||||
|
ESP_LOGE(TAG, "M007 physical mechanics test could not generate its maze");
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
maze32_game_config_t game_config = maze32_game_default_config();
|
||||||
|
game_config.movement_cooldown_ms = 0U;
|
||||||
|
size_t free_before = heap_caps_get_free_size(MALLOC_CAP_8BIT);
|
||||||
|
int64_t started_us = esp_timer_get_time();
|
||||||
|
if (maze32_game_init(&mechanics_test_game, &mechanics_test_maze, &game_config) !=
|
||||||
|
MAZE32_GAME_RESULT_ACCEPTED) {
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
maze32_game_set_phase(&mechanics_test_game, MAZE32_GAME_PHASE_ACTIVE);
|
||||||
|
|
||||||
|
maze32_cell_t entry_position;
|
||||||
|
maze32_game_direction_t entry_direction;
|
||||||
|
uint32_t command_sequence = 0U;
|
||||||
|
if (!entry_move_for(&mechanics_test_maze, mechanics_test_maze.artifacts[0][0], &entry_position,
|
||||||
|
&entry_direction)) {
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
mechanics_test_game.players[0].position = entry_position;
|
||||||
|
maze32_game_enqueue_move(&mechanics_test_game, MAZE32_GAME_ROLE_PLAYER_ONE, entry_direction,
|
||||||
|
100U, &command_sequence);
|
||||||
|
maze32_game_command_result_t artifact_result = maze32_game_process_next(&mechanics_test_game);
|
||||||
|
|
||||||
|
if (!entry_move_for(&mechanics_test_maze, mechanics_test_maze.keys[0], &entry_position,
|
||||||
|
&entry_direction)) {
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
mechanics_test_game.players[0].position = entry_position;
|
||||||
|
maze32_game_enqueue_move(&mechanics_test_game, MAZE32_GAME_ROLE_PLAYER_ONE, entry_direction,
|
||||||
|
300U, &command_sequence);
|
||||||
|
maze32_game_command_result_t key_result = maze32_game_process_next(&mechanics_test_game);
|
||||||
|
|
||||||
|
if (!entry_move_for(&mechanics_test_maze, mechanics_test_maze.compasses[0], &entry_position,
|
||||||
|
&entry_direction)) {
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
mechanics_test_game.players[0].position = entry_position;
|
||||||
|
maze32_game_enqueue_move(&mechanics_test_game, MAZE32_GAME_ROLE_PLAYER_ONE, entry_direction,
|
||||||
|
500U, &command_sequence);
|
||||||
|
maze32_game_command_result_t compass_result = maze32_game_process_next(&mechanics_test_game);
|
||||||
|
|
||||||
|
if (!entry_move_for(&mechanics_test_maze, mechanics_test_maze.exits[0], &entry_position,
|
||||||
|
&entry_direction)) {
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
mechanics_test_game.players[0].position = entry_position;
|
||||||
|
maze32_game_enqueue_move(&mechanics_test_game, MAZE32_GAME_ROLE_PLAYER_ONE, entry_direction,
|
||||||
|
700U, &command_sequence);
|
||||||
|
maze32_game_command_result_t exit_result = maze32_game_process_next(&mechanics_test_game);
|
||||||
|
maze32_game_enqueue_move(&mechanics_test_game, MAZE32_GAME_ROLE_PLAYER_ONE,
|
||||||
|
outward_direction(&mechanics_test_maze, mechanics_test_maze.exits[0]),
|
||||||
|
900U, &command_sequence);
|
||||||
|
maze32_game_command_result_t outward_result = maze32_game_process_next(&mechanics_test_game);
|
||||||
|
int finalized = maze32_game_advance_time(&mechanics_test_game, 1101U);
|
||||||
|
|
||||||
|
if (artifact_result.result != MAZE32_GAME_RESULT_ACCEPTED ||
|
||||||
|
!artifact_result.activated_artifact || key_result.result != MAZE32_GAME_RESULT_ACCEPTED ||
|
||||||
|
!key_result.picked_key || key_result.key != 0U ||
|
||||||
|
compass_result.compass_hint == MAZE32_GAME_COMPASS_NONE || !exit_result.opened_exit ||
|
||||||
|
!outward_result.outward_exit || !finalized ||
|
||||||
|
mechanics_test_game.final_result != MAZE32_GAME_FINAL_PLAYER_ONE ||
|
||||||
|
maze32_game_project_view(&mechanics_test_game, MAZE32_GAME_ROLE_PLAYER_ONE,
|
||||||
|
&mechanics_test_view) != MAZE32_GAME_RESULT_ACCEPTED) {
|
||||||
|
ESP_LOGE(TAG, "M007 physical mechanics transitions failed");
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
size_t player_chunks = maze32_game_view_chunk_count(&mechanics_test_view);
|
||||||
|
size_t payload_bytes = 0U;
|
||||||
|
if (maze32_game_serialize_view_chunk(&mechanics_test_view, 0U, mechanics_test_payload,
|
||||||
|
sizeof(mechanics_test_payload),
|
||||||
|
&payload_bytes) != MAZE32_GAME_RESULT_ACCEPTED ||
|
||||||
|
maze32_game_project_view(&mechanics_test_game, MAZE32_GAME_ROLE_OBSERVER,
|
||||||
|
&mechanics_test_view) != MAZE32_GAME_RESULT_ACCEPTED) {
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
uint64_t elapsed_us = (uint64_t)(esp_timer_get_time() - started_us);
|
||||||
|
size_t free_after = heap_caps_get_free_size(MALLOC_CAP_8BIT);
|
||||||
|
size_t fixed_bytes =
|
||||||
|
sizeof(mechanics_test_game) + sizeof(mechanics_test_view) + sizeof(mechanics_test_payload);
|
||||||
|
ESP_LOGI(TAG,
|
||||||
|
"M007 events: artifact=%u, key=%u, hint=%u, opened=%u, outward=%u, result=%u, "
|
||||||
|
"sequence=%" PRIu32,
|
||||||
|
artifact_result.artifact, key_result.key, compass_result.compass_hint,
|
||||||
|
exit_result.opened_exit, outward_result.outward_exit, mechanics_test_game.final_result,
|
||||||
|
mechanics_test_game.state_sequence);
|
||||||
|
ESP_LOGI(TAG,
|
||||||
|
"M007 views: player_chunks=%u, first_chunk=%u bytes, observer_cells=%u, "
|
||||||
|
"observer_objects=%u, observer_chunks=%u",
|
||||||
|
(unsigned int)player_chunks, (unsigned int)payload_bytes,
|
||||||
|
mechanics_test_view.cell_count, mechanics_test_view.object_count,
|
||||||
|
(unsigned int)maze32_game_view_chunk_count(&mechanics_test_view));
|
||||||
|
ESP_LOGI(TAG, "M007 budget: fixed=%u/%u bytes, runtime=%" PRIu64 "/%u ms, heap_delta=%d",
|
||||||
|
(unsigned int)fixed_bytes, (unsigned int)MAZE32_GAME_RAM_BUDGET_BYTES,
|
||||||
|
elapsed_us / 1000U, (unsigned int)MAZE32_GAME_RUNTIME_BUDGET_MS,
|
||||||
|
(int)(free_before - free_after));
|
||||||
|
if (fixed_bytes > MAZE32_GAME_RAM_BUDGET_BYTES ||
|
||||||
|
elapsed_us > (uint64_t)MAZE32_GAME_RUNTIME_BUDGET_MS * 1000U || free_before != free_after) {
|
||||||
|
ESP_LOGE(TAG, "M007 physical mechanics budget failed");
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
ESP_LOGI(TAG, "M007 physical mechanics test: PASS");
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
#ifdef MAZE32_MILESTONE_006_DEVICE_TEST
|
#ifdef MAZE32_MILESTONE_006_DEVICE_TEST
|
||||||
static maze32_maze_t game_test_maze;
|
static maze32_maze_t game_test_maze;
|
||||||
static maze32_maze_workspace_t game_test_workspace;
|
static maze32_maze_workspace_t game_test_workspace;
|
||||||
@@ -82,7 +243,7 @@ static esp_err_t run_game_device_test(void) {
|
|||||||
maze32_game_command_result_t command_result = maze32_game_process_next(&device_test_game);
|
maze32_game_command_result_t command_result = maze32_game_process_next(&device_test_game);
|
||||||
if (command_result.result != MAZE32_GAME_RESULT_ACCEPTED ||
|
if (command_result.result != MAZE32_GAME_RESULT_ACCEPTED ||
|
||||||
command_result.state_sequence != 1U ||
|
command_result.state_sequence != 1U ||
|
||||||
maze32_game_project_view(&device_test_game, MAZE32_GAME_ROLE_PLAYER_ONE, 3U,
|
maze32_game_project_view(&device_test_game, MAZE32_GAME_ROLE_PLAYER_ONE,
|
||||||
&device_test_view) != MAZE32_GAME_RESULT_ACCEPTED) {
|
&device_test_view) != MAZE32_GAME_RESULT_ACCEPTED) {
|
||||||
return ESP_FAIL;
|
return ESP_FAIL;
|
||||||
}
|
}
|
||||||
@@ -95,8 +256,8 @@ static esp_err_t run_game_device_test(void) {
|
|||||||
&payload_bytes) != MAZE32_GAME_RESULT_ACCEPTED) {
|
&payload_bytes) != MAZE32_GAME_RESULT_ACCEPTED) {
|
||||||
return ESP_FAIL;
|
return ESP_FAIL;
|
||||||
}
|
}
|
||||||
if (maze32_game_project_view(&device_test_game, MAZE32_GAME_ROLE_OBSERVER, 0U,
|
if (maze32_game_project_view(&device_test_game, MAZE32_GAME_ROLE_OBSERVER, &device_test_view) !=
|
||||||
&device_test_view) != MAZE32_GAME_RESULT_ACCEPTED) {
|
MAZE32_GAME_RESULT_ACCEPTED) {
|
||||||
return ESP_FAIL;
|
return ESP_FAIL;
|
||||||
}
|
}
|
||||||
uint64_t elapsed_us = (uint64_t)(esp_timer_get_time() - started_us);
|
uint64_t elapsed_us = (uint64_t)(esp_timer_get_time() - started_us);
|
||||||
@@ -270,6 +431,13 @@ void app_main(void) {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#ifdef MAZE32_MILESTONE_007_DEVICE_TEST
|
||||||
|
if (run_mechanics_device_test() != ESP_OK) {
|
||||||
|
maze32_health_set(&health, MAZE32_HEALTH_ERROR, (int)ESP_FAIL);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
vTaskDelay(pdMS_TO_TICKS(1500));
|
vTaskDelay(pdMS_TO_TICKS(1500));
|
||||||
ESP_LOGI(TAG, "maze32 firmware version %s starting", application->version);
|
ESP_LOGI(TAG, "maze32 firmware version %s starting", application->version);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
#include <assert.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
#include "maze32_game.h"
|
||||||
|
|
||||||
|
static void initialize_game(maze32_maze_t* maze, maze32_maze_workspace_t* workspace,
|
||||||
|
maze32_game_t* game) {
|
||||||
|
maze32_maze_config_t maze_config = maze32_maze_default_config();
|
||||||
|
maze32_maze_report_t report;
|
||||||
|
assert(maze32_maze_generate(maze, workspace, &maze_config, UINT32_C(0x5a320009), &report) ==
|
||||||
|
MAZE32_MAZE_OK);
|
||||||
|
maze32_game_config_t game_config = maze32_game_default_config();
|
||||||
|
game_config.movement_cooldown_ms = 0U;
|
||||||
|
assert(maze32_game_init(game, maze, &game_config) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
maze32_game_set_phase(game, MAZE32_GAME_PHASE_ACTIVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_player_pause_and_reconnect(void) {
|
||||||
|
static maze32_maze_t maze;
|
||||||
|
static maze32_maze_workspace_t workspace;
|
||||||
|
static maze32_game_t game;
|
||||||
|
initialize_game(&maze, &workspace, &game);
|
||||||
|
game.players[0].vision_moves_remaining = 7U;
|
||||||
|
maze32_cell_t position = game.players[0].position;
|
||||||
|
|
||||||
|
assert(maze32_game_set_player_connected(&game, 1U, 0, 1000U) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
assert(game.phase == MAZE32_GAME_PHASE_PAUSED);
|
||||||
|
assert(game.players[1].reconnect_deadline_ms == 61000U);
|
||||||
|
uint32_t sequence = 0U;
|
||||||
|
assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_NORTH,
|
||||||
|
2000U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
assert(maze32_game_process_next(&game).result == MAZE32_GAME_RESULT_PREMATURE);
|
||||||
|
assert(game.players[0].position.x == position.x && game.players[0].position.y == position.y);
|
||||||
|
assert(game.players[0].vision_moves_remaining == 7U);
|
||||||
|
|
||||||
|
assert(maze32_game_set_player_connected(&game, 1U, 1, 60999U) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
assert(game.phase == MAZE32_GAME_PHASE_ACTIVE);
|
||||||
|
assert(game.players[1].reconnect_deadline_ms == 0U);
|
||||||
|
assert(game.players[0].vision_moves_remaining == 7U);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_timeout_draw_and_observer_read_only(void) {
|
||||||
|
static maze32_maze_t maze;
|
||||||
|
static maze32_maze_workspace_t workspace;
|
||||||
|
static maze32_game_t game;
|
||||||
|
static maze32_view_t view;
|
||||||
|
initialize_game(&maze, &workspace, &game);
|
||||||
|
|
||||||
|
uint32_t sequence = 0U;
|
||||||
|
assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_OBSERVER, MAZE32_GAME_DIRECTION_EAST,
|
||||||
|
1U, &sequence) == MAZE32_GAME_RESULT_READ_ONLY);
|
||||||
|
assert(game.state_sequence == 0U);
|
||||||
|
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, &view) ==
|
||||||
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
assert(view.cell_count == maze.config.width * maze.config.height);
|
||||||
|
assert(view.observer_connected[0] && view.observer_connected[1]);
|
||||||
|
|
||||||
|
assert(maze32_game_set_player_connected(&game, 0U, 0, 5000U) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, &view) ==
|
||||||
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
assert(!view.observer_connected[0] && view.observer_connected[1]);
|
||||||
|
assert(!maze32_game_advance_time(&game, 64999U));
|
||||||
|
assert(maze32_game_advance_time(&game, 65000U));
|
||||||
|
assert(game.phase == MAZE32_GAME_PHASE_FINISHED);
|
||||||
|
assert(game.final_result == MAZE32_GAME_FINAL_DRAW);
|
||||||
|
assert(maze32_game_set_player_connected(&game, 0U, 1, 65000U) ==
|
||||||
|
MAZE32_GAME_RESULT_MATCH_FINISHED);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_finish_window_is_frozen_while_paused(void) {
|
||||||
|
static maze32_maze_t maze;
|
||||||
|
static maze32_maze_workspace_t workspace;
|
||||||
|
static maze32_game_t game;
|
||||||
|
initialize_game(&maze, &workspace, &game);
|
||||||
|
game.phase = MAZE32_GAME_PHASE_FINISH_PENDING;
|
||||||
|
game.first_exited_player = 0U;
|
||||||
|
game.has_exit_time[0] = 1U;
|
||||||
|
game.exit_times_ms[0] = 1000U;
|
||||||
|
|
||||||
|
assert(maze32_game_set_player_connected(&game, 1U, 0, 1100U) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
assert(game.phase == MAZE32_GAME_PHASE_PAUSED);
|
||||||
|
assert(maze32_game_set_player_connected(&game, 1U, 1, 60100U) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
assert(game.phase == MAZE32_GAME_PHASE_FINISH_PENDING);
|
||||||
|
assert(game.exit_times_ms[0] == 60000U);
|
||||||
|
assert(!maze32_game_advance_time(&game, 60200U));
|
||||||
|
assert(maze32_game_advance_time(&game, 60201U));
|
||||||
|
assert(game.final_result == MAZE32_GAME_FINAL_PLAYER_ONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
test_player_pause_and_reconnect();
|
||||||
|
test_timeout_draw_and_observer_read_only();
|
||||||
|
test_finish_window_is_frozen_while_paused();
|
||||||
|
puts("disconnect recovery tests: PASS");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -200,6 +200,7 @@ static void serialize_and_inspect(const maze32_view_t* view, int expect_hidden)
|
|||||||
assert(payload[written] == '\0');
|
assert(payload[written] == '\0');
|
||||||
assert(strstr(payload, "{\"v\":1,\"type\":\"view\",\"seq\":") == payload);
|
assert(strstr(payload, "{\"v\":1,\"type\":\"view\",\"seq\":") == payload);
|
||||||
assert(strstr(payload, "\"phase\":\"countdown\"") != NULL);
|
assert(strstr(payload, "\"phase\":\"countdown\"") != NULL);
|
||||||
|
assert(strstr(payload, "\"width\":8,\"height\":8") != NULL);
|
||||||
saw_last_cell |= strstr(payload, "[63,") != NULL;
|
saw_last_cell |= strstr(payload, "[63,") != NULL;
|
||||||
saw_hidden_key |= strstr(payload, "[\"key\",1,0,63]") != NULL;
|
saw_hidden_key |= strstr(payload, "[\"key\",1,0,63]") != NULL;
|
||||||
saw_players |= strstr(payload, "\"players\":[") != NULL;
|
saw_players |= strstr(payload, "\"players\":[") != NULL;
|
||||||
@@ -223,7 +224,7 @@ static void test_visibility_and_serialization(void) {
|
|||||||
game.players[0].position = (maze32_cell_t){3U, 3U};
|
game.players[0].position = (maze32_cell_t){3U, 3U};
|
||||||
game.players[1].position = (maze32_cell_t){4U, 4U};
|
game.players[1].position = (maze32_cell_t){4U, 4U};
|
||||||
maze.passages[index_of(3U, 3U)] = 0U;
|
maze.passages[index_of(3U, 3U)] = 0U;
|
||||||
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, 3U, &view) ==
|
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, &view) ==
|
||||||
MAZE32_GAME_RESULT_ACCEPTED);
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
assert(view.cell_count == 49U);
|
assert(view.cell_count == 49U);
|
||||||
assert(view_has_cell(&view, index_of(6U, 6U)));
|
assert(view_has_cell(&view, index_of(6U, 6U)));
|
||||||
@@ -231,7 +232,7 @@ static void test_visibility_and_serialization(void) {
|
|||||||
|
|
||||||
game.players[0].position = (maze32_cell_t){0U, 0U};
|
game.players[0].position = (maze32_cell_t){0U, 0U};
|
||||||
game.players[1].position = (maze32_cell_t){7U, 7U};
|
game.players[1].position = (maze32_cell_t){7U, 7U};
|
||||||
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, 3U, &view) ==
|
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, &view) ==
|
||||||
MAZE32_GAME_RESULT_ACCEPTED);
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
assert(view.cell_count == 16U);
|
assert(view.cell_count == 16U);
|
||||||
assert(!view.opponent_visible && view.opponent_cell_index == UINT16_MAX);
|
assert(!view.opponent_visible && view.opponent_cell_index == UINT16_MAX);
|
||||||
@@ -239,7 +240,7 @@ static void test_visibility_and_serialization(void) {
|
|||||||
assert(!view_has_object(&view, MAZE32_VIEW_OBJECT_KEY, index_of(7U, 7U)));
|
assert(!view_has_object(&view, MAZE32_VIEW_OBJECT_KEY, index_of(7U, 7U)));
|
||||||
serialize_and_inspect(&view, 1);
|
serialize_and_inspect(&view, 1);
|
||||||
|
|
||||||
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_TWO, 3U, &view) ==
|
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_TWO, &view) ==
|
||||||
MAZE32_GAME_RESULT_ACCEPTED);
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
assert(view.cell_count == 16U && view.self_cell_index == index_of(7U, 7U));
|
assert(view.cell_count == 16U && view.self_cell_index == index_of(7U, 7U));
|
||||||
assert(!view.opponent_visible && !view_has_cell(&view, index_of(0U, 0U)));
|
assert(!view.opponent_visible && !view_has_cell(&view, index_of(0U, 0U)));
|
||||||
@@ -256,18 +257,19 @@ static void test_visibility_and_serialization(void) {
|
|||||||
assert(strstr(player_two_payload, "[\"key\",0,0,10]") == NULL);
|
assert(strstr(player_two_payload, "[\"key\",0,0,10]") == NULL);
|
||||||
}
|
}
|
||||||
|
|
||||||
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, 6U, &view) ==
|
game.players[0].vision_moves_remaining = 1U;
|
||||||
|
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, &view) ==
|
||||||
MAZE32_GAME_RESULT_ACCEPTED);
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
assert(view.cell_count == 49U);
|
assert(view.cell_count == 49U);
|
||||||
assert(!view_has_cell(&view, index_of(7U, 7U)));
|
assert(!view_has_cell(&view, index_of(7U, 7U)));
|
||||||
|
|
||||||
game.players[0].position = (maze32_cell_t){3U, 3U};
|
game.players[0].position = (maze32_cell_t){3U, 3U};
|
||||||
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, 6U, &view) ==
|
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, &view) ==
|
||||||
MAZE32_GAME_RESULT_ACCEPTED);
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
assert(view.cell_count == 64U);
|
assert(view.cell_count == 64U);
|
||||||
assert(view.opponent_visible);
|
assert(view.opponent_visible);
|
||||||
|
|
||||||
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, 0U, &view) ==
|
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, &view) ==
|
||||||
MAZE32_GAME_RESULT_ACCEPTED);
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
assert(view.cell_count == 64U);
|
assert(view.cell_count == 64U);
|
||||||
assert(view.object_count == 10U);
|
assert(view.object_count == 10U);
|
||||||
@@ -286,18 +288,33 @@ static void test_default_map_chunk_bounds(void) {
|
|||||||
assert(maze32_maze_generate(&maze, &workspace, &maze_config, UINT32_C(0x5a320006), &report) ==
|
assert(maze32_maze_generate(&maze, &workspace, &maze_config, UINT32_C(0x5a320006), &report) ==
|
||||||
MAZE32_MAZE_OK);
|
MAZE32_MAZE_OK);
|
||||||
maze32_game_t game = initialized_game(&maze);
|
maze32_game_t game = initialized_game(&maze);
|
||||||
|
game.phase = MAZE32_GAME_PHASE_FINISHED;
|
||||||
|
game.final_result = MAZE32_GAME_FINAL_DRAW;
|
||||||
|
game.state_sequence = UINT32_MAX;
|
||||||
|
game.meeting_sequence = UINT32_MAX;
|
||||||
|
for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) {
|
||||||
|
game.players[player].vision_moves_remaining = MAZE32_GAME_VISION_MOVES;
|
||||||
|
game.players[player].compass_hint = MAZE32_GAME_COMPASS_NORTH_WEST;
|
||||||
|
game.players[player].exited = 1U;
|
||||||
|
game.exits[player].open = 1U;
|
||||||
|
game.has_exit_time[player] = 1U;
|
||||||
|
game.exit_times_ms[player] = UINT64_MAX - player;
|
||||||
|
}
|
||||||
maze32_view_t view;
|
maze32_view_t view;
|
||||||
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, 0U, &view) ==
|
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, &view) ==
|
||||||
MAZE32_GAME_RESULT_ACCEPTED);
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
assert(view.cell_count == 8192U && view.object_count == 30U);
|
assert(view.cell_count == 1024U && view.object_count == 12U);
|
||||||
size_t chunks = maze32_game_view_chunk_count(&view);
|
size_t chunks = maze32_game_view_chunk_count(&view);
|
||||||
assert(chunks == 345U);
|
assert(chunks == 88U);
|
||||||
char payload[MAZE32_GAME_VIEW_CHUNK_BYTES];
|
char payload[MAZE32_GAME_VIEW_CHUNK_BYTES];
|
||||||
size_t largest_chunk = 0U;
|
size_t largest_chunk = 0U;
|
||||||
for (size_t chunk = 0U; chunk < chunks; ++chunk) {
|
for (size_t chunk = 0U; chunk < chunks; ++chunk) {
|
||||||
size_t written = 0U;
|
size_t written = 0U;
|
||||||
assert(maze32_game_serialize_view_chunk(&view, chunk, payload, sizeof(payload), &written) ==
|
assert(maze32_game_serialize_view_chunk(&view, chunk, payload, sizeof(payload), &written) ==
|
||||||
MAZE32_GAME_RESULT_ACCEPTED);
|
MAZE32_GAME_RESULT_ACCEPTED);
|
||||||
|
if (chunk == 0U) {
|
||||||
|
assert(strstr(payload, "\"connected\":1") != NULL);
|
||||||
|
}
|
||||||
if (written > largest_chunk) {
|
if (written > largest_chunk) {
|
||||||
largest_chunk = written;
|
largest_chunk = written;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ static void test_bounded_regeneration_failure(void) {
|
|||||||
|
|
||||||
static void test_large_seed_set(void) {
|
static void test_large_seed_set(void) {
|
||||||
maze32_maze_config_t config = maze32_maze_default_config();
|
maze32_maze_config_t config = maze32_maze_default_config();
|
||||||
|
assert(config.width == 32U && config.height == 32U);
|
||||||
|
assert((uint32_t)config.width * config.height == 1024U);
|
||||||
|
assert(config.cross_half_pairs == 2U && config.extra_cycle_pairs == 8U);
|
||||||
|
assert(config.minimum_exit_separation == 22U);
|
||||||
|
assert(config.landmark_pairs == 2U && config.artifact_pairs == 1U);
|
||||||
uint16_t minimum_cycles = UINT16_MAX;
|
uint16_t minimum_cycles = UINT16_MAX;
|
||||||
uint16_t maximum_cycles = 0U;
|
uint16_t maximum_cycles = 0U;
|
||||||
uint16_t minimum_dead_ends = UINT16_MAX;
|
uint16_t minimum_dead_ends = UINT16_MAX;
|
||||||
|
|||||||
@@ -0,0 +1,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"/);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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
@@ -1 +1 @@
|
|||||||
0.6.0
|
0.14.0
|
||||||
|
|||||||
@@ -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 |
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user