Compare commits
37 Commits
65fe630333
...
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 | |||
| 115a3bd0a1 | |||
| 2aaa1650ae | |||
| 5d780def9d | |||
| 996bda9567 | |||
| b10c7f5972 | |||
| 8ffd6dabcf | |||
| 9c274800b5 | |||
| dd16bc7ade |
@@ -0,0 +1,25 @@
|
||||
BasedOnStyle: LLVM
|
||||
|
||||
UseTab: Never
|
||||
IndentWidth: 4
|
||||
ContinuationIndentWidth: 4
|
||||
IndentCaseLabels: true
|
||||
|
||||
ColumnLimit: 100
|
||||
|
||||
BreakBeforeBraces: Attach
|
||||
|
||||
AllowShortFunctionsOnASingleLine: None
|
||||
AllowShortIfStatementsOnASingleLine: Never
|
||||
AllowShortLoopsOnASingleLine: false
|
||||
AllowShortBlocksOnASingleLine: Never
|
||||
|
||||
DerivePointerAlignment: false
|
||||
PointerAlignment: Left
|
||||
|
||||
SpaceBeforeParens: ControlStatements
|
||||
|
||||
AlignConsecutiveAssignments: None
|
||||
AlignConsecutiveDeclarations: None
|
||||
|
||||
SortIncludes: CaseSensitive
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
.pio/
|
||||
.vscode/
|
||||
.obsidian/
|
||||
|
||||
data/*.gz
|
||||
|
||||
@@ -12,8 +12,10 @@ This directory is a local mirror of the ChatGPT project “maze32”.
|
||||
### Language
|
||||
|
||||
- Write all canonical documentation in English.
|
||||
- Write source comments, test names, protocol identifiers, log messages, and
|
||||
user-facing diagnostic text in English.
|
||||
- Write source comments, test names, protocol identifiers, and system log
|
||||
messages in English.
|
||||
- Write all player-facing browser UI copy, game messages, and user-visible
|
||||
browser errors in Russian.
|
||||
|
||||
### Canonical documents
|
||||
|
||||
@@ -22,6 +24,8 @@ This directory is a local mirror of the ChatGPT project “maze32”.
|
||||
- `PLANS.md` defines the current implementation milestone and its evidence.
|
||||
- `docs/BOARD_PASSPORT.md` defines confirmed hardware facts and explicit
|
||||
unknowns.
|
||||
- `docs/UI_STYLE.md` defines the canonical browser visual language, responsive
|
||||
layout, controls, and presentation behavior.
|
||||
- Files under `sources/` remain read-only references and are never canonical
|
||||
implementation files.
|
||||
|
||||
@@ -44,6 +48,22 @@ Do not silently choose one requirement over another.
|
||||
- Do not mark a milestone `COMPLETE` without concrete recorded evidence for
|
||||
every acceptance criterion.
|
||||
|
||||
### Commit message handoff
|
||||
|
||||
- After completing every user prompt, provide a proposed commit message for
|
||||
the resulting changes. This is mandatory when completing a milestone.
|
||||
- Use the Conventional Commits format `type(scope): imperative summary`, or
|
||||
`type: imperative summary` when no meaningful scope exists.
|
||||
- Choose exactly one appropriate type from `feat`, `fix`, `refactor`, `perf`,
|
||||
`test`, `docs`, `style`, `build`, `ci`, `chore`, or `revert`.
|
||||
- Write the commit message in English, use an imperative summary beginning
|
||||
with a lowercase verb, keep the subject at 72 characters or fewer when
|
||||
practical, and do not end it with a period.
|
||||
- Base the message on the actual completed changes and milestone outcome; do
|
||||
not use vague summaries such as `update files` or `make changes`.
|
||||
- Present the proposed commit message in the final response, but do not create
|
||||
the Git commit unless the user explicitly requests it.
|
||||
|
||||
### Locked platform baseline
|
||||
|
||||
- Target ESP32-C6FH4 with 4 MB Flash.
|
||||
@@ -65,12 +85,22 @@ Do not silently choose one requirement over another.
|
||||
- Active game state need not survive reboot; Wi-Fi configuration must persist
|
||||
in NVS.
|
||||
|
||||
### Code style
|
||||
|
||||
- Follow the repository `.clang-format`.
|
||||
- Format all modified C and C++ files with `clang-format`.
|
||||
- Prefer simple, explicit code that is easy for beginners to read.
|
||||
- Avoid clever one-liners and unnecessary abstractions.
|
||||
- Use braces for all non-trivial control-flow blocks.
|
||||
- Prefer descriptive variable and function names.
|
||||
- Keep functions reasonably small.
|
||||
|
||||
### Build and verification
|
||||
|
||||
- Parse the project configuration with
|
||||
`/home/sasa/.platformio/penv/bin/pio project config` after changing
|
||||
`pio project config` after changing
|
||||
`platformio.ini`.
|
||||
- Build with `/home/sasa/.platformio/penv/bin/pio run` once firmware sources
|
||||
- Build with `pio run` once firmware sources
|
||||
exist.
|
||||
- Add host-side deterministic tests for portable game logic before relying only
|
||||
on device tests.
|
||||
@@ -78,4 +108,3 @@ Do not silently choose one requirement over another.
|
||||
- Test on the physical board before recording hardware acceptance evidence.
|
||||
- Preserve exact commands and relevant outputs in the active milestone's
|
||||
evidence section.
|
||||
|
||||
|
||||
+65
-6
@@ -195,6 +195,16 @@ stateDiagram-v2
|
||||
During `Paused`, movement commands are rejected and move-counted effects do not
|
||||
advance. During `FinishPending`, the first exit is retained while the server
|
||||
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
|
||||
|
||||
@@ -219,7 +229,8 @@ conceptual records include:
|
||||
|
||||
- `MazeConfig`: width, height, movement cooldown, object-density settings, and
|
||||
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,
|
||||
inventory, vision effect, connection state, and last accepted move time;
|
||||
- `KeyState`: owner and either a ground position or carrier;
|
||||
@@ -231,7 +242,7 @@ conceptual records include:
|
||||
outgoing queue.
|
||||
|
||||
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.
|
||||
|
||||
## 8. Visibility projection
|
||||
@@ -315,10 +326,58 @@ The browser application has four presentation modes:
|
||||
3. player match view;
|
||||
4. observer full-map view.
|
||||
|
||||
Player controls support keyboard movement, touch swipes, and an explicit
|
||||
drop-key action. The UI renders only server-provided visible state. The observer
|
||||
view supports fitting, panning, or zooming the complete `128 x 64` map without
|
||||
changing server authority.
|
||||
Player controls support keyboard movement, avatar drag, touch swipes, an
|
||||
explicit four-button directional pad, and an explicit drop-key action. Every
|
||||
movement input uses the same bounded latest-intent path and sends only a
|
||||
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
|
||||
layout supports a minimum `412 x 916` CSS-pixel viewport. Observer mode requires
|
||||
at least `1024 x 768` CSS pixels and presents a large-screen requirement message
|
||||
below that threshold. Responsive layout changes presentation only; they never
|
||||
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
|
||||
must not depend on internet access, third-party CSS, remote fonts, icons,
|
||||
analytics, or content services. The complete compressed web payload is limited
|
||||
to 1.5 MiB within the unchanged 1,984 KiB LittleFS partition.
|
||||
|
||||
A temporary session token is stored in browser-local storage so page refresh
|
||||
can reattach to the existing slot. It is not a permanent account credential and
|
||||
|
||||
+65
-8
@@ -59,7 +59,7 @@ approximately five minutes. There is no match time limit.
|
||||
|
||||
### 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
|
||||
into game logic.
|
||||
- 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.
|
||||
- A small server-enforced minimum interval exists between accepted moves. Its
|
||||
value is configurable for playtesting.
|
||||
- Rejected movement into a wall does not change position and is not a
|
||||
successful move.
|
||||
- A server-accepted impact against the same closed interior wall from the same
|
||||
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.
|
||||
- A meeting occurs when both players occupy the same cell.
|
||||
- There are no attacks, health, damage, or other combat mechanics in MVP.
|
||||
@@ -214,8 +216,58 @@ Browser controls must support:
|
||||
state.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## 11. Technical constraints
|
||||
### 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
|
||||
|
||||
- The browser UI uses one dark, humorous retro arcade visual language across
|
||||
provisioning, lobby, player, and observer modes, as defined in
|
||||
`docs/UI_STYLE.md`.
|
||||
- All player-facing UI copy, game messages, and browser errors are in Russian.
|
||||
- The UI uses a near-black background, purple panels, turquoise interactive
|
||||
accents, yellow for Player 1, blue for Player 2, and plant-like green maze
|
||||
walls. Each player's matching key and exit use the same ownership 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
|
||||
panels. The player view supports a minimum `412 x 916` CSS-pixel viewport
|
||||
without page scrolling.
|
||||
- On portrait phones, the authoritative player focal point is horizontally
|
||||
centered at approximately 30% of the usable map height, above the operating
|
||||
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
|
||||
confirmation and exposes the Russian accessible label `Сбросить ключ`.
|
||||
- Observer mode requires at least a `1024 x 768` CSS-pixel viewport, initially
|
||||
fits the complete map, and supports zoom and pan. Smaller viewports show a
|
||||
Russian large-screen requirement message.
|
||||
- Sound and vibration are enabled by default after the first user interaction
|
||||
when the browser and device support them.
|
||||
- The application has no runtime internet dependency. It uses local assets,
|
||||
locally authored SVG icons, plain CSS, and a system monospace font styled to
|
||||
resemble a pixel font.
|
||||
|
||||
## 12. Technical constraints
|
||||
|
||||
- Target: ESP32-C6FH4, 4 MB Flash, no required PSRAM.
|
||||
- Build: PlatformIO, `espressif32@7.0.1`, ESP-IDF, compatible
|
||||
@@ -224,6 +276,8 @@ Browser controls must support:
|
||||
MVP.
|
||||
- Static browser assets reside in LittleFS and should be compressed for the
|
||||
production image.
|
||||
- The complete compressed web payload must not exceed 1.5 MiB and must fit the
|
||||
unchanged 1,984 KiB LittleFS partition.
|
||||
- HTTP serves the application and provisioning pages; WebSocket carries live
|
||||
lobby, control, and match messages.
|
||||
- The initial LwIP socket limit is 16.
|
||||
@@ -232,7 +286,7 @@ Browser controls must support:
|
||||
- Active lobby and match state may live in RAM and need not survive an ESP
|
||||
reboot. Wi-Fi settings must survive reboot.
|
||||
|
||||
## 12. MVP acceptance criteria
|
||||
## 13. MVP acceptance criteria
|
||||
|
||||
The MVP is accepted when all of the following are demonstrated on the target
|
||||
board:
|
||||
@@ -245,7 +299,7 @@ board:
|
||||
refresh, enter the lobby, and start through the owner-controlled countdown.
|
||||
4. Four additional browsers can observe the complete match simultaneously
|
||||
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
|
||||
player-relative placement metrics.
|
||||
6. Player clients never receive cells or dynamic objects outside their current
|
||||
@@ -263,8 +317,12 @@ board:
|
||||
60-second reconnect timeout ends it as a draw.
|
||||
12. A representative match normally completes in approximately five minutes
|
||||
after balance tuning, without imposing a hard match timer.
|
||||
13. The Russian retro arcade UI follows `docs/UI_STYLE.md`, works at the
|
||||
required player and observer viewport sizes in the current stable Chrome,
|
||||
Safari, Firefox, and Edge releases, requires no runtime internet access,
|
||||
and stays within the 1.5 MiB compressed web-asset budget.
|
||||
|
||||
## 13. Explicitly outside MVP
|
||||
## 14. Explicitly outside MVP
|
||||
|
||||
- More than two active players or more than one concurrent match.
|
||||
- More than four observers.
|
||||
@@ -277,4 +335,3 @@ board:
|
||||
- Persistent match recovery after ESP reboot.
|
||||
- OTA firmware partitions and OTA update flow.
|
||||
- Dependence on PSRAM, an unverified LED, or an assumed board pinout.
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# maze32
|
||||
|
||||
maze32 is an ESP32-C6-hosted browser maze game. The firmware mounts LittleFS,
|
||||
serves compressed local pages, exposes a bounded health response, and connects
|
||||
to saved Wi-Fi credentials or starts a fallback provisioning access point.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- PlatformIO Core installed at `$HOME/.platformio/penv/bin/pio`;
|
||||
- a C11 host compiler available as `cc`;
|
||||
- Python 3.
|
||||
|
||||
## Build and verify
|
||||
|
||||
Parse the locked PlatformIO configuration:
|
||||
|
||||
```sh
|
||||
$HOME/.platformio/penv/bin/pio project config
|
||||
```
|
||||
|
||||
Run the portable host tests and deterministic asset check:
|
||||
|
||||
```sh
|
||||
./scripts/run_host_tests.sh
|
||||
```
|
||||
|
||||
Build the firmware. The PlatformIO hook checks the application image against
|
||||
the 2 MiB `factory` partition:
|
||||
|
||||
```sh
|
||||
$HOME/.platformio/penv/bin/pio run
|
||||
```
|
||||
|
||||
Generate compressed assets explicitly when needed:
|
||||
|
||||
```sh
|
||||
$HOME/.platformio/penv/bin/python scripts/generate_web_assets.py
|
||||
```
|
||||
|
||||
Build the LittleFS image. Asset generation runs automatically before packaging,
|
||||
and the PlatformIO hook checks the image against the 1,984 KiB `littlefs`
|
||||
partition:
|
||||
|
||||
```sh
|
||||
$HOME/.platformio/penv/bin/pio run --target buildfs
|
||||
```
|
||||
|
||||
## Device commands
|
||||
|
||||
Upload the application and LittleFS image:
|
||||
|
||||
```sh
|
||||
$HOME$/.platformio/penv/bin/pio run --target upload
|
||||
$HOME$/.platformio/penv/bin/pio run --target uploadfs
|
||||
```
|
||||
|
||||
Monitor startup at 115200 baud:
|
||||
|
||||
```sh
|
||||
$HOME/.platformio/penv/bin/pio device monitor
|
||||
```
|
||||
|
||||
A successful boot reports the firmware version, LittleFS capacity and use,
|
||||
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
|
||||
|
||||
With no saved credentials, or after a failed station connection, the device
|
||||
starts an open access point named `maze32-XXXX`, where `XXXX` comes from the
|
||||
device MAC address. Connect to it and open:
|
||||
|
||||
```text
|
||||
http://192.168.4.1/provision
|
||||
```
|
||||
|
||||
Scan for nearby networks, select or enter an SSID, and save the password. The
|
||||
device stores a schema-versioned configuration in NVS and retries in station
|
||||
mode. If the attempt fails, the fallback access point returns automatically.
|
||||
After a successful connection, `/api/wifi/status` reports the station address.
|
||||
|
||||
To recover from obsolete or incorrect credentials, reconnect to the fallback
|
||||
access point after the failed attempt and use **Удалить настройки сети**. The
|
||||
same reset operation is available as `POST /api/wifi/reset`. Saving or clearing
|
||||
credentials always invokes the runtime reconfiguration guard first; a future
|
||||
active match service can reject the operation until it has explicitly stopped
|
||||
the match.
|
||||
|
||||
The isolated on-device NVS/reboot diagnostic uses a deliberately unavailable
|
||||
SSID, refuses to overwrite unexpected saved credentials, and clears its test
|
||||
record after verifying fallback recovery:
|
||||
|
||||
```sh
|
||||
$HOME/.platformio/penv/bin/pio run -e milestone-003-device-test
|
||||
$HOME/.platformio/penv/bin/pio run -e milestone-003-device-test --target upload
|
||||
```
|
||||
|
||||
Restore the normal firmware afterward with the standard upload command.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
idf_component_register(
|
||||
SRCS "maze32_config_store.c" "maze32_wifi_config.c"
|
||||
INCLUDE_DIRS "include"
|
||||
REQUIRES nvs_flash
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
#ifndef MAZE32_CONFIG_STORE_H
|
||||
#define MAZE32_CONFIG_STORE_H
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "maze32_wifi_config.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
esp_err_t maze32_config_store_init(void);
|
||||
esp_err_t maze32_config_store_load(maze32_wifi_credentials_t *credentials);
|
||||
esp_err_t maze32_config_store_save(const maze32_wifi_credentials_t *credentials);
|
||||
esp_err_t maze32_config_store_clear(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,45 @@
|
||||
#ifndef MAZE32_WIFI_CONFIG_H
|
||||
#define MAZE32_WIFI_CONFIG_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define MAZE32_WIFI_CONFIG_SCHEMA_VERSION 1U
|
||||
#define MAZE32_WIFI_SSID_MAX_BYTES 32U
|
||||
#define MAZE32_WIFI_PASSWORD_MAX_BYTES 63U
|
||||
|
||||
typedef struct {
|
||||
char ssid[MAZE32_WIFI_SSID_MAX_BYTES + 1U];
|
||||
char password[MAZE32_WIFI_PASSWORD_MAX_BYTES + 1U];
|
||||
} maze32_wifi_credentials_t;
|
||||
|
||||
typedef enum {
|
||||
MAZE32_WIFI_CONFIG_VALID = 0,
|
||||
MAZE32_WIFI_CONFIG_INVALID_SSID,
|
||||
MAZE32_WIFI_CONFIG_INVALID_PASSWORD,
|
||||
} maze32_wifi_config_validation_t;
|
||||
|
||||
typedef int (*maze32_reconfiguration_guard_fn)(void *context);
|
||||
typedef int (*maze32_credentials_save_fn)(const maze32_wifi_credentials_t *credentials,
|
||||
void *context);
|
||||
typedef int (*maze32_credentials_clear_fn)(void *context);
|
||||
|
||||
maze32_wifi_config_validation_t maze32_wifi_credentials_validate(
|
||||
const maze32_wifi_credentials_t *credentials);
|
||||
int maze32_wifi_config_apply(const maze32_wifi_credentials_t *credentials,
|
||||
maze32_reconfiguration_guard_fn guard,
|
||||
maze32_credentials_save_fn save,
|
||||
void *context);
|
||||
int maze32_wifi_config_reset(maze32_reconfiguration_guard_fn guard,
|
||||
maze32_credentials_clear_fn clear,
|
||||
void *context);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,110 @@
|
||||
#include "maze32_config_store.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include "nvs.h"
|
||||
#include "nvs_flash.h"
|
||||
|
||||
static const char *NAMESPACE = "maze32";
|
||||
static const char *SCHEMA_KEY = "cfg_ver";
|
||||
static const char *SSID_KEY = "wifi_ssid";
|
||||
static const char *PASSWORD_KEY = "wifi_pass";
|
||||
|
||||
esp_err_t maze32_config_store_init(void)
|
||||
{
|
||||
esp_err_t result = nvs_flash_init();
|
||||
if (result == ESP_ERR_NVS_NO_FREE_PAGES || result == ESP_ERR_NVS_NEW_VERSION_FOUND) {
|
||||
result = nvs_flash_erase();
|
||||
if (result == ESP_OK) {
|
||||
result = nvs_flash_init();
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
esp_err_t maze32_config_store_load(maze32_wifi_credentials_t *credentials)
|
||||
{
|
||||
if (credentials == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
nvs_handle_t handle;
|
||||
esp_err_t result = nvs_open(NAMESPACE, NVS_READONLY, &handle);
|
||||
if (result == ESP_ERR_NVS_NOT_FOUND) {
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
if (result != ESP_OK) {
|
||||
return result;
|
||||
}
|
||||
|
||||
uint32_t schema_version = 0U;
|
||||
result = nvs_get_u32(handle, SCHEMA_KEY, &schema_version);
|
||||
if (result == ESP_OK && schema_version != MAZE32_WIFI_CONFIG_SCHEMA_VERSION) {
|
||||
result = ESP_ERR_INVALID_VERSION;
|
||||
}
|
||||
|
||||
maze32_wifi_credentials_t loaded = {0};
|
||||
size_t ssid_size = sizeof(loaded.ssid);
|
||||
size_t password_size = sizeof(loaded.password);
|
||||
if (result == ESP_OK) {
|
||||
result = nvs_get_str(handle, SSID_KEY, loaded.ssid, &ssid_size);
|
||||
}
|
||||
if (result == ESP_OK) {
|
||||
result = nvs_get_str(handle, PASSWORD_KEY, loaded.password, &password_size);
|
||||
}
|
||||
nvs_close(handle);
|
||||
|
||||
if (result == ESP_ERR_NVS_NOT_FOUND) {
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
if (result == ESP_OK &&
|
||||
maze32_wifi_credentials_validate(&loaded) != MAZE32_WIFI_CONFIG_VALID) {
|
||||
result = ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (result == ESP_OK) {
|
||||
memcpy(credentials, &loaded, sizeof(*credentials));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
esp_err_t maze32_config_store_save(const maze32_wifi_credentials_t *credentials)
|
||||
{
|
||||
if (maze32_wifi_credentials_validate(credentials) != MAZE32_WIFI_CONFIG_VALID) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
nvs_handle_t handle;
|
||||
esp_err_t result = nvs_open(NAMESPACE, NVS_READWRITE, &handle);
|
||||
if (result != ESP_OK) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result = nvs_set_str(handle, SSID_KEY, credentials->ssid);
|
||||
if (result == ESP_OK) {
|
||||
result = nvs_set_str(handle, PASSWORD_KEY, credentials->password);
|
||||
}
|
||||
if (result == ESP_OK) {
|
||||
result = nvs_set_u32(handle, SCHEMA_KEY, MAZE32_WIFI_CONFIG_SCHEMA_VERSION);
|
||||
}
|
||||
if (result == ESP_OK) {
|
||||
result = nvs_commit(handle);
|
||||
}
|
||||
nvs_close(handle);
|
||||
return result;
|
||||
}
|
||||
|
||||
esp_err_t maze32_config_store_clear(void)
|
||||
{
|
||||
nvs_handle_t handle;
|
||||
esp_err_t result = nvs_open(NAMESPACE, NVS_READWRITE, &handle);
|
||||
if (result != ESP_OK) {
|
||||
return result;
|
||||
}
|
||||
result = nvs_erase_all(handle);
|
||||
if (result == ESP_OK) {
|
||||
result = nvs_commit(handle);
|
||||
}
|
||||
nvs_close(handle);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
#include "maze32_wifi_config.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
static size_t bounded_length(const char *value, size_t capacity)
|
||||
{
|
||||
size_t length = 0U;
|
||||
while (length < capacity && value[length] != '\0') {
|
||||
++length;
|
||||
}
|
||||
return length;
|
||||
}
|
||||
|
||||
static int has_control_bytes(const char *value)
|
||||
{
|
||||
const unsigned char *cursor = (const unsigned char *)value;
|
||||
while (*cursor != '\0') {
|
||||
if (*cursor < 0x20U || *cursor == 0x7fU) {
|
||||
return 1;
|
||||
}
|
||||
++cursor;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
maze32_wifi_config_validation_t maze32_wifi_credentials_validate(
|
||||
const maze32_wifi_credentials_t *credentials)
|
||||
{
|
||||
if (credentials == NULL) {
|
||||
return MAZE32_WIFI_CONFIG_INVALID_SSID;
|
||||
}
|
||||
|
||||
size_t ssid_length = bounded_length(credentials->ssid, sizeof(credentials->ssid));
|
||||
if (ssid_length == 0U || ssid_length > MAZE32_WIFI_SSID_MAX_BYTES ||
|
||||
has_control_bytes(credentials->ssid)) {
|
||||
return MAZE32_WIFI_CONFIG_INVALID_SSID;
|
||||
}
|
||||
|
||||
size_t password_length = bounded_length(credentials->password, sizeof(credentials->password));
|
||||
if (password_length > MAZE32_WIFI_PASSWORD_MAX_BYTES ||
|
||||
(password_length > 0U && password_length < 8U) ||
|
||||
has_control_bytes(credentials->password)) {
|
||||
return MAZE32_WIFI_CONFIG_INVALID_PASSWORD;
|
||||
}
|
||||
|
||||
return MAZE32_WIFI_CONFIG_VALID;
|
||||
}
|
||||
|
||||
int maze32_wifi_config_apply(const maze32_wifi_credentials_t *credentials,
|
||||
maze32_reconfiguration_guard_fn guard,
|
||||
maze32_credentials_save_fn save,
|
||||
void *context)
|
||||
{
|
||||
if (maze32_wifi_credentials_validate(credentials) != MAZE32_WIFI_CONFIG_VALID ||
|
||||
guard == NULL || save == NULL) {
|
||||
return -1;
|
||||
}
|
||||
if (guard(context) != 0) {
|
||||
return -2;
|
||||
}
|
||||
return save(credentials, context) == 0 ? 0 : -3;
|
||||
}
|
||||
|
||||
int maze32_wifi_config_reset(maze32_reconfiguration_guard_fn guard,
|
||||
maze32_credentials_clear_fn clear,
|
||||
void *context)
|
||||
{
|
||||
if (guard == NULL || clear == NULL) {
|
||||
return -1;
|
||||
}
|
||||
if (guard(context) != 0) {
|
||||
return -2;
|
||||
}
|
||||
return clear(context) == 0 ? 0 : -3;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
idf_component_register(
|
||||
SRCS "maze32_game.c"
|
||||
INCLUDE_DIRS "include"
|
||||
REQUIRES maze32_maze
|
||||
)
|
||||
@@ -0,0 +1,265 @@
|
||||
#ifndef MAZE32_GAME_H
|
||||
#define MAZE32_GAME_H
|
||||
|
||||
#include "maze32_maze.h"
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define MAZE32_GAME_PLAYER_COUNT 2U
|
||||
#define MAZE32_GAME_BREAKTHROUGH_HITS 5U
|
||||
#define MAZE32_GAME_BREAKTHROUGH_TIMEOUT_MS 3000U
|
||||
#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 \
|
||||
(6U + 2U * MAZE32_MAZE_MAX_LANDMARK_PAIRS + 2U * MAZE32_MAZE_MAX_ARTIFACT_PAIRS)
|
||||
#define MAZE32_GAME_VIEW_CELLS_PER_CHUNK 12U
|
||||
#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_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_RUNTIME_BUDGET_MS 50U
|
||||
|
||||
typedef enum {
|
||||
MAZE32_GAME_PHASE_COUNTDOWN = 0,
|
||||
MAZE32_GAME_PHASE_ACTIVE,
|
||||
MAZE32_GAME_PHASE_FINISH_PENDING,
|
||||
MAZE32_GAME_PHASE_PAUSED,
|
||||
MAZE32_GAME_PHASE_FINISHED,
|
||||
} maze32_game_phase_t;
|
||||
|
||||
typedef enum {
|
||||
MAZE32_GAME_ROLE_PLAYER_ONE = 0,
|
||||
MAZE32_GAME_ROLE_PLAYER_TWO,
|
||||
MAZE32_GAME_ROLE_OBSERVER,
|
||||
} maze32_game_role_t;
|
||||
|
||||
typedef enum {
|
||||
MAZE32_GAME_DIRECTION_NORTH = 0,
|
||||
MAZE32_GAME_DIRECTION_EAST,
|
||||
MAZE32_GAME_DIRECTION_SOUTH,
|
||||
MAZE32_GAME_DIRECTION_WEST,
|
||||
} maze32_game_direction_t;
|
||||
|
||||
typedef enum {
|
||||
MAZE32_GAME_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 {
|
||||
MAZE32_GAME_RESULT_ACCEPTED = 0,
|
||||
MAZE32_GAME_RESULT_INVALID_ARGUMENT,
|
||||
MAZE32_GAME_RESULT_QUEUE_FULL,
|
||||
MAZE32_GAME_RESULT_QUEUE_EMPTY,
|
||||
MAZE32_GAME_RESULT_READ_ONLY,
|
||||
MAZE32_GAME_RESULT_PREMATURE,
|
||||
MAZE32_GAME_RESULT_RATE_LIMITED,
|
||||
MAZE32_GAME_RESULT_BLOCKED,
|
||||
MAZE32_GAME_RESULT_BUFFER_TOO_SMALL,
|
||||
MAZE32_GAME_RESULT_NO_KEY,
|
||||
MAZE32_GAME_RESULT_PLAYER_EXITED,
|
||||
MAZE32_GAME_RESULT_MATCH_FINISHED,
|
||||
} maze32_game_result_t;
|
||||
|
||||
typedef struct {
|
||||
uint32_t movement_cooldown_ms;
|
||||
} maze32_game_config_t;
|
||||
|
||||
typedef struct {
|
||||
maze32_cell_t position;
|
||||
uint64_t last_move_ms;
|
||||
uint32_t accepted_moves;
|
||||
uint8_t carried_key;
|
||||
uint8_t vision_moves_remaining;
|
||||
maze32_game_compass_direction_t compass_hint;
|
||||
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;
|
||||
|
||||
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 {
|
||||
uint32_t command_sequence;
|
||||
maze32_game_command_type_t type;
|
||||
maze32_game_role_t role;
|
||||
maze32_game_direction_t direction;
|
||||
uint64_t submitted_ms;
|
||||
} maze32_game_command_t;
|
||||
|
||||
typedef struct {
|
||||
maze32_game_result_t result;
|
||||
uint32_t command_sequence;
|
||||
uint32_t state_sequence;
|
||||
uint32_t meeting_sequence;
|
||||
uint8_t meeting;
|
||||
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;
|
||||
|
||||
typedef struct {
|
||||
const maze32_maze_t* maze;
|
||||
maze32_game_config_t config;
|
||||
maze32_game_phase_t phase;
|
||||
maze32_game_player_t players[MAZE32_GAME_PLAYER_COUNT];
|
||||
maze32_game_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];
|
||||
uint8_t command_head;
|
||||
uint8_t command_count;
|
||||
uint32_t next_command_sequence;
|
||||
uint32_t state_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;
|
||||
|
||||
typedef enum {
|
||||
MAZE32_VIEW_OBJECT_KEY = 0,
|
||||
MAZE32_VIEW_OBJECT_EXIT,
|
||||
MAZE32_VIEW_OBJECT_COMPASS,
|
||||
MAZE32_VIEW_OBJECT_LANDMARK,
|
||||
MAZE32_VIEW_OBJECT_VISION_ARTIFACT,
|
||||
} maze32_view_object_kind_t;
|
||||
|
||||
typedef struct {
|
||||
uint16_t cell_index;
|
||||
uint8_t passages;
|
||||
uint8_t opened_passages;
|
||||
uint8_t trail_east;
|
||||
uint8_t trail_south;
|
||||
} maze32_view_cell_t;
|
||||
|
||||
typedef struct {
|
||||
uint16_t cell_index;
|
||||
maze32_view_object_kind_t kind;
|
||||
uint8_t owner;
|
||||
uint8_t variant;
|
||||
} maze32_view_object_t;
|
||||
|
||||
typedef struct {
|
||||
maze32_game_role_t role;
|
||||
maze32_game_phase_t phase;
|
||||
uint32_t state_sequence;
|
||||
uint32_t meeting_sequence;
|
||||
maze32_game_final_result_t final_result;
|
||||
uint16_t width;
|
||||
uint16_t height;
|
||||
uint16_t self_cell_index;
|
||||
uint16_t opponent_cell_index;
|
||||
uint16_t observer_player_cell_indices[MAZE32_GAME_PLAYER_COUNT];
|
||||
uint16_t cell_count;
|
||||
uint8_t 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 opponent_visible;
|
||||
maze32_view_cell_t cells[MAZE32_MAZE_MAX_CELLS];
|
||||
maze32_view_object_t objects[MAZE32_GAME_MAX_OBJECTS];
|
||||
} maze32_view_t;
|
||||
|
||||
maze32_game_config_t maze32_game_default_config(void);
|
||||
maze32_game_result_t maze32_game_init(maze32_game_t* game, const maze32_maze_t* maze,
|
||||
const maze32_game_config_t* config);
|
||||
void maze32_game_set_phase(maze32_game_t* game, maze32_game_phase_t phase);
|
||||
maze32_game_result_t maze32_game_enqueue_move(maze32_game_t* game, maze32_game_role_t role,
|
||||
maze32_game_direction_t direction,
|
||||
uint64_t submitted_ms, uint32_t* command_sequence);
|
||||
maze32_game_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);
|
||||
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_view_t* view);
|
||||
size_t maze32_game_view_chunk_count(const maze32_view_t* view);
|
||||
maze32_game_result_t maze32_game_serialize_view_chunk(const maze32_view_t* view, size_t chunk_index,
|
||||
char* output, size_t output_size,
|
||||
size_t* written);
|
||||
const char* maze32_game_result_name(maze32_game_result_t result);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,956 @@
|
||||
#include "maze32_game.h"
|
||||
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#define MAZE32_GAME_NO_OWNER UINT8_MAX
|
||||
|
||||
static int role_is_player(maze32_game_role_t role) {
|
||||
return role == MAZE32_GAME_ROLE_PLAYER_ONE || role == MAZE32_GAME_ROLE_PLAYER_TWO;
|
||||
}
|
||||
|
||||
static int direction_is_valid(maze32_game_direction_t direction) {
|
||||
return direction >= MAZE32_GAME_DIRECTION_NORTH && direction <= MAZE32_GAME_DIRECTION_WEST;
|
||||
}
|
||||
|
||||
static maze32_passage_t direction_passage(maze32_game_direction_t direction) {
|
||||
static const maze32_passage_t PASSAGES[] = {MAZE32_PASSAGE_NORTH, MAZE32_PASSAGE_EAST,
|
||||
MAZE32_PASSAGE_SOUTH, MAZE32_PASSAGE_WEST};
|
||||
return PASSAGES[direction];
|
||||
}
|
||||
|
||||
static maze32_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) {
|
||||
if (direction == MAZE32_GAME_DIRECTION_NORTH) {
|
||||
cell.y--;
|
||||
} else if (direction == MAZE32_GAME_DIRECTION_EAST) {
|
||||
cell.x++;
|
||||
} else if (direction == MAZE32_GAME_DIRECTION_SOUTH) {
|
||||
cell.y++;
|
||||
} else {
|
||||
cell.x--;
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
|
||||
static uint16_t cell_index(const maze32_maze_t* maze, maze32_cell_t cell) {
|
||||
return (uint16_t)(cell.y * maze->config.width + cell.x);
|
||||
}
|
||||
|
||||
static int valid_cell(const maze32_maze_t* maze, maze32_cell_t cell) {
|
||||
return cell.x < maze->config.width && cell.y < maze->config.height;
|
||||
}
|
||||
|
||||
static int 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) {
|
||||
uint16_t dx = center.x > cell.x ? center.x - cell.x : cell.x - center.x;
|
||||
uint16_t dy = center.y > cell.y ? center.y - cell.y : cell.y - center.y;
|
||||
return dx <= radius && dy <= radius;
|
||||
}
|
||||
|
||||
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) {
|
||||
const maze32_game_config_t config = {.movement_cooldown_ms = 150U};
|
||||
return config;
|
||||
}
|
||||
|
||||
maze32_game_result_t maze32_game_init(maze32_game_t* game, const maze32_maze_t* maze,
|
||||
const maze32_game_config_t* config) {
|
||||
if (game == NULL || maze == NULL || config == NULL || maze->config.width == 0U ||
|
||||
maze->config.height == 0U || maze->config.width > MAZE32_MAZE_MAX_WIDTH ||
|
||||
maze->config.height > MAZE32_MAZE_MAX_HEIGHT ||
|
||||
maze->config.landmark_pairs > MAZE32_MAZE_MAX_LANDMARK_PAIRS ||
|
||||
maze->config.artifact_pairs > MAZE32_MAZE_MAX_ARTIFACT_PAIRS ||
|
||||
!valid_cell(maze, maze->starts[0]) || !valid_cell(maze, maze->starts[1])) {
|
||||
return MAZE32_GAME_RESULT_INVALID_ARGUMENT;
|
||||
}
|
||||
memset(game, 0, sizeof(*game));
|
||||
game->maze = maze;
|
||||
game->config = *config;
|
||||
game->phase = MAZE32_GAME_PHASE_COUNTDOWN;
|
||||
game->phase_before_pause = MAZE32_GAME_PHASE_ACTIVE;
|
||||
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;
|
||||
}
|
||||
|
||||
void maze32_game_set_phase(maze32_game_t* game, maze32_game_phase_t phase) {
|
||||
if (game != NULL && phase >= MAZE32_GAME_PHASE_COUNTDOWN &&
|
||||
phase <= MAZE32_GAME_PHASE_FINISHED) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static maze32_game_result_t enqueue_command(maze32_game_t* game, maze32_game_role_t role,
|
||||
maze32_game_command_type_t type,
|
||||
maze32_game_direction_t 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;
|
||||
}
|
||||
if (!role_is_player(role)) {
|
||||
return MAZE32_GAME_RESULT_READ_ONLY;
|
||||
}
|
||||
if (game->command_count >= MAZE32_GAME_COMMAND_CAPACITY) {
|
||||
return MAZE32_GAME_RESULT_QUEUE_FULL;
|
||||
}
|
||||
uint8_t tail =
|
||||
(uint8_t)((game->command_head + game->command_count) % MAZE32_GAME_COMMAND_CAPACITY);
|
||||
maze32_game_command_t* command = &game->commands[tail];
|
||||
command->command_sequence = game->next_command_sequence++;
|
||||
command->type = type;
|
||||
command->role = role;
|
||||
command->direction = direction;
|
||||
command->submitted_ms = submitted_ms;
|
||||
game->command_count++;
|
||||
*command_sequence = command->command_sequence;
|
||||
return MAZE32_GAME_RESULT_ACCEPTED;
|
||||
}
|
||||
|
||||
maze32_game_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 processed = {
|
||||
.result = MAZE32_GAME_RESULT_INVALID_ARGUMENT,
|
||||
.key = MAZE32_GAME_NO_KEY,
|
||||
.artifact = UINT8_MAX,
|
||||
};
|
||||
if (game == NULL) {
|
||||
return processed;
|
||||
}
|
||||
processed.state_sequence = game->state_sequence;
|
||||
processed.meeting_sequence = game->meeting_sequence;
|
||||
processed.final_result = game->final_result;
|
||||
if (game->command_count == 0U) {
|
||||
processed.result = MAZE32_GAME_RESULT_QUEUE_EMPTY;
|
||||
return processed;
|
||||
}
|
||||
maze32_game_command_t command = game->commands[game->command_head];
|
||||
game->command_head = (uint8_t)((game->command_head + 1U) % MAZE32_GAME_COMMAND_CAPACITY);
|
||||
game->command_count--;
|
||||
processed.command_sequence = command.command_sequence;
|
||||
|
||||
if (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;
|
||||
return processed;
|
||||
}
|
||||
|
||||
uint8_t player_index = (uint8_t)command.role;
|
||||
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 &&
|
||||
(command.submitted_ms < player->last_move_ms ||
|
||||
command.submitted_ms - player->last_move_ms < game->config.movement_cooldown_ms)) {
|
||||
processed.result = MAZE32_GAME_RESULT_RATE_LIMITED;
|
||||
return processed;
|
||||
}
|
||||
if (direction_leaves_map(game->maze, player->position, command.direction)) {
|
||||
reset_perseverance(player);
|
||||
if (!cells_equal(player->position, game->maze->exits[player_index]) ||
|
||||
!game->exits[player_index].open) {
|
||||
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);
|
||||
}
|
||||
game->state_sequence++;
|
||||
processed.result = MAZE32_GAME_RESULT_ACCEPTED;
|
||||
processed.state_sequence = game->state_sequence;
|
||||
processed.meeting_sequence = game->meeting_sequence;
|
||||
processed.final_result = game->final_result;
|
||||
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,
|
||||
maze32_view_object_kind_t kind, uint8_t owner, uint8_t variant,
|
||||
maze32_cell_t center, uint8_t radius, int reveal_all) {
|
||||
if (view->object_count >= MAZE32_GAME_MAX_OBJECTS ||
|
||||
(!reveal_all && !is_visible(center, cell, radius))) {
|
||||
return;
|
||||
}
|
||||
maze32_view_object_t* object = &view->objects[view->object_count++];
|
||||
object->cell_index = cell_index(game->maze, cell);
|
||||
object->kind = kind;
|
||||
object->owner = owner;
|
||||
object->variant = variant;
|
||||
}
|
||||
|
||||
static void project_objects(const maze32_game_t* game, maze32_view_t* view, maze32_cell_t center,
|
||||
uint8_t radius, int reveal_all) {
|
||||
for (uint8_t owner = 0U; owner < MAZE32_GAME_PLAYER_COUNT; ++owner) {
|
||||
if (game->keys[owner].location == MAZE32_GAME_KEY_GROUND) {
|
||||
add_object(game, view, game->keys[owner].ground_position, MAZE32_VIEW_OBJECT_KEY, owner,
|
||||
0U, center, 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,
|
||||
center, radius, reveal_all);
|
||||
}
|
||||
for (uint8_t pair = 0U; pair < game->maze->config.landmark_pairs; ++pair) {
|
||||
for (uint8_t side = 0U; side < 2U; ++side) {
|
||||
add_object(game, view, game->maze->landmarks[pair].cells[side],
|
||||
MAZE32_VIEW_OBJECT_LANDMARK, MAZE32_GAME_NO_OWNER,
|
||||
game->maze->landmarks[pair].kind, center, radius, reveal_all);
|
||||
}
|
||||
}
|
||||
for (uint8_t pair = 0U; pair < game->maze->config.artifact_pairs; ++pair) {
|
||||
for (uint8_t side = 0U; side < 2U; ++side) {
|
||||
uint8_t artifact = (uint8_t)(pair * 2U + side);
|
||||
if (!game->consumed_artifacts[artifact]) {
|
||||
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_view_t* view) {
|
||||
if (game == NULL || game->maze == NULL || view == NULL ||
|
||||
(!role_is_player(role) && role != MAZE32_GAME_ROLE_OBSERVER)) {
|
||||
return MAZE32_GAME_RESULT_INVALID_ARGUMENT;
|
||||
}
|
||||
memset(view, 0, sizeof(*view));
|
||||
view->role = role;
|
||||
view->phase = game->phase;
|
||||
view->state_sequence = game->state_sequence;
|
||||
view->meeting_sequence = game->meeting_sequence;
|
||||
view->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;
|
||||
int reveal_all = role == MAZE32_GAME_ROLE_OBSERVER;
|
||||
uint8_t radius = 0U;
|
||||
maze32_cell_t center = {0U, 0U};
|
||||
if (role_is_player(role)) {
|
||||
uint8_t player_index = (uint8_t)role;
|
||||
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_carried_key = player.carried_key;
|
||||
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_cell_index = cell_index(game->maze, opponent.position);
|
||||
view->opponent_carried_key = opponent.carried_key;
|
||||
}
|
||||
} else {
|
||||
view->self_cell_index = UINT16_MAX;
|
||||
for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) {
|
||||
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 x = 0U; x < game->maze->config.width; ++x) {
|
||||
maze32_cell_t cell = {x, y};
|
||||
if (reveal_all || is_visible(center, cell, radius)) {
|
||||
uint16_t index = cell_index(game->maze, cell);
|
||||
view->cells[view->cell_count].cell_index = index;
|
||||
view->cells[view->cell_count].passages = game_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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
project_objects(game, view, center, radius, reveal_all);
|
||||
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) {
|
||||
if (view == NULL) {
|
||||
return 0U;
|
||||
}
|
||||
size_t cell_chunks = cell_chunk_count(view);
|
||||
size_t object_chunks = (view->object_count + MAZE32_GAME_VIEW_OBJECTS_PER_CHUNK - 1U) /
|
||||
MAZE32_GAME_VIEW_OBJECTS_PER_CHUNK;
|
||||
return cell_chunks + object_chunks;
|
||||
}
|
||||
|
||||
static int append_text(char* output, size_t output_size, size_t* offset, const char* format, ...) {
|
||||
va_list arguments;
|
||||
va_start(arguments, format);
|
||||
int count = vsnprintf(output + *offset, output_size - *offset, format, arguments);
|
||||
va_end(arguments);
|
||||
if (count < 0 || (size_t)count >= output_size - *offset) {
|
||||
return 0;
|
||||
}
|
||||
*offset += (size_t)count;
|
||||
return 1;
|
||||
}
|
||||
|
||||
static const char* role_name(maze32_game_role_t role) {
|
||||
if (role == MAZE32_GAME_ROLE_PLAYER_ONE) {
|
||||
return "player1";
|
||||
}
|
||||
if (role == MAZE32_GAME_ROLE_PLAYER_TWO) {
|
||||
return "player2";
|
||||
}
|
||||
return "observer";
|
||||
}
|
||||
|
||||
static const char* object_name(maze32_view_object_kind_t kind) {
|
||||
static const char* const NAMES[] = {"key", "exit", "compass", "landmark", "vision"};
|
||||
return NAMES[kind];
|
||||
}
|
||||
|
||||
static const char* phase_name(maze32_game_phase_t phase) {
|
||||
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,
|
||||
char* output, size_t output_size,
|
||||
size_t* written) {
|
||||
if (view == NULL || output == NULL || written == NULL || output_size == 0U ||
|
||||
view->cell_count > MAZE32_MAZE_MAX_CELLS || view->object_count > MAZE32_GAME_MAX_OBJECTS ||
|
||||
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;
|
||||
}
|
||||
size_t total_chunks = maze32_game_view_chunk_count(view);
|
||||
if (chunk_index >= total_chunks) {
|
||||
return MAZE32_GAME_RESULT_INVALID_ARGUMENT;
|
||||
}
|
||||
size_t offset = 0U;
|
||||
if (!append_text(output, output_size, &offset,
|
||||
"{\"v\":1,\"type\":\"view\",\"seq\":%lu,\"payload\":{\"role\":\"%s\","
|
||||
"\"phase\":\"%s\",\"result\":\"%s\",\"width\":%u,\"height\":%u,"
|
||||
"\"meetingSequence\":%lu,"
|
||||
"\"chunk\":%u,\"chunks\":%u",
|
||||
(unsigned long)view->state_sequence, role_name(view->role),
|
||||
phase_name(view->phase), final_result_name(view->final_result), view->width,
|
||||
view->height, (unsigned long)view->meeting_sequence, (unsigned int)chunk_index,
|
||||
(unsigned int)total_chunks)) {
|
||||
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
|
||||
}
|
||||
int status_appended = 1;
|
||||
if (chunk_index == 0U) {
|
||||
status_appended = view->role == MAZE32_GAME_ROLE_OBSERVER
|
||||
? append_observer_status(view, output, output_size, &offset)
|
||||
: append_player_status(view, output, output_size, &offset);
|
||||
}
|
||||
if (!status_appended || !append_text(output, output_size, &offset, ",\"cells\":[")) {
|
||||
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
|
||||
}
|
||||
|
||||
size_t cell_chunks = cell_chunk_count(view);
|
||||
if (chunk_index < cell_chunks) {
|
||||
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;
|
||||
if (view->role == MAZE32_GAME_ROLE_OBSERVER && chunk_index == 0U) {
|
||||
end = MAZE32_GAME_OBSERVER_FIRST_CHUNK_CELLS;
|
||||
}
|
||||
if (end > view->cell_count) {
|
||||
end = view->cell_count;
|
||||
}
|
||||
for (size_t index = first; index < end; ++index) {
|
||||
const maze32_view_cell_t* cell = &view->cells[index];
|
||||
if (!append_text(output, output_size, &offset, "%s[%u,%u,%u,%u,%u]",
|
||||
index == first ? "" : ",", cell->cell_index, cell->passages,
|
||||
cell->opened_passages, cell->trail_east, cell->trail_south)) {
|
||||
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!append_text(output, output_size, &offset, "],\"objects\":[")) {
|
||||
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
|
||||
}
|
||||
if (chunk_index >= cell_chunks) {
|
||||
size_t object_chunk = chunk_index - cell_chunks;
|
||||
size_t first = object_chunk * MAZE32_GAME_VIEW_OBJECTS_PER_CHUNK;
|
||||
size_t end = first + MAZE32_GAME_VIEW_OBJECTS_PER_CHUNK;
|
||||
if (end > view->object_count) {
|
||||
end = view->object_count;
|
||||
}
|
||||
for (size_t index = first; index < end; ++index) {
|
||||
const maze32_view_object_t* object = &view->objects[index];
|
||||
if (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]",
|
||||
index == first ? "" : ",", object_name(object->kind), object->owner,
|
||||
object->variant, object->cell_index)) {
|
||||
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!append_text(output, output_size, &offset, "]}}")) {
|
||||
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
|
||||
}
|
||||
*written = offset;
|
||||
return MAZE32_GAME_RESULT_ACCEPTED;
|
||||
}
|
||||
|
||||
const char* maze32_game_result_name(maze32_game_result_t result) {
|
||||
static const char* const NAMES[] = {
|
||||
"accepted", "invalid_argument", "queue_full", "queue_empty",
|
||||
"read_only", "premature", "rate_limited", "blocked",
|
||||
"buffer_too_small", "no_key", "player_exited", "match_finished",
|
||||
};
|
||||
if (result < MAZE32_GAME_RESULT_ACCEPTED || result > MAZE32_GAME_RESULT_MATCH_FINISHED) {
|
||||
return "unknown";
|
||||
}
|
||||
return NAMES[result];
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
idf_component_register(
|
||||
SRCS "maze32_lobby.c" "maze32_protocol.c"
|
||||
INCLUDE_DIRS "include"
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
#ifndef MAZE32_LOBBY_H
|
||||
#define MAZE32_LOBBY_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define MAZE32_PLAYER_LIMIT 2U
|
||||
#define MAZE32_OBSERVER_LIMIT 4U
|
||||
#define MAZE32_SESSION_LIMIT (MAZE32_PLAYER_LIMIT + MAZE32_OBSERVER_LIMIT)
|
||||
#define MAZE32_PLAYER_NAME_MAX_BYTES 32U
|
||||
#define MAZE32_SESSION_TOKEN_BYTES 16U
|
||||
#define MAZE32_SESSION_TOKEN_HEX_LENGTH (MAZE32_SESSION_TOKEN_BYTES * 2U)
|
||||
#define MAZE32_COUNTDOWN_DURATION_MS 3000U
|
||||
|
||||
typedef enum {
|
||||
MAZE32_ROLE_NONE = 0,
|
||||
MAZE32_ROLE_PLAYER,
|
||||
MAZE32_ROLE_OBSERVER,
|
||||
} maze32_role_t;
|
||||
|
||||
typedef enum {
|
||||
MAZE32_LOBBY_WAITING = 0,
|
||||
MAZE32_LOBBY_COUNTDOWN,
|
||||
MAZE32_LOBBY_ACTIVE,
|
||||
} maze32_lobby_phase_t;
|
||||
|
||||
typedef enum {
|
||||
MAZE32_LOBBY_OK = 0,
|
||||
MAZE32_LOBBY_INVALID_NAME,
|
||||
MAZE32_LOBBY_DUPLICATE_NAME,
|
||||
MAZE32_LOBBY_ROLE_FULL,
|
||||
MAZE32_LOBBY_INVALID_TOKEN,
|
||||
MAZE32_LOBBY_SESSION_CONNECTED,
|
||||
MAZE32_LOBBY_NOT_OWNER,
|
||||
MAZE32_LOBBY_NOT_READY,
|
||||
MAZE32_LOBBY_INVALID_PHASE,
|
||||
} maze32_lobby_result_t;
|
||||
|
||||
typedef struct {
|
||||
int connection_id;
|
||||
maze32_role_t role;
|
||||
unsigned int player_slot;
|
||||
int connected;
|
||||
char name[MAZE32_PLAYER_NAME_MAX_BYTES + 1U];
|
||||
char token[MAZE32_SESSION_TOKEN_HEX_LENGTH + 1U];
|
||||
} maze32_session_t;
|
||||
|
||||
typedef struct {
|
||||
maze32_session_t sessions[MAZE32_SESSION_LIMIT];
|
||||
maze32_lobby_phase_t phase;
|
||||
uint64_t countdown_started_ms;
|
||||
uint64_t activation_ms;
|
||||
uint32_t revision;
|
||||
} maze32_lobby_t;
|
||||
|
||||
void maze32_lobby_init(maze32_lobby_t* lobby);
|
||||
maze32_lobby_result_t maze32_lobby_join_player(maze32_lobby_t* lobby, int connection_id,
|
||||
const char* name, const char* token,
|
||||
maze32_session_t** session);
|
||||
maze32_lobby_result_t maze32_lobby_join_observer(maze32_lobby_t* lobby, int connection_id,
|
||||
const char* token, maze32_session_t** session);
|
||||
maze32_lobby_result_t maze32_lobby_restore(maze32_lobby_t* lobby, int connection_id,
|
||||
const char* token, maze32_session_t** session);
|
||||
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);
|
||||
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,
|
||||
int connection_id);
|
||||
unsigned int maze32_lobby_connected_observers(const maze32_lobby_t* lobby);
|
||||
int maze32_lobby_ready(const maze32_lobby_t* lobby);
|
||||
int maze32_lobby_can_reconfigure(const maze32_lobby_t* lobby);
|
||||
const char* maze32_lobby_phase_name(maze32_lobby_phase_t phase);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,56 @@
|
||||
#ifndef MAZE32_PROTOCOL_H
|
||||
#define MAZE32_PROTOCOL_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "maze32_lobby.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define MAZE32_PROTOCOL_VERSION 1U
|
||||
#define MAZE32_PROTOCOL_MESSAGE_LIMIT 512U
|
||||
#define MAZE32_REQUEST_ID_MAX_BYTES 32U
|
||||
|
||||
typedef enum {
|
||||
MAZE32_MESSAGE_INVALID = 0,
|
||||
MAZE32_MESSAGE_JOIN,
|
||||
MAZE32_MESSAGE_RESTORE,
|
||||
MAZE32_MESSAGE_START,
|
||||
MAZE32_MESSAGE_MOVE,
|
||||
MAZE32_MESSAGE_DROP_KEY,
|
||||
MAZE32_MESSAGE_RESTART,
|
||||
MAZE32_MESSAGE_LEAVE,
|
||||
MAZE32_MESSAGE_HEARTBEAT,
|
||||
} maze32_message_type_t;
|
||||
|
||||
typedef enum {
|
||||
MAZE32_PROTOCOL_OK = 0,
|
||||
MAZE32_PROTOCOL_MALFORMED,
|
||||
MAZE32_PROTOCOL_TOO_LARGE,
|
||||
MAZE32_PROTOCOL_UNSUPPORTED_VERSION,
|
||||
MAZE32_PROTOCOL_UNKNOWN_TYPE,
|
||||
MAZE32_PROTOCOL_INVALID_PAYLOAD,
|
||||
} maze32_protocol_result_t;
|
||||
|
||||
typedef struct {
|
||||
maze32_message_type_t type;
|
||||
uint32_t sequence;
|
||||
char request_id[MAZE32_REQUEST_ID_MAX_BYTES + 1U];
|
||||
maze32_role_t role;
|
||||
char name[MAZE32_PLAYER_NAME_MAX_BYTES + 1U];
|
||||
char token[MAZE32_SESSION_TOKEN_HEX_LENGTH + 1U];
|
||||
char direction[6U];
|
||||
} maze32_client_message_t;
|
||||
|
||||
maze32_protocol_result_t maze32_protocol_parse(const char* data, size_t length,
|
||||
maze32_client_message_t* message);
|
||||
const char* maze32_protocol_result_name(maze32_protocol_result_t result);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,297 @@
|
||||
#include "maze32_lobby.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
static int is_valid_name(const char* name) {
|
||||
if (name == NULL) {
|
||||
return 0;
|
||||
}
|
||||
size_t length = strlen(name);
|
||||
if (length == 0U || length > MAZE32_PLAYER_NAME_MAX_BYTES || name[0] == ' ' ||
|
||||
name[length - 1U] == ' ') {
|
||||
return 0;
|
||||
}
|
||||
for (size_t index = 0U; index < length; ++index) {
|
||||
unsigned char byte = (unsigned char)name[index];
|
||||
if (byte < 0x20U || byte == 0x7fU) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int is_valid_token(const char* token) {
|
||||
if (token == NULL || strlen(token) != MAZE32_SESSION_TOKEN_HEX_LENGTH) {
|
||||
return 0;
|
||||
}
|
||||
for (size_t index = 0U; index < MAZE32_SESSION_TOKEN_HEX_LENGTH; ++index) {
|
||||
char character = token[index];
|
||||
if (!((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f'))) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static maze32_session_t* find_player_slot(maze32_lobby_t* lobby, unsigned int slot) {
|
||||
for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) {
|
||||
maze32_session_t* session = &lobby->sessions[index];
|
||||
if (session->role == MAZE32_ROLE_PLAYER && session->player_slot == slot) {
|
||||
return session;
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static maze32_session_t* find_free_session(maze32_lobby_t* lobby) {
|
||||
for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) {
|
||||
if (lobby->sessions[index].role == MAZE32_ROLE_NONE) {
|
||||
return &lobby->sessions[index];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static maze32_session_t* find_reusable_observer(maze32_lobby_t* lobby) {
|
||||
for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) {
|
||||
maze32_session_t* session = &lobby->sessions[index];
|
||||
if (session->role == MAZE32_ROLE_OBSERVER && !session->connected) {
|
||||
return session;
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void maze32_lobby_init(maze32_lobby_t* lobby) {
|
||||
if (lobby == NULL) {
|
||||
return;
|
||||
}
|
||||
memset(lobby, 0, sizeof(*lobby));
|
||||
lobby->phase = MAZE32_LOBBY_WAITING;
|
||||
}
|
||||
|
||||
maze32_lobby_result_t maze32_lobby_join_player(maze32_lobby_t* lobby, int connection_id,
|
||||
const char* name, const char* token,
|
||||
maze32_session_t** session) {
|
||||
if (lobby == NULL || session == NULL || !is_valid_name(name) || !is_valid_token(token)) {
|
||||
return MAZE32_LOBBY_INVALID_NAME;
|
||||
}
|
||||
if (lobby->phase != MAZE32_LOBBY_WAITING) {
|
||||
return MAZE32_LOBBY_INVALID_PHASE;
|
||||
}
|
||||
for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) {
|
||||
if (lobby->sessions[index].role == MAZE32_ROLE_PLAYER &&
|
||||
strcmp(lobby->sessions[index].name, name) == 0) {
|
||||
return MAZE32_LOBBY_DUPLICATE_NAME;
|
||||
}
|
||||
}
|
||||
|
||||
unsigned int slot = find_player_slot(lobby, 1U) == NULL ? 1U : 2U;
|
||||
if (find_player_slot(lobby, slot) != NULL) {
|
||||
return MAZE32_LOBBY_ROLE_FULL;
|
||||
}
|
||||
maze32_session_t* target = find_free_session(lobby);
|
||||
if (target == NULL) {
|
||||
return MAZE32_LOBBY_ROLE_FULL;
|
||||
}
|
||||
target->connection_id = connection_id;
|
||||
target->role = MAZE32_ROLE_PLAYER;
|
||||
target->player_slot = slot;
|
||||
target->connected = 1;
|
||||
memcpy(target->name, name, strlen(name) + 1U);
|
||||
memcpy(target->token, token, MAZE32_SESSION_TOKEN_HEX_LENGTH + 1U);
|
||||
lobby->revision++;
|
||||
*session = target;
|
||||
return MAZE32_LOBBY_OK;
|
||||
}
|
||||
|
||||
maze32_lobby_result_t maze32_lobby_join_observer(maze32_lobby_t* lobby, int connection_id,
|
||||
const char* token, maze32_session_t** session) {
|
||||
if (lobby == NULL || session == NULL || !is_valid_token(token)) {
|
||||
return MAZE32_LOBBY_INVALID_TOKEN;
|
||||
}
|
||||
if (maze32_lobby_connected_observers(lobby) >= MAZE32_OBSERVER_LIMIT) {
|
||||
return MAZE32_LOBBY_ROLE_FULL;
|
||||
}
|
||||
maze32_session_t* target = find_free_session(lobby);
|
||||
if (target == NULL) {
|
||||
target = find_reusable_observer(lobby);
|
||||
}
|
||||
if (target == NULL) {
|
||||
return MAZE32_LOBBY_ROLE_FULL;
|
||||
}
|
||||
memset(target, 0, sizeof(*target));
|
||||
target->connection_id = connection_id;
|
||||
target->role = MAZE32_ROLE_OBSERVER;
|
||||
target->connected = 1;
|
||||
memcpy(target->token, token, MAZE32_SESSION_TOKEN_HEX_LENGTH + 1U);
|
||||
lobby->revision++;
|
||||
*session = target;
|
||||
return MAZE32_LOBBY_OK;
|
||||
}
|
||||
|
||||
maze32_lobby_result_t maze32_lobby_restore(maze32_lobby_t* lobby, int connection_id,
|
||||
const char* token, maze32_session_t** session) {
|
||||
if (lobby == NULL || session == NULL || !is_valid_token(token)) {
|
||||
return MAZE32_LOBBY_INVALID_TOKEN;
|
||||
}
|
||||
for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) {
|
||||
maze32_session_t* candidate = &lobby->sessions[index];
|
||||
if (candidate->role != MAZE32_ROLE_NONE && strcmp(candidate->token, token) == 0) {
|
||||
if (candidate->connected) {
|
||||
return MAZE32_LOBBY_SESSION_CONNECTED;
|
||||
}
|
||||
candidate->connected = 1;
|
||||
candidate->connection_id = connection_id;
|
||||
lobby->revision++;
|
||||
*session = candidate;
|
||||
return MAZE32_LOBBY_OK;
|
||||
}
|
||||
}
|
||||
return MAZE32_LOBBY_INVALID_TOKEN;
|
||||
}
|
||||
|
||||
void maze32_lobby_disconnect(maze32_lobby_t* lobby, int connection_id) {
|
||||
if (lobby == NULL) {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
session->connected = 0;
|
||||
session->connection_id = -1;
|
||||
lobby->revision++;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
uint64_t now_ms) {
|
||||
if (lobby == NULL || lobby->phase != MAZE32_LOBBY_WAITING) {
|
||||
return MAZE32_LOBBY_INVALID_PHASE;
|
||||
}
|
||||
const maze32_session_t* session = maze32_lobby_session_for_connection(lobby, connection_id);
|
||||
if (session == NULL || session->role != MAZE32_ROLE_PLAYER || session->player_slot != 1U) {
|
||||
return MAZE32_LOBBY_NOT_OWNER;
|
||||
}
|
||||
if (!maze32_lobby_ready(lobby)) {
|
||||
return MAZE32_LOBBY_NOT_READY;
|
||||
}
|
||||
lobby->phase = MAZE32_LOBBY_COUNTDOWN;
|
||||
lobby->countdown_started_ms = now_ms;
|
||||
lobby->activation_ms = now_ms + MAZE32_COUNTDOWN_DURATION_MS;
|
||||
lobby->revision++;
|
||||
return MAZE32_LOBBY_OK;
|
||||
}
|
||||
|
||||
int maze32_lobby_activate_if_due(maze32_lobby_t* lobby, uint64_t now_ms) {
|
||||
if (lobby == NULL || lobby->phase != MAZE32_LOBBY_COUNTDOWN || now_ms < lobby->activation_ms) {
|
||||
return 0;
|
||||
}
|
||||
lobby->phase = MAZE32_LOBBY_ACTIVE;
|
||||
lobby->revision++;
|
||||
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,
|
||||
int connection_id) {
|
||||
if (lobby == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) {
|
||||
const maze32_session_t* session = &lobby->sessions[index];
|
||||
if (session->role != MAZE32_ROLE_NONE && session->connected &&
|
||||
session->connection_id == connection_id) {
|
||||
return session;
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
unsigned int maze32_lobby_connected_observers(const maze32_lobby_t* lobby) {
|
||||
unsigned int count = 0U;
|
||||
if (lobby == NULL) {
|
||||
return count;
|
||||
}
|
||||
for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) {
|
||||
const maze32_session_t* session = &lobby->sessions[index];
|
||||
if (session->role == MAZE32_ROLE_OBSERVER && session->connected) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
int maze32_lobby_ready(const maze32_lobby_t* lobby) {
|
||||
if (lobby == NULL) {
|
||||
return 0;
|
||||
}
|
||||
for (unsigned int slot = 1U; slot <= MAZE32_PLAYER_LIMIT; ++slot) {
|
||||
int found = 0;
|
||||
for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) {
|
||||
const maze32_session_t* session = &lobby->sessions[index];
|
||||
if (session->role == MAZE32_ROLE_PLAYER && session->player_slot == slot &&
|
||||
session->connected) {
|
||||
found = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
int maze32_lobby_can_reconfigure(const maze32_lobby_t* lobby) {
|
||||
return lobby != NULL && lobby->phase == MAZE32_LOBBY_WAITING;
|
||||
}
|
||||
|
||||
const char* maze32_lobby_phase_name(maze32_lobby_phase_t phase) {
|
||||
switch (phase) {
|
||||
case MAZE32_LOBBY_WAITING:
|
||||
return "waiting";
|
||||
case MAZE32_LOBBY_COUNTDOWN:
|
||||
return "countdown";
|
||||
case MAZE32_LOBBY_ACTIVE:
|
||||
return "active";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
#include "maze32_protocol.h"
|
||||
|
||||
#include <limits.h>
|
||||
#include <string.h>
|
||||
|
||||
typedef struct {
|
||||
const char* data;
|
||||
size_t length;
|
||||
size_t position;
|
||||
} json_cursor_t;
|
||||
|
||||
static void skip_space(json_cursor_t* cursor) {
|
||||
while (cursor->position < cursor->length) {
|
||||
char character = cursor->data[cursor->position];
|
||||
if (character != ' ' && character != '\t' && character != '\r' && character != '\n') {
|
||||
return;
|
||||
}
|
||||
cursor->position++;
|
||||
}
|
||||
}
|
||||
|
||||
static int consume(json_cursor_t* cursor, char expected) {
|
||||
skip_space(cursor);
|
||||
if (cursor->position >= cursor->length || cursor->data[cursor->position] != expected) {
|
||||
return 0;
|
||||
}
|
||||
cursor->position++;
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int parse_string(json_cursor_t* cursor, char* output, size_t capacity) {
|
||||
skip_space(cursor);
|
||||
if (cursor->position >= cursor->length || cursor->data[cursor->position++] != '"') {
|
||||
return 0;
|
||||
}
|
||||
size_t output_length = 0U;
|
||||
while (cursor->position < cursor->length) {
|
||||
unsigned char character = (unsigned char)cursor->data[cursor->position++];
|
||||
if (character == '"') {
|
||||
if (output_length >= capacity) {
|
||||
return 0;
|
||||
}
|
||||
output[output_length] = '\0';
|
||||
return 1;
|
||||
}
|
||||
if (character < 0x20U) {
|
||||
return 0;
|
||||
}
|
||||
if (character == '\\') {
|
||||
if (cursor->position >= cursor->length) {
|
||||
return 0;
|
||||
}
|
||||
character = (unsigned char)cursor->data[cursor->position++];
|
||||
if (character == '"' || character == '\\' || character == '/') {
|
||||
/* The escaped byte is copied below. */
|
||||
} else if (character == 'b') {
|
||||
character = '\b';
|
||||
} else if (character == 'f') {
|
||||
character = '\f';
|
||||
} else if (character == 'n') {
|
||||
character = '\n';
|
||||
} else if (character == 'r') {
|
||||
character = '\r';
|
||||
} else if (character == 't') {
|
||||
character = '\t';
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
if (output_length + 1U >= capacity) {
|
||||
return 0;
|
||||
}
|
||||
output[output_length++] = (char)character;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int parse_uint32(json_cursor_t* cursor, uint32_t* value) {
|
||||
skip_space(cursor);
|
||||
if (cursor->position >= cursor->length || cursor->data[cursor->position] < '0' ||
|
||||
cursor->data[cursor->position] > '9') {
|
||||
return 0;
|
||||
}
|
||||
uint64_t parsed = 0U;
|
||||
while (cursor->position < cursor->length && cursor->data[cursor->position] >= '0' &&
|
||||
cursor->data[cursor->position] <= '9') {
|
||||
parsed = parsed * 10U + (uint64_t)(cursor->data[cursor->position] - '0');
|
||||
if (parsed > UINT32_MAX) {
|
||||
return 0;
|
||||
}
|
||||
cursor->position++;
|
||||
}
|
||||
*value = (uint32_t)parsed;
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int payload_field_allowed(maze32_message_type_t type, const char* key, char* value,
|
||||
maze32_client_message_t* message, unsigned int* fields) {
|
||||
if (type == MAZE32_MESSAGE_JOIN && strcmp(key, "role") == 0 && !(*fields & 1U)) {
|
||||
if (strcmp(value, "player") == 0) {
|
||||
message->role = MAZE32_ROLE_PLAYER;
|
||||
} else if (strcmp(value, "observer") == 0) {
|
||||
message->role = MAZE32_ROLE_OBSERVER;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
*fields |= 1U;
|
||||
return 1;
|
||||
}
|
||||
if (type == MAZE32_MESSAGE_JOIN && strcmp(key, "name") == 0 && !(*fields & 2U)) {
|
||||
if (strlen(value) > MAZE32_PLAYER_NAME_MAX_BYTES) {
|
||||
return 0;
|
||||
}
|
||||
memcpy(message->name, value, strlen(value) + 1U);
|
||||
*fields |= 2U;
|
||||
return 1;
|
||||
}
|
||||
if (type == MAZE32_MESSAGE_RESTORE && strcmp(key, "token") == 0 && !(*fields & 1U)) {
|
||||
if (strlen(value) != MAZE32_SESSION_TOKEN_HEX_LENGTH) {
|
||||
return 0;
|
||||
}
|
||||
memcpy(message->token, value, MAZE32_SESSION_TOKEN_HEX_LENGTH + 1U);
|
||||
*fields |= 1U;
|
||||
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;
|
||||
}
|
||||
|
||||
static int parse_payload(json_cursor_t* cursor, maze32_client_message_t* message) {
|
||||
if (!consume(cursor, '{')) {
|
||||
return 0;
|
||||
}
|
||||
unsigned int fields = 0U;
|
||||
skip_space(cursor);
|
||||
if (cursor->position < cursor->length && cursor->data[cursor->position] == '}') {
|
||||
cursor->position++;
|
||||
} else {
|
||||
for (;;) {
|
||||
char key[16];
|
||||
char value[MAZE32_PLAYER_NAME_MAX_BYTES + 1U];
|
||||
if (!parse_string(cursor, key, sizeof(key)) || !consume(cursor, ':') ||
|
||||
!parse_string(cursor, value, sizeof(value)) ||
|
||||
!payload_field_allowed(message->type, key, value, message, &fields)) {
|
||||
return 0;
|
||||
}
|
||||
skip_space(cursor);
|
||||
if (consume(cursor, '}')) {
|
||||
break;
|
||||
}
|
||||
if (!consume(cursor, ',')) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (message->type == MAZE32_MESSAGE_JOIN) {
|
||||
return message->role == MAZE32_ROLE_PLAYER ? fields == 3U : fields == 1U;
|
||||
}
|
||||
if (message->type == MAZE32_MESSAGE_RESTORE) {
|
||||
return fields == 1U;
|
||||
}
|
||||
if (message->type == MAZE32_MESSAGE_MOVE) {
|
||||
return fields == 1U;
|
||||
}
|
||||
return fields == 0U;
|
||||
}
|
||||
|
||||
static maze32_message_type_t message_type(const char* value) {
|
||||
if (strcmp(value, "join") == 0) {
|
||||
return MAZE32_MESSAGE_JOIN;
|
||||
}
|
||||
if (strcmp(value, "restore") == 0) {
|
||||
return MAZE32_MESSAGE_RESTORE;
|
||||
}
|
||||
if (strcmp(value, "start") == 0) {
|
||||
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) {
|
||||
return MAZE32_MESSAGE_HEARTBEAT;
|
||||
}
|
||||
return MAZE32_MESSAGE_INVALID;
|
||||
}
|
||||
|
||||
maze32_protocol_result_t maze32_protocol_parse(const char* data, size_t length,
|
||||
maze32_client_message_t* message) {
|
||||
if (data == NULL || message == NULL || length == 0U) {
|
||||
return MAZE32_PROTOCOL_MALFORMED;
|
||||
}
|
||||
if (length > MAZE32_PROTOCOL_MESSAGE_LIMIT) {
|
||||
return MAZE32_PROTOCOL_TOO_LARGE;
|
||||
}
|
||||
memset(message, 0, sizeof(*message));
|
||||
json_cursor_t cursor = {.data = data, .length = length, .position = 0U};
|
||||
if (!consume(&cursor, '{')) {
|
||||
return MAZE32_PROTOCOL_MALFORMED;
|
||||
}
|
||||
|
||||
unsigned int envelope_fields = 0U;
|
||||
uint32_t version = 0U;
|
||||
char type_value[24] = "";
|
||||
for (;;) {
|
||||
char key[24];
|
||||
if (!parse_string(&cursor, key, sizeof(key)) || !consume(&cursor, ':')) {
|
||||
return MAZE32_PROTOCOL_MALFORMED;
|
||||
}
|
||||
if (strcmp(key, "v") == 0 && !(envelope_fields & 1U)) {
|
||||
if (!parse_uint32(&cursor, &version)) {
|
||||
return MAZE32_PROTOCOL_MALFORMED;
|
||||
}
|
||||
envelope_fields |= 1U;
|
||||
} else if (strcmp(key, "type") == 0 && !(envelope_fields & 2U)) {
|
||||
if (!parse_string(&cursor, type_value, sizeof(type_value))) {
|
||||
return MAZE32_PROTOCOL_MALFORMED;
|
||||
}
|
||||
message->type = message_type(type_value);
|
||||
envelope_fields |= 2U;
|
||||
} else if (strcmp(key, "seq") == 0 && !(envelope_fields & 4U)) {
|
||||
if (!parse_uint32(&cursor, &message->sequence) || message->sequence == 0U) {
|
||||
return MAZE32_PROTOCOL_MALFORMED;
|
||||
}
|
||||
envelope_fields |= 4U;
|
||||
} else if (strcmp(key, "requestId") == 0 && !(envelope_fields & 8U)) {
|
||||
if (!parse_string(&cursor, message->request_id, sizeof(message->request_id))) {
|
||||
return MAZE32_PROTOCOL_MALFORMED;
|
||||
}
|
||||
envelope_fields |= 8U;
|
||||
} else if (strcmp(key, "payload") == 0 && !(envelope_fields & 16U)) {
|
||||
if (!(envelope_fields & 2U)) {
|
||||
return MAZE32_PROTOCOL_MALFORMED;
|
||||
}
|
||||
if (message->type == MAZE32_MESSAGE_INVALID) {
|
||||
return MAZE32_PROTOCOL_UNKNOWN_TYPE;
|
||||
}
|
||||
if (!parse_payload(&cursor, message)) {
|
||||
return MAZE32_PROTOCOL_INVALID_PAYLOAD;
|
||||
}
|
||||
envelope_fields |= 16U;
|
||||
} else {
|
||||
return MAZE32_PROTOCOL_MALFORMED;
|
||||
}
|
||||
skip_space(&cursor);
|
||||
if (consume(&cursor, '}')) {
|
||||
break;
|
||||
}
|
||||
if (!consume(&cursor, ',')) {
|
||||
return MAZE32_PROTOCOL_MALFORMED;
|
||||
}
|
||||
}
|
||||
skip_space(&cursor);
|
||||
if (cursor.position != cursor.length ||
|
||||
(envelope_fields & (1U | 2U | 4U | 16U)) != (1U | 2U | 4U | 16U)) {
|
||||
return MAZE32_PROTOCOL_MALFORMED;
|
||||
}
|
||||
if (version != MAZE32_PROTOCOL_VERSION) {
|
||||
return MAZE32_PROTOCOL_UNSUPPORTED_VERSION;
|
||||
}
|
||||
if (message->type == MAZE32_MESSAGE_INVALID) {
|
||||
return MAZE32_PROTOCOL_UNKNOWN_TYPE;
|
||||
}
|
||||
return MAZE32_PROTOCOL_OK;
|
||||
}
|
||||
|
||||
const char* maze32_protocol_result_name(maze32_protocol_result_t result) {
|
||||
switch (result) {
|
||||
case MAZE32_PROTOCOL_OK:
|
||||
return "ok";
|
||||
case MAZE32_PROTOCOL_MALFORMED:
|
||||
return "malformed";
|
||||
case MAZE32_PROTOCOL_TOO_LARGE:
|
||||
return "too_large";
|
||||
case MAZE32_PROTOCOL_UNSUPPORTED_VERSION:
|
||||
return "unsupported_version";
|
||||
case MAZE32_PROTOCOL_UNKNOWN_TYPE:
|
||||
return "unknown_type";
|
||||
case MAZE32_PROTOCOL_INVALID_PAYLOAD:
|
||||
return "invalid_payload";
|
||||
default:
|
||||
return "unknown_error";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
idf_component_register(
|
||||
SRCS "maze32_maze.c"
|
||||
INCLUDE_DIRS "include"
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
#ifndef MAZE32_MAZE_H
|
||||
#define MAZE32_MAZE_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define MAZE32_MAZE_MAX_WIDTH 32U
|
||||
#define MAZE32_MAZE_MAX_HEIGHT 32U
|
||||
#define MAZE32_MAZE_MAX_CELLS (MAZE32_MAZE_MAX_WIDTH * MAZE32_MAZE_MAX_HEIGHT)
|
||||
#define MAZE32_MAZE_DEFAULT_WIDTH 32U
|
||||
#define MAZE32_MAZE_DEFAULT_HEIGHT 32U
|
||||
#define MAZE32_MAZE_MAX_LANDMARK_PAIRS 16U
|
||||
#define MAZE32_MAZE_MAX_ARTIFACT_PAIRS 16U
|
||||
#define MAZE32_MAZE_RAM_BUDGET_BYTES (64U * 1024U)
|
||||
#define MAZE32_MAZE_RUNTIME_BUDGET_MS 250U
|
||||
|
||||
typedef enum {
|
||||
MAZE32_PASSAGE_NORTH = 1U << 0U,
|
||||
MAZE32_PASSAGE_EAST = 1U << 1U,
|
||||
MAZE32_PASSAGE_SOUTH = 1U << 2U,
|
||||
MAZE32_PASSAGE_WEST = 1U << 3U,
|
||||
} maze32_passage_t;
|
||||
|
||||
typedef struct {
|
||||
uint16_t x;
|
||||
uint16_t y;
|
||||
} maze32_cell_t;
|
||||
|
||||
typedef struct {
|
||||
uint16_t width;
|
||||
uint16_t height;
|
||||
uint8_t initial_view_radius;
|
||||
uint8_t cross_half_pairs;
|
||||
uint16_t extra_cycle_pairs;
|
||||
uint16_t minimum_exit_separation;
|
||||
uint8_t landmark_pairs;
|
||||
uint8_t artifact_pairs;
|
||||
uint8_t maximum_attempts;
|
||||
} maze32_maze_config_t;
|
||||
|
||||
typedef struct {
|
||||
maze32_cell_t cells[2];
|
||||
uint8_t kind;
|
||||
} maze32_landmark_pair_t;
|
||||
|
||||
typedef struct {
|
||||
maze32_maze_config_t config;
|
||||
uint32_t requested_seed;
|
||||
uint32_t generation_seed;
|
||||
uint8_t generation_attempts;
|
||||
uint8_t passages[MAZE32_MAZE_MAX_CELLS];
|
||||
maze32_cell_t starts[2];
|
||||
maze32_cell_t keys[2];
|
||||
maze32_cell_t exits[2];
|
||||
maze32_cell_t compasses[2];
|
||||
maze32_landmark_pair_t landmarks[MAZE32_MAZE_MAX_LANDMARK_PAIRS];
|
||||
maze32_cell_t artifacts[MAZE32_MAZE_MAX_ARTIFACT_PAIRS][2];
|
||||
} maze32_maze_t;
|
||||
|
||||
typedef struct {
|
||||
uint8_t visited[(MAZE32_MAZE_MAX_CELLS + 7U) / 8U];
|
||||
uint16_t traversal[MAZE32_MAZE_MAX_CELLS];
|
||||
uint16_t distances[MAZE32_MAZE_MAX_CELLS];
|
||||
} maze32_maze_workspace_t;
|
||||
|
||||
typedef struct {
|
||||
uint16_t reachable_cells;
|
||||
uint16_t passage_edges;
|
||||
uint16_t cycle_count;
|
||||
uint16_t dead_end_count;
|
||||
uint16_t branching_cells;
|
||||
uint16_t cross_half_connections;
|
||||
uint16_t start_to_key[2];
|
||||
uint16_t key_to_exit[2];
|
||||
uint16_t start_to_exit[2];
|
||||
uint16_t start_to_compass[2];
|
||||
uint32_t landmark_distance_total[2];
|
||||
uint32_t artifact_distance_total[2];
|
||||
} maze32_maze_metrics_t;
|
||||
|
||||
typedef enum {
|
||||
MAZE32_MAZE_OK = 0,
|
||||
MAZE32_MAZE_INVALID_ARGUMENT,
|
||||
MAZE32_MAZE_INVALID_CONFIG,
|
||||
MAZE32_MAZE_UNREACHABLE,
|
||||
MAZE32_MAZE_ASYMMETRIC,
|
||||
MAZE32_MAZE_INSUFFICIENT_CYCLES,
|
||||
MAZE32_MAZE_INSUFFICIENT_CROSSINGS,
|
||||
MAZE32_MAZE_PLACEMENT_COLLISION,
|
||||
MAZE32_MAZE_UNFAIR_PLACEMENT,
|
||||
MAZE32_MAZE_KEY_IN_INITIAL_VIEW,
|
||||
MAZE32_MAZE_GENERATION_EXHAUSTED,
|
||||
} maze32_maze_result_t;
|
||||
|
||||
typedef struct {
|
||||
maze32_maze_result_t result;
|
||||
maze32_maze_result_t last_validation_result;
|
||||
uint8_t attempts;
|
||||
maze32_maze_metrics_t metrics;
|
||||
} maze32_maze_report_t;
|
||||
|
||||
maze32_maze_config_t maze32_maze_default_config(void);
|
||||
maze32_maze_result_t maze32_maze_generate(maze32_maze_t* maze, maze32_maze_workspace_t* workspace,
|
||||
const maze32_maze_config_t* config, uint32_t seed,
|
||||
maze32_maze_report_t* report);
|
||||
maze32_maze_result_t maze32_maze_validate(const maze32_maze_t* maze,
|
||||
maze32_maze_workspace_t* workspace,
|
||||
maze32_maze_metrics_t* metrics);
|
||||
uint16_t maze32_maze_cell_index(const maze32_maze_t* maze, maze32_cell_t cell);
|
||||
maze32_cell_t maze32_maze_rotate(const maze32_maze_t* maze, maze32_cell_t cell);
|
||||
int maze32_maze_has_passage(const maze32_maze_t* maze, maze32_cell_t cell,
|
||||
maze32_passage_t passage);
|
||||
uint64_t maze32_maze_hash(const maze32_maze_t* maze);
|
||||
const char* maze32_maze_result_name(maze32_maze_result_t result);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,716 @@
|
||||
#include "maze32_maze.h"
|
||||
|
||||
#include <limits.h>
|
||||
#include <string.h>
|
||||
|
||||
typedef struct {
|
||||
uint32_t state;
|
||||
} maze32_rng_t;
|
||||
|
||||
static uint16_t cell_count(const maze32_maze_t* maze) {
|
||||
return (uint16_t)(maze->config.width * maze->config.height);
|
||||
}
|
||||
|
||||
static uint32_t next_random(maze32_rng_t* random) {
|
||||
uint32_t value = random->state;
|
||||
value ^= value << 13U;
|
||||
value ^= value >> 17U;
|
||||
value ^= value << 5U;
|
||||
random->state = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
static uint32_t bounded_random(maze32_rng_t* random, uint32_t limit) {
|
||||
return limit == 0U ? 0U : next_random(random) % limit;
|
||||
}
|
||||
|
||||
static int config_is_valid(const maze32_maze_config_t* config) {
|
||||
if (config == NULL || config->width < 8U || config->height < 8U ||
|
||||
config->width > MAZE32_MAZE_MAX_WIDTH || config->height > MAZE32_MAZE_MAX_HEIGHT ||
|
||||
(config->width & 1U) != 0U || (config->height & 1U) != 0U ||
|
||||
config->cross_half_pairs < 2U || config->cross_half_pairs > config->height / 2U ||
|
||||
config->minimum_exit_separation > config->width + config->height - 2U ||
|
||||
config->landmark_pairs > MAZE32_MAZE_MAX_LANDMARK_PAIRS || config->artifact_pairs == 0U ||
|
||||
config->artifact_pairs > MAZE32_MAZE_MAX_ARTIFACT_PAIRS || config->maximum_attempts == 0U) {
|
||||
return 0;
|
||||
}
|
||||
return (uint32_t)config->width * config->height <= MAZE32_MAZE_MAX_CELLS;
|
||||
}
|
||||
|
||||
static int cells_equal(maze32_cell_t first, maze32_cell_t second) {
|
||||
return first.x == second.x && first.y == second.y;
|
||||
}
|
||||
|
||||
static maze32_cell_t cell_from_index(const maze32_maze_t* maze, uint16_t index) {
|
||||
maze32_cell_t cell = {
|
||||
.x = (uint16_t)(index % maze->config.width),
|
||||
.y = (uint16_t)(index / maze->config.width),
|
||||
};
|
||||
return cell;
|
||||
}
|
||||
|
||||
uint16_t maze32_maze_cell_index(const maze32_maze_t* maze, maze32_cell_t cell) {
|
||||
if (maze == NULL || cell.x >= maze->config.width || cell.y >= maze->config.height) {
|
||||
return UINT16_MAX;
|
||||
}
|
||||
return (uint16_t)(cell.y * maze->config.width + cell.x);
|
||||
}
|
||||
|
||||
maze32_cell_t maze32_maze_rotate(const maze32_maze_t* maze, maze32_cell_t cell) {
|
||||
maze32_cell_t rotated = {UINT16_MAX, UINT16_MAX};
|
||||
if (maze != NULL && cell.x < maze->config.width && cell.y < maze->config.height) {
|
||||
rotated.x = (uint16_t)(maze->config.width - 1U - cell.x);
|
||||
rotated.y = (uint16_t)(maze->config.height - 1U - cell.y);
|
||||
}
|
||||
return rotated;
|
||||
}
|
||||
|
||||
static maze32_passage_t opposite_passage(maze32_passage_t passage) {
|
||||
switch (passage) {
|
||||
case MAZE32_PASSAGE_NORTH:
|
||||
return MAZE32_PASSAGE_SOUTH;
|
||||
case MAZE32_PASSAGE_EAST:
|
||||
return MAZE32_PASSAGE_WEST;
|
||||
case MAZE32_PASSAGE_SOUTH:
|
||||
return MAZE32_PASSAGE_NORTH;
|
||||
case MAZE32_PASSAGE_WEST:
|
||||
return MAZE32_PASSAGE_EAST;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static int neighbor_for(const maze32_maze_t* maze, uint16_t index, maze32_passage_t passage,
|
||||
uint16_t* neighbor) {
|
||||
maze32_cell_t cell = cell_from_index(maze, index);
|
||||
if (passage == MAZE32_PASSAGE_NORTH && cell.y > 0U) {
|
||||
cell.y--;
|
||||
} else if (passage == MAZE32_PASSAGE_EAST && cell.x + 1U < maze->config.width) {
|
||||
cell.x++;
|
||||
} else if (passage == MAZE32_PASSAGE_SOUTH && cell.y + 1U < maze->config.height) {
|
||||
cell.y++;
|
||||
} else if (passage == MAZE32_PASSAGE_WEST && cell.x > 0U) {
|
||||
cell.x--;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
*neighbor = maze32_maze_cell_index(maze, cell);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void open_edge(maze32_maze_t* maze, uint16_t first, uint16_t second,
|
||||
maze32_passage_t passage) {
|
||||
maze->passages[first] |= (uint8_t)passage;
|
||||
maze->passages[second] |= (uint8_t)opposite_passage(passage);
|
||||
}
|
||||
|
||||
static void open_symmetric_edge(maze32_maze_t* maze, uint16_t first, uint16_t second,
|
||||
maze32_passage_t passage) {
|
||||
open_edge(maze, first, second, passage);
|
||||
maze32_cell_t rotated_first = maze32_maze_rotate(maze, cell_from_index(maze, first));
|
||||
maze32_cell_t rotated_second = maze32_maze_rotate(maze, cell_from_index(maze, second));
|
||||
open_edge(maze, maze32_maze_cell_index(maze, rotated_first),
|
||||
maze32_maze_cell_index(maze, rotated_second), opposite_passage(passage));
|
||||
}
|
||||
|
||||
static void clear_visited(maze32_maze_workspace_t* workspace) {
|
||||
memset(workspace->visited, 0, sizeof(workspace->visited));
|
||||
}
|
||||
|
||||
static int is_visited(const maze32_maze_workspace_t* workspace, uint16_t index) {
|
||||
return (workspace->visited[index / 8U] & (uint8_t)(1U << (index % 8U))) != 0U;
|
||||
}
|
||||
|
||||
static void mark_visited(maze32_maze_workspace_t* workspace, uint16_t index) {
|
||||
workspace->visited[index / 8U] |= (uint8_t)(1U << (index % 8U));
|
||||
}
|
||||
|
||||
static int build_half_tree(maze32_maze_t* maze, maze32_maze_workspace_t* workspace,
|
||||
maze32_rng_t* random) {
|
||||
static const maze32_passage_t DIRECTIONS[] = {
|
||||
MAZE32_PASSAGE_NORTH,
|
||||
MAZE32_PASSAGE_EAST,
|
||||
MAZE32_PASSAGE_SOUTH,
|
||||
MAZE32_PASSAGE_WEST,
|
||||
};
|
||||
uint16_t half_width = maze->config.width / 2U;
|
||||
uint16_t target_cells = (uint16_t)(half_width * maze->config.height);
|
||||
uint16_t visited_cells = 1U;
|
||||
size_t stack_size = 1U;
|
||||
clear_visited(workspace);
|
||||
workspace->traversal[0] = 0U;
|
||||
mark_visited(workspace, 0U);
|
||||
|
||||
while (stack_size > 0U) {
|
||||
uint16_t current = workspace->traversal[stack_size - 1U];
|
||||
maze32_passage_t choices[4];
|
||||
uint16_t neighbors[4];
|
||||
size_t choice_count = 0U;
|
||||
for (size_t direction_index = 0U; direction_index < 4U; ++direction_index) {
|
||||
uint16_t neighbor = 0U;
|
||||
if (neighbor_for(maze, current, DIRECTIONS[direction_index], &neighbor)) {
|
||||
maze32_cell_t neighbor_cell = cell_from_index(maze, neighbor);
|
||||
if (neighbor_cell.x < half_width && !is_visited(workspace, neighbor)) {
|
||||
choices[choice_count] = DIRECTIONS[direction_index];
|
||||
neighbors[choice_count] = neighbor;
|
||||
choice_count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (choice_count == 0U) {
|
||||
stack_size--;
|
||||
continue;
|
||||
}
|
||||
size_t selected = bounded_random(random, (uint32_t)choice_count);
|
||||
open_symmetric_edge(maze, current, neighbors[selected], choices[selected]);
|
||||
mark_visited(workspace, neighbors[selected]);
|
||||
workspace->traversal[stack_size++] = neighbors[selected];
|
||||
visited_cells++;
|
||||
}
|
||||
return visited_cells == target_cells;
|
||||
}
|
||||
|
||||
static int add_cycle_pairs(maze32_maze_t* maze, maze32_rng_t* random) {
|
||||
uint16_t half_width = maze->config.width / 2U;
|
||||
uint16_t added = 0U;
|
||||
uint32_t attempt_limit = (uint32_t)maze->config.extra_cycle_pairs * 64U + 256U;
|
||||
for (uint32_t attempt = 0U; attempt < attempt_limit && added < maze->config.extra_cycle_pairs;
|
||||
++attempt) {
|
||||
maze32_cell_t cell = {
|
||||
.x = (uint16_t)bounded_random(random, half_width),
|
||||
.y = (uint16_t)bounded_random(random, maze->config.height),
|
||||
};
|
||||
maze32_passage_t passage =
|
||||
bounded_random(random, 2U) == 0U ? MAZE32_PASSAGE_EAST : MAZE32_PASSAGE_SOUTH;
|
||||
uint16_t first = maze32_maze_cell_index(maze, cell);
|
||||
uint16_t second = 0U;
|
||||
if (!neighbor_for(maze, first, passage, &second) ||
|
||||
cell_from_index(maze, second).x >= half_width ||
|
||||
(maze->passages[first] & passage) != 0U) {
|
||||
continue;
|
||||
}
|
||||
open_symmetric_edge(maze, first, second, passage);
|
||||
added++;
|
||||
}
|
||||
return added == maze->config.extra_cycle_pairs;
|
||||
}
|
||||
|
||||
static int add_cross_half_connections(maze32_maze_t* maze, maze32_maze_workspace_t* workspace,
|
||||
maze32_rng_t* random) {
|
||||
clear_visited(workspace);
|
||||
uint16_t added_pairs = 0U;
|
||||
uint16_t half_height = maze->config.height / 2U;
|
||||
while (added_pairs < maze->config.cross_half_pairs) {
|
||||
uint16_t y = (uint16_t)bounded_random(random, half_height);
|
||||
if (is_visited(workspace, y)) {
|
||||
continue;
|
||||
}
|
||||
mark_visited(workspace, y);
|
||||
maze32_cell_t left = {(uint16_t)(maze->config.width / 2U - 1U), y};
|
||||
uint16_t first = maze32_maze_cell_index(maze, left);
|
||||
uint16_t second = 0U;
|
||||
if (!neighbor_for(maze, first, MAZE32_PASSAGE_EAST, &second)) {
|
||||
return 0;
|
||||
}
|
||||
open_symmetric_edge(maze, first, second, MAZE32_PASSAGE_EAST);
|
||||
added_pairs++;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static uint16_t calculate_distances(const maze32_maze_t* maze, maze32_maze_workspace_t* workspace,
|
||||
maze32_cell_t start) {
|
||||
uint16_t count = cell_count(maze);
|
||||
for (uint16_t index = 0U; index < count; ++index) {
|
||||
workspace->distances[index] = UINT16_MAX;
|
||||
}
|
||||
uint16_t start_index = maze32_maze_cell_index(maze, start);
|
||||
size_t head = 0U;
|
||||
size_t tail = 1U;
|
||||
workspace->traversal[0] = start_index;
|
||||
workspace->distances[start_index] = 0U;
|
||||
while (head < tail) {
|
||||
uint16_t current = workspace->traversal[head++];
|
||||
const maze32_passage_t directions[] = {MAZE32_PASSAGE_NORTH, MAZE32_PASSAGE_EAST,
|
||||
MAZE32_PASSAGE_SOUTH, MAZE32_PASSAGE_WEST};
|
||||
for (size_t direction_index = 0U; direction_index < 4U; ++direction_index) {
|
||||
maze32_passage_t passage = directions[direction_index];
|
||||
uint16_t neighbor = 0U;
|
||||
if ((maze->passages[current] & passage) != 0U &&
|
||||
neighbor_for(maze, current, passage, &neighbor) &&
|
||||
workspace->distances[neighbor] == UINT16_MAX) {
|
||||
workspace->distances[neighbor] = (uint16_t)(workspace->distances[current] + 1U);
|
||||
workspace->traversal[tail++] = neighbor;
|
||||
}
|
||||
}
|
||||
}
|
||||
return (uint16_t)tail;
|
||||
}
|
||||
|
||||
static int required_cell_occupied(const maze32_maze_t* maze, maze32_cell_t cell,
|
||||
unsigned int placement_stage) {
|
||||
unsigned int pair_counts[] = {2U, 2U, 2U, 2U};
|
||||
const maze32_cell_t* required[] = {maze->starts, maze->keys, maze->exits, maze->compasses};
|
||||
for (unsigned int group = 0U; group < placement_stage && group < 4U; ++group) {
|
||||
for (unsigned int index = 0U; index < pair_counts[group]; ++index) {
|
||||
if (cells_equal(cell, required[group][index])) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
unsigned int placed_landmarks = placement_stage > 4U ? placement_stage - 4U : 0U;
|
||||
if (placed_landmarks > maze->config.landmark_pairs) {
|
||||
placed_landmarks = maze->config.landmark_pairs;
|
||||
}
|
||||
for (unsigned int pair = 0U; pair < placed_landmarks; ++pair) {
|
||||
if (cells_equal(cell, maze->landmarks[pair].cells[0]) ||
|
||||
cells_equal(cell, maze->landmarks[pair].cells[1])) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
unsigned int artifact_stage = 4U + maze->config.landmark_pairs;
|
||||
unsigned int placed_artifacts =
|
||||
placement_stage > artifact_stage ? placement_stage - artifact_stage : 0U;
|
||||
if (placed_artifacts > maze->config.artifact_pairs) {
|
||||
placed_artifacts = maze->config.artifact_pairs;
|
||||
}
|
||||
for (unsigned int pair = 0U; pair < placed_artifacts; ++pair) {
|
||||
if (cells_equal(cell, maze->artifacts[pair][0]) ||
|
||||
cells_equal(cell, maze->artifacts[pair][1])) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int pair_is_available(const maze32_maze_t* maze, maze32_cell_t first,
|
||||
unsigned int placement_stage) {
|
||||
maze32_cell_t second = maze32_maze_rotate(maze, first);
|
||||
return !cells_equal(first, second) && !required_cell_occupied(maze, first, placement_stage) &&
|
||||
!required_cell_occupied(maze, second, placement_stage);
|
||||
}
|
||||
|
||||
static int choose_farthest(const maze32_maze_t* maze, maze32_maze_workspace_t* workspace,
|
||||
unsigned int placement_stage, int boundary_only,
|
||||
uint16_t maximum_distance, const maze32_cell_t* separated_from,
|
||||
uint16_t minimum_separation, maze32_cell_t* selected) {
|
||||
uint16_t best_distance = 0U;
|
||||
int found = 0;
|
||||
uint16_t count = cell_count(maze);
|
||||
for (uint16_t index = 0U; index < count; ++index) {
|
||||
maze32_cell_t cell = cell_from_index(maze, index);
|
||||
int on_boundary = cell.x == 0U || cell.y == 0U || cell.x + 1U == maze->config.width ||
|
||||
cell.y + 1U == maze->config.height;
|
||||
uint16_t separation = 0U;
|
||||
if (separated_from != NULL) {
|
||||
separation = (uint16_t)((cell.x > separated_from->x ? cell.x - separated_from->x
|
||||
: separated_from->x - cell.x) +
|
||||
(cell.y > separated_from->y ? cell.y - separated_from->y
|
||||
: separated_from->y - cell.y));
|
||||
}
|
||||
uint16_t distance = workspace->distances[index];
|
||||
if ((!boundary_only || on_boundary) && distance != UINT16_MAX &&
|
||||
distance <= maximum_distance && separation >= minimum_separation &&
|
||||
pair_is_available(maze, cell, placement_stage) &&
|
||||
(!found || distance > best_distance)) {
|
||||
*selected = cell;
|
||||
best_distance = distance;
|
||||
found = 1;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
static int choose_random_pair(const maze32_maze_t* maze, maze32_rng_t* random,
|
||||
unsigned int placement_stage, maze32_cell_t* selected) {
|
||||
uint16_t count = cell_count(maze);
|
||||
for (uint32_t attempt = 0U; attempt < (uint32_t)count * 2U; ++attempt) {
|
||||
maze32_cell_t candidate = cell_from_index(maze, (uint16_t)bounded_random(random, count));
|
||||
if (pair_is_available(maze, candidate, placement_stage)) {
|
||||
*selected = candidate;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
for (uint16_t index = 0U; index < count; ++index) {
|
||||
maze32_cell_t candidate = cell_from_index(maze, index);
|
||||
if (pair_is_available(maze, candidate, placement_stage)) {
|
||||
*selected = candidate;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int place_objects(maze32_maze_t* maze, maze32_maze_workspace_t* workspace,
|
||||
maze32_rng_t* random) {
|
||||
maze->starts[0].x = 0U;
|
||||
maze->starts[0].y = (uint16_t)bounded_random(random, maze->config.height);
|
||||
maze->starts[1] = maze32_maze_rotate(maze, maze->starts[0]);
|
||||
|
||||
calculate_distances(maze, workspace, maze->starts[0]);
|
||||
if (!choose_farthest(maze, workspace, 1U, 0, UINT16_MAX, NULL, 0U, &maze->keys[0])) {
|
||||
return 0;
|
||||
}
|
||||
maze->keys[1] = maze32_maze_rotate(maze, maze->keys[0]);
|
||||
uint16_t start_key_distance = workspace->distances[maze32_maze_cell_index(maze, maze->keys[0])];
|
||||
|
||||
calculate_distances(maze, workspace, maze->keys[0]);
|
||||
if (!choose_farthest(maze, workspace, 2U, 1, start_key_distance, &maze->starts[0],
|
||||
maze->config.minimum_exit_separation, &maze->exits[0])) {
|
||||
return 0;
|
||||
}
|
||||
maze->exits[1] = maze32_maze_rotate(maze, maze->exits[0]);
|
||||
|
||||
if (!choose_random_pair(maze, random, 3U, &maze->compasses[0])) {
|
||||
return 0;
|
||||
}
|
||||
maze->compasses[1] = maze32_maze_rotate(maze, maze->compasses[0]);
|
||||
|
||||
for (unsigned int pair = 0U; pair < maze->config.landmark_pairs; ++pair) {
|
||||
if (!choose_random_pair(maze, random, 4U + pair, &maze->landmarks[pair].cells[0])) {
|
||||
return 0;
|
||||
}
|
||||
maze->landmarks[pair].cells[1] = maze32_maze_rotate(maze, maze->landmarks[pair].cells[0]);
|
||||
maze->landmarks[pair].kind = (uint8_t)(pair % 4U + 1U);
|
||||
}
|
||||
for (unsigned int pair = 0U; pair < maze->config.artifact_pairs; ++pair) {
|
||||
if (!choose_random_pair(maze, random, 4U + maze->config.landmark_pairs + pair,
|
||||
&maze->artifacts[pair][0])) {
|
||||
return 0;
|
||||
}
|
||||
maze->artifacts[pair][1] = maze32_maze_rotate(maze, maze->artifacts[pair][0]);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static maze32_maze_result_t generate_candidate(maze32_maze_t* maze,
|
||||
maze32_maze_workspace_t* workspace,
|
||||
const maze32_maze_config_t* config,
|
||||
uint32_t requested_seed, uint32_t generation_seed,
|
||||
uint8_t attempt) {
|
||||
memset(maze, 0, sizeof(*maze));
|
||||
maze->config = *config;
|
||||
maze->requested_seed = requested_seed;
|
||||
maze->generation_seed = generation_seed;
|
||||
maze->generation_attempts = attempt;
|
||||
maze32_rng_t random = {.state = generation_seed == 0U ? 0x6d2b79f5U : generation_seed};
|
||||
if (!build_half_tree(maze, workspace, &random) || !add_cycle_pairs(maze, &random) ||
|
||||
!add_cross_half_connections(maze, workspace, &random) ||
|
||||
!place_objects(maze, workspace, &random)) {
|
||||
return MAZE32_MAZE_PLACEMENT_COLLISION;
|
||||
}
|
||||
return MAZE32_MAZE_OK;
|
||||
}
|
||||
|
||||
static int validate_passages(const maze32_maze_t* maze, maze32_maze_metrics_t* metrics) {
|
||||
uint16_t count = cell_count(maze);
|
||||
for (uint16_t index = 0U; index < count; ++index) {
|
||||
if ((maze->passages[index] & 0xf0U) != 0U) {
|
||||
return 0;
|
||||
}
|
||||
unsigned int degree = 0U;
|
||||
const maze32_passage_t directions[] = {MAZE32_PASSAGE_NORTH, MAZE32_PASSAGE_EAST,
|
||||
MAZE32_PASSAGE_SOUTH, MAZE32_PASSAGE_WEST};
|
||||
for (size_t direction_index = 0U; direction_index < 4U; ++direction_index) {
|
||||
maze32_passage_t passage = directions[direction_index];
|
||||
uint16_t neighbor = 0U;
|
||||
if ((maze->passages[index] & passage) == 0U) {
|
||||
continue;
|
||||
}
|
||||
if (!neighbor_for(maze, index, passage, &neighbor) ||
|
||||
(maze->passages[neighbor] & opposite_passage(passage)) == 0U) {
|
||||
return 0;
|
||||
}
|
||||
degree++;
|
||||
if (passage == MAZE32_PASSAGE_EAST || passage == MAZE32_PASSAGE_SOUTH) {
|
||||
metrics->passage_edges++;
|
||||
}
|
||||
}
|
||||
if (degree == 1U) {
|
||||
metrics->dead_end_count++;
|
||||
} else if (degree >= 3U) {
|
||||
metrics->branching_cells++;
|
||||
}
|
||||
maze32_cell_t cell = cell_from_index(maze, index);
|
||||
maze32_cell_t rotated = maze32_maze_rotate(maze, cell);
|
||||
uint16_t rotated_index = maze32_maze_cell_index(maze, rotated);
|
||||
uint8_t expected = 0U;
|
||||
if ((maze->passages[index] & MAZE32_PASSAGE_NORTH) != 0U) {
|
||||
expected |= MAZE32_PASSAGE_SOUTH;
|
||||
}
|
||||
if ((maze->passages[index] & MAZE32_PASSAGE_EAST) != 0U) {
|
||||
expected |= MAZE32_PASSAGE_WEST;
|
||||
}
|
||||
if ((maze->passages[index] & MAZE32_PASSAGE_SOUTH) != 0U) {
|
||||
expected |= MAZE32_PASSAGE_NORTH;
|
||||
}
|
||||
if ((maze->passages[index] & MAZE32_PASSAGE_WEST) != 0U) {
|
||||
expected |= MAZE32_PASSAGE_EAST;
|
||||
}
|
||||
if (maze->passages[rotated_index] != expected) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int object_pair_is_valid(const maze32_maze_t* maze, const maze32_cell_t pair[2]) {
|
||||
return maze32_maze_cell_index(maze, pair[0]) != UINT16_MAX &&
|
||||
maze32_maze_cell_index(maze, pair[1]) != UINT16_MAX &&
|
||||
cells_equal(maze32_maze_rotate(maze, pair[0]), pair[1]);
|
||||
}
|
||||
|
||||
static maze32_maze_result_t validate_objects(const maze32_maze_t* maze,
|
||||
maze32_maze_workspace_t* workspace,
|
||||
maze32_maze_metrics_t* metrics) {
|
||||
maze32_cell_t
|
||||
occupied[8U + MAZE32_MAZE_MAX_LANDMARK_PAIRS * 2U + MAZE32_MAZE_MAX_ARTIFACT_PAIRS * 2U];
|
||||
size_t occupied_count = 0U;
|
||||
const maze32_cell_t* required[] = {maze->starts, maze->keys, maze->exits, maze->compasses};
|
||||
for (size_t group = 0U; group < 4U; ++group) {
|
||||
if (!object_pair_is_valid(maze, required[group])) {
|
||||
return MAZE32_MAZE_UNFAIR_PLACEMENT;
|
||||
}
|
||||
for (size_t index = 0U; index < 2U; ++index) {
|
||||
for (size_t previous = 0U; previous < occupied_count; ++previous) {
|
||||
if (cells_equal(required[group][index], occupied[previous])) {
|
||||
return MAZE32_MAZE_PLACEMENT_COLLISION;
|
||||
}
|
||||
}
|
||||
occupied[occupied_count++] = required[group][index];
|
||||
}
|
||||
}
|
||||
for (size_t pair = 0U; pair < maze->config.landmark_pairs; ++pair) {
|
||||
if (!object_pair_is_valid(maze, maze->landmarks[pair].cells)) {
|
||||
return MAZE32_MAZE_UNFAIR_PLACEMENT;
|
||||
}
|
||||
for (size_t index = 0U; index < 2U; ++index) {
|
||||
for (size_t previous = 0U; previous < occupied_count; ++previous) {
|
||||
if (cells_equal(maze->landmarks[pair].cells[index], occupied[previous])) {
|
||||
return MAZE32_MAZE_PLACEMENT_COLLISION;
|
||||
}
|
||||
}
|
||||
occupied[occupied_count++] = maze->landmarks[pair].cells[index];
|
||||
}
|
||||
}
|
||||
for (size_t pair = 0U; pair < maze->config.artifact_pairs; ++pair) {
|
||||
if (!object_pair_is_valid(maze, maze->artifacts[pair])) {
|
||||
return MAZE32_MAZE_UNFAIR_PLACEMENT;
|
||||
}
|
||||
for (size_t index = 0U; index < 2U; ++index) {
|
||||
for (size_t previous = 0U; previous < occupied_count; ++previous) {
|
||||
if (cells_equal(maze->artifacts[pair][index], occupied[previous])) {
|
||||
return MAZE32_MAZE_PLACEMENT_COLLISION;
|
||||
}
|
||||
}
|
||||
occupied[occupied_count++] = maze->artifacts[pair][index];
|
||||
}
|
||||
}
|
||||
|
||||
for (size_t player = 0U; player < 2U; ++player) {
|
||||
maze32_cell_t start = maze->starts[player];
|
||||
maze32_cell_t exit = maze->exits[player];
|
||||
int start_on_boundary = start.x == 0U || start.y == 0U ||
|
||||
start.x + 1U == maze->config.width ||
|
||||
start.y + 1U == maze->config.height;
|
||||
int exit_on_boundary = exit.x == 0U || exit.y == 0U || exit.x + 1U == maze->config.width ||
|
||||
exit.y + 1U == maze->config.height;
|
||||
uint16_t exit_separation =
|
||||
(uint16_t)((start.x > exit.x ? start.x - exit.x : exit.x - start.x) +
|
||||
(start.y > exit.y ? start.y - exit.y : exit.y - start.y));
|
||||
if (!start_on_boundary || !exit_on_boundary ||
|
||||
exit_separation < maze->config.minimum_exit_separation) {
|
||||
return MAZE32_MAZE_UNFAIR_PLACEMENT;
|
||||
}
|
||||
}
|
||||
|
||||
metrics->reachable_cells = calculate_distances(maze, workspace, maze->starts[0]);
|
||||
if (metrics->reachable_cells != cell_count(maze)) {
|
||||
return MAZE32_MAZE_UNREACHABLE;
|
||||
}
|
||||
metrics->start_to_key[0] = workspace->distances[maze32_maze_cell_index(maze, maze->keys[0])];
|
||||
metrics->start_to_exit[0] = workspace->distances[maze32_maze_cell_index(maze, maze->exits[0])];
|
||||
metrics->start_to_compass[0] =
|
||||
workspace->distances[maze32_maze_cell_index(maze, maze->compasses[0])];
|
||||
for (size_t pair = 0U; pair < maze->config.landmark_pairs; ++pair) {
|
||||
metrics->landmark_distance_total[0] +=
|
||||
workspace->distances[maze32_maze_cell_index(maze, maze->landmarks[pair].cells[0])];
|
||||
}
|
||||
for (size_t pair = 0U; pair < maze->config.artifact_pairs; ++pair) {
|
||||
metrics->artifact_distance_total[0] +=
|
||||
workspace->distances[maze32_maze_cell_index(maze, maze->artifacts[pair][0])];
|
||||
}
|
||||
for (size_t player = 0U; player < 2U; ++player) {
|
||||
int x_distance = maze->starts[player].x > maze->keys[player].x
|
||||
? maze->starts[player].x - maze->keys[player].x
|
||||
: maze->keys[player].x - maze->starts[player].x;
|
||||
int y_distance = maze->starts[player].y > maze->keys[player].y
|
||||
? maze->starts[player].y - maze->keys[player].y
|
||||
: maze->keys[player].y - maze->starts[player].y;
|
||||
if (x_distance <= maze->config.initial_view_radius &&
|
||||
y_distance <= maze->config.initial_view_radius) {
|
||||
return MAZE32_MAZE_KEY_IN_INITIAL_VIEW;
|
||||
}
|
||||
}
|
||||
calculate_distances(maze, workspace, maze->keys[0]);
|
||||
metrics->key_to_exit[0] = workspace->distances[maze32_maze_cell_index(maze, maze->exits[0])];
|
||||
metrics->start_to_key[1] = metrics->start_to_key[0];
|
||||
metrics->key_to_exit[1] = metrics->key_to_exit[0];
|
||||
metrics->start_to_exit[1] = metrics->start_to_exit[0];
|
||||
metrics->start_to_compass[1] = metrics->start_to_compass[0];
|
||||
metrics->landmark_distance_total[1] = metrics->landmark_distance_total[0];
|
||||
metrics->artifact_distance_total[1] = metrics->artifact_distance_total[0];
|
||||
if (metrics->start_to_key[0] < metrics->key_to_exit[0]) {
|
||||
return MAZE32_MAZE_UNFAIR_PLACEMENT;
|
||||
}
|
||||
return MAZE32_MAZE_OK;
|
||||
}
|
||||
|
||||
maze32_maze_config_t maze32_maze_default_config(void) {
|
||||
maze32_maze_config_t config = {
|
||||
.width = MAZE32_MAZE_DEFAULT_WIDTH,
|
||||
.height = MAZE32_MAZE_DEFAULT_HEIGHT,
|
||||
.initial_view_radius = 3U,
|
||||
.cross_half_pairs = 2U,
|
||||
.extra_cycle_pairs = 8U,
|
||||
.minimum_exit_separation = 22U,
|
||||
.landmark_pairs = 2U,
|
||||
.artifact_pairs = 1U,
|
||||
.maximum_attempts = 8U,
|
||||
};
|
||||
return config;
|
||||
}
|
||||
|
||||
maze32_maze_result_t maze32_maze_validate(const maze32_maze_t* maze,
|
||||
maze32_maze_workspace_t* workspace,
|
||||
maze32_maze_metrics_t* metrics) {
|
||||
if (maze == NULL || workspace == NULL || metrics == NULL) {
|
||||
return MAZE32_MAZE_INVALID_ARGUMENT;
|
||||
}
|
||||
memset(metrics, 0, sizeof(*metrics));
|
||||
if (!config_is_valid(&maze->config)) {
|
||||
return MAZE32_MAZE_INVALID_CONFIG;
|
||||
}
|
||||
if (!validate_passages(maze, metrics)) {
|
||||
return MAZE32_MAZE_ASYMMETRIC;
|
||||
}
|
||||
maze32_maze_result_t object_result = validate_objects(maze, workspace, metrics);
|
||||
if (object_result != MAZE32_MAZE_OK) {
|
||||
return object_result;
|
||||
}
|
||||
metrics->cycle_count = (uint16_t)(metrics->passage_edges - metrics->reachable_cells + 1U);
|
||||
if (metrics->cycle_count == 0U || metrics->dead_end_count == 0U) {
|
||||
return MAZE32_MAZE_INSUFFICIENT_CYCLES;
|
||||
}
|
||||
uint16_t seam_x = maze->config.width / 2U - 1U;
|
||||
for (uint16_t y = 0U; y < maze->config.height; ++y) {
|
||||
maze32_cell_t cell = {seam_x, y};
|
||||
if (maze32_maze_has_passage(maze, cell, MAZE32_PASSAGE_EAST)) {
|
||||
metrics->cross_half_connections++;
|
||||
}
|
||||
}
|
||||
if (metrics->cross_half_connections < 4U) {
|
||||
return MAZE32_MAZE_INSUFFICIENT_CROSSINGS;
|
||||
}
|
||||
return MAZE32_MAZE_OK;
|
||||
}
|
||||
|
||||
maze32_maze_result_t maze32_maze_generate(maze32_maze_t* maze, maze32_maze_workspace_t* workspace,
|
||||
const maze32_maze_config_t* config, uint32_t seed,
|
||||
maze32_maze_report_t* report) {
|
||||
if (maze == NULL || workspace == NULL || config == NULL || report == NULL) {
|
||||
return MAZE32_MAZE_INVALID_ARGUMENT;
|
||||
}
|
||||
memset(report, 0, sizeof(*report));
|
||||
if (!config_is_valid(config)) {
|
||||
report->result = MAZE32_MAZE_INVALID_CONFIG;
|
||||
return report->result;
|
||||
}
|
||||
maze32_maze_result_t last_result = MAZE32_MAZE_GENERATION_EXHAUSTED;
|
||||
uint32_t generation_seed = seed;
|
||||
for (uint8_t attempt = 1U; attempt <= config->maximum_attempts; ++attempt) {
|
||||
generation_seed += 0x9e3779b9U;
|
||||
last_result = generate_candidate(maze, workspace, config, seed, generation_seed, attempt);
|
||||
if (last_result == MAZE32_MAZE_OK) {
|
||||
last_result = maze32_maze_validate(maze, workspace, &report->metrics);
|
||||
}
|
||||
report->attempts = attempt;
|
||||
report->last_validation_result = last_result;
|
||||
if (last_result == MAZE32_MAZE_OK) {
|
||||
report->result = MAZE32_MAZE_OK;
|
||||
return MAZE32_MAZE_OK;
|
||||
}
|
||||
}
|
||||
report->result = MAZE32_MAZE_GENERATION_EXHAUSTED;
|
||||
return report->result;
|
||||
}
|
||||
|
||||
int maze32_maze_has_passage(const maze32_maze_t* maze, maze32_cell_t cell,
|
||||
maze32_passage_t passage) {
|
||||
uint16_t index = maze32_maze_cell_index(maze, cell);
|
||||
return index != UINT16_MAX && (maze->passages[index] & passage) != 0U;
|
||||
}
|
||||
|
||||
static void hash_bytes(uint64_t* hash, const void* data, size_t size) {
|
||||
const uint8_t* bytes = data;
|
||||
for (size_t index = 0U; index < size; ++index) {
|
||||
*hash ^= bytes[index];
|
||||
*hash *= UINT64_C(1099511628211);
|
||||
}
|
||||
}
|
||||
|
||||
uint64_t maze32_maze_hash(const maze32_maze_t* maze) {
|
||||
if (maze == NULL || !config_is_valid(&maze->config)) {
|
||||
return 0U;
|
||||
}
|
||||
uint64_t hash = UINT64_C(1469598103934665603);
|
||||
hash_bytes(&hash, &maze->config.width, sizeof(maze->config.width));
|
||||
hash_bytes(&hash, &maze->config.height, sizeof(maze->config.height));
|
||||
hash_bytes(&hash, &maze->config.initial_view_radius, sizeof(maze->config.initial_view_radius));
|
||||
hash_bytes(&hash, &maze->config.cross_half_pairs, sizeof(maze->config.cross_half_pairs));
|
||||
hash_bytes(&hash, &maze->config.extra_cycle_pairs, sizeof(maze->config.extra_cycle_pairs));
|
||||
hash_bytes(&hash, &maze->config.minimum_exit_separation,
|
||||
sizeof(maze->config.minimum_exit_separation));
|
||||
hash_bytes(&hash, &maze->config.landmark_pairs, sizeof(maze->config.landmark_pairs));
|
||||
hash_bytes(&hash, &maze->config.artifact_pairs, sizeof(maze->config.artifact_pairs));
|
||||
hash_bytes(&hash, &maze->config.maximum_attempts, sizeof(maze->config.maximum_attempts));
|
||||
hash_bytes(&hash, &maze->requested_seed, sizeof(maze->requested_seed));
|
||||
hash_bytes(&hash, &maze->generation_seed, sizeof(maze->generation_seed));
|
||||
hash_bytes(&hash, maze->passages, cell_count(maze));
|
||||
hash_bytes(&hash, maze->starts, sizeof(maze->starts));
|
||||
hash_bytes(&hash, maze->keys, sizeof(maze->keys));
|
||||
hash_bytes(&hash, maze->exits, sizeof(maze->exits));
|
||||
hash_bytes(&hash, maze->compasses, sizeof(maze->compasses));
|
||||
hash_bytes(&hash, maze->landmarks, maze->config.landmark_pairs * sizeof(maze->landmarks[0]));
|
||||
hash_bytes(&hash, maze->artifacts, maze->config.artifact_pairs * sizeof(maze->artifacts[0]));
|
||||
return hash;
|
||||
}
|
||||
|
||||
const char* maze32_maze_result_name(maze32_maze_result_t result) {
|
||||
switch (result) {
|
||||
case MAZE32_MAZE_OK:
|
||||
return "ok";
|
||||
case MAZE32_MAZE_INVALID_ARGUMENT:
|
||||
return "invalid_argument";
|
||||
case MAZE32_MAZE_INVALID_CONFIG:
|
||||
return "invalid_config";
|
||||
case MAZE32_MAZE_UNREACHABLE:
|
||||
return "unreachable";
|
||||
case MAZE32_MAZE_ASYMMETRIC:
|
||||
return "asymmetric";
|
||||
case MAZE32_MAZE_INSUFFICIENT_CYCLES:
|
||||
return "insufficient_cycles";
|
||||
case MAZE32_MAZE_INSUFFICIENT_CROSSINGS:
|
||||
return "insufficient_crossings";
|
||||
case MAZE32_MAZE_PLACEMENT_COLLISION:
|
||||
return "placement_collision";
|
||||
case MAZE32_MAZE_UNFAIR_PLACEMENT:
|
||||
return "unfair_placement";
|
||||
case MAZE32_MAZE_KEY_IN_INITIAL_VIEW:
|
||||
return "key_in_initial_view";
|
||||
case MAZE32_MAZE_GENERATION_EXHAUSTED:
|
||||
return "generation_exhausted";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
idf_component_register(
|
||||
SRCS "maze32_network.c" "maze32_provisioning.c" "maze32_provisioning_protocol.c"
|
||||
INCLUDE_DIRS "include"
|
||||
REQUIRES esp_event esp_http_server esp_netif esp_wifi maze32_config
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
#ifndef MAZE32_NETWORK_H
|
||||
#define MAZE32_NETWORK_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "esp_netif.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/event_groups.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "maze32_wifi_config.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define MAZE32_WIFI_SCAN_LIMIT 8U
|
||||
|
||||
typedef enum {
|
||||
MAZE32_NETWORK_BOOTING = 0,
|
||||
MAZE32_NETWORK_STATION_CONNECTING,
|
||||
MAZE32_NETWORK_STATION_CONNECTED,
|
||||
MAZE32_NETWORK_PROVISIONING,
|
||||
MAZE32_NETWORK_ERROR,
|
||||
} maze32_network_state_t;
|
||||
|
||||
typedef struct {
|
||||
char ssid[MAZE32_WIFI_SSID_MAX_BYTES + 1U];
|
||||
int8_t rssi;
|
||||
uint8_t auth_mode;
|
||||
} maze32_wifi_scan_result_t;
|
||||
|
||||
typedef struct {
|
||||
EventGroupHandle_t events;
|
||||
SemaphoreHandle_t operation_mutex;
|
||||
esp_netif_t* station_netif;
|
||||
esp_netif_t* access_point_netif;
|
||||
volatile maze32_network_state_t state;
|
||||
volatile int station_wait_active;
|
||||
unsigned int retry_count;
|
||||
char access_point_ssid[24];
|
||||
maze32_reconfiguration_guard_fn reconfiguration_guard;
|
||||
void* guard_context;
|
||||
httpd_handle_t server;
|
||||
} maze32_network_t;
|
||||
|
||||
esp_err_t maze32_network_init(maze32_network_t* network, maze32_reconfiguration_guard_fn guard,
|
||||
void* guard_context);
|
||||
esp_err_t maze32_network_start(maze32_network_t* network);
|
||||
esp_err_t maze32_network_apply_credentials(maze32_network_t* network,
|
||||
const maze32_wifi_credentials_t* credentials);
|
||||
esp_err_t maze32_network_reset_credentials(maze32_network_t* network);
|
||||
void maze32_network_retry_saved_credentials(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,
|
||||
size_t capacity, size_t* count);
|
||||
maze32_network_state_t maze32_network_state(const maze32_network_t* network);
|
||||
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);
|
||||
esp_err_t maze32_provisioning_register(httpd_handle_t server, maze32_network_t* network);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,20 @@
|
||||
#ifndef MAZE32_PROVISIONING_PROTOCOL_H
|
||||
#define MAZE32_PROVISIONING_PROTOCOL_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#include "maze32_wifi_config.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
int maze32_provisioning_parse_credentials(const char *body,
|
||||
size_t length,
|
||||
maze32_wifi_credentials_t *credentials);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,354 @@
|
||||
#include "maze32_network.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "esp_log.h"
|
||||
#include "esp_mac.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "maze32_config_store.h"
|
||||
|
||||
#define MAZE32_CONNECTED_BIT BIT0
|
||||
#define MAZE32_FAILED_BIT BIT1
|
||||
#define MAZE32_STATION_RETRY_LIMIT 3U
|
||||
#define MAZE32_STATION_TIMEOUT_MS 12000U
|
||||
|
||||
static const char* TAG = "maze32_network";
|
||||
static maze32_network_t* active_network;
|
||||
|
||||
static void set_state(maze32_network_t* network, maze32_network_state_t state) {
|
||||
network->state = state;
|
||||
ESP_LOGI(TAG, "Network state: %s", maze32_network_state_name(state));
|
||||
}
|
||||
|
||||
static void network_event(void* argument, esp_event_base_t event_base, int32_t event_id,
|
||||
void* event_data) {
|
||||
(void)event_data;
|
||||
maze32_network_t* network = argument;
|
||||
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START &&
|
||||
network->state == MAZE32_NETWORK_STATION_CONNECTING) {
|
||||
esp_wifi_connect();
|
||||
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED &&
|
||||
(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) {
|
||||
++network->retry_count;
|
||||
ESP_LOGW(TAG, "Station connection failed; retry %u/%u", network->retry_count,
|
||||
MAZE32_STATION_RETRY_LIMIT);
|
||||
esp_wifi_connect();
|
||||
} else if (network->station_wait_active) {
|
||||
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) {
|
||||
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) {
|
||||
esp_err_t result = esp_wifi_stop();
|
||||
if (result == ESP_ERR_WIFI_NOT_STARTED || result == ESP_ERR_WIFI_NOT_INIT) {
|
||||
return ESP_OK;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static esp_err_t start_provisioning(maze32_network_t* network) {
|
||||
set_state(network, MAZE32_NETWORK_PROVISIONING);
|
||||
esp_err_t result = stop_wifi();
|
||||
if (result != ESP_OK) {
|
||||
return result;
|
||||
}
|
||||
|
||||
wifi_config_t access_point = {0};
|
||||
size_t ssid_length = strlen(network->access_point_ssid);
|
||||
memcpy(access_point.ap.ssid, network->access_point_ssid, ssid_length);
|
||||
access_point.ap.ssid_len = ssid_length;
|
||||
access_point.ap.channel = 1U;
|
||||
access_point.ap.max_connection = 4U;
|
||||
access_point.ap.authmode = WIFI_AUTH_OPEN;
|
||||
access_point.ap.pmf_cfg.required = false;
|
||||
|
||||
result = esp_wifi_set_mode(WIFI_MODE_APSTA);
|
||||
if (result == ESP_OK) {
|
||||
result = esp_wifi_set_config(WIFI_IF_AP, &access_point);
|
||||
}
|
||||
if (result == ESP_OK) {
|
||||
result = esp_wifi_start();
|
||||
}
|
||||
if (result == ESP_OK) {
|
||||
ESP_LOGI(TAG, "Fallback access point ready: SSID=%s, URL=http://192.168.4.1/provision",
|
||||
network->access_point_ssid);
|
||||
} else {
|
||||
set_state(network, MAZE32_NETWORK_ERROR);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static esp_err_t connect_station(maze32_network_t* network,
|
||||
const maze32_wifi_credentials_t* credentials) {
|
||||
esp_err_t result = stop_wifi();
|
||||
if (result != ESP_OK) {
|
||||
return result;
|
||||
}
|
||||
|
||||
wifi_config_t station = {0};
|
||||
memcpy(station.sta.ssid, credentials->ssid, strlen(credentials->ssid));
|
||||
memcpy(station.sta.password, credentials->password, strlen(credentials->password));
|
||||
station.sta.threshold.authmode = WIFI_AUTH_OPEN;
|
||||
station.sta.sae_pwe_h2e = WPA3_SAE_PWE_BOTH;
|
||||
|
||||
network->retry_count = 0U;
|
||||
xEventGroupClearBits(network->events, MAZE32_CONNECTED_BIT | MAZE32_FAILED_BIT);
|
||||
set_state(network, MAZE32_NETWORK_STATION_CONNECTING);
|
||||
ESP_LOGI(TAG, "Connecting to configured station SSID: %s", credentials->ssid);
|
||||
|
||||
result = esp_wifi_set_mode(WIFI_MODE_STA);
|
||||
if (result == ESP_OK) {
|
||||
result = esp_wifi_set_config(WIFI_IF_STA, &station);
|
||||
}
|
||||
if (result == ESP_OK) {
|
||||
network->station_wait_active = 1;
|
||||
result = esp_wifi_start();
|
||||
}
|
||||
if (result != ESP_OK) {
|
||||
network->station_wait_active = 0;
|
||||
ESP_LOGE(TAG, "Station startup failed: %s", esp_err_to_name(result));
|
||||
return start_provisioning(network);
|
||||
}
|
||||
|
||||
EventBits_t bits =
|
||||
xEventGroupWaitBits(network->events, MAZE32_CONNECTED_BIT | MAZE32_FAILED_BIT, pdTRUE,
|
||||
pdFALSE, pdMS_TO_TICKS(MAZE32_STATION_TIMEOUT_MS));
|
||||
network->station_wait_active = 0;
|
||||
if ((bits & MAZE32_CONNECTED_BIT) != 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, "Station connected: IP=%s", address);
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
ESP_LOGW(TAG, "Station connection timed out or exhausted retries; enabling fallback");
|
||||
return start_provisioning(network);
|
||||
}
|
||||
|
||||
static int save_credentials(const maze32_wifi_credentials_t* credentials, void* context) {
|
||||
(void)context;
|
||||
return maze32_config_store_save(credentials) == ESP_OK ? 0 : -1;
|
||||
}
|
||||
|
||||
static int clear_credentials(void* context) {
|
||||
(void)context;
|
||||
return maze32_config_store_clear() == ESP_OK ? 0 : -1;
|
||||
}
|
||||
|
||||
esp_err_t maze32_network_init(maze32_network_t* network, maze32_reconfiguration_guard_fn guard,
|
||||
void* guard_context) {
|
||||
if (network == NULL || guard == NULL || active_network != NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
memset(network, 0, sizeof(*network));
|
||||
network->state = MAZE32_NETWORK_BOOTING;
|
||||
network->reconfiguration_guard = guard;
|
||||
network->guard_context = guard_context;
|
||||
network->events = xEventGroupCreate();
|
||||
network->operation_mutex = xSemaphoreCreateMutex();
|
||||
if (network->events == NULL || network->operation_mutex == NULL) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
uint8_t mac[6];
|
||||
esp_err_t result = esp_read_mac(mac, ESP_MAC_WIFI_SOFTAP);
|
||||
if (result != ESP_OK) {
|
||||
return result;
|
||||
}
|
||||
snprintf(network->access_point_ssid, sizeof(network->access_point_ssid), "maze32-%02X%02X",
|
||||
mac[4], mac[5]);
|
||||
|
||||
wifi_init_config_t wifi_configuration = WIFI_INIT_CONFIG_DEFAULT();
|
||||
result = esp_wifi_init(&wifi_configuration);
|
||||
if (result != ESP_OK) {
|
||||
return result;
|
||||
}
|
||||
network->station_netif = esp_netif_create_default_wifi_sta();
|
||||
network->access_point_netif = esp_netif_create_default_wifi_ap();
|
||||
if (network->station_netif == NULL || network->access_point_netif == NULL) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
result = esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, network_event, network);
|
||||
if (result == ESP_OK) {
|
||||
result = esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, network_event, network);
|
||||
}
|
||||
if (result == ESP_OK) {
|
||||
active_network = network;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
esp_err_t maze32_network_start(maze32_network_t* network) {
|
||||
if (network == NULL || xSemaphoreTake(network->operation_mutex, portMAX_DELAY) != pdTRUE) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
maze32_wifi_credentials_t credentials = {0};
|
||||
esp_err_t result = maze32_config_store_load(&credentials);
|
||||
if (result == ESP_OK) {
|
||||
result = connect_station(network, &credentials);
|
||||
} else if (result == ESP_ERR_NOT_FOUND || result == ESP_ERR_INVALID_VERSION ||
|
||||
result == ESP_ERR_INVALID_STATE) {
|
||||
ESP_LOGI(TAG, "No usable saved station configuration; enabling fallback");
|
||||
result = start_provisioning(network);
|
||||
}
|
||||
xSemaphoreGive(network->operation_mutex);
|
||||
return result;
|
||||
}
|
||||
|
||||
esp_err_t maze32_network_apply_credentials(maze32_network_t* network,
|
||||
const maze32_wifi_credentials_t* credentials) {
|
||||
if (network == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
int result = maze32_wifi_config_apply(credentials, network->reconfiguration_guard,
|
||||
save_credentials, network->guard_context);
|
||||
if (result == -1) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (result == -2) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
return result == 0 ? ESP_OK : ESP_FAIL;
|
||||
}
|
||||
|
||||
esp_err_t maze32_network_reset_credentials(maze32_network_t* network) {
|
||||
if (network == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
int result = maze32_wifi_config_reset(network->reconfiguration_guard, clear_credentials,
|
||||
network->guard_context);
|
||||
if (result == -2) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
return result == 0 ? ESP_OK : ESP_FAIL;
|
||||
}
|
||||
|
||||
void maze32_network_retry_saved_credentials(void* context) {
|
||||
maze32_network_t* network = context;
|
||||
if (network == NULL || xSemaphoreTake(network->operation_mutex, portMAX_DELAY) != pdTRUE) {
|
||||
return;
|
||||
}
|
||||
maze32_wifi_credentials_t credentials = {0};
|
||||
esp_err_t result = maze32_config_store_load(&credentials);
|
||||
if (result == ESP_OK) {
|
||||
result = connect_station(network, &credentials);
|
||||
}
|
||||
if (result != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Saved station retry failed: %s", esp_err_to_name(result));
|
||||
start_provisioning(network);
|
||||
}
|
||||
xSemaphoreGive(network->operation_mutex);
|
||||
}
|
||||
|
||||
void maze32_network_return_to_provisioning(void* context) {
|
||||
maze32_network_t* network = context;
|
||||
if (network == NULL || xSemaphoreTake(network->operation_mutex, portMAX_DELAY) != pdTRUE) {
|
||||
return;
|
||||
}
|
||||
esp_err_t result = start_provisioning(network);
|
||||
if (result != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to restore provisioning access point: %s", esp_err_to_name(result));
|
||||
}
|
||||
xSemaphoreGive(network->operation_mutex);
|
||||
}
|
||||
|
||||
esp_err_t maze32_network_scan(maze32_network_t* network, maze32_wifi_scan_result_t* results,
|
||||
size_t capacity, size_t* count) {
|
||||
if (network == NULL || results == NULL || count == NULL || capacity == 0U ||
|
||||
capacity > MAZE32_WIFI_SCAN_LIMIT ||
|
||||
xSemaphoreTake(network->operation_mutex, pdMS_TO_TICKS(1000U)) != pdTRUE) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (network->state != MAZE32_NETWORK_PROVISIONING) {
|
||||
xSemaphoreGive(network->operation_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
wifi_scan_config_t scan_configuration = {0};
|
||||
esp_err_t result = esp_wifi_scan_start(&scan_configuration, true);
|
||||
uint16_t found = (uint16_t)capacity;
|
||||
wifi_ap_record_t records[MAZE32_WIFI_SCAN_LIMIT];
|
||||
if (result == ESP_OK) {
|
||||
result = esp_wifi_scan_get_ap_records(&found, records);
|
||||
}
|
||||
if (result == ESP_OK) {
|
||||
*count = found;
|
||||
for (size_t index = 0U; index < found; ++index) {
|
||||
memcpy(results[index].ssid, records[index].ssid, MAZE32_WIFI_SSID_MAX_BYTES);
|
||||
results[index].ssid[MAZE32_WIFI_SSID_MAX_BYTES] = '\0';
|
||||
results[index].rssi = records[index].rssi;
|
||||
results[index].auth_mode = (uint8_t)records[index].authmode;
|
||||
}
|
||||
}
|
||||
xSemaphoreGive(network->operation_mutex);
|
||||
return result;
|
||||
}
|
||||
|
||||
maze32_network_state_t maze32_network_state(const maze32_network_t* network) {
|
||||
return network == NULL ? MAZE32_NETWORK_ERROR : network->state;
|
||||
}
|
||||
|
||||
const char* maze32_network_state_name(maze32_network_state_t state) {
|
||||
switch (state) {
|
||||
case MAZE32_NETWORK_BOOTING:
|
||||
return "booting";
|
||||
case MAZE32_NETWORK_STATION_CONNECTING:
|
||||
return "station_connecting";
|
||||
case MAZE32_NETWORK_STATION_CONNECTED:
|
||||
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) {
|
||||
return network == NULL ? "" : network->access_point_ssid;
|
||||
}
|
||||
|
||||
esp_err_t maze32_network_ip_address(const maze32_network_t* network, char* output,
|
||||
size_t output_size) {
|
||||
if (network == NULL || output == NULL || output_size < 16U || network->station_netif == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
esp_netif_ip_info_t information;
|
||||
esp_err_t result = esp_netif_get_ip_info(network->station_netif, &information);
|
||||
if (result == ESP_OK) {
|
||||
snprintf(output, output_size, IPSTR, IP2STR(&information.ip));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
#include "maze32_network.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "esp_log.h"
|
||||
#include "maze32_provisioning_protocol.h"
|
||||
|
||||
#define MAZE32_PROVISION_BODY_LIMIT 256U
|
||||
#define MAZE32_PROVISION_RESPONSE_LIMIT 2048U
|
||||
|
||||
static const char *TAG = "maze32_provision";
|
||||
static const char *PROVISION_PATH = "/littlefs/provision.html.gz";
|
||||
|
||||
static int append_json_string(char *output,
|
||||
size_t capacity,
|
||||
size_t *length,
|
||||
const char *value)
|
||||
{
|
||||
if (*length + 1U >= capacity) {
|
||||
return 0;
|
||||
}
|
||||
output[(*length)++] = '"';
|
||||
for (const unsigned char *cursor = (const unsigned char *)value;
|
||||
*cursor != '\0'; ++cursor) {
|
||||
const char *escape = NULL;
|
||||
if (*cursor == '"') {
|
||||
escape = "\\\"";
|
||||
} else if (*cursor == '\\') {
|
||||
escape = "\\\\";
|
||||
} else if (*cursor == '\b') {
|
||||
escape = "\\b";
|
||||
} else if (*cursor == '\f') {
|
||||
escape = "\\f";
|
||||
} else if (*cursor == '\n') {
|
||||
escape = "\\n";
|
||||
} else if (*cursor == '\r') {
|
||||
escape = "\\r";
|
||||
} else if (*cursor == '\t') {
|
||||
escape = "\\t";
|
||||
}
|
||||
if (escape != NULL) {
|
||||
if (*length + 2U >= capacity) {
|
||||
return 0;
|
||||
}
|
||||
output[(*length)++] = escape[0];
|
||||
output[(*length)++] = escape[1];
|
||||
} else {
|
||||
if (*cursor < 0x20U) {
|
||||
if (*length + 6U >= capacity) {
|
||||
return 0;
|
||||
}
|
||||
int written = snprintf(output + *length, capacity - *length,
|
||||
"\\u%04x", *cursor);
|
||||
if (written != 6) {
|
||||
return 0;
|
||||
}
|
||||
*length += 6U;
|
||||
continue;
|
||||
}
|
||||
if (*length + 1U >= capacity) {
|
||||
return 0;
|
||||
}
|
||||
output[(*length)++] = (char)*cursor;
|
||||
}
|
||||
}
|
||||
if (*length + 1U >= capacity) {
|
||||
return 0;
|
||||
}
|
||||
output[(*length)++] = '"';
|
||||
output[*length] = '\0';
|
||||
return 1;
|
||||
}
|
||||
|
||||
static esp_err_t send_json(httpd_req_t *request, const char *status, const char *body)
|
||||
{
|
||||
httpd_resp_set_status(request, status);
|
||||
httpd_resp_set_type(request, "application/json");
|
||||
httpd_resp_set_hdr(request, "Cache-Control", "no-store");
|
||||
return httpd_resp_sendstr(request, body);
|
||||
}
|
||||
|
||||
static esp_err_t send_provision_page(httpd_req_t *request)
|
||||
{
|
||||
FILE *file = fopen(PROVISION_PATH, "rb");
|
||||
if (file == NULL) {
|
||||
ESP_LOGE(TAG, "Provisioning asset is unavailable: %s", PROVISION_PATH);
|
||||
return httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR,
|
||||
"Provisioning application is unavailable");
|
||||
}
|
||||
httpd_resp_set_type(request, "text/html; charset=utf-8");
|
||||
httpd_resp_set_hdr(request, "Content-Encoding", "gzip");
|
||||
httpd_resp_set_hdr(request, "Cache-Control", "no-cache");
|
||||
|
||||
char buffer[1024];
|
||||
esp_err_t result = ESP_OK;
|
||||
size_t bytes_read;
|
||||
while ((bytes_read = fread(buffer, 1, sizeof(buffer), file)) > 0U) {
|
||||
result = httpd_resp_send_chunk(request, buffer, bytes_read);
|
||||
if (result != ESP_OK) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ferror(file) != 0) {
|
||||
result = ESP_FAIL;
|
||||
}
|
||||
fclose(file);
|
||||
return result == ESP_OK ? httpd_resp_send_chunk(request, NULL, 0) : result;
|
||||
}
|
||||
|
||||
static esp_err_t send_status(httpd_req_t *request)
|
||||
{
|
||||
maze32_network_t *network = request->user_ctx;
|
||||
char address[16] = "";
|
||||
if (maze32_network_state(network) == MAZE32_NETWORK_STATION_CONNECTED) {
|
||||
maze32_network_ip_address(network, address, sizeof(address));
|
||||
}
|
||||
char response[256];
|
||||
int length = snprintf(response, sizeof(response),
|
||||
"{\"state\":\"%s\",\"accessPointSsid\":\"%s\",\"ip\":\"%s\"}",
|
||||
maze32_network_state_name(maze32_network_state(network)),
|
||||
maze32_network_access_point_ssid(network), address);
|
||||
if (length < 0 || (size_t)length >= sizeof(response)) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
return send_json(request, "200 OK", response);
|
||||
}
|
||||
|
||||
static esp_err_t send_scan(httpd_req_t *request)
|
||||
{
|
||||
maze32_network_t *network = request->user_ctx;
|
||||
maze32_wifi_scan_result_t results[MAZE32_WIFI_SCAN_LIMIT];
|
||||
size_t count = 0U;
|
||||
esp_err_t result = maze32_network_scan(network, results, MAZE32_WIFI_SCAN_LIMIT, &count);
|
||||
if (result != ESP_OK) {
|
||||
return send_json(request, "503 Service Unavailable",
|
||||
"{\"error\":\"Сканирование сейчас недоступно\"}");
|
||||
}
|
||||
|
||||
char response[MAZE32_PROVISION_RESPONSE_LIMIT] = "{\"networks\":[";
|
||||
size_t length = strlen(response);
|
||||
for (size_t index = 0U; index < count; ++index) {
|
||||
const char *prefix = index == 0U ? "{" : ",{";
|
||||
int written = snprintf(response + length, sizeof(response) - length,
|
||||
"%s\"ssid\":", prefix);
|
||||
if (written < 0 || (size_t)written >= sizeof(response) - length) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
length += (size_t)written;
|
||||
if (!append_json_string(response, sizeof(response), &length, results[index].ssid)) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
written = snprintf(response + length, sizeof(response) - length,
|
||||
",\"rssi\":%d,\"secure\":%s}",
|
||||
results[index].rssi,
|
||||
results[index].auth_mode == 0U ? "false" : "true");
|
||||
if (written < 0 || (size_t)written >= sizeof(response) - length) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
length += (size_t)written;
|
||||
}
|
||||
int written = snprintf(response + length, sizeof(response) - length, "]}");
|
||||
if (written < 0 || (size_t)written >= sizeof(response) - length) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
return send_json(request, "200 OK", response);
|
||||
}
|
||||
|
||||
static esp_err_t read_credentials(httpd_req_t *request,
|
||||
maze32_wifi_credentials_t *credentials)
|
||||
{
|
||||
if (request->content_len == 0U || request->content_len > MAZE32_PROVISION_BODY_LIMIT) {
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
char body[MAZE32_PROVISION_BODY_LIMIT + 1U];
|
||||
size_t received = 0U;
|
||||
while (received < request->content_len) {
|
||||
int current = httpd_req_recv(request, body + received, request->content_len - received);
|
||||
if (current <= 0) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
received += (size_t)current;
|
||||
}
|
||||
body[received] = '\0';
|
||||
|
||||
if (!maze32_provisioning_parse_credentials(body, received, credentials)) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t apply_credentials(httpd_req_t *request)
|
||||
{
|
||||
maze32_network_t *network = request->user_ctx;
|
||||
maze32_wifi_credentials_t credentials;
|
||||
esp_err_t result = read_credentials(request, &credentials);
|
||||
if (result != ESP_OK) {
|
||||
return send_json(request, "400 Bad Request",
|
||||
"{\"error\":\"Проверьте имя сети и пароль\"}");
|
||||
}
|
||||
result = maze32_network_apply_credentials(network, &credentials);
|
||||
memset(&credentials, 0, sizeof(credentials));
|
||||
if (result == ESP_ERR_INVALID_STATE) {
|
||||
return send_json(request, "409 Conflict",
|
||||
"{\"error\":\"Сначала завершите текущую игру\"}");
|
||||
}
|
||||
if (result != ESP_OK) {
|
||||
return send_json(request, "500 Internal Server Error",
|
||||
"{\"error\":\"Не удалось сохранить настройки\"}");
|
||||
}
|
||||
result = httpd_queue_work(network->server, maze32_network_retry_saved_credentials, network);
|
||||
if (result != ESP_OK) {
|
||||
return send_json(request, "500 Internal Server Error",
|
||||
"{\"error\":\"Настройки сохранены; перезагрузите устройство\"}");
|
||||
}
|
||||
return send_json(request, "202 Accepted",
|
||||
"{\"status\":\"saved\",\"message\":\"Подключаемся к сети…\"}");
|
||||
}
|
||||
|
||||
static esp_err_t reset_credentials(httpd_req_t *request)
|
||||
{
|
||||
maze32_network_t *network = request->user_ctx;
|
||||
esp_err_t result = maze32_network_reset_credentials(network);
|
||||
if (result == ESP_ERR_INVALID_STATE) {
|
||||
return send_json(request, "409 Conflict",
|
||||
"{\"error\":\"Сначала завершите текущую игру\"}");
|
||||
}
|
||||
if (result != ESP_OK) {
|
||||
return send_json(request, "500 Internal Server Error",
|
||||
"{\"error\":\"Не удалось сбросить настройки\"}");
|
||||
}
|
||||
result = httpd_queue_work(network->server, maze32_network_return_to_provisioning, network);
|
||||
if (result != ESP_OK) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
return send_json(request, "202 Accepted",
|
||||
"{\"status\":\"reset\",\"message\":\"Настройки сети удалены\"}");
|
||||
}
|
||||
|
||||
esp_err_t maze32_provisioning_register(httpd_handle_t server, maze32_network_t *network)
|
||||
{
|
||||
if (server == NULL || network == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
network->server = server;
|
||||
const httpd_uri_t routes[] = {
|
||||
{.uri = "/provision", .method = HTTP_GET, .handler = send_provision_page, .user_ctx = network},
|
||||
{.uri = "/api/wifi/status", .method = HTTP_GET, .handler = send_status, .user_ctx = network},
|
||||
{.uri = "/api/wifi/scan", .method = HTTP_GET, .handler = send_scan, .user_ctx = network},
|
||||
{.uri = "/api/wifi/config", .method = HTTP_POST, .handler = apply_credentials, .user_ctx = network},
|
||||
{.uri = "/api/wifi/reset", .method = HTTP_POST, .handler = reset_credentials, .user_ctx = network},
|
||||
};
|
||||
for (size_t index = 0U; index < sizeof(routes) / sizeof(routes[0]); ++index) {
|
||||
esp_err_t result = httpd_register_uri_handler(server, &routes[index]);
|
||||
if (result != ESP_OK) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
ESP_LOGI(TAG, "Provisioning endpoints ready");
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
#include "maze32_provisioning_protocol.h"
|
||||
|
||||
#include <ctype.h>
|
||||
#include <string.h>
|
||||
|
||||
static const char *skip_space(const char *cursor, const char *end)
|
||||
{
|
||||
while (cursor < end && isspace((unsigned char)*cursor)) {
|
||||
++cursor;
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
static int parse_json_string(const char **cursor_pointer,
|
||||
const char *end,
|
||||
char *output,
|
||||
size_t capacity)
|
||||
{
|
||||
const char *cursor = *cursor_pointer;
|
||||
size_t length = 0U;
|
||||
if (cursor >= end || *cursor++ != '"') {
|
||||
return 0;
|
||||
}
|
||||
while (cursor < end && *cursor != '"') {
|
||||
unsigned char value = (unsigned char)*cursor++;
|
||||
if (value == '\\') {
|
||||
if (cursor >= end) {
|
||||
return 0;
|
||||
}
|
||||
char escaped = *cursor++;
|
||||
if (escaped == '"' || escaped == '\\' || escaped == '/') {
|
||||
value = (unsigned char)escaped;
|
||||
} else if (escaped == 'b') {
|
||||
value = '\b';
|
||||
} else if (escaped == 'f') {
|
||||
value = '\f';
|
||||
} else if (escaped == 'n') {
|
||||
value = '\n';
|
||||
} else if (escaped == 'r') {
|
||||
value = '\r';
|
||||
} else if (escaped == 't') {
|
||||
value = '\t';
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
if (value < 0x20U || length + 1U >= capacity) {
|
||||
return 0;
|
||||
}
|
||||
output[length++] = (char)value;
|
||||
}
|
||||
if (cursor >= end || *cursor++ != '"') {
|
||||
return 0;
|
||||
}
|
||||
output[length] = '\0';
|
||||
*cursor_pointer = cursor;
|
||||
return 1;
|
||||
}
|
||||
|
||||
int maze32_provisioning_parse_credentials(const char *body,
|
||||
size_t length,
|
||||
maze32_wifi_credentials_t *credentials)
|
||||
{
|
||||
if (body == NULL || credentials == NULL) {
|
||||
return 0;
|
||||
}
|
||||
const char *cursor = body;
|
||||
const char *end = body + length;
|
||||
int have_ssid = 0;
|
||||
int have_password = 0;
|
||||
memset(credentials, 0, sizeof(*credentials));
|
||||
cursor = skip_space(cursor, end);
|
||||
if (cursor >= end || *cursor++ != '{') {
|
||||
return 0;
|
||||
}
|
||||
for (int field_index = 0; field_index < 2; ++field_index) {
|
||||
char key[12];
|
||||
char value[MAZE32_WIFI_PASSWORD_MAX_BYTES + 1U];
|
||||
cursor = skip_space(cursor, end);
|
||||
if (!parse_json_string(&cursor, end, key, sizeof(key))) {
|
||||
return 0;
|
||||
}
|
||||
cursor = skip_space(cursor, end);
|
||||
if (cursor >= end || *cursor++ != ':') {
|
||||
return 0;
|
||||
}
|
||||
cursor = skip_space(cursor, end);
|
||||
if (!parse_json_string(&cursor, end, value, sizeof(value))) {
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(key, "ssid") == 0 && !have_ssid &&
|
||||
strlen(value) <= MAZE32_WIFI_SSID_MAX_BYTES) {
|
||||
memcpy(credentials->ssid, value, strlen(value) + 1U);
|
||||
have_ssid = 1;
|
||||
} else if (strcmp(key, "password") == 0 && !have_password) {
|
||||
memcpy(credentials->password, value, strlen(value) + 1U);
|
||||
have_password = 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
cursor = skip_space(cursor, end);
|
||||
if (field_index == 0 && (cursor >= end || *cursor++ != ',')) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
cursor = skip_space(cursor, end);
|
||||
if (cursor >= end || *cursor++ != '}') {
|
||||
return 0;
|
||||
}
|
||||
cursor = skip_space(cursor, end);
|
||||
return cursor == end && have_ssid && have_password &&
|
||||
maze32_wifi_credentials_validate(credentials) == MAZE32_WIFI_CONFIG_VALID;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
idf_component_register(
|
||||
SRCS "maze32_health.c"
|
||||
INCLUDE_DIRS "include"
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
#ifndef MAZE32_HEALTH_H
|
||||
#define MAZE32_HEALTH_H
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef enum {
|
||||
MAZE32_HEALTH_BOOTING = 0,
|
||||
MAZE32_HEALTH_FILESYSTEM_READY,
|
||||
MAZE32_HEALTH_SERVING,
|
||||
MAZE32_HEALTH_ERROR,
|
||||
} maze32_health_state_t;
|
||||
|
||||
typedef struct {
|
||||
const char *version;
|
||||
maze32_health_state_t state;
|
||||
int error_code;
|
||||
} maze32_health_t;
|
||||
|
||||
void maze32_health_init(maze32_health_t *health, const char *version);
|
||||
void maze32_health_set(maze32_health_t *health, maze32_health_state_t state, int error_code);
|
||||
const char *maze32_health_state_name(maze32_health_state_t state);
|
||||
int maze32_health_is_healthy(const maze32_health_t *health);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,45 @@
|
||||
#include "maze32_health.h"
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
void maze32_health_init(maze32_health_t *health, const char *version)
|
||||
{
|
||||
if (health == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
health->version = version;
|
||||
health->state = MAZE32_HEALTH_BOOTING;
|
||||
health->error_code = 0;
|
||||
}
|
||||
|
||||
void maze32_health_set(maze32_health_t *health, maze32_health_state_t state, int error_code)
|
||||
{
|
||||
if (health == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
health->state = state;
|
||||
health->error_code = error_code;
|
||||
}
|
||||
|
||||
const char *maze32_health_state_name(maze32_health_state_t state)
|
||||
{
|
||||
switch (state) {
|
||||
case MAZE32_HEALTH_BOOTING:
|
||||
return "booting";
|
||||
case MAZE32_HEALTH_FILESYSTEM_READY:
|
||||
return "filesystem_ready";
|
||||
case MAZE32_HEALTH_SERVING:
|
||||
return "serving";
|
||||
case MAZE32_HEALTH_ERROR:
|
||||
return "error";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
int maze32_health_is_healthy(const maze32_health_t *health)
|
||||
{
|
||||
return health != NULL && health->state == MAZE32_HEALTH_SERVING;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
idf_component_register(
|
||||
SRCS "maze32_web.c" "maze32_gateway.c"
|
||||
INCLUDE_DIRS "include"
|
||||
REQUIRES esp_http_server esp_timer heap maze32_game maze32_lobby maze32_maze maze32_platform
|
||||
)
|
||||
@@ -0,0 +1,91 @@
|
||||
#ifndef MAZE32_GATEWAY_H
|
||||
#define MAZE32_GATEWAY_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "esp_timer.h"
|
||||
#include "maze32_game.h"
|
||||
#include "maze32_lobby.h"
|
||||
#include "maze32_maze.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define MAZE32_GATEWAY_CONNECTION_LIMIT MAZE32_SESSION_LIMIT
|
||||
#define MAZE32_GATEWAY_QUEUE_LIMIT 12U
|
||||
#define MAZE32_GATEWAY_OUTGOING_LIMIT 768U
|
||||
#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 {
|
||||
int active;
|
||||
int socket_fd;
|
||||
uint32_t last_client_sequence;
|
||||
uint64_t rate_window_started_ms;
|
||||
unsigned int messages_in_window;
|
||||
int closing;
|
||||
} maze32_gateway_connection_t;
|
||||
|
||||
typedef struct {
|
||||
int socket_fd;
|
||||
size_t length;
|
||||
int drop_if_busy;
|
||||
char payload[MAZE32_GATEWAY_OUTGOING_LIMIT];
|
||||
} 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 {
|
||||
httpd_handle_t server;
|
||||
maze32_lobby_t lobby;
|
||||
maze32_gateway_connection_t connections[MAZE32_GATEWAY_CONNECTION_LIMIT];
|
||||
maze32_gateway_outgoing_t outgoing[MAZE32_GATEWAY_QUEUE_LIMIT];
|
||||
size_t outgoing_head;
|
||||
size_t outgoing_count;
|
||||
uint32_t server_sequence;
|
||||
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;
|
||||
|
||||
esp_err_t maze32_gateway_init(maze32_gateway_t* gateway);
|
||||
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);
|
||||
void maze32_gateway_close_socket(httpd_handle_t server, int socket_fd);
|
||||
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
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,20 @@
|
||||
#ifndef MAZE32_WEB_H
|
||||
#define MAZE32_WEB_H
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "maze32_gateway.h"
|
||||
#include "maze32_health.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
esp_err_t maze32_web_start(httpd_handle_t* server, maze32_health_t* health,
|
||||
maze32_gateway_t* gateway);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,201 @@
|
||||
#include "maze32_web.h"
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#include "esp_log.h"
|
||||
|
||||
#define MAZE32_STATIC_CHUNK_SIZE 1024
|
||||
|
||||
static const char* TAG = "maze32_web";
|
||||
|
||||
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) {
|
||||
(void)context;
|
||||
}
|
||||
|
||||
static esp_err_t send_static_asset(httpd_req_t* request) {
|
||||
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) {
|
||||
ESP_LOGE(TAG, "Static asset is unavailable: %s", asset->path);
|
||||
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR,
|
||||
"Приложение временно недоступно");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
httpd_resp_set_type(request, asset->content_type);
|
||||
httpd_resp_set_hdr(request, "Content-Encoding", "gzip");
|
||||
httpd_resp_set_hdr(request, "Cache-Control", "no-cache");
|
||||
|
||||
char buffer[MAZE32_STATIC_CHUNK_SIZE];
|
||||
size_t bytes_read;
|
||||
esp_err_t result = ESP_OK;
|
||||
while ((bytes_read = fread(buffer, 1, sizeof(buffer), file)) > 0) {
|
||||
result = httpd_resp_send_chunk(request, buffer, bytes_read);
|
||||
if (result != ESP_OK) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (ferror(file) != 0) {
|
||||
ESP_LOGE(TAG, "Failed while reading static asset: %s", asset->path);
|
||||
result = ESP_FAIL;
|
||||
}
|
||||
fclose(file);
|
||||
|
||||
if (result == ESP_OK) {
|
||||
result = httpd_resp_send_chunk(request, NULL, 0);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static esp_err_t send_health(httpd_req_t* request) {
|
||||
const maze32_health_t* health = request->user_ctx;
|
||||
char response[192];
|
||||
int length = snprintf(response, sizeof(response),
|
||||
"{\"status\":\"%s\",\"healthy\":%s,\"version\":\"%s\",\"errorCode\":%d}",
|
||||
maze32_health_state_name(health->state),
|
||||
maze32_health_is_healthy(health) ? "true" : "false", health->version,
|
||||
health->error_code);
|
||||
if (length < 0 || (size_t)length >= sizeof(response)) {
|
||||
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, 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,
|
||||
maze32_gateway_t* gateway) {
|
||||
if (server == NULL || health == NULL || gateway == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
httpd_config_t configuration = HTTPD_DEFAULT_CONFIG();
|
||||
configuration.max_uri_handlers = 14;
|
||||
configuration.max_open_sockets = 9;
|
||||
configuration.lru_purge_enable = false;
|
||||
configuration.recv_wait_timeout = 5;
|
||||
configuration.send_wait_timeout = 5;
|
||||
configuration.global_user_ctx = gateway;
|
||||
configuration.global_user_ctx_free_fn = preserve_static_context;
|
||||
configuration.close_fn = maze32_gateway_close_socket;
|
||||
|
||||
esp_err_t result = httpd_start(server, &configuration);
|
||||
if (result != ESP_OK) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const httpd_uri_t root = {
|
||||
.uri = "/",
|
||||
.method = HTTP_GET,
|
||||
.handler = send_static_asset,
|
||||
.user_ctx = (void*)&INDEX_ASSET,
|
||||
};
|
||||
const httpd_uri_t index = {
|
||||
.uri = "/index.html",
|
||||
.method = HTTP_GET,
|
||||
.handler = send_static_asset,
|
||||
.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 = {
|
||||
.uri = "/health",
|
||||
.method = HTTP_GET,
|
||||
.handler = send_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);
|
||||
if (result == ESP_OK) {
|
||||
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) {
|
||||
result = httpd_register_uri_handler(*server, &health_uri);
|
||||
}
|
||||
if (result == ESP_OK) {
|
||||
result = httpd_register_uri_handler(*server, &diagnostics_uri);
|
||||
}
|
||||
if (result != ESP_OK) {
|
||||
httpd_stop(*server);
|
||||
*server = NULL;
|
||||
return result;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "HTTP server ready with static and health endpoints");
|
||||
return ESP_OK;
|
||||
}
|
||||
Binary file not shown.
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.
|
||||
+48
-66
@@ -1,84 +1,66 @@
|
||||
# Board passport
|
||||
|
||||
## Identification status
|
||||
## Confirmed hardware
|
||||
|
||||
The target PCB has not been identified from physical markings. No photograph
|
||||
of the markings is available in the repository and no `sources/` directory is
|
||||
present. A read-only `esptool.py flash_id` probe did identify the installed SoC
|
||||
and flash, but it cannot identify the PCB manufacturer, board model/revision,
|
||||
built-in LED, or board-specific pinout.
|
||||
The target contains an ESP32-C6FH4 (chip revision 0.2) with 4 MB of embedded
|
||||
Flash and a 40 MHz crystal. It was identified electronically on 2026-08-27 with
|
||||
`esptool.py` 4.11.0 through the native Espressif USB Serial/JTAG interface at
|
||||
`/dev/ttyACM0` (USB VID:PID `303a:1001`). No PSRAM was reported or is required.
|
||||
|
||||
Required evidence to unblock Milestone 000:
|
||||
These observations establish chip-level facts only. The carrier PCB maker,
|
||||
commercial model, revision, GPIO routing, and any built-in LED remain unknown.
|
||||
Firmware must not depend on those unverified details.
|
||||
|
||||
- clear photographs of both sides of the board, including all PCB and module
|
||||
markings;
|
||||
- the manufacturer's product page, schematic, and pinout matching those
|
||||
markings;
|
||||
- the PCB/model ordering code that matches the photographs.
|
||||
## Locked project baseline
|
||||
|
||||
## Confirmed electronically
|
||||
The canonical build uses:
|
||||
|
||||
On 2026-08-27, the board appeared as `/dev/ttyACM0` with USB VID:PID
|
||||
`303a:1001` and product name `Espressif USB JTAG/serial debug unit`.
|
||||
`esptool.py` 4.11.0 reported:
|
||||
- PlatformIO with `platformio/espressif32@7.0.1`;
|
||||
- ESP-IDF;
|
||||
- the compatible `esp32-c6-devkitm-1` build profile;
|
||||
- explicit 4 MB build and upload Flash sizes;
|
||||
- `sdkconfig.defaults` with 4 MB Flash and 16 LwIP sockets;
|
||||
- the root `partitions.csv` layout;
|
||||
- LittleFS for browser assets;
|
||||
- no OTA partition and no PSRAM requirement.
|
||||
|
||||
- ESP32-C6FH4 in QFN32, chip revision 0.2;
|
||||
- 4 MB embedded flash, with 4 MB detected;
|
||||
- 40 MHz crystal;
|
||||
- native USB-Serial/JTAG mode.
|
||||
The PlatformIO board profile is a compatible compilation target. It is not
|
||||
evidence that the physical carrier is an Espressif ESP32-C6-DevKitM-1.
|
||||
|
||||
The ESP32-C6FH4 ordering code and probe establish the 4 MB in-package flash.
|
||||
No PSRAM was reported. These are chip-level facts; they do not establish the
|
||||
carrier PCB model or its LED and pin routing.
|
||||
## Canonical Flash layout
|
||||
|
||||
## Current repository baseline (not hardware confirmation)
|
||||
| Name | Type | Subtype | Offset | Size | End |
|
||||
| ---------- | ---- | -------- | --------- | ---------- | ---------- |
|
||||
| `nvs` | data | nvs | `0x9000` | `0x6000` | `0xF000` |
|
||||
| `phy_init` | data | phy | `0xF000` | `0x1000` | `0x10000` |
|
||||
| `factory` | app | factory | `0x10000` | `0x200000` | `0x210000` |
|
||||
| `littlefs` | data | littlefs | `0x210000`| `0x1F0000` | `0x400000` |
|
||||
|
||||
| Item | Observed value | Status |
|
||||
| ---------------------------- | ------------------------------- | --------------------------------------------------------------------- |
|
||||
| PlatformIO Core | 6.1.19 | Installed locally |
|
||||
| PlatformIO platform | `platformio/espressif32` 7.0.1 | Installed locally; `platformio.ini` is not version-pinned |
|
||||
| Framework | ESP-IDF 6.0.1 | Current project setting conflicts with the Arduino-first MVP baseline |
|
||||
| Arduino Core | Not installed | Cannot be pinned and verified against this board yet |
|
||||
| PlatformIO board | `esp32-c6-devkitm-1` | Unverified candidate, not evidence of the physical model |
|
||||
| Board-profile flash size | 4 MB | Unverified; profile metadata only |
|
||||
| Detected physical flash size | 4 MB | Confirmed by `esptool.py flash_id` |
|
||||
| Generated SDK flash size | 2 MB | Conflicts with the detected hardware and board profile |
|
||||
| Generated partition table | Single application, no LittleFS | Does not meet Milestone 000 |
|
||||
The partitions are contiguous from NVS through the end of Flash. The factory
|
||||
application partition is 2 MiB, the LittleFS partition is 1,984 KiB, and the
|
||||
layout ends exactly at the confirmed 4 MB boundary. The area below `0x9000` is
|
||||
reserved for the bootloader and partition table by the ESP-IDF layout.
|
||||
|
||||
The current configuration is not yet a reproducible baseline: its generated
|
||||
SDK configuration must be changed from 2 MB to the confirmed 4 MB and its
|
||||
partition table must add LittleFS before firmware is uploaded.
|
||||
## Explicit unknowns
|
||||
|
||||
## Candidate reference only
|
||||
The following facts have not been verified and must not be assumed:
|
||||
|
||||
If the physical markings prove that the board is an Espressif
|
||||
ESP32-C6-DevKitM-1, the matching authoritative references are:
|
||||
- carrier PCB manufacturer, model, or revision;
|
||||
- header and peripheral GPIO mapping;
|
||||
- presence, GPIO, polarity, or type of a built-in LED;
|
||||
- whether the carrier uses native USB only or includes another USB bridge;
|
||||
- availability of optional external RAM or peripherals.
|
||||
|
||||
Clear photographs of both sides of the board and matching manufacturer
|
||||
documentation are required before adopting any carrier-specific pin mapping.
|
||||
|
||||
## Candidate documentation
|
||||
|
||||
The `esp32-c6-devkitm-1` PlatformIO profile is used for compatibility only.
|
||||
Espressif's DevKitM-1 documentation may be consulted as a candidate reference,
|
||||
but its PCB-specific details remain noncanonical until physical markings prove
|
||||
that the target is that model:
|
||||
|
||||
- [ESP32-C6-DevKitM-1 user guide](https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32c6/esp32-c6-devkitm-1/user_guide.html)
|
||||
- [ESP32-C6-DevKitM-1 schematic](https://docs.espressif.com/projects/esp-dev-kits/en/latest/_static/esp32-c6-devkitm-1/schematics/esp32-c6-devkitm-1-schematics.pdf)
|
||||
- [ESP32-C6-MINI-1 datasheet](https://www.espressif.com/sites/default/files/documentation/esp32-c6-mini-1_mini-1u_datasheet_en.pdf)
|
||||
|
||||
For that board only, Espressif documents an ESP32-C6-MINI-1(U) module with
|
||||
4 MB in-package SPI flash, no PSRAM, a USB-C connector connected through a
|
||||
USB-to-UART bridge, UART0 on GPIO16/GPIO17, native USB D-/D+ on GPIO12/GPIO13,
|
||||
and an addressable RGB LED on GPIO8. These values are deliberately not adopted
|
||||
as the target specifications until the markings match.
|
||||
|
||||
## Memory map and partition status
|
||||
|
||||
No partition CSV has been selected yet. The confirmed 4 MB capacity permits a
|
||||
layout reserving, at minimum, NVS, PHY initialization, one application
|
||||
partition, and one LittleFS data partition. Exact offsets and sizes still need
|
||||
to be defined and checked against the framework's bootloader and partition
|
||||
alignment requirements.
|
||||
|
||||
## Risks carried to the next action
|
||||
|
||||
- A generic “ESP32-C6 Mini” may not match Espressif's DevKitM-1 pinout, LED
|
||||
polarity/type, or USB bridge. The flash capacity is confirmed independently.
|
||||
- GPIO8, GPIO12, GPIO13, GPIO16, and GPIO17 must not be assigned from the
|
||||
candidate documentation until the exact board is confirmed.
|
||||
- Official `platformio/espressif32` 7.0.1 exposes the C6 DevKitM profile only
|
||||
for ESP-IDF and its bundled Arduino Core 2.0.17 does not support ESP32-C6.
|
||||
An exact Arduino-compatible PlatformIO platform/Core pairing must be chosen
|
||||
and pinned after the board is identified, then proven by Milestone 001.
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# Authoritative game core
|
||||
|
||||
Milestones 006, 007, and 009 provide the portable movement engine, gameplay
|
||||
object systems, terminal results, view projector, and disconnect recovery. The
|
||||
ESP remains the only owner of mutable game state.
|
||||
|
||||
## Command processing
|
||||
|
||||
Move commands enter a fixed 16-entry FIFO. The server assigns a monotonically
|
||||
increasing command sequence when an entry is accepted into the queue, then
|
||||
processes entries one at a time. An accepted move crosses exactly one open
|
||||
north, east, south, or west passage. Countdown commands, closed-wall moves,
|
||||
out-of-order timestamps, and commands inside the configured per-player
|
||||
cooldown leave authoritative game state and the state sequence unchanged.
|
||||
|
||||
Each player has an independent cooldown clock. Player positions never block
|
||||
one another. When an accepted move ends on the other player's cell, that
|
||||
command emits exactly one meeting event and advances the meeting sequence.
|
||||
Observers cannot enqueue commands.
|
||||
|
||||
## Projection boundary
|
||||
|
||||
Player projections contain only cells whose Chebyshev distance from the
|
||||
player is at most the server-owned active radius:
|
||||
|
||||
`max(abs(x - player_x), abs(y - player_y)) <= radius`
|
||||
|
||||
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
|
||||
rebuilt from zero, so an opponent or object that leaves the current square is
|
||||
omitted instead of surviving as stale state. Observer projections contain all
|
||||
maze cells, static objects, and both current player positions.
|
||||
|
||||
The serialized view is split into bounded JSON records of at most 512 bytes,
|
||||
matching the WebSocket gateway's message bound.
|
||||
Every record uses the architecture's versioned envelope and carries the
|
||||
authoritative state sequence, role, match phase, maze width and height,
|
||||
meeting sequence, chunk index, and chunk count. Role status appears once in chunk zero instead of being
|
||||
duplicated across the snapshot. Cell records contain only the cell index and
|
||||
current passage flags. Object records contain kind, owner, variant, and cell
|
||||
index. Player records omit the `opponent` member when that player is not
|
||||
currently visible; observer records instead carry both positions in `players`.
|
||||
|
||||
## Gameplay resolution
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Maze generation and validation
|
||||
|
||||
Milestone 005 provides a portable, deterministic maze component. It does not
|
||||
implement movement, visibility projection, inventory, or match behavior from
|
||||
later milestones.
|
||||
|
||||
## Representation and limits
|
||||
|
||||
- Dimensions are runtime configuration values. Supported maps are even-width,
|
||||
even-height rectangles from `8 x 8` through `32 x 32` and contain at most
|
||||
1,024 cells.
|
||||
- Each cell uses one byte of four-direction passage flags. A fixed bit set,
|
||||
traversal queue, and distance array form the reusable generation workspace.
|
||||
- Starts, keys, boundary exits, compass cells, landmarks, and vision artifacts
|
||||
use bounded fixed arrays. No generation or validation path calls a heap API.
|
||||
- The maze plus workspace budget is 64 KiB. The default `32 x 32`
|
||||
configuration targets generation within 250 ms on the ESP32-C6.
|
||||
- Candidate regeneration is capped by `maximum_attempts`; failure returns a
|
||||
diagnostic result instead of retrying indefinitely.
|
||||
|
||||
## Deterministic construction
|
||||
|
||||
The generator builds a seeded randomized depth-first tree in one conceptual
|
||||
half and rotates every opened edge by 180 degrees into the other half. It then
|
||||
adds a configured number of symmetric cycle edges and symmetric pairs of seam
|
||||
connections. This construction retains dead ends while guaranteeing cycles,
|
||||
alternative routes, and several ways to cross between halves once validation
|
||||
succeeds.
|
||||
|
||||
The requested seed and the derived candidate seed are recorded in the maze.
|
||||
Using the same configuration and requested seed therefore reproduces the same
|
||||
topology, placements, metrics, and hash.
|
||||
|
||||
## Placement and validation
|
||||
|
||||
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
|
||||
requires at least 22 cells of Manhattan separation between each start and its
|
||||
exit. Keys are selected outside the owners' initial Chebyshev-radius-three
|
||||
views. The default places two landmark pairs and one vision-artifact pair, with
|
||||
no placement collisions.
|
||||
|
||||
Validation independently checks reciprocal passages, rotational symmetry,
|
||||
full reachability, edge and cycle counts, dead ends, half crossings, placement
|
||||
bounds and collisions, initial key visibility, and corresponding route
|
||||
distances for keys, exits, compasses, every landmark, and every artifact.
|
||||
Reports expose these topology and route metrics for diagnostics and later
|
||||
playtesting; geometric distance alone is not treated as proof of search
|
||||
difficulty.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,91 @@
|
||||
# maze32 WebSocket protocol v1
|
||||
|
||||
## Limits and ordering
|
||||
|
||||
- Endpoint: `/ws`.
|
||||
- At most six WebSocket connections are accepted: two players and four
|
||||
observers.
|
||||
- A text frame is at most 512 bytes. Binary frames are rejected.
|
||||
- Each connection may submit at most 20 messages in a one-second monotonic
|
||||
window.
|
||||
- The gateway uses a fixed 12-entry outgoing queue with 768 bytes per entry.
|
||||
- Client sequence numbers start above zero and strictly increase for the life
|
||||
of one WebSocket connection. Replayed or decreasing values are rejected.
|
||||
- Server sequence numbers strictly increase until reboot. Clients discard
|
||||
stale server messages.
|
||||
|
||||
All messages use this envelope:
|
||||
|
||||
```json
|
||||
{"v":1,"type":"message_type","seq":1,"requestId":"optional","payload":{}}
|
||||
```
|
||||
|
||||
Envelope keys may appear in any order except that `type` must precede
|
||||
`payload`. Unknown and duplicate keys are rejected. `requestId` is optional and
|
||||
is only echoed for correlation; it grants no authority.
|
||||
|
||||
## Client messages
|
||||
|
||||
- `join`: `{"role":"player","name":"..."}` or `{"role":"observer"}`.
|
||||
Player names contain 1–32 UTF-8 bytes, have no control bytes or outer spaces,
|
||||
and are unique within the current lobby.
|
||||
- `restore`: `{"token":"32 lowercase hexadecimal characters"}`.
|
||||
- `start`: `{}`. Only connected Player 1 may use it, and both player slots must
|
||||
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.
|
||||
|
||||
## Server messages
|
||||
|
||||
- `session`: the private role result containing `role`, `slot`, and temporary
|
||||
`token`. The token is never included in a shared snapshot.
|
||||
- `lobby`: the authoritative phase, revision, readiness, public player slots,
|
||||
connected observer count, and limits.
|
||||
- `countdown`: the single authoritative `[3,2,1]` event with common monotonic
|
||||
`startedAtMs` and `activationAtMs` values. It is sent to both players.
|
||||
- `active`: the exact authoritative activation instant.
|
||||
- `view`: a bounded chunk of a role-specific authoritative game projection.
|
||||
Observer chunks contain the complete maze and both player records, including
|
||||
their connection state; player chunks retain the fog-of-war boundary. 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`.
|
||||
|
||||
An observer session can receive snapshots but cannot issue `start`, `move`, or
|
||||
`drop_key`; attempts are rejected with `read_only` where applicable and logged
|
||||
by the gateway. Player tokens reserve their original slot after disconnection
|
||||
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.
|
||||
@@ -0,0 +1,274 @@
|
||||
# Browser UI style
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
This document defines the canonical visual language, controls, responsive
|
||||
layout, and presentation behavior for every maze32 browser mode. Product rules
|
||||
remain in `MVP.md`, and browser authority boundaries remain in
|
||||
`ARCHITECTURE.md`.
|
||||
|
||||
## 2. Visual direction and tone
|
||||
|
||||
- Use a dark retro arcade-game style throughout network provisioning, name
|
||||
selection, lobby, player, and observer modes.
|
||||
- Use humorous copy, imagery, animation, object names, and suggested player
|
||||
names wherever the game remains clear and usable.
|
||||
- Humor must be understandable to children, non-offensive, and based on
|
||||
internet humor that is current when the release is designed. Runtime access
|
||||
to external meme or content services is forbidden.
|
||||
- Use only a dark theme. A light theme is outside the MVP.
|
||||
- Use small SVG icons, pixel-art graphics, or graphics styled to resemble
|
||||
pixel art. Locally authored icons must not require an external icon library.
|
||||
- Use a system monospace font stack with CSS pixel-style treatment. The UI must
|
||||
not download Google Fonts or any other external font at runtime.
|
||||
|
||||
## 3. Color system
|
||||
|
||||
The exact color values may be tuned during implementation, but their semantic
|
||||
roles must remain stable:
|
||||
|
||||
- black or near-black for the application and map background;
|
||||
- purple for panels and elevated surfaces;
|
||||
- turquoise for interactive accents;
|
||||
- yellow for Player 1 and blue for Player 2 across every screen, map object,
|
||||
and message; each player's matching key and exit use the same ownership
|
||||
color;
|
||||
- green for success, amber for warnings, red for errors, periwinkle for pause,
|
||||
and orange for disconnection;
|
||||
- green vegetation, bushes, and plants for maze walls.
|
||||
|
||||
When two semantic roles use related colors, icons, labels, outlines, or shapes
|
||||
must keep them unambiguous. Important player and object information must not be
|
||||
communicated by color alone. Key objects use an additional label, outline, or
|
||||
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
|
||||
are not MVP requirements.
|
||||
|
||||
## 4. Animation, sound, and haptics
|
||||
|
||||
- Use frequent but purposeful animation for screen transitions, movement,
|
||||
pickups, game events, and humorous feedback.
|
||||
- Pending controls blink until their command is resolved.
|
||||
- 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
|
||||
user interaction when required by the browser, and are used only when the
|
||||
browser and device support them.
|
||||
- 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
|
||||
|
||||
- Controls use a retro arcade appearance with clear normal, hover, pressed,
|
||||
disabled, and pending states.
|
||||
- Visible keyboard-focus outlines are not required for the MVP.
|
||||
- On computers, movement works from the keyboard and from an on-screen
|
||||
directional control cluster fixed in the lower-right corner.
|
||||
- On touch devices, an always-visible four-button directional pad in the lower
|
||||
right is the primary movement control. Its centre is inactive, each button
|
||||
is at least 48 by 48 CSS pixels, and it sends only one cardinal intention.
|
||||
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,
|
||||
and is positioned closer to the upper edge of the screen than the
|
||||
directional controls. Dropping does not require confirmation.
|
||||
- The key-drop control has the Russian accessible label `Сбросить ключ`.
|
||||
|
||||
## 6. Shared layout
|
||||
|
||||
- The map fills all viewport space not required by fixed status and control
|
||||
panels. The player match screen must not require page scrolling.
|
||||
- A fixed status panel occupies the top of the screen.
|
||||
- The persistent player status shows the player name, carried key when one is
|
||||
held, active vision effect, and match state.
|
||||
- The player identity is represented by a distinct character shape, not `P1`
|
||||
or `P2`. Object, blocked-move, pause, and result feedback must pair a shape
|
||||
or icon with its color.
|
||||
- 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.
|
||||
- Textual system notices use one live message rail above the top bar and player
|
||||
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.
|
||||
- Network provisioning uses the same colors, type, controls, and humor as the
|
||||
game application.
|
||||
|
||||
## 7. Player map presentation
|
||||
|
||||
- Keep the portrait player horizontally centered at approximately 30% of the
|
||||
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
|
||||
decorate maze geometry outside the server-provided visible projection.
|
||||
- Compute cell size from the current server projection and available map area.
|
||||
At the minimum supported phone viewport a common 7 by 7 projection must use
|
||||
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.
|
||||
- Use different colors plus labels, outlines, shapes, or icons to distinguish
|
||||
player ownership and object types.
|
||||
- Server-approved breakthrough impacts one through four bend the visible bush
|
||||
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
|
||||
|
||||
- The minimum supported player viewport is `412 x 916` CSS pixels.
|
||||
- Support both portrait and landscape orientations. Suggest landscape when it
|
||||
materially improves play, but do not block portrait play.
|
||||
- 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
|
||||
priority and fixed panel placement.
|
||||
- Validate the MVP against the current stable releases of Chrome, Safari,
|
||||
Firefox, and Edge available at the time of validation. Record the tested
|
||||
browser versions and date; legacy browser support is outside the MVP.
|
||||
|
||||
## 9. Observer presentation
|
||||
|
||||
- Observer mode is supported only at viewports of at least `1024 x 768` CSS
|
||||
pixels. Smaller viewports show a Russian message explaining that a larger
|
||||
screen is required.
|
||||
- Place player and match status blocks above the full maze.
|
||||
- Initially fit the complete `32 x 32` map into the available map area.
|
||||
- Provide zoom and pan without granting any gameplay control or changing
|
||||
server authority.
|
||||
|
||||
## 10. Asset and dependency budget
|
||||
|
||||
- Use plain project CSS without a third-party CSS framework.
|
||||
- All runtime web assets must be served locally from the ESP. The UI must not
|
||||
require internet access, remote fonts, external icons, analytics, or content
|
||||
services.
|
||||
- Precompress production web assets. Their complete compressed payload must
|
||||
not exceed 1.5 MiB and must fit the existing 1,984 KiB LittleFS partition.
|
||||
- The LittleFS partition size and Flash layout remain unchanged.
|
||||
|
||||
## 11. Visual verification
|
||||
|
||||
- Verify player layout at the minimum `412 x 916` viewport and representative
|
||||
tablet and desktop viewports.
|
||||
- Verify observer layout at `1024 x 768` and at a larger desktop viewport.
|
||||
- Verify every control state, modal state, responsive transition, Russian
|
||||
message, and semantic color role manually and through focused browser tests
|
||||
where practical.
|
||||
- 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 |
+41
-1
@@ -8,15 +8,55 @@
|
||||
; Please visit documentation for the other options and examples
|
||||
; https://docs.platformio.org/page/projectconf.html
|
||||
|
||||
[platformio]
|
||||
default_envs = esp32-c6-devkitm-1
|
||||
|
||||
[env:esp32-c6-devkitm-1]
|
||||
platform = espressif32 @ 7.0.1
|
||||
board = esp32-c6-devkitm-1
|
||||
framework = espidf
|
||||
board_build.flash_size = 4MB
|
||||
board_build.partitions = partitions.csv
|
||||
board_build.filesystem = littlefs
|
||||
board_build.sdkconfig_defaults = sdkconfig.defaults
|
||||
board_upload.flash_size = 4MB
|
||||
lib_deps = https://github.com/joltwallet/esp_littlefs.git#v1.20.4
|
||||
extra_scripts =
|
||||
build_flags =
|
||||
-DCONFIG_LITTLEFS_MAX_PARTITIONS=1
|
||||
-DCONFIG_LITTLEFS_PAGE_SIZE=256
|
||||
-DCONFIG_LITTLEFS_OBJ_NAME_LEN=64
|
||||
-DCONFIG_LITTLEFS_READ_SIZE=128
|
||||
-DCONFIG_LITTLEFS_WRITE_SIZE=128
|
||||
-DCONFIG_LITTLEFS_LOOKAHEAD_SIZE=128
|
||||
-DCONFIG_LITTLEFS_CACHE_SIZE=512
|
||||
-DCONFIG_LITTLEFS_BLOCK_CYCLES=512
|
||||
-DCONFIG_LITTLEFS_MALLOC_STRATEGY_DEFAULT=1
|
||||
extra_scripts = post:scripts/platformio_build.py
|
||||
monitor_port = /dev/ttyACM0
|
||||
monitor_speed = 115200
|
||||
monitor_dtr = 0
|
||||
monitor_rts = 0
|
||||
|
||||
[env:milestone-003-device-test]
|
||||
extends = env:esp32-c6-devkitm-1
|
||||
build_flags =
|
||||
${env:esp32-c6-devkitm-1.build_flags}
|
||||
-DMAZE32_MILESTONE_003_DEVICE_TEST=1
|
||||
|
||||
[env:milestone-005-device-test]
|
||||
extends = env:esp32-c6-devkitm-1
|
||||
build_flags =
|
||||
${env:esp32-c6-devkitm-1.build_flags}
|
||||
-DMAZE32_MILESTONE_005_DEVICE_TEST=1
|
||||
|
||||
[env:milestone-006-device-test]
|
||||
extends = env:esp32-c6-devkitm-1
|
||||
build_flags =
|
||||
${env:esp32-c6-devkitm-1.build_flags}
|
||||
-DMAZE32_MILESTONE_006_DEVICE_TEST=1
|
||||
|
||||
[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
|
||||
|
||||
Binary file not shown.
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate deterministic gzip-compressed LittleFS web assets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SUPPORTED_SUFFIXES = {".css", ".html", ".ico", ".js", ".json", ".svg"}
|
||||
|
||||
|
||||
def generate_assets(source_directory: Path, output_directory: Path) -> list[Path]:
|
||||
source_directory = source_directory.resolve()
|
||||
output_directory = output_directory.resolve()
|
||||
if not source_directory.is_dir():
|
||||
raise ValueError(f"Web source directory does not exist: {source_directory}")
|
||||
if source_directory == output_directory:
|
||||
raise ValueError("Web source and output directories must differ")
|
||||
|
||||
source_files = sorted(
|
||||
path for path in source_directory.rglob("*")
|
||||
if path.is_file() and path.suffix.lower() in SUPPORTED_SUFFIXES
|
||||
)
|
||||
if not source_files:
|
||||
raise ValueError(f"No supported web assets found in {source_directory}")
|
||||
|
||||
if output_directory.exists():
|
||||
shutil.rmtree(output_directory)
|
||||
output_directory.mkdir(parents=True)
|
||||
|
||||
generated_files: list[Path] = []
|
||||
for source_path in source_files:
|
||||
relative_path = source_path.relative_to(source_directory)
|
||||
output_path = output_directory / f"{relative_path}.gz"
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with source_path.open("rb") as source, output_path.open("wb") as output:
|
||||
with gzip.GzipFile(
|
||||
filename="",
|
||||
mode="wb",
|
||||
compresslevel=9,
|
||||
fileobj=output,
|
||||
mtime=0,
|
||||
) as compressed:
|
||||
shutil.copyfileobj(source, compressed)
|
||||
os.utime(output_path, ns=(0, 0))
|
||||
generated_files.append(output_path)
|
||||
|
||||
for directory in sorted(
|
||||
(path for path in output_directory.rglob("*") if path.is_dir()),
|
||||
reverse=True,
|
||||
):
|
||||
os.utime(directory, ns=(0, 0))
|
||||
os.utime(output_directory, ns=(0, 0))
|
||||
|
||||
return generated_files
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--source", type=Path, default=Path("web"))
|
||||
parser.add_argument("--output", type=Path, default=Path("data"))
|
||||
arguments = parser.parse_args()
|
||||
|
||||
generated_files = generate_assets(arguments.source, arguments.output)
|
||||
for path in generated_files:
|
||||
print(path)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,93 @@
|
||||
"""PlatformIO hooks for deterministic assets and partition size limits."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
Import("env")
|
||||
|
||||
|
||||
PROJECT_DIRECTORY = Path(env.subst("$PROJECT_DIR"))
|
||||
BUILD_DIRECTORY = Path(env.subst("$BUILD_DIR"))
|
||||
PARTITION_FILE = PROJECT_DIRECTORY / "partitions.csv"
|
||||
ASSET_SCRIPT = PROJECT_DIRECTORY / "scripts" / "generate_web_assets.py"
|
||||
FILESYSTEM_WRAPPER = PROJECT_DIRECTORY / "scripts" / "reproducible_mklittlefs.py"
|
||||
|
||||
|
||||
def parse_size(value: str) -> int:
|
||||
value = value.strip()
|
||||
suffixes = {"K": 1024, "M": 1024 * 1024}
|
||||
if value[-1:].upper() in suffixes:
|
||||
return int(value[:-1], 0) * suffixes[value[-1].upper()]
|
||||
return int(value, 0)
|
||||
|
||||
|
||||
def partition_sizes() -> dict[str, int]:
|
||||
sizes: dict[str, int] = {}
|
||||
with PARTITION_FILE.open(newline="", encoding="utf-8") as partition_stream:
|
||||
for row in csv.reader(partition_stream):
|
||||
if not row or row[0].lstrip().startswith("#"):
|
||||
continue
|
||||
if len(row) < 5:
|
||||
raise RuntimeError(f"Invalid partition row: {row}")
|
||||
sizes[row[0].strip()] = parse_size(row[4])
|
||||
return sizes
|
||||
|
||||
|
||||
def generate_assets(source, target, env) -> None:
|
||||
command = [
|
||||
env.subst("$PYTHONEXE"),
|
||||
str(ASSET_SCRIPT),
|
||||
"--source",
|
||||
str(PROJECT_DIRECTORY / "web"),
|
||||
"--output",
|
||||
str(PROJECT_DIRECTORY / "data"),
|
||||
]
|
||||
subprocess.run(command, check=True)
|
||||
|
||||
|
||||
def enforce_size(path: Path, partition_name: str) -> None:
|
||||
limits = partition_sizes()
|
||||
if partition_name not in limits:
|
||||
raise RuntimeError(f"Missing required partition: {partition_name}")
|
||||
actual_size = path.stat().st_size
|
||||
maximum_size = limits[partition_name]
|
||||
if actual_size > maximum_size:
|
||||
raise RuntimeError(
|
||||
f"{path.name} is {actual_size} bytes and exceeds the "
|
||||
f"{partition_name} partition limit of {maximum_size} bytes"
|
||||
)
|
||||
print(
|
||||
f"maze32 size check: {path.name} uses {actual_size}/{maximum_size} bytes "
|
||||
f"in partition {partition_name}"
|
||||
)
|
||||
|
||||
|
||||
def check_application(source, target, env) -> None:
|
||||
enforce_size(application_image, "factory")
|
||||
|
||||
|
||||
def check_filesystem(source, target, env) -> None:
|
||||
enforce_size(filesystem_image, "littlefs")
|
||||
|
||||
|
||||
filesystem_image = BUILD_DIRECTORY / "littlefs.bin"
|
||||
application_image = BUILD_DIRECTORY / f"{env.subst('$PROGNAME')}.bin"
|
||||
real_filesystem_tool = env.subst("$MKFSTOOL")
|
||||
|
||||
env["ENV"]["MAZE32_REAL_MKFSTOOL"] = real_filesystem_tool
|
||||
env["ENV"]["MAZE32_BUILD_DIR"] = str(BUILD_DIRECTORY)
|
||||
env.Replace(MKFSTOOL=str(FILESYSTEM_WRAPPER))
|
||||
|
||||
env.AddPreAction("$BUILD_DIR/littlefs.bin", generate_assets)
|
||||
env.AddPostAction("$BUILD_DIR/littlefs.bin", check_filesystem)
|
||||
env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", check_application)
|
||||
env.AddCustomTarget(
|
||||
"webassets",
|
||||
None,
|
||||
generate_assets,
|
||||
title="Generate deterministic web assets",
|
||||
description="Compress web sources into the LittleFS data directory",
|
||||
)
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run mklittlefs with a fixed wall clock for reproducible images."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def build_time_override(output_path: Path) -> None:
|
||||
source_path = Path(__file__).with_name("reproducible_time.c")
|
||||
compiler = shutil.which("cc")
|
||||
if compiler is None:
|
||||
raise RuntimeError("A host C compiler named 'cc' is required")
|
||||
subprocess.run(
|
||||
[
|
||||
compiler,
|
||||
"-shared",
|
||||
"-fPIC",
|
||||
"-O2",
|
||||
str(source_path),
|
||||
"-o",
|
||||
str(output_path),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
real_tool = os.environ.get("MAZE32_REAL_MKFSTOOL")
|
||||
build_directory = os.environ.get("MAZE32_BUILD_DIR")
|
||||
if not real_tool or not build_directory:
|
||||
raise RuntimeError("PlatformIO did not provide the filesystem tool environment")
|
||||
|
||||
override_path = Path(build_directory) / "reproducible_time.so"
|
||||
override_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
build_time_override(override_path)
|
||||
|
||||
environment = os.environ.copy()
|
||||
existing_preload = environment.get("LD_PRELOAD")
|
||||
environment["LD_PRELOAD"] = (
|
||||
f"{override_path}:{existing_preload}" if existing_preload else str(override_path)
|
||||
)
|
||||
completed = subprocess.run([real_tool, *sys.argv[1:]], env=environment)
|
||||
return completed.returncode
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,37 @@
|
||||
#define _GNU_SOURCE
|
||||
|
||||
#include <stddef.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <sys/time.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static const time_t FIXED_TIME = 0;
|
||||
|
||||
time_t time(time_t *result)
|
||||
{
|
||||
if (result != NULL) {
|
||||
*result = FIXED_TIME;
|
||||
}
|
||||
return FIXED_TIME;
|
||||
}
|
||||
|
||||
int gettimeofday(struct timeval *value, void *timezone)
|
||||
{
|
||||
(void)timezone;
|
||||
if (value != NULL) {
|
||||
value->tv_sec = FIXED_TIME;
|
||||
value->tv_usec = 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int clock_gettime(clockid_t clock_id, struct timespec *value)
|
||||
{
|
||||
if (clock_id == CLOCK_REALTIME) {
|
||||
value->tv_sec = FIXED_TIME;
|
||||
value->tv_nsec = 0;
|
||||
return 0;
|
||||
}
|
||||
return (int)syscall(SYS_clock_gettime, clock_id, value);
|
||||
}
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
project_directory=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
test_directory=$(mktemp -d "${TMPDIR:-/tmp}/maze32-host-tests.XXXXXX")
|
||||
trap 'rm -rf "$test_directory"' EXIT HUP INT TERM
|
||||
|
||||
cc -std=c11 -Wall -Wextra -Werror -pedantic \
|
||||
-I"$project_directory/components/maze32_platform/include" \
|
||||
"$project_directory/components/maze32_platform/maze32_health.c" \
|
||||
"$project_directory/test/host/test_health.c" \
|
||||
-o "$test_directory/test_health"
|
||||
|
||||
"$test_directory/test_health"
|
||||
|
||||
cc -std=c11 -Wall -Wextra -Werror -pedantic \
|
||||
-I"$project_directory/components/maze32_config/include" \
|
||||
"$project_directory/components/maze32_config/maze32_wifi_config.c" \
|
||||
"$project_directory/test/host/test_wifi_config.c" \
|
||||
-o "$test_directory/test_wifi_config"
|
||||
|
||||
"$test_directory/test_wifi_config"
|
||||
|
||||
cc -std=c11 -Wall -Wextra -Werror -pedantic \
|
||||
-I"$project_directory/test/host/fakes" \
|
||||
-I"$project_directory/components/maze32_config/include" \
|
||||
"$project_directory/components/maze32_config/maze32_wifi_config.c" \
|
||||
"$project_directory/components/maze32_config/maze32_config_store.c" \
|
||||
"$project_directory/test/host/fake_nvs.c" \
|
||||
"$project_directory/test/host/test_config_store.c" \
|
||||
-o "$test_directory/test_config_store"
|
||||
|
||||
"$test_directory/test_config_store"
|
||||
|
||||
cc -std=c11 -Wall -Wextra -Werror -pedantic \
|
||||
-I"$project_directory/components/maze32_config/include" \
|
||||
-I"$project_directory/components/maze32_network/include" \
|
||||
"$project_directory/components/maze32_config/maze32_wifi_config.c" \
|
||||
"$project_directory/components/maze32_network/maze32_provisioning_protocol.c" \
|
||||
"$project_directory/test/host/test_provisioning_protocol.c" \
|
||||
-o "$test_directory/test_provisioning_protocol"
|
||||
|
||||
"$test_directory/test_provisioning_protocol"
|
||||
|
||||
cc -std=c11 -Wall -Wextra -Werror -pedantic \
|
||||
-I"$project_directory/components/maze32_lobby/include" \
|
||||
"$project_directory/components/maze32_lobby/maze32_lobby.c" \
|
||||
"$project_directory/components/maze32_lobby/maze32_protocol.c" \
|
||||
"$project_directory/test/host/test_lobby_protocol.c" \
|
||||
-o "$test_directory/test_lobby_protocol"
|
||||
|
||||
"$test_directory/test_lobby_protocol"
|
||||
|
||||
cc -std=c11 -Wall -Wextra -Werror -pedantic \
|
||||
-I"$project_directory/components/maze32_maze/include" \
|
||||
"$project_directory/components/maze32_maze/maze32_maze.c" \
|
||||
"$project_directory/test/host/test_maze_generation.c" \
|
||||
-o "$test_directory/test_maze_generation"
|
||||
|
||||
"$test_directory/test_maze_generation"
|
||||
|
||||
cc -std=c11 -Wall -Wextra -Werror -pedantic \
|
||||
-I"$project_directory/components/maze32_maze/include" \
|
||||
-I"$project_directory/components/maze32_game/include" \
|
||||
"$project_directory/components/maze32_maze/maze32_maze.c" \
|
||||
"$project_directory/components/maze32_game/maze32_game.c" \
|
||||
"$project_directory/test/host/test_game_core.c" \
|
||||
-o "$test_directory/test_game_core"
|
||||
|
||||
"$test_directory/test_game_core"
|
||||
|
||||
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"
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify deterministic web generation and the checked-in generated assets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import hashlib
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from generate_web_assets import generate_assets
|
||||
|
||||
|
||||
PROJECT_DIRECTORY = Path(__file__).resolve().parent.parent
|
||||
COMPRESSED_PAYLOAD_LIMIT = 1572864
|
||||
|
||||
|
||||
def tree_digest(directory: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
for path in sorted(item for item in directory.rglob("*") if item.is_file()):
|
||||
digest.update(path.relative_to(directory).as_posix().encode("utf-8"))
|
||||
digest.update(b"\0")
|
||||
digest.update(path.read_bytes())
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
source_directory = PROJECT_DIRECTORY / "web"
|
||||
checked_in_directory = PROJECT_DIRECTORY / "data"
|
||||
with tempfile.TemporaryDirectory(prefix="maze32-assets-") as temporary:
|
||||
temporary_directory = Path(temporary)
|
||||
first = temporary_directory / "first"
|
||||
second = temporary_directory / "second"
|
||||
generate_assets(source_directory, first)
|
||||
generate_assets(source_directory, second)
|
||||
|
||||
first_digest = tree_digest(first)
|
||||
second_digest = tree_digest(second)
|
||||
checked_in_digest = tree_digest(checked_in_directory)
|
||||
if first_digest != second_digest:
|
||||
raise AssertionError("Repeated web-asset generation produced different output")
|
||||
if first_digest != checked_in_digest:
|
||||
raise AssertionError("Generated web assets are stale; regenerate the data directory")
|
||||
|
||||
for compressed_path in sorted(path for path in first.rglob("*.gz") if path.is_file()):
|
||||
relative_path = compressed_path.relative_to(first)
|
||||
source_relative = Path(str(relative_path)[:-3])
|
||||
if gzip.decompress(compressed_path.read_bytes()) != (
|
||||
source_directory / source_relative
|
||||
).read_bytes():
|
||||
raise AssertionError(f"Compressed asset does not reproduce {source_relative}")
|
||||
|
||||
compressed_size = sum(path.stat().st_size for path in first.rglob("*.gz"))
|
||||
if compressed_size > COMPRESSED_PAYLOAD_LIMIT:
|
||||
raise AssertionError(
|
||||
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}, "
|
||||
f"{compressed_size}/{COMPRESSED_PAYLOAD_LIMIT} bytes)"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+604
@@ -0,0 +1,604 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dependency-free maze32 WebSocket protocol and six-client verifier."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
MALFORMED_STRESS_MESSAGES = 80
|
||||
|
||||
|
||||
class WebSocket:
|
||||
def __init__(self, host: str, port: int = 80, timeout: float = 8.0):
|
||||
self.host = host
|
||||
self.sequence = 0
|
||||
self.socket = socket.create_connection((host, port), timeout=timeout)
|
||||
self.socket.settimeout(timeout)
|
||||
key = base64.b64encode(os.urandom(16)).decode("ascii")
|
||||
request = (
|
||||
f"GET /ws HTTP/1.1\r\nHost: {host}:{port}\r\nUpgrade: websocket\r\n"
|
||||
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n"
|
||||
"Sec-WebSocket-Version: 13\r\n\r\n"
|
||||
)
|
||||
self.socket.sendall(request.encode("ascii"))
|
||||
response = self._receive_until(b"\r\n\r\n")
|
||||
if not response.startswith(b"HTTP/1.1 101"):
|
||||
self.socket.close()
|
||||
raise RuntimeError(response.decode("utf-8", "replace").strip())
|
||||
expected = base64.b64encode(
|
||||
hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()
|
||||
)
|
||||
if expected not in response:
|
||||
self.socket.close()
|
||||
raise RuntimeError("WebSocket accept key did not match")
|
||||
|
||||
def _receive_until(self, marker: bytes) -> bytes:
|
||||
data = bytearray()
|
||||
while marker not in data:
|
||||
chunk = self.socket.recv(1024)
|
||||
if not chunk:
|
||||
raise ConnectionError("connection closed")
|
||||
data.extend(chunk)
|
||||
return bytes(data)
|
||||
|
||||
def send(self, message_type: str, payload: dict[str, Any], request_id: str) -> None:
|
||||
self.sequence += 1
|
||||
message = json.dumps(
|
||||
{"v": 1, "type": message_type, "seq": self.sequence,
|
||||
"requestId": request_id, "payload": payload},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
self.send_raw(message)
|
||||
|
||||
def send_raw(self, message: bytes) -> None:
|
||||
mask = os.urandom(4)
|
||||
length = len(message)
|
||||
if length < 126:
|
||||
header = bytes((0x81, 0x80 | length))
|
||||
else:
|
||||
header = bytes((0x81, 0x80 | 126)) + struct.pack("!H", length)
|
||||
masked = bytes(value ^ mask[index % 4] for index, value in enumerate(message))
|
||||
self.socket.sendall(header + mask + masked)
|
||||
|
||||
def receive(self) -> dict[str, Any]:
|
||||
first, second = self._read_exact(2)
|
||||
opcode = first & 0x0F
|
||||
length = second & 0x7F
|
||||
if length == 126:
|
||||
length = struct.unpack("!H", self._read_exact(2))[0]
|
||||
elif length == 127:
|
||||
length = struct.unpack("!Q", self._read_exact(8))[0]
|
||||
if second & 0x80:
|
||||
mask = self._read_exact(4)
|
||||
else:
|
||||
mask = None
|
||||
payload = self._read_exact(length)
|
||||
if mask:
|
||||
payload = bytes(value ^ mask[index % 4] for index, value in enumerate(payload))
|
||||
if opcode == 0x8:
|
||||
raise ConnectionError("WebSocket closed")
|
||||
if opcode != 0x1:
|
||||
return self.receive()
|
||||
return json.loads(payload.decode("utf-8"))
|
||||
|
||||
def receive_type(self, message_type: str, attempts: int = 20) -> dict[str, Any]:
|
||||
for _ in range(attempts):
|
||||
message = self.receive()
|
||||
if message.get("type") == message_type:
|
||||
return message
|
||||
raise RuntimeError(f"did not receive {message_type}")
|
||||
|
||||
def _read_exact(self, length: int) -> bytes:
|
||||
data = bytearray()
|
||||
while len(data) < length:
|
||||
chunk = self.socket.recv(length - len(data))
|
||||
if not chunk:
|
||||
raise ConnectionError("connection closed")
|
||||
data.extend(chunk)
|
||||
return bytes(data)
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
self.socket.sendall(b"\x88\x80" + b"\0\0\0\0")
|
||||
except OSError:
|
||||
pass
|
||||
self.socket.close()
|
||||
|
||||
|
||||
def join(client: WebSocket, role: str, name: str | None = None) -> dict[str, Any]:
|
||||
payload = {"role": role}
|
||||
if name is not None:
|
||||
payload["name"] = name
|
||||
client.send("join", payload, f"join-{role}")
|
||||
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:
|
||||
clients: list[WebSocket] = []
|
||||
try:
|
||||
player_1 = WebSocket(host)
|
||||
clients.append(player_1)
|
||||
player_1.send_raw(b"{")
|
||||
error = player_1.receive_type("error")
|
||||
assert error["payload"]["code"] == "malformed"
|
||||
session_1 = join(player_1, "player", "Тестовый Пилот")
|
||||
player_1.send_raw(
|
||||
b'{"v":1,"type":"start","seq":1,"requestId":"stale","payload":{}}'
|
||||
)
|
||||
error = player_1.receive_type("error")
|
||||
assert error["payload"]["code"] == "stale_sequence"
|
||||
print("malformed and stale message rejection: PASS")
|
||||
duplicate = WebSocket(host)
|
||||
clients.append(duplicate)
|
||||
duplicate.send("join", {"role": "player", "name": "Тестовый Пилот"}, "duplicate")
|
||||
error = duplicate.receive_type("error")
|
||||
assert error["payload"]["code"] == "duplicate_name"
|
||||
duplicate.send("join", {"role": "player", "name": "Запасной Пилот"}, "player-2")
|
||||
session_2 = duplicate.receive_type("session")["payload"]
|
||||
assert session_1["slot"] == 1 and session_2["slot"] == 2
|
||||
|
||||
observers = []
|
||||
for _ in range(4):
|
||||
observer = WebSocket(host)
|
||||
clients.append(observer)
|
||||
join(observer, "observer")
|
||||
observers.append(observer)
|
||||
lobby = observers[-1].receive_type("lobby")["payload"]
|
||||
assert lobby["ready"] and lobby["observers"] == 4
|
||||
print("six-client capacity: PASS (2 players + 4 observers)")
|
||||
print("duplicate player name: PASS")
|
||||
|
||||
try:
|
||||
excess = WebSocket(host, timeout=1.0)
|
||||
except (OSError, RuntimeError, ConnectionError):
|
||||
print("seventh connection rejection: PASS")
|
||||
else:
|
||||
try:
|
||||
excess.send("join", {"role": "observer"}, "excess")
|
||||
excess.receive()
|
||||
except (OSError, ConnectionError):
|
||||
print("seventh connection rejection: PASS")
|
||||
else:
|
||||
raise AssertionError("seventh WebSocket unexpectedly remained connected")
|
||||
finally:
|
||||
excess.close()
|
||||
time.sleep(0.5)
|
||||
|
||||
duplicate.send("start", {}, "not-owner")
|
||||
error = duplicate.receive_type("error")
|
||||
assert error["payload"]["code"] == "not_owner"
|
||||
print("Player 2 start authorization: PASS")
|
||||
|
||||
token_2 = session_2["token"]
|
||||
duplicate.close()
|
||||
clients.remove(duplicate)
|
||||
time.sleep(0.15)
|
||||
restored = WebSocket(host)
|
||||
clients.append(restored)
|
||||
restored.send("restore", {"token": token_2}, "restore")
|
||||
restored_session = restored.receive_type("session")["payload"]
|
||||
assert restored_session["slot"] == 2 and restored_session["token"] == token_2
|
||||
print("session refresh restoration: PASS (Player 2 retained slot 2)")
|
||||
|
||||
player_1.send("start", {}, "owner-start")
|
||||
countdown_1 = player_1.receive_type("countdown")["payload"]
|
||||
countdown_2 = restored.receive_type("countdown")["payload"]
|
||||
assert countdown_1 == countdown_2
|
||||
assert countdown_1["steps"] == [3, 2, 1]
|
||||
active_1 = player_1.receive_type("active")["payload"]
|
||||
active_2 = restored.receive_type("active")["payload"]
|
||||
assert active_1["activatedAtMs"] == countdown_1["activationAtMs"]
|
||||
assert active_1 == active_2
|
||||
print(
|
||||
"authoritative countdown: PASS "
|
||||
f"(activationAtMs={countdown_1['activationAtMs']})"
|
||||
)
|
||||
|
||||
observer = observers.pop()
|
||||
observer.close()
|
||||
clients.remove(observer)
|
||||
time.sleep(0.5)
|
||||
oversized = WebSocket(host)
|
||||
oversized.send_raw(b"x" * 513)
|
||||
try:
|
||||
error = oversized.receive_type("error")
|
||||
assert error["payload"]["code"] == "too_large"
|
||||
except (OSError, ConnectionError):
|
||||
pass
|
||||
print("oversized message rejection: PASS")
|
||||
oversized.close()
|
||||
finally:
|
||||
for client in clients:
|
||||
client.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
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-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()
|
||||
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)
|
||||
else:
|
||||
parser.error("choose --verify, --verify-m009, or --verify-m010")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1 +1,6 @@
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
|
||||
CONFIG_LWIP_MAX_SOCKETS=16
|
||||
CONFIG_HTTPD_WS_SUPPORT=y
|
||||
CONFIG_HTTPD_WS_PRE_HANDSHAKE_CB_SUPPORT=y
|
||||
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
|
||||
CONFIG_ESP_CONSOLE_SECONDARY_NONE=y
|
||||
|
||||
@@ -1008,24 +1008,16 @@ CONFIG_ESPTOOLPY_FLASHFREQ_80M=y
|
||||
# CONFIG_ESPTOOLPY_FLASHFREQ_20M is not set
|
||||
# default:
|
||||
CONFIG_ESPTOOLPY_FLASHFREQ="80m"
|
||||
# default:
|
||||
# CONFIG_ESPTOOLPY_FLASHSIZE_1MB is not set
|
||||
# default:
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_2MB=y
|
||||
# default:
|
||||
# CONFIG_ESPTOOLPY_FLASHSIZE_4MB is not set
|
||||
# default:
|
||||
# CONFIG_ESPTOOLPY_FLASHSIZE_2MB is not set
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
|
||||
# CONFIG_ESPTOOLPY_FLASHSIZE_8MB is not set
|
||||
# default:
|
||||
# CONFIG_ESPTOOLPY_FLASHSIZE_16MB is not set
|
||||
# default:
|
||||
# CONFIG_ESPTOOLPY_FLASHSIZE_32MB is not set
|
||||
# default:
|
||||
# CONFIG_ESPTOOLPY_FLASHSIZE_64MB is not set
|
||||
# default:
|
||||
# CONFIG_ESPTOOLPY_FLASHSIZE_128MB is not set
|
||||
# default:
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE="2MB"
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE="4MB"
|
||||
# default:
|
||||
# CONFIG_ESPTOOLPY_HEADER_FLASHSIZE_UPDATE is not set
|
||||
# default:
|
||||
@@ -1567,12 +1559,14 @@ CONFIG_HTTPD_ERR_RESP_NO_DELAY=y
|
||||
CONFIG_HTTPD_PURGE_BUF_LEN=32
|
||||
# default:
|
||||
# CONFIG_HTTPD_LOG_PURGE_DATA is not set
|
||||
# default:
|
||||
# CONFIG_HTTPD_WS_SUPPORT is not set
|
||||
CONFIG_HTTPD_WS_SUPPORT=y
|
||||
# default:
|
||||
# CONFIG_HTTPD_QUEUE_WORK_BLOCKING is not set
|
||||
# default:
|
||||
CONFIG_HTTPD_SERVER_EVENT_POST_TIMEOUT=2000
|
||||
CONFIG_HTTPD_WS_PRE_HANDSHAKE_CB_SUPPORT=y
|
||||
# default:
|
||||
# CONFIG_HTTPD_WS_POST_HANDSHAKE_CB_SUPPORT is not set
|
||||
# end of HTTP Server
|
||||
|
||||
#
|
||||
@@ -1950,28 +1944,17 @@ CONFIG_ESP_CRYPTO_DPA_PROTECTION_LEVEL=1
|
||||
#
|
||||
# ESP-STDIO
|
||||
#
|
||||
# default:
|
||||
CONFIG_ESP_CONSOLE_UART_DEFAULT=y
|
||||
# default:
|
||||
# CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG is not set
|
||||
# default:
|
||||
# CONFIG_ESP_CONSOLE_UART_DEFAULT is not set
|
||||
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
|
||||
# CONFIG_ESP_CONSOLE_UART_CUSTOM is not set
|
||||
# default:
|
||||
# CONFIG_ESP_CONSOLE_NONE is not set
|
||||
# default:
|
||||
# CONFIG_ESP_CONSOLE_SECONDARY_NONE is not set
|
||||
# default:
|
||||
CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y
|
||||
CONFIG_ESP_CONSOLE_SECONDARY_NONE=y
|
||||
# default:
|
||||
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG_ENABLED=y
|
||||
# default:
|
||||
CONFIG_ESP_CONSOLE_UART=y
|
||||
CONFIG_ESP_CONSOLE_UART_NUM=-1
|
||||
# default:
|
||||
CONFIG_ESP_CONSOLE_UART_NUM=0
|
||||
# default:
|
||||
CONFIG_ESP_CONSOLE_ROM_SERIAL_PORT_NUM=0
|
||||
# default:
|
||||
CONFIG_ESP_CONSOLE_UART_BAUDRATE=115200
|
||||
CONFIG_ESP_CONSOLE_ROM_SERIAL_PORT_NUM=3
|
||||
# end of ESP-STDIO
|
||||
|
||||
#
|
||||
@@ -3397,11 +3380,11 @@ CONFIG_SD_ENABLE_SDIO_SUPPORT=y
|
||||
# MMU Config
|
||||
#
|
||||
# default:
|
||||
CONFIG_MMU_PAGE_SIZE_32KB=y
|
||||
CONFIG_MMU_PAGE_SIZE_64KB=y
|
||||
# default:
|
||||
CONFIG_MMU_PAGE_MODE="32KB"
|
||||
CONFIG_MMU_PAGE_MODE="64KB"
|
||||
# default:
|
||||
CONFIG_MMU_PAGE_SIZE=0x8000
|
||||
CONFIG_MMU_PAGE_SIZE=0x10000
|
||||
# end of MMU Config
|
||||
|
||||
#
|
||||
@@ -3717,13 +3700,11 @@ CONFIG_ESP32_PHY_MAX_TX_POWER=20
|
||||
# CONFIG_REDUCE_PHY_TX_POWER is not set
|
||||
# CONFIG_ESP32_REDUCE_PHY_TX_POWER is not set
|
||||
CONFIG_ESP_SYSTEM_PM_POWER_DOWN_CPU=y
|
||||
CONFIG_CONSOLE_UART_DEFAULT=y
|
||||
# CONFIG_CONSOLE_UART_DEFAULT is not set
|
||||
# CONFIG_CONSOLE_UART_CUSTOM is not set
|
||||
# CONFIG_CONSOLE_UART_NONE is not set
|
||||
# CONFIG_ESP_CONSOLE_UART_NONE is not set
|
||||
CONFIG_CONSOLE_UART=y
|
||||
CONFIG_CONSOLE_UART_NUM=0
|
||||
CONFIG_CONSOLE_UART_BAUDRATE=115200
|
||||
CONFIG_CONSOLE_UART_NUM=-1
|
||||
CONFIG_ESP_SYSTEM_PMP_IDRAM_SPLIT=y
|
||||
CONFIG_ESP_SYSTEM_MEMPROT_FEATURE_VIA_TEE=y
|
||||
CONFIG_ESP_SYSTEM_MEMPROT_FEATURE=y
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+4
-6
@@ -1,6 +1,4 @@
|
||||
# This file was automatically generated for projects
|
||||
# without default 'CMakeLists.txt' file.
|
||||
|
||||
FILE(GLOB_RECURSE app_sources ${CMAKE_SOURCE_DIR}/src/*.*)
|
||||
|
||||
idf_component_register(SRCS ${app_sources})
|
||||
idf_component_register(
|
||||
SRCS "main.c"
|
||||
REQUIRES esp_event esp_netif esp_timer heap maze32_config maze32_game maze32_maze maze32_network maze32_platform maze32_web
|
||||
)
|
||||
|
||||
+527
-1
@@ -1 +1,527 @@
|
||||
void app_main() {}
|
||||
#include "esp_app_desc.h"
|
||||
#include "esp_err.h"
|
||||
#include "esp_event.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_littlefs.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_netif.h"
|
||||
#include "esp_timer.h"
|
||||
#include <inttypes.h>
|
||||
#ifdef MAZE32_MILESTONE_003_DEVICE_TEST
|
||||
#include "esp_system.h"
|
||||
#include <string.h>
|
||||
#endif
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "maze32_config_store.h"
|
||||
#include "maze32_game.h"
|
||||
#include "maze32_health.h"
|
||||
#include "maze32_maze.h"
|
||||
#include "maze32_network.h"
|
||||
#include "maze32_web.h"
|
||||
|
||||
static const char* TAG = "maze32";
|
||||
static maze32_health_t health;
|
||||
static httpd_handle_t web_server;
|
||||
static maze32_network_t network;
|
||||
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
|
||||
static maze32_maze_t game_test_maze;
|
||||
static maze32_maze_workspace_t game_test_workspace;
|
||||
static maze32_game_t device_test_game;
|
||||
static maze32_view_t device_test_view;
|
||||
static char device_test_payload[MAZE32_GAME_VIEW_CHUNK_BYTES];
|
||||
|
||||
static maze32_game_direction_t first_open_direction(const maze32_maze_t* maze, maze32_cell_t cell) {
|
||||
static const maze32_game_direction_t DIRECTIONS[] = {
|
||||
MAZE32_GAME_DIRECTION_NORTH,
|
||||
MAZE32_GAME_DIRECTION_EAST,
|
||||
MAZE32_GAME_DIRECTION_SOUTH,
|
||||
MAZE32_GAME_DIRECTION_WEST,
|
||||
};
|
||||
static const maze32_passage_t PASSAGES[] = {
|
||||
MAZE32_PASSAGE_NORTH,
|
||||
MAZE32_PASSAGE_EAST,
|
||||
MAZE32_PASSAGE_SOUTH,
|
||||
MAZE32_PASSAGE_WEST,
|
||||
};
|
||||
for (size_t index = 0U; index < sizeof(DIRECTIONS) / sizeof(DIRECTIONS[0]); ++index) {
|
||||
if (maze32_maze_has_passage(maze, cell, PASSAGES[index])) {
|
||||
return DIRECTIONS[index];
|
||||
}
|
||||
}
|
||||
return (maze32_game_direction_t)UINT8_MAX;
|
||||
}
|
||||
|
||||
static esp_err_t run_game_device_test(void) {
|
||||
maze32_maze_config_t maze_config = maze32_maze_default_config();
|
||||
maze32_maze_report_t maze_report;
|
||||
if (maze32_maze_generate(&game_test_maze, &game_test_workspace, &maze_config,
|
||||
UINT32_C(0x5a320006), &maze_report) != MAZE32_MAZE_OK) {
|
||||
ESP_LOGE(TAG, "M006 physical game test could not generate its maze");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
maze32_game_config_t game_config = maze32_game_default_config();
|
||||
size_t free_before = heap_caps_get_free_size(MALLOC_CAP_8BIT);
|
||||
int64_t started_us = esp_timer_get_time();
|
||||
if (maze32_game_init(&device_test_game, &game_test_maze, &game_config) !=
|
||||
MAZE32_GAME_RESULT_ACCEPTED) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
maze32_game_set_phase(&device_test_game, MAZE32_GAME_PHASE_ACTIVE);
|
||||
maze32_game_direction_t direction =
|
||||
first_open_direction(&game_test_maze, device_test_game.players[0].position);
|
||||
uint32_t command_sequence = 0U;
|
||||
if (direction > MAZE32_GAME_DIRECTION_WEST ||
|
||||
maze32_game_enqueue_move(&device_test_game, MAZE32_GAME_ROLE_PLAYER_ONE, direction, 0U,
|
||||
&command_sequence) != MAZE32_GAME_RESULT_ACCEPTED) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
maze32_game_command_result_t command_result = maze32_game_process_next(&device_test_game);
|
||||
if (command_result.result != MAZE32_GAME_RESULT_ACCEPTED ||
|
||||
command_result.state_sequence != 1U ||
|
||||
maze32_game_project_view(&device_test_game, MAZE32_GAME_ROLE_PLAYER_ONE,
|
||||
&device_test_view) != MAZE32_GAME_RESULT_ACCEPTED) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
uint16_t player_cells = device_test_view.cell_count;
|
||||
uint8_t player_objects = device_test_view.object_count;
|
||||
size_t player_chunks = maze32_game_view_chunk_count(&device_test_view);
|
||||
size_t payload_bytes = 0U;
|
||||
if (maze32_game_serialize_view_chunk(&device_test_view, 0U, device_test_payload,
|
||||
sizeof(device_test_payload),
|
||||
&payload_bytes) != MAZE32_GAME_RESULT_ACCEPTED) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
if (maze32_game_project_view(&device_test_game, MAZE32_GAME_ROLE_OBSERVER, &device_test_view) !=
|
||||
MAZE32_GAME_RESULT_ACCEPTED) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
uint64_t elapsed_us = (uint64_t)(esp_timer_get_time() - started_us);
|
||||
size_t free_after = heap_caps_get_free_size(MALLOC_CAP_8BIT);
|
||||
size_t fixed_bytes =
|
||||
sizeof(device_test_game) + sizeof(device_test_view) + sizeof(device_test_payload);
|
||||
ESP_LOGI(TAG, "M006 command: command=%" PRIu32 ", state=%" PRIu32 ", result=%s, position=%u,%u",
|
||||
command_sequence, command_result.state_sequence,
|
||||
maze32_game_result_name(command_result.result), device_test_game.players[0].position.x,
|
||||
device_test_game.players[0].position.y);
|
||||
ESP_LOGI(TAG,
|
||||
"M006 views: player_cells=%u, player_objects=%u, player_chunks=%u, "
|
||||
"observer_cells=%u, observer_objects=%u, observer_chunks=%u, first_chunk=%u bytes",
|
||||
player_cells, player_objects, (unsigned int)player_chunks, device_test_view.cell_count,
|
||||
device_test_view.object_count,
|
||||
(unsigned int)maze32_game_view_chunk_count(&device_test_view),
|
||||
(unsigned int)payload_bytes);
|
||||
ESP_LOGI(TAG, "M006 budget: fixed=%u/%u bytes, runtime=%" PRIu64 "/%u ms, heap_delta=%d",
|
||||
(unsigned int)fixed_bytes, (unsigned int)MAZE32_GAME_RAM_BUDGET_BYTES,
|
||||
elapsed_us / 1000U, (unsigned int)MAZE32_GAME_RUNTIME_BUDGET_MS,
|
||||
(int)(free_before - free_after));
|
||||
if (device_test_view.cell_count != MAZE32_MAZE_DEFAULT_WIDTH * MAZE32_MAZE_DEFAULT_HEIGHT ||
|
||||
fixed_bytes > MAZE32_GAME_RAM_BUDGET_BYTES ||
|
||||
elapsed_us > (uint64_t)MAZE32_GAME_RUNTIME_BUDGET_MS * 1000U || free_before != free_after) {
|
||||
ESP_LOGE(TAG, "M006 physical game test failed");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
ESP_LOGI(TAG, "M006 physical game test: PASS");
|
||||
return ESP_OK;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef MAZE32_MILESTONE_005_DEVICE_TEST
|
||||
static maze32_maze_t device_test_maze;
|
||||
static maze32_maze_workspace_t device_test_workspace;
|
||||
|
||||
static esp_err_t run_maze_device_test(void) {
|
||||
maze32_maze_config_t config = maze32_maze_default_config();
|
||||
maze32_maze_report_t report;
|
||||
size_t free_before = heap_caps_get_free_size(MALLOC_CAP_8BIT);
|
||||
int64_t started_us = esp_timer_get_time();
|
||||
maze32_maze_result_t result = maze32_maze_generate(&device_test_maze, &device_test_workspace,
|
||||
&config, UINT32_C(0x5a320005), &report);
|
||||
uint64_t elapsed_us = (uint64_t)(esp_timer_get_time() - started_us);
|
||||
size_t free_after = heap_caps_get_free_size(MALLOC_CAP_8BIT);
|
||||
size_t fixed_bytes = sizeof(device_test_maze) + sizeof(device_test_workspace);
|
||||
ESP_LOGI(TAG, "M005 maze: result=%s, seed=%" PRIu32 ", attempts=%u, hash=%016" PRIx64,
|
||||
maze32_maze_result_name(result), device_test_maze.requested_seed, report.attempts,
|
||||
maze32_maze_hash(&device_test_maze));
|
||||
ESP_LOGI(TAG,
|
||||
"M005 topology: cells=%u, reachable=%u, edges=%u, cycles=%u, crossings=%u, "
|
||||
"dead_ends=%u, branches=%u",
|
||||
(unsigned int)(config.width * config.height), report.metrics.reachable_cells,
|
||||
report.metrics.passage_edges, report.metrics.cycle_count,
|
||||
report.metrics.cross_half_connections, report.metrics.dead_end_count,
|
||||
report.metrics.branching_cells);
|
||||
ESP_LOGI(TAG,
|
||||
"M005 fairness: start_key=%u/%u, key_exit=%u/%u, start_exit=%u/%u, "
|
||||
"compass=%u/%u, landmarks=%" PRIu32 "/%" PRIu32 ", artifacts=%" PRIu32 "/%" PRIu32,
|
||||
report.metrics.start_to_key[0], report.metrics.start_to_key[1],
|
||||
report.metrics.key_to_exit[0], report.metrics.key_to_exit[1],
|
||||
report.metrics.start_to_exit[0], report.metrics.start_to_exit[1],
|
||||
report.metrics.start_to_compass[0], report.metrics.start_to_compass[1],
|
||||
report.metrics.landmark_distance_total[0], report.metrics.landmark_distance_total[1],
|
||||
report.metrics.artifact_distance_total[0], report.metrics.artifact_distance_total[1]);
|
||||
ESP_LOGI(TAG, "M005 budget: fixed=%u/%u bytes, runtime=%" PRIu64 "/%u ms, heap_delta=%d",
|
||||
(unsigned int)fixed_bytes, (unsigned int)MAZE32_MAZE_RAM_BUDGET_BYTES,
|
||||
elapsed_us / 1000U, (unsigned int)MAZE32_MAZE_RUNTIME_BUDGET_MS,
|
||||
(int)(free_before - free_after));
|
||||
if (result != MAZE32_MAZE_OK || fixed_bytes > MAZE32_MAZE_RAM_BUDGET_BYTES ||
|
||||
elapsed_us > (uint64_t)MAZE32_MAZE_RUNTIME_BUDGET_MS * 1000U || free_before != free_after) {
|
||||
ESP_LOGE(TAG, "M005 physical maze test failed");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
ESP_LOGI(TAG, "M005 physical maze test: PASS");
|
||||
return ESP_OK;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef MAZE32_MILESTONE_003_DEVICE_TEST
|
||||
static int device_test_has_persisted_credentials;
|
||||
|
||||
static esp_err_t run_device_test_before_network(void) {
|
||||
const maze32_wifi_credentials_t expected = {
|
||||
.ssid = "maze32-unavailable-test",
|
||||
.password = "test-only-password",
|
||||
};
|
||||
maze32_wifi_credentials_t loaded = {0};
|
||||
esp_err_t result = maze32_config_store_load(&loaded);
|
||||
if (result == ESP_ERR_NOT_FOUND) {
|
||||
result = maze32_config_store_save(&expected);
|
||||
if (result == ESP_OK) {
|
||||
ESP_LOGI(TAG, "M003 device test: credentials saved; restarting");
|
||||
esp_restart();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (result != ESP_OK || strcmp(loaded.ssid, expected.ssid) != 0 ||
|
||||
strcmp(loaded.password, expected.password) != 0) {
|
||||
ESP_LOGE(TAG, "M003 device test refused to overwrite unexpected NVS credentials");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
device_test_has_persisted_credentials = 1;
|
||||
ESP_LOGI(TAG, "M003 device test: credentials survived restart");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t finish_device_test(void) {
|
||||
if (!device_test_has_persisted_credentials) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
esp_err_t result = maze32_config_store_clear();
|
||||
if (result == ESP_OK) {
|
||||
ESP_LOGI(TAG, "M003 device test: failed station returned to fallback; credentials cleared");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
#endif
|
||||
|
||||
static int prepare_for_network_reconfiguration(void* context) {
|
||||
const maze32_gateway_t* current_gateway = context;
|
||||
if (!maze32_gateway_can_reconfigure(current_gateway)) {
|
||||
ESP_LOGW(TAG, "Network reconfiguration rejected while lobby is active");
|
||||
return -1;
|
||||
}
|
||||
ESP_LOGW(TAG, "Runtime accepted network reconfiguration while lobby is waiting");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static esp_err_t mount_littlefs(void) {
|
||||
const esp_vfs_littlefs_conf_t configuration = {
|
||||
.base_path = "/littlefs",
|
||||
.partition_label = "littlefs",
|
||||
.format_if_mount_failed = false,
|
||||
.dont_mount = false,
|
||||
};
|
||||
|
||||
esp_err_t result = esp_vfs_littlefs_register(&configuration);
|
||||
if (result != ESP_OK) {
|
||||
return result;
|
||||
}
|
||||
|
||||
size_t total_bytes = 0;
|
||||
size_t used_bytes = 0;
|
||||
result = esp_littlefs_info(configuration.partition_label, &total_bytes, &used_bytes);
|
||||
if (result != ESP_OK) {
|
||||
esp_vfs_littlefs_unregister(configuration.partition_label);
|
||||
return result;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "LittleFS mounted: total=%u bytes, used=%u bytes", (unsigned int)total_bytes,
|
||||
(unsigned int)used_bytes);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void app_main(void) {
|
||||
const esp_app_desc_t* application = esp_app_get_description();
|
||||
maze32_health_init(&health, application->version);
|
||||
|
||||
#ifdef MAZE32_MILESTONE_005_DEVICE_TEST
|
||||
if (run_maze_device_test() != ESP_OK) {
|
||||
maze32_health_set(&health, MAZE32_HEALTH_ERROR, (int)ESP_FAIL);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef MAZE32_MILESTONE_006_DEVICE_TEST
|
||||
if (run_game_device_test() != ESP_OK) {
|
||||
maze32_health_set(&health, MAZE32_HEALTH_ERROR, (int)ESP_FAIL);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
#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));
|
||||
ESP_LOGI(TAG, "maze32 firmware version %s starting", application->version);
|
||||
|
||||
esp_err_t result = mount_littlefs();
|
||||
if (result != ESP_OK) {
|
||||
maze32_health_set(&health, MAZE32_HEALTH_ERROR, (int)result);
|
||||
ESP_LOGE(TAG, "LittleFS mount failed: %s", esp_err_to_name(result));
|
||||
return;
|
||||
}
|
||||
|
||||
maze32_health_set(&health, MAZE32_HEALTH_FILESYSTEM_READY, 0);
|
||||
|
||||
result = maze32_config_store_init();
|
||||
if (result != ESP_OK) {
|
||||
maze32_health_set(&health, MAZE32_HEALTH_ERROR, (int)result);
|
||||
ESP_LOGE(TAG, "Persistent configuration initialization failed: %s",
|
||||
esp_err_to_name(result));
|
||||
return;
|
||||
}
|
||||
|
||||
#ifdef MAZE32_MILESTONE_003_DEVICE_TEST
|
||||
result = run_device_test_before_network();
|
||||
if (result != ESP_OK) {
|
||||
maze32_health_set(&health, MAZE32_HEALTH_ERROR, (int)result);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
result = esp_netif_init();
|
||||
if (result != ESP_OK) {
|
||||
maze32_health_set(&health, MAZE32_HEALTH_ERROR, (int)result);
|
||||
ESP_LOGE(TAG, "TCP/IP stack initialization failed: %s", esp_err_to_name(result));
|
||||
return;
|
||||
}
|
||||
|
||||
result = esp_event_loop_create_default();
|
||||
if (result != ESP_OK) {
|
||||
maze32_health_set(&health, MAZE32_HEALTH_ERROR, (int)result);
|
||||
ESP_LOGE(TAG, "Default event loop initialization failed: %s", esp_err_to_name(result));
|
||||
return;
|
||||
}
|
||||
|
||||
result = maze32_gateway_init(&gateway);
|
||||
if (result != ESP_OK) {
|
||||
maze32_health_set(&health, MAZE32_HEALTH_ERROR, (int)result);
|
||||
ESP_LOGE(TAG, "WebSocket gateway initialization failed: %s", esp_err_to_name(result));
|
||||
return;
|
||||
}
|
||||
|
||||
result = maze32_network_init(&network, prepare_for_network_reconfiguration, &gateway);
|
||||
if (result == ESP_OK) {
|
||||
result = maze32_network_start(&network);
|
||||
}
|
||||
if (result != ESP_OK) {
|
||||
maze32_health_set(&health, MAZE32_HEALTH_ERROR, (int)result);
|
||||
ESP_LOGE(TAG, "Network initialization failed: %s", esp_err_to_name(result));
|
||||
return;
|
||||
}
|
||||
|
||||
#ifdef MAZE32_MILESTONE_003_DEVICE_TEST
|
||||
result = finish_device_test();
|
||||
if (result != ESP_OK) {
|
||||
maze32_health_set(&health, MAZE32_HEALTH_ERROR, (int)result);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
result = maze32_web_start(&web_server, &health, &gateway);
|
||||
if (result == ESP_OK) {
|
||||
result = maze32_gateway_register(web_server, &gateway);
|
||||
}
|
||||
if (result == ESP_OK) {
|
||||
result = maze32_provisioning_register(web_server, &network);
|
||||
}
|
||||
if (result != ESP_OK) {
|
||||
if (web_server != NULL) {
|
||||
httpd_stop(web_server);
|
||||
web_server = NULL;
|
||||
}
|
||||
maze32_health_set(&health, MAZE32_HEALTH_ERROR, (int)result);
|
||||
ESP_LOGE(TAG, "HTTP server startup failed: %s", esp_err_to_name(result));
|
||||
return;
|
||||
}
|
||||
|
||||
maze32_health_set(&health, MAZE32_HEALTH_SERVING, 0);
|
||||
ESP_LOGI(TAG, "Health state: %s", maze32_health_state_name(health.state));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
#include "nvs.h"
|
||||
#include "nvs_flash.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
static int initialized;
|
||||
static int present;
|
||||
static uint32_t schema;
|
||||
static char ssid[33];
|
||||
static char password[64];
|
||||
|
||||
esp_err_t nvs_flash_init(void)
|
||||
{
|
||||
initialized = 1;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t nvs_flash_deinit(void)
|
||||
{
|
||||
initialized = 0;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t nvs_flash_erase(void)
|
||||
{
|
||||
present = 0;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t nvs_open(const char *namespace_name, int mode, nvs_handle_t *handle)
|
||||
{
|
||||
if (!initialized || strcmp(namespace_name, "maze32") != 0) return ESP_FAIL;
|
||||
if (mode == NVS_READONLY && !present) return ESP_ERR_NVS_NOT_FOUND;
|
||||
*handle = 1U;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void nvs_close(nvs_handle_t handle)
|
||||
{
|
||||
(void)handle;
|
||||
}
|
||||
|
||||
esp_err_t nvs_get_u32(nvs_handle_t handle, const char *key, uint32_t *value)
|
||||
{
|
||||
(void)handle;
|
||||
if (!present || strcmp(key, "cfg_ver") != 0) return ESP_ERR_NVS_NOT_FOUND;
|
||||
*value = schema;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t nvs_set_u32(nvs_handle_t handle, const char *key, uint32_t value)
|
||||
{
|
||||
(void)handle;
|
||||
if (strcmp(key, "cfg_ver") != 0) return ESP_FAIL;
|
||||
schema = value;
|
||||
present = 1;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t nvs_get_str(nvs_handle_t handle, const char *key, char *output, size_t *length)
|
||||
{
|
||||
(void)handle;
|
||||
const char *source = strcmp(key, "wifi_ssid") == 0 ? ssid :
|
||||
strcmp(key, "wifi_pass") == 0 ? password : NULL;
|
||||
if (!present || source == NULL) return ESP_ERR_NVS_NOT_FOUND;
|
||||
size_t required = strlen(source) + 1U;
|
||||
if (*length < required) return ESP_FAIL;
|
||||
memcpy(output, source, required);
|
||||
*length = required;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t nvs_set_str(nvs_handle_t handle, const char *key, const char *value)
|
||||
{
|
||||
(void)handle;
|
||||
char *destination = strcmp(key, "wifi_ssid") == 0 ? ssid :
|
||||
strcmp(key, "wifi_pass") == 0 ? password : NULL;
|
||||
size_t capacity = strcmp(key, "wifi_ssid") == 0 ? sizeof(ssid) : sizeof(password);
|
||||
if (destination == NULL || strlen(value) >= capacity) return ESP_FAIL;
|
||||
memcpy(destination, value, strlen(value) + 1U);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t nvs_erase_all(nvs_handle_t handle)
|
||||
{
|
||||
(void)handle;
|
||||
present = 0;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t nvs_commit(nvs_handle_t handle)
|
||||
{
|
||||
(void)handle;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void fake_nvs_set_schema(uint32_t version)
|
||||
{
|
||||
schema = version;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#ifndef TEST_ESP_ERR_H
|
||||
#define TEST_ESP_ERR_H
|
||||
|
||||
typedef int esp_err_t;
|
||||
|
||||
#define ESP_OK 0
|
||||
#define ESP_FAIL 0x101
|
||||
#define ESP_ERR_NO_MEM 0x102
|
||||
#define ESP_ERR_INVALID_ARG 0x103
|
||||
#define ESP_ERR_INVALID_STATE 0x104
|
||||
#define ESP_ERR_INVALID_VERSION 0x105
|
||||
#define ESP_ERR_NOT_FOUND 0x106
|
||||
#define ESP_ERR_NVS_NOT_FOUND 0x1102
|
||||
#define ESP_ERR_NVS_NO_FREE_PAGES 0x110d
|
||||
#define ESP_ERR_NVS_NEW_VERSION_FOUND 0x1110
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,25 @@
|
||||
#ifndef TEST_NVS_H
|
||||
#define TEST_NVS_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
typedef unsigned int nvs_handle_t;
|
||||
|
||||
#define NVS_READONLY 0
|
||||
#define NVS_READWRITE 1
|
||||
|
||||
esp_err_t nvs_open(const char *namespace_name, int mode, nvs_handle_t *handle);
|
||||
void nvs_close(nvs_handle_t handle);
|
||||
esp_err_t nvs_get_u32(nvs_handle_t handle, const char *key, uint32_t *value);
|
||||
esp_err_t nvs_set_u32(nvs_handle_t handle, const char *key, uint32_t value);
|
||||
esp_err_t nvs_get_str(nvs_handle_t handle, const char *key, char *output, size_t *length);
|
||||
esp_err_t nvs_set_str(nvs_handle_t handle, const char *key, const char *value);
|
||||
esp_err_t nvs_erase_all(nvs_handle_t handle);
|
||||
esp_err_t nvs_commit(nvs_handle_t handle);
|
||||
|
||||
void fake_nvs_set_schema(uint32_t version);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,10 @@
|
||||
#ifndef TEST_NVS_FLASH_H
|
||||
#define TEST_NVS_FLASH_H
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
esp_err_t nvs_flash_init(void);
|
||||
esp_err_t nvs_flash_erase(void);
|
||||
esp_err_t nvs_flash_deinit(void);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,45 @@
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "maze32_config_store.h"
|
||||
#include "nvs.h"
|
||||
#include "nvs_flash.h"
|
||||
|
||||
static maze32_wifi_credentials_t credentials(const char *ssid, const char *password)
|
||||
{
|
||||
maze32_wifi_credentials_t value = {0};
|
||||
memcpy(value.ssid, ssid, strlen(ssid) + 1U);
|
||||
memcpy(value.password, password, strlen(password) + 1U);
|
||||
return value;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
assert(maze32_config_store_init() == ESP_OK);
|
||||
maze32_wifi_credentials_t loaded = {0};
|
||||
assert(maze32_config_store_load(&loaded) == ESP_ERR_NOT_FOUND);
|
||||
|
||||
maze32_wifi_credentials_t saved = credentials("PersistentNetwork", "safe-passphrase");
|
||||
assert(maze32_config_store_save(&saved) == ESP_OK);
|
||||
assert(nvs_flash_deinit() == ESP_OK);
|
||||
assert(maze32_config_store_init() == ESP_OK);
|
||||
assert(maze32_config_store_load(&loaded) == ESP_OK);
|
||||
assert(strcmp(loaded.ssid, saved.ssid) == 0);
|
||||
assert(strcmp(loaded.password, saved.password) == 0);
|
||||
|
||||
maze32_wifi_credentials_t invalid = credentials("PersistentNetwork", "short");
|
||||
assert(maze32_config_store_save(&invalid) == ESP_ERR_INVALID_ARG);
|
||||
memset(&loaded, 0, sizeof(loaded));
|
||||
assert(maze32_config_store_load(&loaded) == ESP_OK);
|
||||
assert(strcmp(loaded.password, saved.password) == 0);
|
||||
|
||||
fake_nvs_set_schema(MAZE32_WIFI_CONFIG_SCHEMA_VERSION + 1U);
|
||||
assert(maze32_config_store_load(&loaded) == ESP_ERR_INVALID_VERSION);
|
||||
fake_nvs_set_schema(MAZE32_WIFI_CONFIG_SCHEMA_VERSION);
|
||||
|
||||
assert(maze32_config_store_clear() == ESP_OK);
|
||||
assert(maze32_config_store_load(&loaded) == ESP_ERR_NOT_FOUND);
|
||||
puts("configuration NVS tests: PASS");
|
||||
return 0;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
#include "maze32_game.h"
|
||||
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
static uint16_t index_of(uint16_t x, uint16_t y) {
|
||||
return (uint16_t)(y * 8U + x);
|
||||
}
|
||||
|
||||
static void open_test_grid(maze32_maze_t* maze) {
|
||||
memset(maze, 0, sizeof(*maze));
|
||||
maze->config.width = 8U;
|
||||
maze->config.height = 8U;
|
||||
maze->config.landmark_pairs = 1U;
|
||||
maze->config.artifact_pairs = 1U;
|
||||
for (uint16_t y = 0U; y < 8U; ++y) {
|
||||
for (uint16_t x = 0U; x < 8U; ++x) {
|
||||
uint8_t passages = 0U;
|
||||
if (y > 0U) {
|
||||
passages |= MAZE32_PASSAGE_NORTH;
|
||||
}
|
||||
if (x < 7U) {
|
||||
passages |= MAZE32_PASSAGE_EAST;
|
||||
}
|
||||
if (y < 7U) {
|
||||
passages |= MAZE32_PASSAGE_SOUTH;
|
||||
}
|
||||
if (x > 0U) {
|
||||
passages |= MAZE32_PASSAGE_WEST;
|
||||
}
|
||||
maze->passages[index_of(x, y)] = passages;
|
||||
}
|
||||
}
|
||||
maze->starts[0] = (maze32_cell_t){1U, 1U};
|
||||
maze->starts[1] = (maze32_cell_t){6U, 6U};
|
||||
maze->keys[0] = (maze32_cell_t){2U, 1U};
|
||||
maze->keys[1] = (maze32_cell_t){7U, 7U};
|
||||
maze->exits[0] = (maze32_cell_t){0U, 0U};
|
||||
maze->exits[1] = (maze32_cell_t){7U, 6U};
|
||||
maze->compasses[0] = (maze32_cell_t){1U, 0U};
|
||||
maze->compasses[1] = (maze32_cell_t){6U, 7U};
|
||||
maze->landmarks[0].cells[0] = (maze32_cell_t){0U, 1U};
|
||||
maze->landmarks[0].cells[1] = (maze32_cell_t){7U, 6U};
|
||||
maze->landmarks[0].kind = 4U;
|
||||
maze->artifacts[0][0] = (maze32_cell_t){2U, 2U};
|
||||
maze->artifacts[0][1] = (maze32_cell_t){6U, 6U};
|
||||
}
|
||||
|
||||
static maze32_game_t initialized_game(maze32_maze_t* maze) {
|
||||
maze32_game_t game;
|
||||
maze32_game_config_t config = maze32_game_default_config();
|
||||
assert(config.movement_cooldown_ms == 150U);
|
||||
assert(maze32_game_init(&game, maze, &config) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||
return game;
|
||||
}
|
||||
|
||||
static void assert_position(const maze32_game_t* game, uint8_t player, uint16_t x, uint16_t y) {
|
||||
assert(game->players[player].position.x == x);
|
||||
assert(game->players[player].position.y == y);
|
||||
}
|
||||
|
||||
static void test_movement_cooldown_and_ordering(void) {
|
||||
maze32_maze_t maze;
|
||||
open_test_grid(&maze);
|
||||
maze32_game_t game = initialized_game(&maze);
|
||||
uint32_t sequence = 0U;
|
||||
|
||||
assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST,
|
||||
100U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert(sequence == 1U);
|
||||
maze32_game_command_result_t result = maze32_game_process_next(&game);
|
||||
assert(result.result == MAZE32_GAME_RESULT_PREMATURE);
|
||||
assert(result.command_sequence == 1U);
|
||||
assert(game.state_sequence == 0U);
|
||||
assert_position(&game, 0U, 1U, 1U);
|
||||
|
||||
maze32_game_set_phase(&game, MAZE32_GAME_PHASE_ACTIVE);
|
||||
assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST,
|
||||
100U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert(sequence == 2U);
|
||||
assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_TWO, MAZE32_GAME_DIRECTION_WEST,
|
||||
100U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert(sequence == 3U);
|
||||
result = maze32_game_process_next(&game);
|
||||
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert(result.command_sequence == 2U && result.state_sequence == 1U);
|
||||
assert_position(&game, 0U, 2U, 1U);
|
||||
assert_position(&game, 1U, 6U, 6U);
|
||||
result = maze32_game_process_next(&game);
|
||||
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert(result.command_sequence == 3U && result.state_sequence == 2U);
|
||||
assert_position(&game, 1U, 5U, 6U);
|
||||
|
||||
assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_SOUTH,
|
||||
200U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||
result = maze32_game_process_next(&game);
|
||||
assert(result.result == MAZE32_GAME_RESULT_RATE_LIMITED);
|
||||
assert(game.state_sequence == 2U && game.players[0].last_move_ms == 100U);
|
||||
assert_position(&game, 0U, 2U, 1U);
|
||||
|
||||
assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_SOUTH,
|
||||
250U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||
result = maze32_game_process_next(&game);
|
||||
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert_position(&game, 0U, 2U, 2U);
|
||||
assert(game.players[0].accepted_moves == 2U);
|
||||
|
||||
uint16_t current = index_of(2U, 2U);
|
||||
maze.passages[current] &= (uint8_t)~MAZE32_PASSAGE_EAST;
|
||||
assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST,
|
||||
400U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||
result = maze32_game_process_next(&game);
|
||||
assert(result.result == MAZE32_GAME_RESULT_BLOCKED);
|
||||
assert(game.state_sequence == 3U && game.players[0].last_move_ms == 250U);
|
||||
assert_position(&game, 0U, 2U, 2U);
|
||||
|
||||
assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_WEST,
|
||||
200U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||
result = maze32_game_process_next(&game);
|
||||
assert(result.result == MAZE32_GAME_RESULT_RATE_LIMITED);
|
||||
assert_position(&game, 0U, 2U, 2U);
|
||||
|
||||
assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_OBSERVER, MAZE32_GAME_DIRECTION_EAST,
|
||||
500U, &sequence) == MAZE32_GAME_RESULT_READ_ONLY);
|
||||
assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_ONE, (maze32_game_direction_t)99,
|
||||
500U, &sequence) == MAZE32_GAME_RESULT_INVALID_ARGUMENT);
|
||||
assert(game.state_sequence == 3U);
|
||||
printf("game movement, cooldown, and FIFO ordering: PASS (commands 1-7, states 0-3)\n");
|
||||
}
|
||||
|
||||
static void test_bounded_queue(void) {
|
||||
maze32_maze_t maze;
|
||||
open_test_grid(&maze);
|
||||
maze32_game_t game = initialized_game(&maze);
|
||||
uint32_t sequence = 0U;
|
||||
for (uint32_t index = 0U; index < MAZE32_GAME_COMMAND_CAPACITY; ++index) {
|
||||
assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_ONE,
|
||||
MAZE32_GAME_DIRECTION_EAST, index,
|
||||
&sequence) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert(sequence == index + 1U);
|
||||
}
|
||||
assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST,
|
||||
20U, &sequence) == MAZE32_GAME_RESULT_QUEUE_FULL);
|
||||
assert(game.next_command_sequence == MAZE32_GAME_COMMAND_CAPACITY + 1U);
|
||||
assert(game.state_sequence == 0U);
|
||||
printf("game bounded command queue: PASS (%u entries)\n", MAZE32_GAME_COMMAND_CAPACITY);
|
||||
}
|
||||
|
||||
static void test_shared_cell_meeting(void) {
|
||||
maze32_maze_t maze;
|
||||
open_test_grid(&maze);
|
||||
maze.starts[1] = (maze32_cell_t){2U, 1U};
|
||||
maze32_game_t game = initialized_game(&maze);
|
||||
maze32_game_set_phase(&game, MAZE32_GAME_PHASE_ACTIVE);
|
||||
uint32_t sequence = 0U;
|
||||
assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST,
|
||||
10U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||
maze32_game_command_result_t result = maze32_game_process_next(&game);
|
||||
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert(result.meeting == 1U && result.meeting_sequence == 1U);
|
||||
assert_position(&game, 0U, 2U, 1U);
|
||||
assert_position(&game, 1U, 2U, 1U);
|
||||
result = maze32_game_process_next(&game);
|
||||
assert(result.result == MAZE32_GAME_RESULT_QUEUE_EMPTY);
|
||||
assert(result.meeting == 0U && result.meeting_sequence == 1U);
|
||||
printf("game shared-cell meeting event: PASS (one event)\n");
|
||||
}
|
||||
|
||||
static int view_has_cell(const maze32_view_t* view, uint16_t cell_index) {
|
||||
for (uint16_t index = 0U; index < view->cell_count; ++index) {
|
||||
if (view->cells[index].cell_index == cell_index) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int view_has_object(const maze32_view_t* view, maze32_view_object_kind_t kind,
|
||||
uint16_t cell_index) {
|
||||
for (uint8_t index = 0U; index < view->object_count; ++index) {
|
||||
if (view->objects[index].kind == kind && view->objects[index].cell_index == cell_index) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void serialize_and_inspect(const maze32_view_t* view, int expect_hidden) {
|
||||
char payload[MAZE32_GAME_VIEW_CHUNK_BYTES];
|
||||
size_t chunks = maze32_game_view_chunk_count(view);
|
||||
int saw_last_cell = 0;
|
||||
int saw_hidden_key = 0;
|
||||
int saw_players = 0;
|
||||
for (size_t chunk = 0U; chunk < chunks; ++chunk) {
|
||||
size_t written = 0U;
|
||||
assert(maze32_game_serialize_view_chunk(view, chunk, payload, sizeof(payload), &written) ==
|
||||
MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert(written < sizeof(payload));
|
||||
assert(payload[written] == '\0');
|
||||
assert(strstr(payload, "{\"v\":1,\"type\":\"view\",\"seq\":") == payload);
|
||||
assert(strstr(payload, "\"phase\":\"countdown\"") != NULL);
|
||||
assert(strstr(payload, "\"width\":8,\"height\":8") != NULL);
|
||||
saw_last_cell |= strstr(payload, "[63,") != NULL;
|
||||
saw_hidden_key |= strstr(payload, "[\"key\",1,0,63]") != NULL;
|
||||
saw_players |= strstr(payload, "\"players\":[") != NULL;
|
||||
if (expect_hidden) {
|
||||
assert(strstr(payload, "\"opponent\":63") == NULL);
|
||||
}
|
||||
}
|
||||
if (expect_hidden) {
|
||||
assert(!saw_last_cell && !saw_hidden_key && !saw_players);
|
||||
} else {
|
||||
assert(saw_last_cell && saw_hidden_key && saw_players);
|
||||
}
|
||||
}
|
||||
|
||||
static void test_visibility_and_serialization(void) {
|
||||
maze32_maze_t maze;
|
||||
open_test_grid(&maze);
|
||||
maze32_game_t game = initialized_game(&maze);
|
||||
maze32_view_t view;
|
||||
|
||||
game.players[0].position = (maze32_cell_t){3U, 3U};
|
||||
game.players[1].position = (maze32_cell_t){4U, 4U};
|
||||
maze.passages[index_of(3U, 3U)] = 0U;
|
||||
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, &view) ==
|
||||
MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert(view.cell_count == 49U);
|
||||
assert(view_has_cell(&view, index_of(6U, 6U)));
|
||||
assert(view.opponent_visible && view.opponent_cell_index == index_of(4U, 4U));
|
||||
|
||||
game.players[0].position = (maze32_cell_t){0U, 0U};
|
||||
game.players[1].position = (maze32_cell_t){7U, 7U};
|
||||
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, &view) ==
|
||||
MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert(view.cell_count == 16U);
|
||||
assert(!view.opponent_visible && view.opponent_cell_index == UINT16_MAX);
|
||||
assert(view_has_object(&view, MAZE32_VIEW_OBJECT_KEY, index_of(2U, 1U)));
|
||||
assert(!view_has_object(&view, MAZE32_VIEW_OBJECT_KEY, index_of(7U, 7U)));
|
||||
serialize_and_inspect(&view, 1);
|
||||
|
||||
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_TWO, &view) ==
|
||||
MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert(view.cell_count == 16U && view.self_cell_index == index_of(7U, 7U));
|
||||
assert(!view.opponent_visible && !view_has_cell(&view, index_of(0U, 0U)));
|
||||
assert(view_has_object(&view, MAZE32_VIEW_OBJECT_KEY, index_of(7U, 7U)));
|
||||
assert(!view_has_object(&view, MAZE32_VIEW_OBJECT_KEY, index_of(2U, 1U)));
|
||||
char player_two_payload[MAZE32_GAME_VIEW_CHUNK_BYTES];
|
||||
for (size_t chunk = 0U; chunk < maze32_game_view_chunk_count(&view); ++chunk) {
|
||||
size_t written = 0U;
|
||||
assert(maze32_game_serialize_view_chunk(&view, chunk, player_two_payload,
|
||||
sizeof(player_two_payload),
|
||||
&written) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert(strstr(player_two_payload, "\"role\":\"player2\"") != NULL);
|
||||
assert(strstr(player_two_payload, "\"opponent\":0") == NULL);
|
||||
assert(strstr(player_two_payload, "[\"key\",0,0,10]") == NULL);
|
||||
}
|
||||
|
||||
game.players[0].vision_moves_remaining = 1U;
|
||||
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, &view) ==
|
||||
MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert(view.cell_count == 49U);
|
||||
assert(!view_has_cell(&view, index_of(7U, 7U)));
|
||||
|
||||
game.players[0].position = (maze32_cell_t){3U, 3U};
|
||||
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, &view) ==
|
||||
MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert(view.cell_count == 64U);
|
||||
assert(view.opponent_visible);
|
||||
|
||||
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, &view) ==
|
||||
MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert(view.cell_count == 64U);
|
||||
assert(view.object_count == 10U);
|
||||
assert(view.observer_player_cell_indices[0] == index_of(3U, 3U));
|
||||
assert(view.observer_player_cell_indices[1] == index_of(7U, 7U));
|
||||
assert(view.phase == MAZE32_GAME_PHASE_COUNTDOWN);
|
||||
serialize_and_inspect(&view, 0);
|
||||
printf("game visibility and payload security: PASS (r3=49/16, r6=64/49, observer=64)\n");
|
||||
}
|
||||
|
||||
static void test_default_map_chunk_bounds(void) {
|
||||
maze32_maze_t maze;
|
||||
maze32_maze_workspace_t workspace;
|
||||
maze32_maze_config_t maze_config = maze32_maze_default_config();
|
||||
maze32_maze_report_t report;
|
||||
assert(maze32_maze_generate(&maze, &workspace, &maze_config, UINT32_C(0x5a320006), &report) ==
|
||||
MAZE32_MAZE_OK);
|
||||
maze32_game_t game = initialized_game(&maze);
|
||||
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;
|
||||
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, &view) ==
|
||||
MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert(view.cell_count == 1024U && view.object_count == 12U);
|
||||
size_t chunks = maze32_game_view_chunk_count(&view);
|
||||
assert(chunks == 88U);
|
||||
char payload[MAZE32_GAME_VIEW_CHUNK_BYTES];
|
||||
size_t largest_chunk = 0U;
|
||||
for (size_t chunk = 0U; chunk < chunks; ++chunk) {
|
||||
size_t written = 0U;
|
||||
assert(maze32_game_serialize_view_chunk(&view, chunk, payload, sizeof(payload), &written) ==
|
||||
MAZE32_GAME_RESULT_ACCEPTED);
|
||||
if (chunk == 0U) {
|
||||
assert(strstr(payload, "\"connected\":1") != NULL);
|
||||
}
|
||||
if (written > largest_chunk) {
|
||||
largest_chunk = written;
|
||||
}
|
||||
}
|
||||
assert(largest_chunk < MAZE32_GAME_VIEW_CHUNK_BYTES);
|
||||
printf("game default observer serialization: PASS (%u chunks, maximum %u bytes)\n",
|
||||
(unsigned int)chunks, (unsigned int)largest_chunk);
|
||||
}
|
||||
|
||||
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) {
|
||||
test_movement_cooldown_and_ordering();
|
||||
test_bounded_queue();
|
||||
test_shared_cell_meeting();
|
||||
test_visibility_and_serialization();
|
||||
test_default_map_chunk_bounds();
|
||||
test_authoritative_trails_and_visibility();
|
||||
puts("game core tests: PASS");
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#include "maze32_health.h"
|
||||
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
int main(void)
|
||||
{
|
||||
maze32_health_t health;
|
||||
maze32_health_init(&health, "test-version");
|
||||
|
||||
assert(strcmp(health.version, "test-version") == 0);
|
||||
assert(health.state == MAZE32_HEALTH_BOOTING);
|
||||
assert(strcmp(maze32_health_state_name(health.state), "booting") == 0);
|
||||
assert(maze32_health_is_healthy(&health) == 0);
|
||||
|
||||
maze32_health_set(&health, MAZE32_HEALTH_FILESYSTEM_READY, 0);
|
||||
assert(strcmp(maze32_health_state_name(health.state), "filesystem_ready") == 0);
|
||||
assert(maze32_health_is_healthy(&health) == 0);
|
||||
|
||||
maze32_health_set(&health, MAZE32_HEALTH_SERVING, 0);
|
||||
assert(strcmp(maze32_health_state_name(health.state), "serving") == 0);
|
||||
assert(maze32_health_is_healthy(&health) == 1);
|
||||
|
||||
maze32_health_set(&health, MAZE32_HEALTH_ERROR, 17);
|
||||
assert(strcmp(maze32_health_state_name(health.state), "error") == 0);
|
||||
assert(health.error_code == 17);
|
||||
assert(maze32_health_is_healthy(&health) == 0);
|
||||
assert(strcmp(maze32_health_state_name((maze32_health_state_t)99), "unknown") == 0);
|
||||
|
||||
printf("health state tests: PASS\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "maze32_lobby.h"
|
||||
#include "maze32_protocol.h"
|
||||
|
||||
static const char* TOKEN_1 = "000102030405060708090a0b0c0d0e0f";
|
||||
static const char* TOKEN_2 = "101112131415161718191a1b1c1d1e1f";
|
||||
static const char* OBSERVER_TOKENS[] = {
|
||||
"202122232425262728292a2b2c2d2e2f", "303132333435363738393a3b3c3d3e3f",
|
||||
"404142434445464748494a4b4c4d4e4f", "505152535455565758595a5b5c5d5e5f",
|
||||
"606162636465666768696a6b6c6d6e6f",
|
||||
};
|
||||
|
||||
static void test_protocol_schema(void) {
|
||||
maze32_client_message_t message;
|
||||
const char* join = "{\"v\":1,\"type\":\"join\",\"seq\":1,\"requestId\":\"join-1\","
|
||||
"\"payload\":{\"role\":\"player\",\"name\":\"Лорд Лабиринт\"}}";
|
||||
assert(maze32_protocol_parse(join, strlen(join), &message) == MAZE32_PROTOCOL_OK);
|
||||
assert(message.type == MAZE32_MESSAGE_JOIN);
|
||||
assert(message.sequence == 1U);
|
||||
assert(message.role == MAZE32_ROLE_PLAYER);
|
||||
assert(strcmp(message.name, "Лорд Лабиринт") == 0);
|
||||
|
||||
const char* restore = "{\"v\":1,\"type\":\"restore\",\"seq\":2,\"payload\":{\"token\":"
|
||||
"\"000102030405060708090a0b0c0d0e0f\"}}";
|
||||
assert(maze32_protocol_parse(restore, strlen(restore), &message) == MAZE32_PROTOCOL_OK);
|
||||
assert(message.type == MAZE32_MESSAGE_RESTORE);
|
||||
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\":{}}";
|
||||
assert(maze32_protocol_parse(bad_version, strlen(bad_version), &message) ==
|
||||
MAZE32_PROTOCOL_UNSUPPORTED_VERSION);
|
||||
const char* unknown = "{\"v\":1,\"type\":\"teleport\",\"seq\":1,\"payload\":{}}";
|
||||
assert(maze32_protocol_parse(unknown, strlen(unknown), &message) ==
|
||||
MAZE32_PROTOCOL_UNKNOWN_TYPE);
|
||||
const char* extra = "{\"v\":1,\"type\":\"start\",\"seq\":1,\"payload\":{\"power\":\"yes\"}}";
|
||||
assert(maze32_protocol_parse(extra, strlen(extra), &message) ==
|
||||
MAZE32_PROTOCOL_INVALID_PAYLOAD);
|
||||
const char* duplicate = "{\"v\":1,\"v\":1,\"type\":\"start\",\"seq\":1,\"payload\":{}}";
|
||||
assert(maze32_protocol_parse(duplicate, strlen(duplicate), &message) ==
|
||||
MAZE32_PROTOCOL_MALFORMED);
|
||||
char oversized[MAZE32_PROTOCOL_MESSAGE_LIMIT + 2U];
|
||||
memset(oversized, 'x', sizeof(oversized));
|
||||
assert(maze32_protocol_parse(oversized, sizeof(oversized), &message) ==
|
||||
MAZE32_PROTOCOL_TOO_LARGE);
|
||||
}
|
||||
|
||||
static void test_lobby_limits_restoration_and_authority(void) {
|
||||
maze32_lobby_t lobby;
|
||||
maze32_lobby_init(&lobby);
|
||||
maze32_session_t* player_1 = NULL;
|
||||
maze32_session_t* player_2 = NULL;
|
||||
maze32_session_t* extra = NULL;
|
||||
assert(maze32_lobby_join_player(&lobby, 10, "Капитан Клавиша", TOKEN_1, &player_1) ==
|
||||
MAZE32_LOBBY_OK);
|
||||
assert(player_1->player_slot == 1U);
|
||||
uint32_t revision = lobby.revision;
|
||||
assert(maze32_lobby_join_player(&lobby, 11, "Капитан Клавиша", TOKEN_2, &player_2) ==
|
||||
MAZE32_LOBBY_DUPLICATE_NAME);
|
||||
assert(lobby.revision == revision);
|
||||
assert(maze32_lobby_join_player(&lobby, 11, "Мышь в доспехах", TOKEN_2, &player_2) ==
|
||||
MAZE32_LOBBY_OK);
|
||||
assert(player_2->player_slot == 2U);
|
||||
assert(maze32_lobby_ready(&lobby));
|
||||
|
||||
for (unsigned int index = 0U; index < MAZE32_OBSERVER_LIMIT; ++index) {
|
||||
maze32_session_t* observer = NULL;
|
||||
assert(maze32_lobby_join_observer(&lobby, 20 + (int)index, OBSERVER_TOKENS[index],
|
||||
&observer) == MAZE32_LOBBY_OK);
|
||||
assert(observer->role == MAZE32_ROLE_OBSERVER);
|
||||
}
|
||||
assert(maze32_lobby_connected_observers(&lobby) == MAZE32_OBSERVER_LIMIT);
|
||||
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;
|
||||
assert(maze32_lobby_join_observer(&lobby, 30, OBSERVER_TOKENS[4], &extra) ==
|
||||
MAZE32_LOBBY_ROLE_FULL);
|
||||
assert(lobby.revision == revision);
|
||||
|
||||
assert(maze32_lobby_start(&lobby, 11, 1000U) == MAZE32_LOBBY_NOT_OWNER);
|
||||
assert(lobby.phase == MAZE32_LOBBY_WAITING);
|
||||
maze32_lobby_disconnect(&lobby, 11);
|
||||
assert(!maze32_lobby_ready(&lobby));
|
||||
assert(maze32_lobby_start(&lobby, 10, 1000U) == MAZE32_LOBBY_NOT_READY);
|
||||
assert(maze32_lobby_restore(&lobby, 31, TOKEN_2, &player_2) == MAZE32_LOBBY_OK);
|
||||
assert(player_2->player_slot == 2U && player_2->connection_id == 31);
|
||||
|
||||
assert(maze32_lobby_start(&lobby, 10, 2000U) == MAZE32_LOBBY_OK);
|
||||
assert(lobby.phase == MAZE32_LOBBY_COUNTDOWN);
|
||||
assert(lobby.activation_ms == 5000U);
|
||||
assert(!maze32_lobby_can_reconfigure(&lobby));
|
||||
assert(!maze32_lobby_activate_if_due(&lobby, 4999U));
|
||||
assert(maze32_lobby_activate_if_due(&lobby, 5000U));
|
||||
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) {
|
||||
test_protocol_schema();
|
||||
test_lobby_limits_restoration_and_authority();
|
||||
test_explicit_leave_releases_only_departing_session();
|
||||
puts("lobby and protocol tests: PASS");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
#include <assert.h>
|
||||
#include <inttypes.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "maze32_maze.h"
|
||||
|
||||
#define SEED_COUNT 1024U
|
||||
|
||||
static maze32_maze_t maze;
|
||||
static maze32_maze_t repeated_maze;
|
||||
static maze32_maze_t tampered_maze;
|
||||
static maze32_maze_workspace_t workspace;
|
||||
|
||||
static char object_at(const maze32_maze_t* current_maze, maze32_cell_t cell) {
|
||||
const char labels[] = {'1', '2', 'k', 'K', 'e', 'E', 'c', 'C'};
|
||||
const maze32_cell_t* required[] = {current_maze->starts, current_maze->keys,
|
||||
current_maze->exits, current_maze->compasses};
|
||||
for (size_t group = 0U; group < 4U; ++group) {
|
||||
for (size_t player = 0U; player < 2U; ++player) {
|
||||
if (required[group][player].x == cell.x && required[group][player].y == cell.y) {
|
||||
return labels[group * 2U + player];
|
||||
}
|
||||
}
|
||||
}
|
||||
for (size_t pair = 0U; pair < current_maze->config.landmark_pairs; ++pair) {
|
||||
for (size_t side = 0U; side < 2U; ++side) {
|
||||
if (current_maze->landmarks[pair].cells[side].x == cell.x &&
|
||||
current_maze->landmarks[pair].cells[side].y == cell.y) {
|
||||
return 'L';
|
||||
}
|
||||
}
|
||||
}
|
||||
for (size_t pair = 0U; pair < current_maze->config.artifact_pairs; ++pair) {
|
||||
for (size_t side = 0U; side < 2U; ++side) {
|
||||
if (current_maze->artifacts[pair][side].x == cell.x &&
|
||||
current_maze->artifacts[pair][side].y == cell.y) {
|
||||
return 'V';
|
||||
}
|
||||
}
|
||||
}
|
||||
return ' ';
|
||||
}
|
||||
|
||||
static void print_representative_map(const maze32_maze_t* current_maze) {
|
||||
puts("representative 16x8 map (1/2 starts, k/K keys, e/E exits, c/C compasses):");
|
||||
for (uint16_t y = 0U; y < current_maze->config.height; ++y) {
|
||||
putchar('+');
|
||||
for (uint16_t x = 0U; x < current_maze->config.width; ++x) {
|
||||
maze32_cell_t cell = {x, y};
|
||||
fputs(maze32_maze_has_passage(current_maze, cell, MAZE32_PASSAGE_NORTH) ? " +" : "--+",
|
||||
stdout);
|
||||
}
|
||||
putchar('\n');
|
||||
putchar('|');
|
||||
for (uint16_t x = 0U; x < current_maze->config.width; ++x) {
|
||||
maze32_cell_t cell = {x, y};
|
||||
printf("%c%c", object_at(current_maze, cell),
|
||||
maze32_maze_has_passage(current_maze, cell, MAZE32_PASSAGE_EAST) ? ' ' : '|');
|
||||
}
|
||||
putchar('\n');
|
||||
}
|
||||
for (uint16_t x = 0U; x < current_maze->config.width; ++x) {
|
||||
fputs("+--", stdout);
|
||||
}
|
||||
puts("+");
|
||||
}
|
||||
|
||||
static void test_invalid_configuration(void) {
|
||||
maze32_maze_config_t config = maze32_maze_default_config();
|
||||
maze32_maze_report_t report;
|
||||
config.width = 127U;
|
||||
assert(maze32_maze_generate(&maze, &workspace, &config, 1U, &report) ==
|
||||
MAZE32_MAZE_INVALID_CONFIG);
|
||||
assert(report.result == MAZE32_MAZE_INVALID_CONFIG);
|
||||
}
|
||||
|
||||
static void test_bounded_regeneration_failure(void) {
|
||||
maze32_maze_config_t config = maze32_maze_default_config();
|
||||
maze32_maze_report_t report;
|
||||
config.width = 8U;
|
||||
config.height = 8U;
|
||||
config.extra_cycle_pairs = 64U;
|
||||
config.minimum_exit_separation = 4U;
|
||||
config.maximum_attempts = 3U;
|
||||
assert(maze32_maze_generate(&maze, &workspace, &config, 7U, &report) ==
|
||||
MAZE32_MAZE_GENERATION_EXHAUSTED);
|
||||
assert(report.attempts == config.maximum_attempts);
|
||||
assert(report.last_validation_result != MAZE32_MAZE_OK);
|
||||
puts("maze bounded regeneration failure: PASS (3/3 attempts)");
|
||||
}
|
||||
|
||||
static void test_large_seed_set(void) {
|
||||
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 maximum_cycles = 0U;
|
||||
uint16_t minimum_dead_ends = UINT16_MAX;
|
||||
uint16_t maximum_dead_ends = 0U;
|
||||
uint16_t minimum_start_key = UINT16_MAX;
|
||||
uint16_t maximum_start_key = 0U;
|
||||
uint16_t minimum_key_exit = UINT16_MAX;
|
||||
uint16_t maximum_key_exit = 0U;
|
||||
uint16_t minimum_start_exit = UINT16_MAX;
|
||||
uint16_t maximum_start_exit = 0U;
|
||||
uint8_t maximum_attempts = 0U;
|
||||
clock_t maximum_ticks = 0;
|
||||
uint64_t combined_hash = 0U;
|
||||
|
||||
for (uint32_t seed = 0U; seed < SEED_COUNT; ++seed) {
|
||||
maze32_maze_report_t report;
|
||||
clock_t started = clock();
|
||||
assert(maze32_maze_generate(&maze, &workspace, &config, seed, &report) == MAZE32_MAZE_OK);
|
||||
clock_t elapsed = clock() - started;
|
||||
if (elapsed > maximum_ticks) {
|
||||
maximum_ticks = elapsed;
|
||||
}
|
||||
assert(report.result == MAZE32_MAZE_OK);
|
||||
assert(report.attempts >= 1U && report.attempts <= config.maximum_attempts);
|
||||
if (report.attempts > maximum_attempts) {
|
||||
maximum_attempts = report.attempts;
|
||||
}
|
||||
assert(report.metrics.reachable_cells == MAZE32_MAZE_MAX_CELLS);
|
||||
assert(report.metrics.cycle_count > 0U);
|
||||
assert(report.metrics.cross_half_connections >= 4U);
|
||||
assert(report.metrics.dead_end_count > 0U);
|
||||
assert(report.metrics.start_to_key[0] == report.metrics.start_to_key[1]);
|
||||
assert(report.metrics.key_to_exit[0] == report.metrics.key_to_exit[1]);
|
||||
assert(report.metrics.start_to_exit[0] == report.metrics.start_to_exit[1]);
|
||||
assert(report.metrics.start_to_compass[0] == report.metrics.start_to_compass[1]);
|
||||
assert(report.metrics.landmark_distance_total[0] ==
|
||||
report.metrics.landmark_distance_total[1]);
|
||||
assert(report.metrics.artifact_distance_total[0] ==
|
||||
report.metrics.artifact_distance_total[1]);
|
||||
assert(report.metrics.start_to_key[0] >= report.metrics.key_to_exit[0]);
|
||||
|
||||
uint64_t first_hash = maze32_maze_hash(&maze);
|
||||
maze32_maze_report_t repeated_report;
|
||||
assert(maze32_maze_generate(&repeated_maze, &workspace, &config, seed, &repeated_report) ==
|
||||
MAZE32_MAZE_OK);
|
||||
assert(first_hash == maze32_maze_hash(&repeated_maze));
|
||||
combined_hash ^= first_hash + seed * UINT64_C(0x9e3779b97f4a7c15);
|
||||
|
||||
if (report.metrics.cycle_count < minimum_cycles) {
|
||||
minimum_cycles = report.metrics.cycle_count;
|
||||
}
|
||||
if (report.metrics.cycle_count > maximum_cycles) {
|
||||
maximum_cycles = report.metrics.cycle_count;
|
||||
}
|
||||
if (report.metrics.dead_end_count < minimum_dead_ends) {
|
||||
minimum_dead_ends = report.metrics.dead_end_count;
|
||||
}
|
||||
if (report.metrics.dead_end_count > maximum_dead_ends) {
|
||||
maximum_dead_ends = report.metrics.dead_end_count;
|
||||
}
|
||||
if (report.metrics.start_to_key[0] < minimum_start_key) {
|
||||
minimum_start_key = report.metrics.start_to_key[0];
|
||||
}
|
||||
if (report.metrics.start_to_key[0] > maximum_start_key) {
|
||||
maximum_start_key = report.metrics.start_to_key[0];
|
||||
}
|
||||
if (report.metrics.key_to_exit[0] < minimum_key_exit) {
|
||||
minimum_key_exit = report.metrics.key_to_exit[0];
|
||||
}
|
||||
if (report.metrics.key_to_exit[0] > maximum_key_exit) {
|
||||
maximum_key_exit = report.metrics.key_to_exit[0];
|
||||
}
|
||||
if (report.metrics.start_to_exit[0] < minimum_start_exit) {
|
||||
minimum_start_exit = report.metrics.start_to_exit[0];
|
||||
}
|
||||
if (report.metrics.start_to_exit[0] > maximum_start_exit) {
|
||||
maximum_start_exit = report.metrics.start_to_exit[0];
|
||||
}
|
||||
}
|
||||
|
||||
double maximum_ms = (double)maximum_ticks * 1000.0 / CLOCKS_PER_SEC;
|
||||
printf("maze seed sweep: PASS (%u seeds, combined hash=%016" PRIx64 ")\n", SEED_COUNT,
|
||||
combined_hash);
|
||||
printf("maze metrics: cycles=%u..%u, dead ends=%u..%u, start-key=%u..%u, "
|
||||
"key-exit=%u..%u, start-exit=%u..%u, max attempts=%u\n",
|
||||
minimum_cycles, maximum_cycles, minimum_dead_ends, maximum_dead_ends, minimum_start_key,
|
||||
maximum_start_key, minimum_key_exit, maximum_key_exit, minimum_start_exit,
|
||||
maximum_start_exit, maximum_attempts);
|
||||
printf("maze host runtime: maximum %.3f ms per generated map\n", maximum_ms);
|
||||
}
|
||||
|
||||
static void test_validator_rejects_corruption(void) {
|
||||
maze32_maze_config_t config = maze32_maze_default_config();
|
||||
maze32_maze_report_t report;
|
||||
maze32_maze_metrics_t metrics;
|
||||
assert(maze32_maze_generate(&maze, &workspace, &config, 42U, &report) == MAZE32_MAZE_OK);
|
||||
|
||||
tampered_maze = maze;
|
||||
tampered_maze.passages[0] ^= MAZE32_PASSAGE_EAST;
|
||||
assert(maze32_maze_validate(&tampered_maze, &workspace, &metrics) == MAZE32_MAZE_ASYMMETRIC);
|
||||
|
||||
tampered_maze = maze;
|
||||
tampered_maze.keys[0] = tampered_maze.starts[0];
|
||||
tampered_maze.keys[1] = tampered_maze.starts[1];
|
||||
assert(maze32_maze_validate(&tampered_maze, &workspace, &metrics) ==
|
||||
MAZE32_MAZE_PLACEMENT_COLLISION);
|
||||
puts("maze corruption rejection: PASS");
|
||||
}
|
||||
|
||||
static void test_memory_and_representative_map(void) {
|
||||
size_t fixed_bytes = sizeof(maze32_maze_t) + sizeof(maze32_maze_workspace_t);
|
||||
assert(fixed_bytes <= MAZE32_MAZE_RAM_BUDGET_BYTES);
|
||||
printf("maze fixed memory: PASS (%zu/%u bytes, no heap API)\n", fixed_bytes,
|
||||
MAZE32_MAZE_RAM_BUDGET_BYTES);
|
||||
|
||||
maze32_maze_config_t config = maze32_maze_default_config();
|
||||
config.width = 16U;
|
||||
config.height = 8U;
|
||||
config.cross_half_pairs = 2U;
|
||||
config.extra_cycle_pairs = 4U;
|
||||
config.minimum_exit_separation = 4U;
|
||||
config.landmark_pairs = 2U;
|
||||
config.artifact_pairs = 2U;
|
||||
maze32_maze_report_t report;
|
||||
assert(maze32_maze_generate(&maze, &workspace, &config, UINT32_C(0x5a17), &report) ==
|
||||
MAZE32_MAZE_OK);
|
||||
printf("representative map hash: %016" PRIx64 "\n", maze32_maze_hash(&maze));
|
||||
print_representative_map(&maze);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
test_invalid_configuration();
|
||||
test_bounded_regeneration_failure();
|
||||
test_large_seed_set();
|
||||
test_validator_rejects_corruption();
|
||||
test_memory_and_representative_map();
|
||||
puts("maze generation tests: PASS");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "maze32_provisioning_protocol.h"
|
||||
|
||||
static void accept(const char *json, const char *ssid, const char *password)
|
||||
{
|
||||
maze32_wifi_credentials_t credentials;
|
||||
assert(maze32_provisioning_parse_credentials(json, strlen(json), &credentials));
|
||||
assert(strcmp(credentials.ssid, ssid) == 0);
|
||||
assert(strcmp(credentials.password, password) == 0);
|
||||
}
|
||||
|
||||
static void reject(const char *json)
|
||||
{
|
||||
maze32_wifi_credentials_t credentials;
|
||||
assert(!maze32_provisioning_parse_credentials(json, strlen(json), &credentials));
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
accept("{\"ssid\":\"Maze WiFi\",\"password\":\"password8\"}",
|
||||
"Maze WiFi", "password8");
|
||||
accept(" { \"password\" : \"quote\\\"pass\" , \"ssid\" : \"Maze\" } ",
|
||||
"Maze", "quote\"pass");
|
||||
accept("{\"ssid\":\"Open\",\"password\":\"\"}", "Open", "");
|
||||
|
||||
reject("{}");
|
||||
reject("{\"ssid\":\"Maze\"}");
|
||||
reject("{\"ssid\":\"Maze\",\"password\":\"short\"}");
|
||||
reject("{\"ssid\":\"Maze\",\"password\":\"password8\",\"extra\":1}");
|
||||
reject("{\"ssid\":12,\"password\":\"password8\"}");
|
||||
reject("{\"ssid\":\"Maze\\nBad\",\"password\":\"password8\"}");
|
||||
reject("{\"ssid\":\"Maze\",\"ssid\":\"Again\"}");
|
||||
reject("not-json");
|
||||
|
||||
puts("provisioning protocol tests: PASS");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "maze32_wifi_config.h"
|
||||
|
||||
typedef struct {
|
||||
int guard_result;
|
||||
int guard_calls;
|
||||
int save_calls;
|
||||
int clear_calls;
|
||||
maze32_wifi_credentials_t saved;
|
||||
} fixture_t;
|
||||
|
||||
static int guard(void *context)
|
||||
{
|
||||
fixture_t *fixture = context;
|
||||
++fixture->guard_calls;
|
||||
return fixture->guard_result;
|
||||
}
|
||||
|
||||
static int save(const maze32_wifi_credentials_t *credentials, void *context)
|
||||
{
|
||||
fixture_t *fixture = context;
|
||||
++fixture->save_calls;
|
||||
fixture->saved = *credentials;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int clear(void *context)
|
||||
{
|
||||
fixture_t *fixture = context;
|
||||
++fixture->clear_calls;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static maze32_wifi_credentials_t valid_credentials(void)
|
||||
{
|
||||
maze32_wifi_credentials_t credentials = {0};
|
||||
memcpy(credentials.ssid, "Labyrinth", sizeof("Labyrinth"));
|
||||
memcpy(credentials.password, "eight888", sizeof("eight888"));
|
||||
return credentials;
|
||||
}
|
||||
|
||||
static void test_validation_boundaries(void)
|
||||
{
|
||||
maze32_wifi_credentials_t credentials = valid_credentials();
|
||||
assert(maze32_wifi_credentials_validate(&credentials) == MAZE32_WIFI_CONFIG_VALID);
|
||||
|
||||
credentials.password[0] = '\0';
|
||||
assert(maze32_wifi_credentials_validate(&credentials) == MAZE32_WIFI_CONFIG_VALID);
|
||||
|
||||
memcpy(credentials.password, "short", sizeof("short"));
|
||||
assert(maze32_wifi_credentials_validate(&credentials) ==
|
||||
MAZE32_WIFI_CONFIG_INVALID_PASSWORD);
|
||||
|
||||
credentials = valid_credentials();
|
||||
credentials.ssid[0] = '\0';
|
||||
assert(maze32_wifi_credentials_validate(&credentials) == MAZE32_WIFI_CONFIG_INVALID_SSID);
|
||||
|
||||
memset(credentials.ssid, 's', sizeof(credentials.ssid));
|
||||
assert(maze32_wifi_credentials_validate(&credentials) == MAZE32_WIFI_CONFIG_INVALID_SSID);
|
||||
|
||||
credentials = valid_credentials();
|
||||
credentials.ssid[2] = '\n';
|
||||
assert(maze32_wifi_credentials_validate(&credentials) == MAZE32_WIFI_CONFIG_INVALID_SSID);
|
||||
}
|
||||
|
||||
static void test_invalid_input_never_mutates_storage(void)
|
||||
{
|
||||
fixture_t fixture = {0};
|
||||
maze32_wifi_credentials_t credentials = valid_credentials();
|
||||
credentials.password[0] = 'x';
|
||||
credentials.password[1] = '\0';
|
||||
assert(maze32_wifi_config_apply(&credentials, guard, save, &fixture) == -1);
|
||||
assert(fixture.guard_calls == 0);
|
||||
assert(fixture.save_calls == 0);
|
||||
}
|
||||
|
||||
static void test_guard_precedes_every_mutation(void)
|
||||
{
|
||||
fixture_t fixture = {.guard_result = -1};
|
||||
maze32_wifi_credentials_t credentials = valid_credentials();
|
||||
assert(maze32_wifi_config_apply(&credentials, guard, save, &fixture) == -2);
|
||||
assert(fixture.guard_calls == 1);
|
||||
assert(fixture.save_calls == 0);
|
||||
assert(maze32_wifi_config_reset(guard, clear, &fixture) == -2);
|
||||
assert(fixture.guard_calls == 2);
|
||||
assert(fixture.clear_calls == 0);
|
||||
}
|
||||
|
||||
static void test_valid_apply_and_reset(void)
|
||||
{
|
||||
fixture_t fixture = {0};
|
||||
maze32_wifi_credentials_t credentials = valid_credentials();
|
||||
assert(maze32_wifi_config_apply(&credentials, guard, save, &fixture) == 0);
|
||||
assert(fixture.guard_calls == 1);
|
||||
assert(fixture.save_calls == 1);
|
||||
assert(strcmp(fixture.saved.ssid, credentials.ssid) == 0);
|
||||
assert(strcmp(fixture.saved.password, credentials.password) == 0);
|
||||
|
||||
assert(maze32_wifi_config_reset(guard, clear, &fixture) == 0);
|
||||
assert(fixture.guard_calls == 2);
|
||||
assert(fixture.clear_calls == 1);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
test_validation_boundaries();
|
||||
test_invalid_input_never_mutates_storage();
|
||||
test_guard_precedes_every_mutation();
|
||||
test_valid_apply_and_reset();
|
||||
puts("wifi configuration tests: PASS");
|
||||
return 0;
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
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 |
@@ -0,0 +1,95 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<meta name="theme-color" content="#090711">
|
||||
<title>maze32 — побег из кустов</title>
|
||||
<link rel="stylesheet" href="/player.css">
|
||||
</head>
|
||||
<body>
|
||||
<main id="app">
|
||||
<div class="message-rail" id="notice" role="status" aria-live="polite" aria-atomic="true" hidden></div>
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/" aria-label="maze32, на главную">MAZE<span>32</span></a>
|
||||
<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>
|
||||
<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>
|
||||
<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 class="screen observer-screen" id="observer-screen" hidden aria-labelledby="observer-title">
|
||||
<div class="observer-toolbar">
|
||||
<div><p class="eyebrow">Режим наблюдателя · только чтение</p><h1 id="observer-title">Весь куст целиком</h1></div>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
<div class="observer-stage" id="observer-stage" tabindex="0" aria-label="Полная карта лабиринта. Масштабируйте колёсиком или кнопками, перетаскивайте мышью.">
|
||||
<div class="observer-maze" id="observer-maze" role="img" aria-label="Полная карта лабиринта"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<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>
|
||||
<script src="/player.js" defer></script>
|
||||
</body>
|
||||
</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
@@ -0,0 +1,47 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>maze32 — настройка сети</title>
|
||||
<style>
|
||||
:root{color-scheme:dark;--bg:#09070f;--panel:#281445;--accent:#38e8d1;--ok:#68dc78;--warn:#ffd45c;--bad:#ff647c}
|
||||
*{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(circle at top,#211138,var(--bg) 58%);color:#f4efff;font:16px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace;padding:24px}
|
||||
main{width:min(620px,100%);margin:auto;background:var(--panel);border:2px solid #8058b7;box-shadow:8px 8px 0 #10071d;padding:clamp(18px,5vw,36px)}
|
||||
h1{margin:0;color:var(--accent);letter-spacing:.08em}p{margin:.55rem 0 1.3rem}.status{border-left:4px solid var(--warn);padding:.7rem 1rem;background:#160d27}
|
||||
label{display:block;margin:1rem 0 .35rem}input,select,button{width:100%;min-height:48px;border:2px solid #8058b7;background:#10091d;color:#fff;font:inherit;padding:.65rem}
|
||||
input:focus,select:focus,button:focus{outline:3px solid var(--accent);outline-offset:2px}button{margin-top:1rem;border-color:var(--accent);color:var(--accent);font-weight:700;cursor:pointer}button:disabled{opacity:.45;cursor:wait}.danger{border-color:var(--bad);color:var(--bad)}
|
||||
#message[data-kind="ok"]{color:var(--ok)}#message[data-kind="error"]{color:var(--bad)}small{color:#c9badd}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>MAZE32 // WI-FI</h1>
|
||||
<p>Поможем коробочке найти интернет. Сам лабиринт в интернет не убежит.</p>
|
||||
<div class="status" id="status">Проверяем сетевой режим…</div>
|
||||
<button id="scan" type="button">Сканировать сети</button>
|
||||
<form id="form">
|
||||
<label for="networks">Найденные сети</label>
|
||||
<select id="networks"><option value="">Выберите сеть или введите имя ниже</option></select>
|
||||
<label for="ssid">Имя сети (SSID)</label>
|
||||
<input id="ssid" name="ssid" maxlength="32" required autocomplete="off">
|
||||
<label for="password">Пароль</label>
|
||||
<input id="password" name="password" type="password" maxlength="63" autocomplete="new-password" aria-describedby="password-help">
|
||||
<small id="password-help">Оставьте пустым для открытой сети; иначе нужно 8–63 символа.</small>
|
||||
<button id="save" type="submit">Сохранить и подключиться</button>
|
||||
</form>
|
||||
<button id="reset" class="danger" type="button">Удалить настройки сети</button>
|
||||
<p id="message" role="status" aria-live="polite"></p>
|
||||
</main>
|
||||
<script>
|
||||
const $=id=>document.getElementById(id),message=(text,kind='')=>{$('message').textContent=text;$('message').dataset.kind=kind};
|
||||
async function json(url,options){const response=await fetch(url,options),body=await response.json().catch(()=>({}));if(!response.ok)throw new Error(body.error||'Устройство не ответило');return body}
|
||||
async function status(){try{const value=await json('/api/wifi/status');$('status').textContent=value.state==='provisioning'?`Точка настройки: ${value.accessPointSsid}`:value.state==='station_connected'?`Подключено. Адрес: ${value.ip}`:`Состояние: ${value.state}`}catch(error){$('status').textContent=error.message}}
|
||||
$('networks').addEventListener('change',event=>{if(event.target.value)$('ssid').value=event.target.value});
|
||||
$('scan').addEventListener('click',async()=>{const button=$('scan');button.disabled=true;message('Ищем Wi-Fi…');try{const value=await json('/api/wifi/scan'),select=$('networks');select.length=1;for(const network of value.networks){const option=document.createElement('option');option.value=network.ssid;option.textContent=`${network.ssid||'<скрытая>'} (${network.rssi} dBm${network.secure?', 🔒':''})`;select.append(option)}message(`Найдено сетей: ${value.networks.length}`,'ok')}catch(error){message(error.message,'error')}finally{button.disabled=false}});
|
||||
$('form').addEventListener('submit',async event=>{event.preventDefault();const button=$('save');button.disabled=true;message('Сохраняем настройки…');try{const value=await json('/api/wifi/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ssid:$('ssid').value,password:$('password').value})});$('password').value='';message(value.message,'ok');setTimeout(status,14000)}catch(error){message(error.message,'error')}finally{button.disabled=false}});
|
||||
$('reset').addEventListener('click',async()=>{const button=$('reset');button.disabled=true;try{const value=await json('/api/wifi/reset',{method:'POST'});$('password').value='';message(value.message,'ok');setTimeout(status,1000)}catch(error){message(error.message,'error')}finally{button.disabled=false}});
|
||||
status();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user