Compare commits

...

37 Commits

Author SHA1 Message Date
sasa 3518f8a152 feat(trails): render authoritative player route trails 2026-09-13 23:31:26 +03:00
sasa 8ad856b931 fix(session): reset lobby when player leaves finished match 2026-09-13 23:14:09 +03:00
sasa b6d484dbc9 feat(session): add explicit leave and immediate slot release 2026-09-13 22:53:18 +03:00
sasa 44433aee9c feat(ui): consolidate secondary controls in settings menu 2026-09-13 22:16:11 +03:00
sasa 8a37412416 feat(player): animate authoritative hedge breakthroughs 2026-09-12 23:38:01 +03:00
sasa 720cd18b6e fix(lobby): restore mobile scrolling outside matches 2026-09-12 22:53:26 +03:00
sasa 07eaed9c93 feat(web): render canonical living hedge maze walls 2026-09-11 23:46:02 +03:00
sasa 9ce44cdef7 feat(art): add living hedge SVG review library 2026-09-10 23:25:08 +03:00
sasa e10ad3acb8 feat(game): add persistent bush-wall breakthroughs 2026-09-10 23:12:42 +03:00
sasa bb5bd73ebd fix(player): make avatar handle transparent 2026-09-10 22:38:48 +03:00
sasa 8949891a6b feat(player): reposition mobile player focal point 2026-09-10 22:26:55 +03:00
sasa 2501345740 docs(plans): add playful visual feedback milestone 2026-09-10 22:08:02 +03:00
sasa abb3ff27b7 feat(ui): replace gameplay icons with local SVGs 2026-09-10 21:47:15 +03:00
sasa 8214e7f7ed feat(player): add mobile directional pad controls 2026-09-10 21:23:38 +03:00
sasa 5a806fb5b8 fix(player): align edge projections with avatar drag 2026-09-10 01:02:43 +03:00
sasa 1396bad76d feat(player): add avatar-centered drag steering 2026-09-09 23:15:00 +03:00
sasa 381eacf403 docs(plan): record M016 evidence deviation and complete milestone 2026-09-09 23:03:17 +03:00
sasa 37704028c5 feat(diagnostics): correlate movement acknowledgements with painted views 2026-09-09 22:12:12 +03:00
sasa 3827ba7ec7 feat(player-ui): add pre-reader controls and onboarding 2026-09-09 20:56:38 +03:00
sasa f043b63732 feat(maze): shrink map and rebalance compact gameplay 2026-09-08 22:14:13 +03:00
sasa 5346f9296f feat(login): randomize Russian welcome headlines 2026-09-08 21:36:26 +03:00
sasa 4b1ac6ca5a docs(plans): add combinatorial name suggestions milestone 2026-09-08 21:32:22 +03:00
sasa a2a8f4a59d docs(m011): record approved MVP playtest evidence 2026-09-08 21:15:16 +03:00
sasa cf187bdd1c docs(m011): record MVP balance and acceptance evidence 2026-09-08 21:02:20 +03:00
sasa 7902800ff7 fix(gateway): harden websocket streaming under client backpressure 2026-09-08 00:18:41 +03:00
sasa c730cb818d feat(runtime): add integration hardening and diagnostics 2026-09-05 21:56:09 +03:00
sasa ffdaa299bc feat(observer): add observer mode and disconnect recovery 2026-09-05 21:19:52 +03:00
sasa d042c3ce90 feat(ui): implement responsive player browser experience 2026-09-04 21:57:17 +03:00
sasa 9339578dc9 feat(game): complete authoritative gameplay mechanics 2026-09-04 21:07:22 +03:00
sasa 115a3bd0a1 docs: update README 2026-09-04 11:36:04 +03:00
sasa 2aaa1650ae feat(game): implement authoritative movement and visibility core 2026-09-04 11:29:20 +03:00
sasa 5d780def9d feat(maze): add deterministic symmetric maze generation 2026-09-04 02:43:16 +03:00
sasa 996bda9567 feat(lobby): implement bounded WebSocket sessions and countdown 2026-09-04 02:13:26 +03:00
sasa b10c7f5972 feat: implement Wi-Fi provisioning and persistent configuration 2026-09-03 22:59:47 +03:00
sasa 8ffd6dabcf docs(ui): define web application visual style requirements 2026-09-03 22:06:45 +03:00
sasa 9c274800b5 feat: create firmware skeleton and reproducible build 2026-09-03 01:02:40 +03:00
sasa dd16bc7ade feat: add requirements and technical baseline 2026-09-03 00:24:46 +03:00
93 changed files with 23835 additions and 186 deletions
+25
View File
@@ -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
View File
@@ -1,4 +1,4 @@
.pio/
.vscode/
.obsidian/
data/*.gz
+34 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+3407 -56
View File
File diff suppressed because it is too large Load Diff
+103
View File
@@ -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.
+5
View File
@@ -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;
}
+5
View File
@@ -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
+956
View File
@@ -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];
}
+4
View File
@@ -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
+297
View File
@@ -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";
}
}
+301
View File
@@ -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";
}
}
+4
View File
@@ -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
+716
View File
@@ -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";
}
}
+5
View File
@@ -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
+354
View File
@@ -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;
}
+5
View File
@@ -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
+201
View File
@@ -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.
+72
View File
@@ -0,0 +1,72 @@
# MVP gameplay balance
This document locks the compact gameplay configuration evaluated for Milestone
014. Human confirmation of the revised map belongs in
`docs/PLAYTEST_REPORT.md`.
## Locked configuration
| Parameter | Locked value | Purpose |
| --- | ---: | --- |
| Maze dimensions | 32 x 32 cells | Square map with one eighth of the former area |
| Initial vision radius | 3 cells | Current-view-only exploration |
| Cross-half connection pairs | 2 | Four seam crossings between conceptual halves |
| Extra cycle pairs | 8 | Proportional route density without removing dead ends |
| Minimum start-to-exit separation | 22 cells | Linear scaling by approximately the square root of eight |
| Landmark pairs | 2 | Four spatial-memory cues in symmetric positions |
| Vision-artifact pairs | 1 | Two one-use artifacts; preserves the MVP minimum |
| Compass pairs | 1 | One compass for each player's own exit |
| Movement cooldown | 150 ms | Server-enforced minimum accepted-move interval |
| Enhanced-vision duration | 10 successful moves | MVP artifact duration |
| Draw window | 200 ms | Authoritative simultaneous-exit boundary |
| Match timer | None | Matches end only by defined result conditions |
The values come from `maze32_maze_default_config()` and
`maze32_game_default_config()`. They are server-owned defaults; browser
clients cannot alter them.
## Deterministic generation evidence
`test/host/test_maze_generation.c` generates and validates seeds 0 through
1,023 with this configuration. For every generated map it proves reachability,
central symmetry, positive cycles and dead ends, at least four cross-half
connections, paired player-relative route distances, and this balance
invariant:
```
start_to_key_distance >= key_to_exit_distance
```
The test measures shortest traversable routes, not straight-line distance. It
therefore establishes that finding a key is never made easier by a shorter
required route than the later exit search. It does not substitute for human
search behaviour in a maze with fog of war, landmarks, compasses, and
artifacts.
The most recent recorded sweep (2026-09-08) reported:
| Metric | Observed range across 1,024 seeds |
| --- | ---: |
| Cycles | 19..19 |
| Dead ends | 74..128 |
| Start to key | 93..422 cells |
| Key to exit | 84..352 cells |
| Start to exit | 26..242 cells |
| Generation attempts | 1..2 |
At the 150 ms cooldown, an uninterrupted shortest 93-cell key route takes at
least 13.95 seconds of accepted-move time. Actual match duration also includes
route discovery, current-view-only navigation, the exit search, detours,
interactions, and player decisions. The approximately five-minute target must
be confirmed by the representative human-playtest procedure; no hard timer is
introduced to force that outcome.
## Tuning decision
The topology and object counts were reduced with the map area while retaining
the three-cell base view, ten-move artifact effect, 150 ms cooldown, and 200 ms
draw window. These interaction values remain unchanged until representative
playtesting of the compact map supplies evidence for a further adjustment. If
playtesting identifies a material duration or navigation problem, change only
the smallest relevant locked value, rerun the 1,024-seed suite, record the new
range here, and repeat the affected playtests.
+48 -66
View File
@@ -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.
+105
View File
@@ -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.
+38
View File
@@ -0,0 +1,38 @@
# Hedge sample library manifest
`hedge-sample-library.svg` is a review-only, hand-editable SVG source. It is
not a web, firmware, or generated LittleFS asset.
## Symbols
- Intact straight variants: `hedge-intact-1` through `hedge-intact-6`.
- Topology examples: `hedge-base`, `hedge-corner`, `hedge-crossing`, and
`hedge-end`.
- Perseverance frames: `hedge-impact-1` through `hedge-impact-4` and
`hedge-breakthrough`.
- Permanent openings: `hedge-breach-1` through `hedge-breach-3`.
- Reusable primitives: `leaf-cluster-a`, `leaf-cluster-b`, and `twig-fork`.
All edge art is authored left-to-right. Later integration may rotate it by 90,
180, or 270 degrees and mirror it only when the shared-edge centre remains
aligned. Highlights are distributed rather than directional, so rotation does
not create a lighting contradiction.
## Accessibility and review
The dark outline, branch forks, irregular leaf silhouette, and clear central
gap distinguish the symbols without relying only on hue. The comparison sheet
contains neutral and dark-cell previews at 34, 40, and 46 CSS-pixel targets.
Impact frames are static review frames; no animation, particle system, or
runtime event is implied.
## Size and dependency record
| Source | Raw bytes | gzip -9 bytes | Dependencies |
| --- | ---: | ---: | --- |
| `hedge-sample-library.svg` | 6,495 | 1,884 | None; inline SVG only |
The validator rejects scripts, raster payloads, external URLs, editor metadata,
and missing stable identifiers. Reused `<use>` primitives avoid duplicating
leaf and twig paths. The current sheet is deliberately outside the 1.5 MiB
runtime budget until a future approved integration milestone selects a family.
+76
View File
@@ -0,0 +1,76 @@
# On-device integration testing
This procedure verifies Milestone 010 on the ESP32-C6FH4 without PSRAM or any
assumed GPIO. Run it against production firmware and the production LittleFS
image. Start from a freshly booted device with no occupied lobby sessions.
## Build and delivery
```sh
/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1
/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1 --target buildfs
/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1 --target upload --upload-port /dev/ttyACM0
/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1 --target uploadfs --upload-port /dev/ttyACM0
```
Monitor the USB serial log at 115200 baud. Confirm application version
`0.14.0`, the 4 MB partition table, successful LittleFS mount, station or
fallback network startup, registration of all HTTP/WebSocket routes, and
health state `serving`.
## Resource and load scenario
Keep the development host connected to `Mazda7`; do not switch it to the
device fallback access point. When the board has station address
`192.168.1.50`, run:
```sh
python3 scripts/ws_protocol_client.py 192.168.1.50 \
--verify-m010 --stress-moves 80 --repeat-matches 2
```
The scenario intentionally takes more than two minutes because both repeated
matches use the real 60-second reconnect deadline. It performs these checks:
1. Connect two players and four observers and assemble all four complete
1,024-cell observer projections.
2. Verify heartbeat round-trip latency for all six sessions.
3. Stop consuming one observer stream while Player 1 performs accepted moves;
intermediate observer updates may be dropped, but player acknowledgements
must remain within the reported 500 ms budget.
4. Flood a second observer with malformed messages. The fixed rate limiter
closes sustained abuse, and the players remain responsive.
5. Complete and authoritatively reset two matches through the real disconnect
timeout. Each reset generates a new server-owned maze while preserving the
connected sessions.
6. Fetch `/diagnostics` before, during, and after the run. Assert the connection
and queue high-water marks remain within six and twelve, internal free heap
stays above 32 KiB, the largest internal block stays above 16 KiB, and the
post-reset free-heap samples remain within a 32 KiB range.
Capture the command output, the final diagnostics JSON, and serial lines that
begin with `M010 resources:`. The JSON counters are monotonic since boot and
include connections, queue use, dropped messages, failures, malformed/rate-
limited input, commands, match cycles, maximum handler/send time, and current
and minimum internal heap samples.
## Station, fallback, and browser recovery
These checks involve network state and must be performed from a separate phone
or computer so the development host retains its Mazda7 internet connection.
1. Reboot with the saved Mazda7 configuration and confirm
`/api/wifi/status` reports `station_connected`. Reload both player pages
during an active match and verify their tokens restore the same slots and
authoritative state.
2. From the separate client, exercise the established provisioning procedure
with an intentionally unavailable test network. Confirm the bounded station
attempts return the device to `maze32-XXXX` at
`http://192.168.4.1/provision` and that the Russian page remains usable.
3. Restore Mazda7 through the provisioning page without recording its
password. Reboot and confirm station connection, browser recovery, `/health`,
and `/diagnostics` again.
Do not record an acceptance result unless the physical logs and samples are
available. Do not use an LED, a board pin, or a PSRAM capability as a test
oracle.
+54
View File
@@ -0,0 +1,54 @@
# Milestone 016 movement-latency procedure
## Measurement model
Each accepted movement uses one correlation path. Browser times use
`performance.now()` and ESP times use its monotonic timer; do not subtract a
browser timestamp from an ESP timestamp.
| Interval | Clock | Measurement point |
| --- | --- | --- |
| pointer feedback | browser | pointer event to next animation frame |
| command to acknowledgement | browser | `move` send to matching `ack` |
| command to complete view | browser | `move` send to matching assembled view sequence |
| command to painted view | browser | `move` send to next frame after matching view render |
| handler and send | ESP | existing `/diagnostics` maxima |
| network round trip | client | ICMP and WebSocket heartbeat samples |
| ESP heartbeat handling | ESP | bounded acknowledgement preparation interval |
The accepted command acknowledgement contains the authoritative `stateSequence`.
The browser records a sample only after the complete view with that sequence is
rendered and the next animation frame occurs. Samples are local-only, capped at
256, and do not alter a command, projection, or server result.
## Controlled runs
1. Record the firmware build identity, device address, Wi-Fi access point,
client device, browser/version, signal conditions, and viewport.
2. Collect 100 `ping -c 100 -q <device>` samples. Record loss, min, average,
maximum, and deviation.
3. Run 200 accepted moves per player with two players and no observers. Include
direction changes, a sustained hold, a release, and one blocked move.
4. Repeat with two players and four observers. Capture `/diagnostics` before
and after each run.
5. For each browser, copy
`window.maze32PlayerApp.latencySamples()` after the run. Compute p50, p95,
p99, and maximum for all browser intervals. Keep raw samples only for the
duration of the report and do not store player names or tokens.
6. Compare command-to-acknowledgement with command-to-painted-view, ESP handler
and send maxima, queue high-water marks, observer view chunk count, and
ICMP/WebSocket round trips. Attribute a delay only when the matching
interval is elevated.
7. For the compact quality indicator, record the rounded `Отклик`, `Связь`,
and `ESP` values after the five-second heartbeat cadence has collected a
fresh sample. `Связь` is the non-negative round-trip remainder, not a pure
Wi-Fi measurement.
## Safety checks
- Confirm no dropped, failed, malformed, oversized, or rate-limited message
increased unexpectedly outside the deliberate blocked-move case.
- Confirm queue high water stays within its published limit and browser sample
storage remains at or below 256 entries.
- Record blocked-command rejection separately; it does not count as an
accepted-move sample.
+55
View File
@@ -0,0 +1,55 @@
# Milestone 017 latency remediation report
## Baseline
Milestone 016 recorded an unsuitable local Wi-Fi tail: 100 HTTP requests had
7.5 ms minimum, 186.7 ms average, 508.0 ms p95, 713.3 ms p99, and 985.4 ms
maximum round-trip time. The available browser samples ranged from 19 to 548
ms for command acknowledgement and from 30 to 551 ms for an authoritative
painted view. ESP handler and send measurements were materially smaller during
the partial runs. The M016 project-owner-approved deviation records that the
remaining controlled runs could not be completed on that path.
## Browser remediation
The player now uses the centered avatar as a 96 by 96 CSS-pixel transparent
touch target. Pointer movement changes only a local avatar deflection capped at
28 CSS pixels. A 12-pixel dead zone, diagonal hysteresis, and a 360-to-170 ms
monotonic cadence resolve a cardinal movement intention without exceeding the
150 ms server cooldown.
The browser sends at most one move at a time. While that command awaits an
acknowledgement or error, changing drag direction replaces one local latest
intention. Release removes the unsent intention. Consequently a slow socket
cannot accumulate a client-side movement backlog. No browser code predicts a
maze position, collision result, inventory, visibility, timer, or outcome.
Each received projection is translated so its authoritative `self` cell is
directly below the fixed avatar. This replaces rectangle-centering, which could
put the fixed avatar over a wall when a visible projection reached a maze edge.
Drag displacement is measured from the pointer-down position rather than from
the edge of the transparent target, preventing an unintended move on touch.
## Required deployment-network remediation and retest
Do not use the M016 Wi-Fi path for latency acceptance. Place the ESP and both
test clients on the same dedicated 5 GHz access point or the ESP fallback AP,
with no WAN routing, captive portal, VPN, power-saving transition, or active
bulk traffic. Record access-point model/band/channel, client hardware,
browser/version, RSSI, device address, build identity, and viewport.
Repeat the M016 procedure for two players with zero observers and with four
observers. For each player, collect at least 200 accepted moves and export:
```js
window.maze32PlayerApp.pointerFeedbackSamples()
window.maze32PlayerApp.latencySamples()
```
Record p50, p95, p99, and maximum values separately for pointer feedback,
command acknowledgement, complete authoritative view, and painted
authoritative view. Capture `/diagnostics` before and after each run and 100
ICMP plus application/WebSocket round trips. The acceptance target remains
painted-update p95 at or below 100 ms, maximum at or below 150 ms, and no
accepted sample at or above 500 ms. Do not mark this report as a passing
device measurement until those controlled results exist.
+49
View File
@@ -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.
+54
View File
@@ -0,0 +1,54 @@
# MVP acceptance evidence register
This register maps the MVP acceptance criteria to existing evidence and the
remaining Milestone 011 evidence. It prevents release approval from relying on
unrecorded assumptions.
| MVP criterion | Evidence | Status |
| --- | --- | --- |
| 1. Firmware and LittleFS fit | Milestone 010 target builds: application 1,028,956 / 2,097,152 bytes; LittleFS 2,031,616 / 2,031,616 bytes. | Passing; repeat for release candidate. |
| 2. Persistent station and fallback provisioning | Milestone 010 owner confirmation: persisted station connection and bounded retry; fallback provisioning page worked. | Passing. |
| 3. Two-player lobby, countdown, and refresh | Host lobby/protocol and browser tests; Milestone 010 owner confirmation that both player tokens restored their slots. | Passing. |
| 4. Four read-only observers | Milestone 010 six-client target run assembled four complete observer maps; Milestone 009 authorization tests. | Passing. |
| 5. Fair repeated 32 x 32 maps | Milestone 014 1,024-seed host sweep and maze validation; locked metrics in `docs/BALANCE_REPORT.md`. | Passing at host level; target playtest pending. |
| 6. Projection security | Host game visibility and payload-security tests; player and observer browser-contract tests. | Passing. |
| 7. Controls and cooldown | Host game-core and browser keyboard/swipe tests; 150 ms locked cooldown. | Passing. |
| 8. Key and inventory mechanics | Host game-mechanics tests covering pickup, drop, sabotage, meeting return, and exits. | Passing. |
| 9. Artifact and compass rules | Host game-mechanics tests covering ten successful moves and role-specific compass projections. | Passing. |
| 10. Victory and draw window | Host game-mechanics test covering outward exit and 200 ms draw boundary. | Passing. |
| 11. Disconnect recovery | Host disconnect-recovery tests and Milestone 009 physical acceptance evidence. | Passing. |
| 12. Approximately five-minute normal match | The Milestone 011 evidence applies to the superseded `128 x 64` configuration. Repeat representative testing for the compact map or approve a new deviation. | Pending Milestone 014 confirmation. |
| 13. Russian offline UI and browser coverage | Milestone 008 owner checks in Chrome 152, Firefox 155, Safari 26.6, and Edge 152.0.4191.53; asset verification enforces the offline and 1.5 MiB budget. | Passing; repeat asset check for release candidate. |
## Release-candidate record
Before marking Milestone 011 complete, add the exact date, application version,
firmware and LittleFS build commands, sizes, and the web-asset reproducibility
digest from the final candidate below. This record must match the tested image.
| Field | Release-candidate value |
| --- | --- |
| Date | 2026-09-08 |
| Application version | 0.10.0 |
| Firmware build command and size | `pio run -e esp32-c6-devkitm-1`; 1,028,956 / 2,097,152 application bytes; `firmware.bin` 1,029,584 bytes, SHA-256 `e654643cb9f16ec03c8d54d981da32ab8e64616f99d23d4a6bb14c8ac7f69c17` |
| LittleFS build command and size | `pio run -e esp32-c6-devkitm-1 --target buildfs`; 2,031,616 / 2,031,616 bytes; SHA-256 `526e2d03ca4473adb0fe075f4b075aaaeec85ef982240f6578e8cbbb4bec7f35` |
| Web asset digest and compressed size | `8f3207a208c26aa9f0faff0b0fd8db8e8f8e44537bd6db2c0f914ca5f352920a`; 19,253 / 1,572,864 bytes |
| Approved deviations | Criterion 12: omit playtest count and timing statistics; project owner confirmed representative physical playtests passed without issues. |
No pending criterion is treated as passing evidence. The only approved
deviation is recorded above.
## Milestone 014 compact-map candidate
The following host-verified candidate supersedes the map configuration in the
Milestone 011 release record. It is not the final accepted release until the
representative compact-map playtest is confirmed.
| Field | Compact-map candidate value |
| --- | --- |
| Date | 2026-09-08 |
| Application version | 0.14.0 |
| Firmware build command and size | `pio run -e esp32-c6-devkitm-1`; 1,027,934 / 2,097,152 application bytes; `firmware.bin` 1,028,560 bytes, SHA-256 `428e7690bed50d30caf61aca174d838f00d7de1a603f14ea669e996e3d167123` |
| LittleFS build command and size | `pio run -e esp32-c6-devkitm-1 --target buildfs`; 2,031,616 / 2,031,616 bytes; SHA-256 `467f0a07413c83bff5ea246b5063289eb61f8bf40b95d16d639b3241ceda8a9d` |
| Web asset digest and compressed size | `3461d20742e1999c1489465d7f3a68317d56b3acfc5ac5003341e1cf2d478eed`; 20,314 / 1,572,864 bytes |
| Pending evidence | Representative two-player confirmation for the `32 x 32` configuration. |
+37
View File
@@ -0,0 +1,37 @@
# Observer browser mode
Milestone 009 adds a password-free observer role to the same local browser
application. The role is still authenticated by its temporary session token,
but joining it does not request a player name or consume either player slot.
At most four observer sessions are connected at once.
## Projection and authorization
An observer renders only the server's `observer` view projection. That
projection contains all maze cells, remaining objects, both player positions,
inventories, effects, exit state, phase, and result. The browser does not
simulate movements or infer updates. Each player status also reflects the
server-owned WebSocket connection state. A complete projection establishes the
server-approved static geometry on join, restoration, and match start.
Subsequent bounded projections replace all dynamic player and object state
while reusing only that authoritative geometry, avoiding a 1,024-cell
retransmission after every move.
The observer screen contains no movement, start, or drop-key control. This is
only presentation-level defense: the gateway also rejects and logs observer
`start`, `move`, and `drop_key` attempts before they reach authoritative
mutation. Disconnecting an observer changes only lobby presence and never
pauses the game.
## Viewport and navigation
At `1024 x 768` CSS pixels and above, the complete `32 x 32` default map is
initially scaled to fit the available stage. The observer can zoom with the
labelled buttons or mouse wheel and pan with pointer dragging. Zoom is bounded
between 0.25 and 6 times the eight-pixel base cell size; the **Вся карта**
control restores the calculated fit and centered position.
When either viewport dimension is below the supported minimum, the map and
controls are hidden and the Russian message **Нужен экран побольше** explains
the `1024 x 768` requirement. All assets remain local and use the shared
retro-arcade visual language.
+145
View File
@@ -0,0 +1,145 @@
# Player browser application
Milestone 008 provides the responsive player-facing browser application. The
browser remains a renderer and input device: it sends movement and key-drop
intentions, and it never calculates a legal move, inventory change, timer,
visibility region, compass result, or match result.
## Local assets
The application consists of `index.html`, `player.css`, `player.js`, and the
locally authored `icons.svg` sprite. The deterministic asset build compresses
each file with gzip for LittleFS. There are no remote fonts, frameworks,
analytics, icon services, or runtime network dependencies beyond the local ESP.
## Gameplay icon mapping
`icons.svg` contains editable, local SVG symbols with a shared `64 x 64` view
box for the reference-derived gameplay set: `player`, `key`, `exit-closed`,
`crystal`, `exit-open`, and `spikes`. Player 1 is yellow and Player 2 is blue;
their matching keys and exits use the same owner color. The player, key, and
exit silhouettes remain distinct, and Player 2 uses a dashed secondary
treatment so ownership does not depend on color alone.
The closed and opened arched exits show the existing locked and opened exit
states. The crystal represents the existing vision artifact. The spike symbol
is kept as a local catalog asset but is not shown for blocked movement; it does
not add a trap, damage, protocol, or server-state feature.
## Input contract
Arrow keys and WASD map to `north`, `east`, `south`, and `west` move intentions.
Touch swipes use the same four strings after a 28 CSS-pixel threshold. Phone
layouts hide the direction pad and retain swipes; tablet and desktop layouts
show the direction pad and also accept swipes. The persistent `OK` control
sends `drop_key` and exposes the accessible label `Сбросить ключ`.
On phones, an always-visible four-button directional pad is the primary touch
control. Its inactive centre prevents diagonal input; each button sends one of
the same four authoritative movement intentions and a held button repeats no
faster than every 170 ms. The centered player avatar is also an optional touch
movement handle. Its transparent
target is 96 by 96 CSS pixels while the visible avatar can deflect at most 28
CSS pixels. A 12-pixel dead zone and diagonal hysteresis resolve only cardinal
intentions. Drag displacement selects a monotonically bounded local cadence
from 360 ms to 170 ms, which remains slower than the authoritative 150 ms
cooldown. The browser keeps one sent move in flight and coalesces all later
drag changes into one latest intention; release discards that unsent intention.
The deflection is local input feedback only. The maze redraws only after a
server-authoritative projection and the browser never predicts a cell change.
For every projection, the browser translates only the received cells so the
authoritative `self` cell stays under the responsive avatar focal point. On a
portrait phone this is horizontally centered at 30% of the map height; compact
landscape uses a stable left-biased unobstructed point away from the directional
pad. This also keeps the avatar off a wall when the visible projection reaches
a maze boundary, and is recalculated on resize and rotation.
## Feedback and message rail
## Compact settings
The persistent gear button beside the connection indicator opens one compact
popover. It shows live connection details and Wi-Fi settings on every screen.
During an active player match it additionally provides sound, reduced-motion,
and optional-help controls with visible Russian on/off labels. Opening or
closing this presentation-only menu never sends a command or changes server
state. Escape and outside activation dismiss it; player actions also close it
before opening their existing destination.
Authenticated sessions also see `Выйти из игры`. Its confirmation states that
a player exit from any non-waiting phase, including the result screen, returns
the remaining participants to the waiting lobby; observer exits and waiting
lobby player exits release only the departing browser's place. The browser
removes its token only after the correlated server acknowledgement.
The header response indicator takes at most one heartbeat sample every five
seconds, holds one in-flight request, and uses a five-sample median. Good is
at most 150 ms, delayed is 151--500 ms, and slow is above 500 ms. Hysteresis
holds good until above 175 ms and slow until at most 450 ms; a sample is stale
after 15 seconds. Hidden documents and closed sockets do not probe.
The browser stores the direction for each outstanding move request. A matching
server `blocked` rejection may include only that player's approved breakthrough
stage, which bends the already rendered bush wall for at most 340 ms without
blocked-move text. A received opened-passage projection paired with an adjacent
authoritative player move may show one 480 ms hedge-parting dash and four
bounded decorative accents before settling at the server-approved destination.
Opened-passage bits render a permanent trampled bush gap with a 60% clear
centre. Effects have restartable timers and clear on a newer projection, pause,
result, reset, disconnect, screen change, and control reset. Reduced motion is
static and settles directly at the received destination.
The map draws only a bounded canonical hedge-edge layer generated from current
received cells. It hashes each visible edge deterministically to choose the
approved local SVG variant, so the browser neither retains unseen geometry nor
decides topology. The layer is decorative and cannot receive input.
Each complete projection also replaces one bounded SVG trail layer. It draws
only server-authorized visible east/south passage masks: yellow and blue use
different dotted rhythms, and a shared passage retains both patterns.
All remaining Russian system messages use one `role=status` live message rail
above the application top bar. One connection-loss message has highest priority
and remains until a successful recovery; other messages deduplicate, replace
lower-priority content, and dismiss after 2.6 seconds.
Commands use the version-one protocol envelope and contain intention data only:
```json
{"v":1,"type":"move","seq":7,"requestId":"move-7","payload":{"direction":"west"}}
```
```json
{"v":1,"type":"drop_key","seq":8,"requestId":"drop_key-8","payload":{}}
```
## Projection handling
View chunks are grouped by authoritative view sequence and rendered only after
all chunks arrive. Each completed view replaces the previous maze DOM in full.
The client does not merge a new view with previously visible cells, infer
missing passages, or retain an explored map. Maze dimensions, cells, objects,
player position, opponent visibility, inventory, vision duration, compass hint,
phase, and result all come from the server projection.
The session token is the only game-related value saved in browser storage. On
reload, the client sends that token in a `restore` intention and waits for a
fresh role-appropriate server projection. Position, inventory, effects, hints,
maze cells, and results are never restored from browser storage.
## Presentation states
Name selection, lobby, countdown, active play, pause, and match result use the
shared dark retro-arcade visual system. The fixed player status identifies the
player and labels inventory, remaining boosted-vision moves, compass direction,
and match phase. Modal overlays distinguish countdown, pause, and results.
Rejected movement produces a local wall-bump animation without revealing any
additional maze state. All player-facing copy and browser-generated errors are
Russian; protocol identifiers remain English.
Sound and supported vibration are enabled after the first pointer or keyboard
interaction. Both are progressive enhancements: unsupported devices continue
without an error.
Observer presentation and its read-only interaction boundary are documented in
`OBSERVER_UI.md`.
+48
View File
@@ -0,0 +1,48 @@
# Milestone 011 playtest report
## Protocol
Run representative completed two-player matches on the target ESP32-C6 with
the locked configuration in `docs/BALANCE_REPORT.md`. Use two active players
and, where practical, one observer. Do not impose a time limit or coach a
route. Start timing when the authoritative countdown completes and stop at the
authoritative result. A draw, reconnect timeout, abandoned match, or technical
failure is recorded separately and does not count as a completed match.
For each completed match, record the maze seed or map hash, both players'
experience level, winner or draw, elapsed time, whether either player used a
vision artifact or compass, and any gameplay defect. Do not record session
tokens, Wi-Fi passwords, or other credentials.
Use at least five completed matches involving more than one pair of players.
The target is that ordinary completed matches cluster around five minutes;
there is deliberately no hard timer. If a result materially misses that target
or a participant reports a balance issue, describe it and repeat the affected
tests after any parameter change.
## Results
On 2026-09-08, the project owner confirmed that the representative physical
two-player playtests covered by this protocol worked as expected, with no
gameplay defects or balance issues observed. The owner explicitly approved a
deviation from the protocol's count and elapsed-time statistics: individual
match timing, seed or hash, and per-run fields are not required for MVP
acceptance. No hard match timer was introduced.
## Assessment
The project owner accepted the approximately five-minute target based on the
representative playtests above and approved the absence of a quantified timing
sample. This is an approved evidence deviation, not a claim of statistical
measurement. The locked configuration remains unchanged because no issue was
reported.
## Milestone 014 compact-map revalidation
The earlier results apply to the superseded `128 x 64` configuration. Test the
new `32 x 32` configuration with two players after the Milestone 014 firmware
and web assets are installed. Confirm that navigation remains understandable,
both players consider the symmetric race fair, no gameplay-blocking defect is
observed, and the match duration remains acceptable without a hard timer.
Representative compact-map results have not yet been recorded.
+38
View File
@@ -0,0 +1,38 @@
# Pre-reader usability observation guide
## Purpose
Use this short, consent-based observation to verify the player interface with
a child who does not rely on reading. It is an acceptance activity for
Milestone 015, not a source of analytics. Do not record names, images, audio,
contact details, or any other identifying information.
## Setup
- Use the player screen at a `412 x 916` CSS-pixel viewport with sound enabled.
- Start a normal two-player match with the observer screen hidden.
- An adult may read a prompt once but must not point to the answer or operate
the child's device.
## Prompts
Ask the child to demonstrate each task. Mark it as completed only when the
child succeeds without reading the Russian support text.
| Task | Expected non-reading cue |
| --- | --- |
| Move the hero | centered character drag, direction arrow, and map movement |
| Find and pick up a key | key shape and pickup feedback |
| Recognize farther vision | eye symbol and map expansion |
| Follow the compass | arrow direction and compass icon |
| Find the exit | outlined exit shape and owner character shape |
| Recognize a wall | wall shape plus rejection feedback |
| Recognize the result | result icon/overlay plus local feedback |
## Record
Record only the date, viewport, browser, whether Russian labels were read,
and the seven task outcomes. Summarize unclear cues and any adult assistance.
If no representative child observation is feasible, the project owner may
approve a documented deviation instead; include the reason and the remaining
risk in the active milestone evidence.
+91
View File
@@ -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.
+45
View File
@@ -0,0 +1,45 @@
# Runtime resource budget
Milestone 010 fixes and exposes the following production limits. They are
compile-time bounds, not targets that may grow with match duration.
| Resource | Limit | Defensive behavior |
| --- | ---: | --- |
| WebSocket connections | 6 | A seventh WebSocket handshake is rejected. |
| LwIP sockets | 16 | ESP-IDF remains configured by `sdkconfig.defaults`. |
| Client message | 512 bytes | An oversized frame is counted and disconnected. |
| Outgoing message | 768 bytes | An over-limit message cannot enter the queue. |
| Outgoing queue | 12 entries | Queue overflow is counted; memory cannot grow. |
| Game command queue | 16 entries | Excess commands receive `queue_full`. |
| Input rate | 20 messages/second | Excess messages are rejected; sustained abuse above 40 in the same window is disconnected. |
| Player response budget | 500 ms | The physical stress client fails above this round-trip threshold. |
| Observer update policy | Latest bounded projection | A busy observer may lose intermediate dynamic chunks and recover on a newer projection. Failed or stalled required sends close the socket. |
| Reconnect deadline | 60,000 ms | Gameplay pauses, then ends in a draw at expiry. |
## Build-time measurements
The Milestone 010 production build on 2026-09-05 reported 124,276 of 327,680
static RAM bytes and 1,028,600 of 2,097,152 application bytes. The independent
binary check measured 1,029,232 bytes in the 2 MiB factory partition, leaving
1,067,920 bytes of application margin. The compressed web
payload totals 19,253 bytes against its 1.5 MiB budget, leaving 1,553,611 bytes
of payload margin. LittleFS remains the fixed 1,984 KiB partition and its image
is checked against `partitions.csv` on every build.
The global authoritative gateway, including its one maze, one projection
buffer, fixed queues, sessions, timers, and metrics, occupies 86,840 bytes of
static storage in the production ELF. It does not allocate a maze or
projection per client. No production source or test requires PSRAM or a GPIO.
## Runtime measurements
`GET /diagnostics` returns current and minimum internal 8-bit heap values and
the largest internal free block. It deliberately samples
`MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT`, so external RAM cannot make a failing
internal-memory result appear healthy. Serial samples use the same capability
mask.
Final six-client, slow-client, repeated-match, and network-recovery samples
belong in the active milestone evidence in `PLANS.md`. Build-time numbers in
this document must be refreshed if the production image changes before that
evidence is recorded.
+274
View File
@@ -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.
+48
View File
@@ -0,0 +1,48 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 760" role="img" aria-labelledby="title desc">
<title id="title">Top-down living hedge sample library</title>
<desc id="desc">Review-only editable vector symbols for intact hedges, joins, impact stages, and breached passages.</desc>
<defs>
<g id="leaf-cluster-a" stroke="#173d2a" stroke-width="1.5" stroke-linejoin="round">
<path fill="#296443" d="M2 13C0 6 7 0 13 3c2-7 12-5 14 2 8-1 11 8 5 13 3 8-6 13-12 8-6 5-14 1-13-6-7 0-9-5-5-7Z"/>
<path fill="#4f9b55" d="M7 13c2-5 7-8 12-7 5 0 8 4 9 8-3 5-8 8-14 7-5 0-8-3-7-8Z"/>
<path fill="#c7e68a" d="M12 9c3-3 8-3 10 0-2 3-7 4-10 0Z"/>
</g>
<g id="leaf-cluster-b" stroke="#173d2a" stroke-width="1.5" stroke-linejoin="round">
<path fill="#296443" d="M1 16C-1 8 5 2 11 4c1-7 11-7 14-1 7-3 13 5 8 11 5 6-2 14-9 11-4 7-15 4-15-3-8 2-12-4-8-6Z"/>
<path fill="#8cc968" d="M8 14c2-5 8-7 13-5 4 2 6 7 4 11-5 4-13 3-17-2Z"/>
<path fill="#4f9b55" d="M4 18c5 1 10 4 15 1-1 5-8 8-13 5Z"/>
</g>
<g id="twig-fork" fill="none" stroke="#765033" stroke-width="2" stroke-linecap="round">
<path d="M3 20 16 8 29 3M16 8l2 12M16 8 7 4"/>
</g>
<g id="hedge-base">
<path d="M0 11C9 2 20 4 30 9c12-8 23-7 34 1 10-5 22-5 32 1 11-7 23-6 34 1 9-5 22-4 30 4v17c-10 5-22 5-32 0-11 7-23 6-34 0-10 6-22 6-32 0-11 6-23 6-34 0-10 5-20 4-30-3Z" fill="#173d2a"/>
<use href="#twig-fork" x="5" y="9"/><use href="#twig-fork" x="54" y="8"/><use href="#twig-fork" x="105" y="9"/>
<use href="#leaf-cluster-a" x="1" y="5"/><use href="#leaf-cluster-b" x="27" y="4"/><use href="#leaf-cluster-a" x="54" y="6"/><use href="#leaf-cluster-b" x="80" y="4"/><use href="#leaf-cluster-a" x="107" y="5"/><use href="#leaf-cluster-b" x="132" y="5"/>
</g>
<g id="hedge-intact-1"><use href="#hedge-base"/></g>
<g id="hedge-intact-2"><use href="#hedge-base"/><path fill="#8cc968" d="M20 7l8-3 4 4-8 4Zm61 3 8-4 4 4-7 4Zm48 12 7-3 3 5-7 3Z"/></g>
<g id="hedge-intact-3"><use href="#hedge-base"/><path fill="none" stroke="#765033" stroke-width="2" d="M38 27 49 9m36 18L73 7m47 20 13-17"/></g>
<g id="hedge-intact-4"><use href="#hedge-base"/><path fill="#4f9b55" d="M6 27c12-5 20 6 31 0 11-6 20 6 32 0 11-6 21 5 33-1 10-5 19 3 29 0v8H6Z"/></g>
<g id="hedge-intact-5"><use href="#hedge-base"/><path fill="#c7e68a" d="m10 17 5-4 4 3-5 4Zm42-8 5-4 4 3-5 4Zm46 11 5-4 4 3-5 4Zm38-8 5-4 4 3-5 4Z"/></g>
<g id="hedge-intact-6"><use href="#hedge-base"/><path fill="none" stroke="#173d2a" stroke-width="2" d="M19 30 32 14l14 13m33 3 12-15 13 14m17 1 11-12 12 10"/></g>
<g id="hedge-corner"><use href="#hedge-base" transform="translate(0 80) rotate(-90)"/><use href="#hedge-base"/></g>
<g id="hedge-crossing"><use href="#hedge-base"/><use href="#hedge-base" transform="translate(80 -40) rotate(90)"/></g>
<g id="hedge-end"><use href="#hedge-base"/><path fill="#173d2a" stroke="#765033" stroke-width="2" d="M150 7c18 1 19 29 0 31-11-2-11-28 0-31Z"/></g>
<g id="hedge-impact-1"><use href="#hedge-intact-1"/><path fill="#8cc968" d="m70 2 8 3-3 6-8-3Zm12 28 7 2-3 5-7-2Z"/></g>
<g id="hedge-impact-2"><use href="#hedge-intact-1"/><path fill="#173d2a" d="M64 0h30v40H64Z" opacity=".18"/><path fill="#8cc968" d="m66 1 8 3-3 6-8-3Zm15 7 8 3-3 6-8-3Zm4 20 7 2-3 5-7-2Z"/></g>
<g id="hedge-impact-3"><use href="#hedge-intact-1" transform="skewX(-4)"/><path fill="#173d2a" d="M58 0h42v40H58Z" opacity=".25"/><path fill="none" stroke="#765033" stroke-width="3" d="m71 28 11-18m1 16 11-13"/></g>
<g id="hedge-impact-4"><use href="#hedge-intact-1" transform="translate(0 4) scale(1 .86)"/><path fill="#173d2a" d="M53 0h54v40H53Z" opacity=".32"/><path fill="#c7e68a" d="m61 2 8 3-3 6-8-3Zm13 7 8 3-3 6-8-3Zm14-4 8 3-3 6-8-3Zm4 20 7 2-3 5-7-2Z"/></g>
<g id="hedge-breakthrough"><use href="#hedge-intact-1"/><path fill="#11131a" stroke="#173d2a" stroke-width="2" d="M62 0h36v40H62Z"/><path fill="#765033" d="m60 6 10 8-4 5-11-8Zm40 12 10-8 5 5-10 9Z"/><path fill="#8cc968" d="m52 5 8 4-4 6-8-4Zm55 19 8-4 4 6-8 4Z"/></g>
<g id="hedge-breach-1"><use href="#hedge-breakthrough"/><path fill="#4f9b55" d="M44 28c8-7 14-5 18 0v8H42Zm56 0c7-6 13-5 18 0v8H98Z"/></g>
<g id="hedge-breach-2"><use href="#hedge-breakthrough"/><path fill="#296443" d="M35 25c11-8 18-4 27 3v10H33Zm65 3c10-7 18-11 27-3v13H98Z"/></g>
<g id="hedge-breach-3"><use href="#hedge-breakthrough"/><path fill="none" stroke="#765033" stroke-width="3" d="m38 33 17-12m50 12 17-12"/><path fill="#c7e68a" d="m49 29 7-3 3 5-7 3Zm61 0 7-3 3 5-7 3Z"/></g>
</defs>
<rect width="960" height="760" fill="#e7e3d8"/>
<g fill="#173d2a" font-family="monospace" font-size="16"><text x="32" y="34">INTACT STRAIGHT HEDGE FAMILIES</text><text x="32" y="178">JOINS AND BOUNDARIES</text><text x="32" y="322">PERSEVERANCE PROGRESSION</text><text x="32" y="466">PERMANENT BREACHES</text><text x="32" y="610">DARK CELL SCALE PREVIEWS: 34 / 40 / 46 CSS px</text></g>
<g transform="translate(32 48)"><use href="#hedge-intact-1"/><use href="#hedge-intact-2" x="155"/><use href="#hedge-intact-3" x="310"/><use href="#hedge-intact-4" x="465"/><use href="#hedge-intact-5" x="620"/><use href="#hedge-intact-6" x="775"/></g>
<g transform="translate(32 190)"><use href="#hedge-base"/><use href="#hedge-corner" x="190"/><use href="#hedge-crossing" x="380"/><use href="#hedge-end" x="570"/></g>
<g transform="translate(32 335)"><use href="#hedge-intact-1"/><use href="#hedge-impact-1" x="155"/><use href="#hedge-impact-2" x="310"/><use href="#hedge-impact-3" x="465"/><use href="#hedge-impact-4" x="620"/><use href="#hedge-breakthrough" x="775"/></g>
<g transform="translate(32 480)"><use href="#hedge-breach-1"/><use href="#hedge-breach-2" x="220"/><use href="#hedge-breach-3" x="440"/></g>
<g transform="translate(32 630)"><g transform="scale(.85)"><rect width="170" height="85" fill="#11131a"/><use href="#hedge-intact-2" transform="translate(4 21)"/><use href="#hedge-breach-1" transform="translate(4 51)"/></g><g transform="translate(240 0)"><rect width="200" height="100" fill="#11131a"/><use href="#hedge-intact-3" transform="translate(8 28) scale(1.15)"/><use href="#hedge-breach-2" transform="translate(8 62) scale(1.15)"/></g><g transform="translate(510 0)"><rect width="230" height="115" fill="#11131a"/><use href="#hedge-intact-5" transform="translate(10 33) scale(1.3)"/><use href="#hedge-breach-3" transform="translate(10 73) scale(1.3)"/></g></g>
</svg>

After

Width:  |  Height:  |  Size: 6.3 KiB

+41 -1
View File
@@ -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
+75
View File
@@ -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())
+93
View File
@@ -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",
)
+52
View File
@@ -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())
+37
View File
@@ -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);
}
+95
View File
@@ -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"
+68
View File
@@ -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())
+604
View File
@@ -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()
+5
View File
@@ -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
+17 -36
View File
@@ -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
View File
@@ -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
View File
@@ -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));
}
+100
View File
@@ -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;
}
+17
View File
@@ -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
+25
View File
@@ -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
+10
View File
@@ -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
+45
View File
@@ -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;
}
+96
View File
@@ -0,0 +1,96 @@
#include <assert.h>
#include <stdio.h>
#include "maze32_game.h"
static void initialize_game(maze32_maze_t* maze, maze32_maze_workspace_t* workspace,
maze32_game_t* game) {
maze32_maze_config_t maze_config = maze32_maze_default_config();
maze32_maze_report_t report;
assert(maze32_maze_generate(maze, workspace, &maze_config, UINT32_C(0x5a320009), &report) ==
MAZE32_MAZE_OK);
maze32_game_config_t game_config = maze32_game_default_config();
game_config.movement_cooldown_ms = 0U;
assert(maze32_game_init(game, maze, &game_config) == MAZE32_GAME_RESULT_ACCEPTED);
maze32_game_set_phase(game, MAZE32_GAME_PHASE_ACTIVE);
}
static void test_player_pause_and_reconnect(void) {
static maze32_maze_t maze;
static maze32_maze_workspace_t workspace;
static maze32_game_t game;
initialize_game(&maze, &workspace, &game);
game.players[0].vision_moves_remaining = 7U;
maze32_cell_t position = game.players[0].position;
assert(maze32_game_set_player_connected(&game, 1U, 0, 1000U) == MAZE32_GAME_RESULT_ACCEPTED);
assert(game.phase == MAZE32_GAME_PHASE_PAUSED);
assert(game.players[1].reconnect_deadline_ms == 61000U);
uint32_t sequence = 0U;
assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_NORTH,
2000U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED);
assert(maze32_game_process_next(&game).result == MAZE32_GAME_RESULT_PREMATURE);
assert(game.players[0].position.x == position.x && game.players[0].position.y == position.y);
assert(game.players[0].vision_moves_remaining == 7U);
assert(maze32_game_set_player_connected(&game, 1U, 1, 60999U) == MAZE32_GAME_RESULT_ACCEPTED);
assert(game.phase == MAZE32_GAME_PHASE_ACTIVE);
assert(game.players[1].reconnect_deadline_ms == 0U);
assert(game.players[0].vision_moves_remaining == 7U);
}
static void test_timeout_draw_and_observer_read_only(void) {
static maze32_maze_t maze;
static maze32_maze_workspace_t workspace;
static maze32_game_t game;
static maze32_view_t view;
initialize_game(&maze, &workspace, &game);
uint32_t sequence = 0U;
assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_OBSERVER, MAZE32_GAME_DIRECTION_EAST,
1U, &sequence) == MAZE32_GAME_RESULT_READ_ONLY);
assert(game.state_sequence == 0U);
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, &view) ==
MAZE32_GAME_RESULT_ACCEPTED);
assert(view.cell_count == maze.config.width * maze.config.height);
assert(view.observer_connected[0] && view.observer_connected[1]);
assert(maze32_game_set_player_connected(&game, 0U, 0, 5000U) == MAZE32_GAME_RESULT_ACCEPTED);
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, &view) ==
MAZE32_GAME_RESULT_ACCEPTED);
assert(!view.observer_connected[0] && view.observer_connected[1]);
assert(!maze32_game_advance_time(&game, 64999U));
assert(maze32_game_advance_time(&game, 65000U));
assert(game.phase == MAZE32_GAME_PHASE_FINISHED);
assert(game.final_result == MAZE32_GAME_FINAL_DRAW);
assert(maze32_game_set_player_connected(&game, 0U, 1, 65000U) ==
MAZE32_GAME_RESULT_MATCH_FINISHED);
}
static void test_finish_window_is_frozen_while_paused(void) {
static maze32_maze_t maze;
static maze32_maze_workspace_t workspace;
static maze32_game_t game;
initialize_game(&maze, &workspace, &game);
game.phase = MAZE32_GAME_PHASE_FINISH_PENDING;
game.first_exited_player = 0U;
game.has_exit_time[0] = 1U;
game.exit_times_ms[0] = 1000U;
assert(maze32_game_set_player_connected(&game, 1U, 0, 1100U) == MAZE32_GAME_RESULT_ACCEPTED);
assert(game.phase == MAZE32_GAME_PHASE_PAUSED);
assert(maze32_game_set_player_connected(&game, 1U, 1, 60100U) == MAZE32_GAME_RESULT_ACCEPTED);
assert(game.phase == MAZE32_GAME_PHASE_FINISH_PENDING);
assert(game.exit_times_ms[0] == 60000U);
assert(!maze32_game_advance_time(&game, 60200U));
assert(maze32_game_advance_time(&game, 60201U));
assert(game.final_result == MAZE32_GAME_FINAL_PLAYER_ONE);
}
int main(void) {
test_player_pause_and_reconnect();
test_timeout_draw_and_observer_read_only();
test_finish_window_is_frozen_while_paused();
puts("disconnect recovery tests: PASS");
return 0;
}
+371
View File
@@ -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;
}
+390
View File
@@ -0,0 +1,390 @@
#include "maze32_game.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
#define TEST_WIDTH 8U
#define TEST_HEIGHT 8U
static uint16_t index_of(uint16_t x, uint16_t y) {
return (uint16_t)(y * TEST_WIDTH + x);
}
static void open_test_grid(maze32_maze_t* maze) {
memset(maze, 0, sizeof(*maze));
maze->config.width = TEST_WIDTH;
maze->config.height = TEST_HEIGHT;
maze->config.artifact_pairs = 1U;
for (uint16_t y = 0U; y < TEST_HEIGHT; ++y) {
for (uint16_t x = 0U; x < TEST_WIDTH; ++x) {
uint8_t passages = 0U;
if (y > 0U) {
passages |= MAZE32_PASSAGE_NORTH;
}
if (x + 1U < TEST_WIDTH) {
passages |= MAZE32_PASSAGE_EAST;
}
if (y + 1U < TEST_HEIGHT) {
passages |= MAZE32_PASSAGE_SOUTH;
}
if (x > 0U) {
passages |= MAZE32_PASSAGE_WEST;
}
maze->passages[index_of(x, y)] = passages;
}
}
maze->starts[0] = (maze32_cell_t){0U, 1U};
maze->starts[1] = (maze32_cell_t){7U, 6U};
maze->keys[0] = (maze32_cell_t){2U, 1U};
maze->keys[1] = (maze32_cell_t){1U, 1U};
maze->exits[0] = (maze32_cell_t){0U, 1U};
maze->exits[1] = (maze32_cell_t){7U, 6U};
maze->compasses[0] = (maze32_cell_t){1U, 1U};
maze->compasses[1] = (maze32_cell_t){6U, 6U};
maze->artifacts[0][0] = (maze32_cell_t){1U, 2U};
maze->artifacts[0][1] = (maze32_cell_t){7U, 7U};
}
static maze32_game_t active_game(maze32_maze_t* maze) {
maze32_game_t game;
maze32_game_config_t config = maze32_game_default_config();
config.movement_cooldown_ms = 0U;
assert(maze32_game_init(&game, maze, &config) == MAZE32_GAME_RESULT_ACCEPTED);
maze32_game_set_phase(&game, MAZE32_GAME_PHASE_ACTIVE);
return game;
}
static maze32_game_command_result_t move_player(maze32_game_t* game, maze32_game_role_t role,
maze32_game_direction_t direction,
uint64_t submitted_ms) {
uint32_t sequence = 0U;
assert(maze32_game_enqueue_move(game, role, direction, submitted_ms, &sequence) ==
MAZE32_GAME_RESULT_ACCEPTED);
maze32_game_command_result_t result = maze32_game_process_next(game);
assert(result.command_sequence == sequence);
return result;
}
static maze32_game_command_result_t drop_key(maze32_game_t* game, maze32_game_role_t role,
uint64_t submitted_ms) {
uint32_t sequence = 0U;
assert(maze32_game_enqueue_drop_key(game, role, submitted_ms, &sequence) ==
MAZE32_GAME_RESULT_ACCEPTED);
maze32_game_command_result_t result = maze32_game_process_next(game);
assert(result.command_sequence == sequence);
return result;
}
static void give_key(maze32_game_t* game, uint8_t carrier, uint8_t key) {
game->players[carrier].carried_key = key;
game->keys[key].location = MAZE32_GAME_KEY_CARRIED;
game->keys[key].carrier = carrier;
}
static void assert_key_conservation(const maze32_game_t* game) {
uint8_t references[MAZE32_GAME_KEY_COUNT] = {0U, 0U};
for (uint8_t key = 0U; key < MAZE32_GAME_KEY_COUNT; ++key) {
if (game->keys[key].location == MAZE32_GAME_KEY_GROUND) {
references[key]++;
assert(game->keys[key].carrier == UINT8_MAX);
} else {
assert(game->keys[key].carrier < MAZE32_GAME_PLAYER_COUNT);
}
}
for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) {
uint8_t key = game->players[player].carried_key;
if (key != MAZE32_GAME_NO_KEY) {
assert(key < MAZE32_GAME_KEY_COUNT);
assert(game->keys[key].location == MAZE32_GAME_KEY_CARRIED);
assert(game->keys[key].carrier == player);
references[key]++;
}
}
assert(references[0] == 1U && references[1] == 1U);
}
static void test_inventory_drop_and_sabotage(void) {
maze32_maze_t maze;
open_test_grid(&maze);
maze32_game_t game = active_game(&maze);
maze32_game_command_result_t result =
move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, 1U);
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED && result.picked_key);
assert(result.key == 1U && game.players[0].carried_key == 1U);
assert_key_conservation(&game);
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, 2U);
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED && !result.picked_key);
assert(game.players[0].carried_key == 1U);
assert(game.keys[0].location == MAZE32_GAME_KEY_GROUND);
assert_key_conservation(&game);
result = drop_key(&game, MAZE32_GAME_ROLE_PLAYER_ONE, 3U);
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED && result.dropped_key && result.key == 1U);
assert(game.players[0].carried_key == MAZE32_GAME_NO_KEY);
assert(game.keys[0].location == MAZE32_GAME_KEY_GROUND &&
game.keys[1].location == MAZE32_GAME_KEY_GROUND);
assert(game.keys[0].ground_position.x == 2U && game.keys[1].ground_position.x == 2U);
assert_key_conservation(&game);
assert(move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_WEST, 4U).result ==
MAZE32_GAME_RESULT_ACCEPTED);
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, 5U);
assert(result.picked_key && result.key == 0U);
assert(game.players[0].carried_key == 0U);
assert(game.keys[1].location == MAZE32_GAME_KEY_GROUND);
assert_key_conservation(&game);
uint32_t state_sequence = game.state_sequence;
game.players[0].carried_key = MAZE32_GAME_NO_KEY;
game.keys[0].location = MAZE32_GAME_KEY_GROUND;
game.keys[0].ground_position = game.players[0].position;
game.keys[0].carrier = UINT8_MAX;
result = drop_key(&game, MAZE32_GAME_ROLE_PLAYER_ONE, 6U);
assert(result.result == MAZE32_GAME_RESULT_NO_KEY && game.state_sequence == state_sequence);
assert_key_conservation(&game);
printf("game key inventory, drop, and sabotage: PASS\n");
}
static void test_atomic_meeting_return(void) {
maze32_maze_t maze;
open_test_grid(&maze);
maze32_game_t game = active_game(&maze);
game.players[0].position = (maze32_cell_t){2U, 2U};
game.players[1].position = (maze32_cell_t){4U, 2U};
give_key(&game, 0U, 1U);
give_key(&game, 1U, 0U);
assert_key_conservation(&game);
maze32_game_command_result_t result =
move_player(&game, MAZE32_GAME_ROLE_PLAYER_TWO, MAZE32_GAME_DIRECTION_WEST, 10U);
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED && !result.meeting);
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, 10U);
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED && result.meeting);
assert(result.returned_keys == 3U);
assert(game.players[0].carried_key == 0U && game.players[1].carried_key == 1U);
assert(game.keys[0].carrier == 0U && game.keys[1].carrier == 1U);
assert_key_conservation(&game);
printf("game atomic meeting key return: PASS (opponent keys swapped to owners)\n");
}
static void test_owner_exit_restrictions(void) {
maze32_maze_t maze;
open_test_grid(&maze);
maze32_game_t game = active_game(&maze);
game.players[0].position = (maze32_cell_t){1U, 1U};
give_key(&game, 0U, 1U);
maze32_game_command_result_t result =
move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_WEST, 20U);
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED && !result.opened_exit);
assert(!game.exits[0].open);
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_WEST, 21U);
assert(result.result == MAZE32_GAME_RESULT_BLOCKED && game.phase == MAZE32_GAME_PHASE_ACTIVE);
open_test_grid(&maze);
game = active_game(&maze);
game.players[0].position = (maze32_cell_t){6U, 6U};
give_key(&game, 0U, 1U);
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, 30U);
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED && !result.opened_exit);
assert(!game.exits[1].open);
game.exits[1].open = 1U;
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, 31U);
assert(result.result == MAZE32_GAME_RESULT_BLOCKED && !game.players[0].exited);
open_test_grid(&maze);
game = active_game(&maze);
game.players[0].position = (maze32_cell_t){1U, 1U};
give_key(&game, 0U, 0U);
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_WEST, 40U);
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED && result.opened_exit);
assert(game.exits[0].open && game.phase == MAZE32_GAME_PHASE_ACTIVE);
assert(game.players[0].carried_key == 0U);
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_WEST, 41U);
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED && result.outward_exit);
assert(game.phase == MAZE32_GAME_PHASE_FINISH_PENDING &&
game.final_result == MAZE32_GAME_FINAL_NONE);
printf("game owner-only exit opening and outward move: PASS\n");
}
static void test_vision_artifact_duration(void) {
maze32_maze_t maze;
open_test_grid(&maze);
maze.starts[0] = (maze32_cell_t){0U, 2U};
maze32_game_t game = active_game(&maze);
maze32_game_command_result_t result =
move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, 100U);
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED && result.activated_artifact);
assert(result.artifact == 0U && game.consumed_artifacts[0]);
assert(game.players[0].vision_moves_remaining == MAZE32_GAME_VISION_MOVES);
assert(maze32_game_player_view_radius(&game, 0U) == 6U);
maze.passages[index_of(1U, 2U)] &= (uint8_t)~MAZE32_PASSAGE_NORTH;
uint32_t accepted_moves = game.players[0].accepted_moves;
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_NORTH, 101U);
assert(result.result == MAZE32_GAME_RESULT_BLOCKED);
assert(game.players[0].vision_moves_remaining == 10U);
assert(game.players[0].accepted_moves == accepted_moves);
for (uint8_t move = 0U; move < MAZE32_GAME_VISION_MOVES; ++move) {
maze32_game_direction_t direction =
move % 2U == 0U ? MAZE32_GAME_DIRECTION_WEST : MAZE32_GAME_DIRECTION_EAST;
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, direction, 102U + move);
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED && !result.activated_artifact);
assert(game.players[0].vision_moves_remaining == MAZE32_GAME_VISION_MOVES - move - 1U);
if (move < MAZE32_GAME_VISION_MOVES - 1U) {
assert(maze32_game_player_view_radius(&game, 0U) == 6U);
}
}
assert(maze32_game_player_view_radius(&game, 0U) == 3U);
printf("game one-use vision duration: PASS (10 successful moves, failed move ignored)\n");
}
static void test_compass_and_role_projections(void) {
maze32_maze_t maze;
open_test_grid(&maze);
maze.exits[0] = (maze32_cell_t){7U, 0U};
maze32_game_t game = active_game(&maze);
give_key(&game, 0U, 1U);
maze32_game_command_result_t result =
move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, 200U);
assert(result.compass_hint == MAZE32_GAME_COMPASS_NORTH_EAST);
assert(game.players[0].compass_hint == MAZE32_GAME_COMPASS_NORTH_EAST);
maze32_view_t view;
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, &view) ==
MAZE32_GAME_RESULT_ACCEPTED);
assert(view.self_carried_key == 1U && view.self_compass_hint == MAZE32_GAME_COMPASS_NORTH_EAST);
char payload[MAZE32_GAME_VIEW_CHUNK_BYTES];
size_t written = 0U;
assert(maze32_game_serialize_view_chunk(&view, 0U, payload, sizeof(payload), &written) ==
MAZE32_GAME_RESULT_ACCEPTED);
assert(strstr(payload, "\"hint\":\"ne\"") != NULL);
assert(strstr(payload, "\"inventory\":1") != NULL);
assert(strstr(payload, "\"exitTimes\"") == NULL);
game.keys[0].location = MAZE32_GAME_KEY_CARRIED;
game.keys[0].carrier = 1U;
game.players[1].carried_key = 0U;
game.players[1].position = (maze32_cell_t){2U, 1U};
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, &view) ==
MAZE32_GAME_RESULT_ACCEPTED);
assert(view.opponent_visible && view.opponent_carried_key == 0U);
assert(maze32_game_serialize_view_chunk(&view, 0U, payload, sizeof(payload), &written) ==
MAZE32_GAME_RESULT_ACCEPTED);
assert(strstr(payload, "\"opponentKey\":0") != NULL);
game.players[1].position = (maze32_cell_t){7U, 7U};
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_TWO, &view) ==
MAZE32_GAME_RESULT_ACCEPTED);
assert(!view.opponent_visible);
assert(maze32_game_serialize_view_chunk(&view, 0U, payload, sizeof(payload), &written) ==
MAZE32_GAME_RESULT_ACCEPTED);
assert(strstr(payload, "\"hint\":\"ne\"") == NULL);
assert(strstr(payload, "\"opponentKey\"") == NULL);
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, &view) ==
MAZE32_GAME_RESULT_ACCEPTED);
assert(view.observer_carried_keys[0] == 1U);
assert(maze32_game_serialize_view_chunk(&view, 0U, payload, sizeof(payload), &written) ==
MAZE32_GAME_RESULT_ACCEPTED);
assert(strstr(payload, "\"players\":[") != NULL);
assert(strstr(payload, "\"key\":1") != NULL);
assert(strstr(payload, "\"exitTimes\":[null,null]") != NULL);
maze.passages[index_of(1U, 1U)] &= (uint8_t)~MAZE32_PASSAGE_NORTH;
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_NORTH, 201U);
assert(result.result == MAZE32_GAME_RESULT_BLOCKED);
assert(game.players[0].compass_hint == MAZE32_GAME_COMPASS_NORTH_EAST);
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_WEST, 202U);
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED);
assert(game.players[0].compass_hint == MAZE32_GAME_COMPASS_NONE);
printf("game compass bearing and role-specific projections: PASS\n");
}
static void prepare_open_exits(maze32_game_t* game) {
game->players[0].position = game->maze->exits[0];
game->players[1].position = game->maze->exits[1];
game->exits[0].open = 1U;
game->exits[1].open = 1U;
}
static void test_victory_and_draw_window(void) {
maze32_maze_t maze;
open_test_grid(&maze);
maze32_game_t game = active_game(&maze);
prepare_open_exits(&game);
maze32_game_command_result_t result =
move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_WEST, 1000U);
assert(result.outward_exit && !result.finished);
assert(game.phase == MAZE32_GAME_PHASE_FINISH_PENDING);
assert(!maze32_game_advance_time(&game, 1200U));
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_TWO, MAZE32_GAME_DIRECTION_EAST, 1200U);
assert(result.outward_exit && result.finished);
assert(game.phase == MAZE32_GAME_PHASE_FINISHED && game.final_result == MAZE32_GAME_FINAL_DRAW);
open_test_grid(&maze);
game = active_game(&maze);
prepare_open_exits(&game);
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_WEST, 2000U);
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED);
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_TWO, MAZE32_GAME_DIRECTION_EAST, 2201U);
assert(result.result == MAZE32_GAME_RESULT_MATCH_FINISHED && result.finished);
assert(!game.players[1].exited && game.final_result == MAZE32_GAME_FINAL_PLAYER_ONE);
open_test_grid(&maze);
game = active_game(&maze);
prepare_open_exits(&game);
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_TWO, MAZE32_GAME_DIRECTION_EAST, 3000U);
assert(result.outward_exit && game.final_result == MAZE32_GAME_FINAL_NONE);
assert(!maze32_game_advance_time(&game, 3200U));
assert(maze32_game_advance_time(&game, 3201U));
assert(game.final_result == MAZE32_GAME_FINAL_PLAYER_TWO);
printf("game outward result window: PASS (200 ms draw boundary, 201 ms winner)\n");
}
static void test_persistent_bush_wall_breakthrough(void) {
maze32_maze_t maze;
open_test_grid(&maze);
maze.passages[index_of(2U, 2U)] &= (uint8_t)~MAZE32_PASSAGE_EAST;
maze.passages[index_of(3U, 2U)] &= (uint8_t)~MAZE32_PASSAGE_WEST;
maze32_game_t game = active_game(&maze);
game.players[0].position = (maze32_cell_t){2U, 2U};
for (uint8_t hit = 1U; hit < MAZE32_GAME_BREAKTHROUGH_HITS; ++hit) {
maze32_game_command_result_t result =
move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, hit * 500U);
assert(result.result == MAZE32_GAME_RESULT_BLOCKED);
assert(result.breakthrough_hits == hit);
assert(!result.breakthrough_opened);
assert(game.players[0].position.x == 2U);
}
maze32_game_command_result_t result =
move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, 2500U);
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED && result.breakthrough_opened);
assert(result.breakthrough_hits == MAZE32_GAME_BREAKTHROUGH_HITS);
assert(game.players[0].position.x == 3U);
assert(game.opened_passages[index_of(2U, 2U)] & MAZE32_PASSAGE_EAST);
assert(game.opened_passages[index_of(3U, 2U)] & MAZE32_PASSAGE_WEST);
game.players[1].position = (maze32_cell_t){3U, 2U};
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_TWO, MAZE32_GAME_DIRECTION_WEST, 2600U);
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED);
assert(game.players[1].position.x == 2U);
printf("game persistent bush-wall breakthrough: PASS (five impacts, shared passage)\n");
}
int main(void) {
test_inventory_drop_and_sabotage();
test_atomic_meeting_return();
test_owner_exit_restrictions();
test_vision_artifact_duration();
test_compass_and_role_projections();
test_victory_and_draw_window();
test_persistent_bush_wall_breakthrough();
puts("game mechanics tests: PASS");
return 0;
}
+33
View File
@@ -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;
}
+183
View File
@@ -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;
}
+239
View File
@@ -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;
}
+40
View File
@@ -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;
}
+115
View File
@@ -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;
}
+52
View File
@@ -0,0 +1,52 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const zlib = require("node:zlib");
const samplePath = path.join(__dirname, "..", "..", "docs", "hedge-sample-library.svg");
const sample = fs.readFileSync(samplePath, "utf8");
const runtimePath = path.join(__dirname, "..", "..", "web", "hedges.svg");
const runtime = fs.readFileSync(runtimePath, "utf8");
test("hedge review sheet is local editable SVG with complete stable symbols", () => {
assert.match(sample, /^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg"/);
assert.doesNotMatch(sample, /<(?:script|image|foreignObject)\b/i);
assert.doesNotMatch(sample, /(?:href="https?:|url\(|data:|@import|<style\b)/i);
assert.doesNotMatch(sample, /(?:sodipodi:|inkscape:|metadata\b)/i);
for (const identifier of [
"leaf-cluster-a", "leaf-cluster-b", "twig-fork", "hedge-base",
"hedge-intact-1", "hedge-intact-2", "hedge-intact-3", "hedge-intact-4",
"hedge-intact-5", "hedge-intact-6", "hedge-corner", "hedge-crossing",
"hedge-end", "hedge-impact-1", "hedge-impact-2", "hedge-impact-3",
"hedge-impact-4", "hedge-breakthrough", "hedge-breach-1",
"hedge-breach-2", "hedge-breach-3"
]) {
assert.match(sample, new RegExp(`id="${identifier}"`));
}
assert.ok((sample.match(/<use href=/g) || []).length >= 20);
assert.ok(zlib.gzipSync(sample, {level: 9}).length < sample.length);
});
test("runtime hedge sprite remains local and contains approved runtime symbols", () => {
assert.match(runtime, /^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg"/);
assert.doesNotMatch(runtime, /<(?:script|image|foreignObject|metadata)\b/i);
assert.doesNotMatch(runtime, /(?:href="https?:|url\(|data:|@import|<style\b)/i);
for (const identifier of [
"hedge-intact-1", "hedge-intact-2", "hedge-intact-3", "hedge-intact-4",
"hedge-intact-5", "hedge-intact-6", "hedge-impact-1", "hedge-impact-2",
"hedge-impact-3", "hedge-impact-4", "hedge-breakthrough", "hedge-breach-1",
"hedge-breach-2", "hedge-breach-3"
]) {
assert.match(runtime, new RegExp(`id="${identifier}"`));
assert.match(sample, new RegExp(`id="${identifier}"`));
}
assert.ok(zlib.gzipSync(runtime, {level: 9}).length < 4096);
});
test("permanent hedge breaches leave a 60 percent clear centre", () => {
for (const identifier of ["hedge-breach-1", "hedge-breach-2", "hedge-breach-3"]) {
const symbol = runtime.match(new RegExp(`<g id="${identifier}">([\\s\\S]*?)</g>`))[1];
assert.match(symbol, /fill="#11131a" d="M32 0h96v40H32Z"/);
}
});
+523
View File
@@ -0,0 +1,523 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const project = path.resolve(__dirname, "../..");
const player = require(path.join(project, "web/player.js"));
test("keyboard and swipe controls produce equivalent directions", () => {
const cases = [
["ArrowUp", [50, 80, 50, 20], "north"],
["KeyD", [20, 50, 80, 50], "east"],
["ArrowDown", [50, 20, 50, 80], "south"],
["KeyA", [80, 50, 20, 50], "west"]
];
for (const [key, swipe, expected] of cases) {
assert.equal(player.directionFromKey(key), expected);
assert.equal(player.directionFromSwipe(...swipe), expected);
}
assert.equal(player.directionFromSwipe(0, 0, 12, 4), null);
});
test("avatar drag produces only cardinal, bounded movement intentions", () => {
assert.equal(player.avatarDirection(10, 10, null), null);
assert.equal(player.avatarDirection(44, 8, null), "east");
assert.equal(player.avatarDirection(-8, -44, null), "north");
assert.equal(player.avatarDirection(42, 38, "east"), "east");
assert.equal(player.avatarDirection(38, 42, "north"), "south");
const vector = player.avatarVector(300, 0, 48, null);
assert.equal(vector.direction, "east");
assert.equal(vector.x, player.AVATAR_MAX_DEFLECTION);
assert.equal(vector.y, 0);
assert.ok(vector.distance <= player.AVATAR_MAX_DEFLECTION);
});
test("avatar cadence is monotonic and remains slower than cooldown", () => {
assert.equal(player.avatarRepeatMs(player.AVATAR_DEAD_ZONE, 48), null);
const near = player.avatarRepeatMs(18, 48);
const far = player.avatarRepeatMs(48, 48);
assert.ok(near > far);
assert.ok(far >= player.AVATAR_FAST_REPEAT_MS);
assert.ok(player.AVATAR_FAST_REPEAT_MS > 150);
});
test("latest movement intent coalesces while one command is in flight", () => {
const dispatched = [];
const flow = new player.LatestIntentFlow(direction => {
const requestId = `move-${dispatched.length + 1}`;
dispatched.push({direction, requestId});
return requestId;
});
assert.equal(flow.request("east"), "move-1");
assert.equal(flow.request("north"), null);
assert.equal(flow.request("west"), null);
assert.deepEqual(dispatched.map(item => item.direction), ["east"]);
flow.complete("move-1");
assert.deepEqual(dispatched.map(item => item.direction), ["east", "west"]);
flow.release();
flow.complete("move-2");
assert.equal(flow.inFlight, null);
assert.equal(flow.latest, null);
});
test("directional pad repeat remains slower than the authoritative cooldown", () => {
assert.ok(player.DIRECTIONAL_PAD_REPEAT_MS > 150);
const dispatched = [];
const flow = new player.LatestIntentFlow((direction, element) => {
dispatched.push({direction, element});
return "move-1";
});
const button = {id: "north"};
flow.request("north", button);
flow.request("east", {id: "east"});
assert.deepEqual(flow.latest, {direction: "east", element: {id: "east"}});
flow.release();
assert.equal(flow.latest, null);
assert.deepEqual(dispatched, [{direction: "north", element: button}]);
});
test("latency correlation is bounded and only completes matching authoritative views", () => {
let clock = 0;
const tracker = new player.LatencyTracker(() => clock);
tracker.sent("move-1");
clock = 12;
tracker.acknowledged("move-1", 41);
clock = 16;
tracker.painted(40);
assert.equal(tracker.snapshot().length, 0);
clock = 24;
tracker.painted(41);
assert.deepEqual(tracker.snapshot(), [{requestId: "move-1", commandToAckMs: 12,
commandToPaintMs: 24, ackToPaintMs: 12}]);
for (let index = 0; index < player.LATENCY_SAMPLE_LIMIT + 4; index++) {
tracker.sent(`move-${index + 2}`);
}
assert.ok(tracker.pending.size <= player.LATENCY_SAMPLE_LIMIT);
for (let index = 0; index < player.LATENCY_SAMPLE_LIMIT + 4; index++) {
clock++;
tracker.pointerPainted(0);
}
assert.equal(tracker.pointerSnapshot().length, player.LATENCY_SAMPLE_LIMIT);
});
test("connection quality uses one correlated heartbeat and bounded local smoothing", () => {
let clock = 0;
const quality = new player.ConnectionQuality(() => clock);
assert.equal(quality.begin("heartbeat-1"), true);
assert.equal(quality.begin("heartbeat-2"), false);
clock = 85;
assert.equal(quality.acknowledge("wrong", 2), false);
assert.equal(quality.acknowledge("heartbeat-1", 2), true);
assert.deepEqual(quality.latest, {totalMs: 85, communicationMs: 83, handlingMs: 2, received: 85});
for (let index = 0; index < player.QUALITY_SAMPLE_LIMIT + 2; index++) {
clock += 10;
assert.equal(quality.begin(`heartbeat-${index + 2}`), true);
clock += 20;
assert.equal(quality.acknowledge(`heartbeat-${index + 2}`, 1), true);
}
assert.equal(quality.samples.length, player.QUALITY_SAMPLE_LIMIT);
assert.equal(quality.acknowledge("heartbeat-99", 1001), false);
clock += player.HEARTBEAT_STALE_MS + 1;
assert.equal(quality.status(), "stale");
quality.reset();
assert.equal(quality.status(), "unknown");
});
test("player map cells enlarge small projections without exceeding a readable bound", () => {
const sevenBySeven = player.playerCellSize(396, 730, 7, 7);
const fourByFour = player.playerCellSize(396, 730, 4, 4);
assert.ok(sevenBySeven >= 46);
assert.ok(fourByFour > sevenBySeven);
assert.ok(fourByFour <= 72);
});
test("authoritative self cell remains below the centered avatar at map edges", () => {
assert.deepEqual(player.playerMapOffset(0, 7, 0, 0, 7, 7, 46), {x: 138, y: 138});
assert.deepEqual(player.playerMapOffset(48, 7, 0, 0, 7, 7, 46), {x: -138, y: -138});
assert.deepEqual(player.playerMapOffset(24, 7, 1, 1, 5, 5, 46), {x: 0, y: 0});
assert.deepEqual(player.playerMapOffset(-1, 7, 0, 0, 7, 7, 46), {x: 0, y: 0});
});
test("responsive player focal point stays above phone controls and composes with map offsets", () => {
assert.deepEqual(player.playerFocalPoint(400, 800), {x: 200, y: 240});
assert.deepEqual(player.playerFocalPoint(900, 400), {x: 378, y: 180});
assert.deepEqual(player.playerFocalMapOffset(24, 7, 1, 1, 5, 5, 46, 400, 800), {x: 0, y: -160});
assert.deepEqual(player.playerFocalMapOffset(0, 7, 0, 0, 7, 7, 46, 400, 800), {x: 138, y: -22});
});
test("onboarding has bounded, replayable steps", () => {
assert.equal(player.ONBOARDING_STEPS.length, 8);
assert.equal(player.nextOnboardingStep(0), 1);
assert.equal(player.nextOnboardingStep(6), 7);
assert.equal(player.nextOnboardingStep(7), null);
});
test("countdown completion leaves help closed until the player requests it", () => {
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
const activeView = script.match(/function renderSnapshot\(snapshot\) \{([\s\S]*?)\n \}/)[1];
assert.match(activeView, /setScreen\("match"\)/);
assert.match(activeView, /setDirectionalPadEnabled\(snapshot\.phase === "active"/);
assert.match(activeView, /else hideModal\(\);/);
assert.doesNotMatch(activeView, /showOnboarding\(0\)/);
assert.match(script, /byId\("settings-help"\)\.addEventListener\("click", \(\) => \{ closeSettings\(false\); showOnboarding\(0\); \}\)/);
assert.match(script, /byId\("onboarding-skip"\)\.addEventListener\("click", hideOnboarding\)/);
assert.match(script, /if \(next == null\) hideOnboarding\(\)/);
});
test("avatar control resets safely and retains accessible alternatives", () => {
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
assert.match(script, /addEventListener\("pointerup", stopAvatarControl\)/);
assert.match(script, /addEventListener\("pointercancel", stopAvatarControl\)/);
assert.match(script, /addEventListener\("lostpointercapture", stopAvatarControl\)/);
assert.match(script, /addEventListener\("blur", resetPlayerControls\)/);
assert.match(script, /connectionState === "offline"\) \{ resetPlayerControls\(\);/);
assert.match(script, /snapshot\.phase === "paused"\) \{ resetPlayerControls\(\)/);
assert.match(script, /state\.avatar\.startX = event\.clientX/);
assert.match(script, /event\.clientX - state\.avatar\.startX/);
assert.match(script, /windowObject\.addEventListener\("resize", refreshPlayerFocalPoint\)/);
assert.match(script, /windowObject\.addEventListener\("orientationchange", refreshPlayerFocalPoint\)/);
assert.match(script, /setMuted\(!state\.muted\)/);
assert.match(script, /setReducedMotion\(!state\.reducedMotion\)/);
assert.match(html, /id="avatar-control"/);
assert.match(html, /aria-label="Тянуть героя для движения"/);
assert.match(script, /settings-help/);
assert.match(html, /id="onboarding-dots"[\s\S]*?<span><\/span><span><\/span><span><\/span><span><\/span><span><\/span><span><\/span><span><\/span><span><\/span>/);
});
test("playful feedback uses bounded maze-only effects and a top message rail", () => {
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
assert.match(html, /<main id="app">\s*<div class="message-rail" id="notice" role="status" aria-live="polite" aria-atomic="true" hidden><\/div>\s*<header/);
assert.doesNotMatch(html, /wall-bump-text/);
assert.match(css, /\.maze\.impact-north/);
assert.match(css, /\.maze\.rate-limited \{ filter: brightness\(\.48\) blur\(2px\)/);
assert.match(css, /\[data-reduced-motion="true"\] \.maze\[class\*="impact-"\] \{ animation: none;/);
assert.match(css, /\[data-reduced-motion="true"\] \.maze\.rate-limited \{ transition: none;/);
assert.match(css, /\.message-rail \{ position: fixed; top:/);
assert.match(script, /moveDirections: new Map\(\)/);
assert.match(script, /if \(message\.payload\.code === "blocked"\) \{ showImpact\(direction, message\.payload\.breakthroughHits\); feedback\(false\); return; \}/);
assert.match(css, /\.cell\.bush-impact/);
assert.match(css, /\.hedge-layer/);
assert.match(css, /\.hedge-edge \{[^}]*height: calc\(var\(--cell\) \* \.2\)/);
assert.match(css, /\.hedge-edge\.hedge-vertical \{[^}]*left: calc\(var\(--hedge-x\) \* var\(--cell\) - var\(--cell\) \* \.5\);[^}]*top: calc\(var\(--hedge-y\) \* var\(--cell\) \+ var\(--cell\) \* \.4\)/);
assert.match(css, /\.observer-maze \.hedge-edge\.hedge-vertical \{[^}]*top: calc\(var\(--hedge-y\) \* var\(--cell\) \+ var\(--cell\) \* \.5 - 1px\)/);
assert.match(css, /\.cell::after \{[^}]*background: #1c1b27;[^}]*opacity: \.55/);
assert.match(script, /projectedHedgeEdges/);
assert.match(script, /svg\.setAttribute\("viewBox", "0 0 160 40"\)/);
assert.match(script, /svg\.setAttribute\("preserveAspectRatio", "none"\)/);
assert.match(script, /\/hedges\.svg#/);
assert.match(script, /if \(message\.payload\.code === "rate_limited"\) \{ showRateLimit\(\); feedback\(false\); return; \}/);
assert.doesNotMatch(script, /Там стена\. Очень убедительная\.|Слишком быстро! Сервер просит немного меньше турбо\./);
});
test("mobile directional pad is explicit, bounded, and resettable", () => {
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
for (const label of ["Двигаться вверх", "Двигаться вниз", "Двигаться влево", "Двигаться вправо"]) {
assert.match(html, new RegExp(`aria-label="${label}"`));
}
assert.match(html, /class="dpad-center" aria-hidden="true"/);
assert.match(html, /data-direction="north"[^>]*disabled/);
assert.match(css, /\.dpad \{[^}]*grid-template-rows: repeat\(3, 58px\)/);
assert.match(css, /\.dpad button:disabled/);
assert.match(css, /\.dpad button\.active/);
assert.match(css, /\.dpad button\.pending/);
assert.match(css, /\.dpad \{[^}]*touch-action: none/);
assert.match(css, /\.dpad button \{[^}]*touch-action: none/);
assert.match(css, /grid-template-columns: repeat\(3, 62px\)/);
assert.match(script, /function resetDirectionalPad\(\)/);
assert.match(script, /addEventListener\("pointercancel", stopDirectionalPad\)/);
assert.match(script, /addEventListener\("lostpointercapture", stopDirectionalPad\)/);
assert.match(script, /windowObject\.addEventListener\("blur", resetPlayerControls\)/);
assert.match(script, /state\.moveFlow\.request\(direction, element\)/);
});
test("mobile welcome and lobby screens scroll while an active match remains fixed", () => {
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
const mobileRules = css.slice(
css.indexOf("@media (max-width: 720px)"),
css.indexOf("@media (min-width: 721px)")
);
assert.doesNotMatch(mobileRules, /html, body \{[^}]*overflow: hidden/);
assert.doesNotMatch(mobileRules, /^ #app \{[^}]*height: 100dvh[^}]*overflow: hidden/m);
assert.match(mobileRules, /body\.match-active \{ height: 100%; overflow: hidden; \}/);
assert.match(mobileRules, /body\.match-active #app \{ height: 100dvh; min-height: 0; overflow: hidden; \}/);
assert.match(mobileRules, /#app \{ padding: max\(8px, env\(safe-area-inset-top\)\).*max\(8px, env\(safe-area-inset-bottom\)\)/);
assert.match(css, /\.match-screen \{[^}]*overflow: hidden/);
assert.match(css, /\.map-shell \{[^}]*touch-action: none/);
assert.match(script, /documentObject\.body\.classList\.toggle\("match-active", name === "match"\)/);
for (const viewport of [[412, 915], [412, 916], [320, 568], [568, 320]]) {
assert.ok(viewport[0] > 0 && viewport[1] > 0);
}
});
test("frontend name suggestions randomize both words and remain bounded", () => {
const picks = [3, 4];
const suggestion = player.randomSuggestedName([], () => picks.shift());
assert.equal(suggestion, "Смелый-Кактус");
assert.match(suggestion, /^[^-]+-[^-]+$/u);
assert.ok(Buffer.byteLength(suggestion, "utf8") <= 32);
assert.equal(player.NAME_COMBINATION_COUNT, 144);
const occupied = "Бодрый-Барсук";
const skipped = player.randomSuggestedName([occupied], () => 0);
assert.notEqual(skipped, occupied);
assert.equal(skipped, "Бодрый-Крот");
const allNames = [];
for (let adjective = 0; adjective < 12; adjective++) {
for (let noun = 0; noun < 12; noun++) {
const pair = [adjective, noun];
allNames.push(player.randomSuggestedName([], () => pair.shift()));
}
}
assert.equal(player.randomSuggestedName(allNames, () => 0), null);
assert.equal(player.randomIndex(12, () => 99), 0);
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
assert.doesNotMatch(script, /suggest_name|name_suggestion/);
});
test("login headline selection is randomizable, Russian, and has a fallback", () => {
assert.ok(player.LOGIN_HEADLINES.length >= 8);
const selected = player.randomLoginHeadline(() => 5);
assert.deepEqual(selected, {first: "Крот сказал", highlight: "что это срез"});
assert.equal(player.randomLoginHeadline(() => -1), player.LOGIN_HEADLINES[0]);
for (const headline of player.LOGIN_HEADLINES) {
assert.match(headline.first, /[А-Яа-яЁё]/u);
assert.match(headline.highlight, /[А-Яа-яЁё]/u);
}
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
assert.match(script, /renderWelcomeHeadline\(randomLoginHeadline\(\)\)/);
assert.match(script, /title\.replaceChildren/);
});
test("command envelopes contain intentions rather than client-owned state", () => {
const move = player.createEnvelope("move", 7, "move-7", {direction: "west"});
assert.deepEqual(move, {v: 1, type: "move", seq: 7, requestId: "move-7", payload: {direction: "west"}});
assert.equal("position" in move.payload, false);
assert.deepEqual(player.createEnvelope("drop_key", 8, "drop_key-8", {}), {v: 1, type: "drop_key", seq: 8, requestId: "drop_key-8", payload: {}});
});
test("chunk assembly replaces projection cells without explored-map memory", () => {
const assembler = new player.SnapshotAssembler();
const second = {v: 1, type: "view", seq: 20, payload: {role: "player1", width: 8, height: 8, chunk: 1, chunks: 2, cells: [[10, 15, 0]], objects: []}};
const first = {v: 1, type: "view", seq: 20, payload: {role: "player1", phase: "active", result: "none", width: 8, height: 8, self: 9, inventory: null, visionMoves: 0, hint: "none", chunk: 0, chunks: 2, cells: [[9, 15, 2]], objects: [["key", 0, 0, 10]]}};
assert.equal(assembler.accept(second), null);
const complete = assembler.accept(first);
assert.deepEqual(player.projectedCellIndices(complete), [9, 10]);
const replacement = assembler.accept({v: 1, type: "view", seq: 21, payload: {role: "player1", phase: "active", result: "none", width: 8, height: 8, self: 18, inventory: 0, visionMoves: 9, hint: "ne", chunk: 0, chunks: 1, cells: [[18, 15, 0]], objects: []}});
assert.deepEqual(player.projectedCellIndices(replacement), [18]);
assert.equal(replacement.cells.some(cell => cell[0] === 9 || cell[0] === 10), false);
assert.equal(assembler.accept({v: 1, type: "view", seq: 22, payload: {chunk: 0, chunks: player.MAX_VIEW_CHUNKS + 1, cells: [], objects: []}}), null);
assert.equal(player.snapshotIsValid({width: 32, height: 32, cells: [[1023, 15, 0]], objects: []}), true);
assert.equal(player.snapshotIsValid({width: 33, height: 32, cells: [], objects: []}), false);
assert.equal(player.snapshotIsValid({width: 32, height: 33, cells: [], objects: []}), false);
});
test("hedge edges are canonical, bounded, and deterministic", () => {
const cells = [[0, 0, 0], [1, 0, 0], [2, 0, 0], [3, 0, 0]];
const first = player.projectedHedgeEdges(cells, 2);
const second = player.projectedHedgeEdges(cells, 2);
assert.deepEqual(first, second);
assert.equal(new Set(first.map(edge => edge.key)).size, first.length);
assert.ok(first.length <= cells.length * 2 + 2 * 2);
assert.equal(player.hedgeVariant(first[0], 6), player.hedgeVariant(first[0], 6));
const opened = player.projectedHedgeEdges([[0, 0, 2], [1, 0, 8]], 2);
assert.equal(opened.filter(edge => edge.opened).length, 1);
assert.equal(player.hedgeKeyForMove(0, 2, "east"), "v:1:0");
});
test("breakthrough travel is bounded and starts only from an opened authoritative edge", () => {
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
assert.match(script, /function breakthroughDirection\(previous, snapshot\)/);
assert.match(script, /projectedHedgeEdges\(snapshot\.cells, snapshot\.width\)\.some\(edge => edge\.key === key && edge\.opened\)/);
assert.match(script, /if \(direction\) showBreakthroughTravel\(direction\);\n else stopBreakthroughTravel\(\);/);
assert.match(script, /for \(let index = 0; index < 4; index\+\+\)/);
assert.match(script, /setTimeout\(stopBreakthroughTravel, 480\)/);
assert.match(script, /if \(reduced\) stopBreakthroughTravel\(\);/);
assert.match(script, /if \(name !== "match"\) stopBreakthroughTravel\(\);/);
assert.match(css, /\.breakthrough-runner \{[^}]*pointer-events: none[^}]*480ms/);
assert.match(css, /@keyframes breakthrough-run/);
assert.match(css, /\[data-reduced-motion="true"\] \.breakthrough-runner/);
});
test("refresh contract restores only the token and waits for server projection", () => {
const restore = player.createEnvelope("restore", 1, "restore-1", {token: "0123456789abcdef0123456789abcdef"});
assert.deepEqual(Object.keys(restore.payload), ["token"]);
assert.equal("position" in restore.payload, false);
assert.equal("inventory" in restore.payload, false);
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
assert.equal((script.match(/localStorage\.setItem/g) || []).length, 2);
assert.match(script, /localStorage\.setItem\(TOKEN_KEY, message\.payload\.token\)/);
assert.match(script, /localStorage\.setItem\(NAME_KEY, byId\("player-name"\)\.value\.slice\(0, 32\)\)/);
});
test("player assets are local, Russian, accessible, and responsive", () => {
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
const icons = fs.readFileSync(path.join(project, "web/icons.svg"), "utf8");
const all = `${html}\n${css}\n${script}\n${icons}`;
assert.doesNotMatch(all, /(?:src|href)=["']https?:|url\(["']?https?:/i);
assert.doesNotMatch(all, /@import|googleapis|analytics/i);
assert.match(html, /lang="ru"/);
assert.match(html, /aria-label="Сбросить ключ"/);
assert.match(html, /Кусты уже|Сбор заблудившихся/);
assert.match(css, /@media \(max-width: 720px\)/);
assert.match(css, /@media \(min-width: 721px\) and \(max-width: 980px\)/);
assert.match(css, /height: calc\(100dvh - 78px\)/);
assert.match(css, /\.dpad \{[^}]*grid-template-columns: repeat\(3, 58px\)/);
assert.match(html, /id="avatar-control"/);
assert.match(html, /id="onboarding"/);
assert.match(html, /id="settings-mute"/);
assert.match(css, /\.avatar-control \{[^}]*width: 96px; height: 96px/);
assert.match(css, /\.avatar-handle \{[^}]*transition: transform/);
assert.match(css, /\.avatar-handle \{[^}]*background: transparent/);
assert.match(script, /resetAvatarControl/);
assert.match(script, /playerCellSize/);
assert.match(css, /\(hover: none\) and \(pointer: coarse\) and \(max-height: 720px\)/);
assert.match(icons, /id="key"/);
assert.match(icons, /id="maze"/);
});
test("one compact settings popover replaces standalone secondary controls", () => {
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
assert.match(html, /id="settings-toggle"[^>]*aria-controls="settings-menu"[^>]*aria-expanded="false"[^>]*aria-label="Открыть настройки"/);
for (const identifier of ["settings-menu", "details-text", "settings-mute", "settings-motion", "settings-help", "settings-wifi"]) {
assert.match(html, new RegExp(`id="${identifier}"`));
}
assert.doesNotMatch(html, /id="details-toggle"|id="details-panel"|id="mute-toggle"|id="motion-toggle"|id="onboarding-replay"/);
assert.doesNotMatch(html, /access-controls|access-button/);
assert.match(css, /\.settings-menu \{[^}]*max-height: calc\(100dvh/);
assert.match(css, /\.settings-item\[aria-pressed="true"\]/);
assert.match(script, /function openSettings\(\)/);
assert.match(script, /function closeSettings\(restoreFocus\)/);
assert.match(script, /event\.key === "Escape" && !byId\("settings-menu"\)\.hidden/);
assert.match(script, /!menu\.hidden && !menu\.contains\(event\.target\) && !trigger\.contains\(event\.target\)/);
assert.match(script, /\["settings-mute", "settings-motion", "settings-help"\]\.forEach\(id => \{ byId\(id\)\.hidden = name !== "match"; \}\)/);
assert.match(script, /button\.textContent = muted \? "Звук: выкл" : "Звук: вкл"/);
assert.match(script, /button\.textContent = reduced \? "Движение: выкл" : "Движение: вкл"/);
});
test("connection quality stays compact and exposes an honest response split", () => {
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
assert.match(html, /id="quality"[^>]*aria-label="Отклик неизвестен"/);
assert.match(html, /id="quality-details"/);
assert.match(css, /\.quality-pill \{[^}]*min-width: 48px/);
assert.match(script, /send\("heartbeat", \{\}, null\)/);
assert.match(script, /communicationMs: Math\.max\(0, smoothedMs - handlingMs\)/);
assert.match(script, /message\.payload\.command === "heartbeat"/);
assert.match(script, /documentObject\.hidden/);
});
test("reference-derived SVG gameplay symbols preserve local yellow and blue ownership", () => {
const icons = fs.readFileSync(path.join(project, "web/icons.svg"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
const playerUi = fs.readFileSync(path.join(project, "docs/PLAYER_UI.md"), "utf8");
for (const iconId of ["player", "key", "exit-closed", "crystal", "exit-open", "spikes"]) {
assert.match(icons, new RegExp(`<symbol id="${iconId}" viewBox="0 0 64 64">`));
}
assert.doesNotMatch(icons, /<(?:image|foreignObject)\b/i);
assert.doesNotMatch(icons, /data:image\//i);
assert.match(script, /icon\(variant \? "exit-open" : "exit-closed", "object object-exit"\)/);
assert.match(script, /icon\("crystal", "object object-vision"\)/);
assert.match(script, /icon\("player", `pawn/);
assert.match(html, /\/icons\.svg#spikes/);
assert.match(css, /--p1: #ffd33d/);
assert.match(css, /--p2: #41b6ff/);
assert.match(css, /--warning: #ffad38/);
assert.match(css, /--pause: #9b8cff/);
assert.match(css, /\.pawn-owner-two \{ border-style: dashed; \}/);
assert.match(html, /Жёлтый/);
assert.match(html, /Синий/);
assert.match(playerUi, /`player`, `key`, `exit-closed`,\n`crystal`, `exit-open`, and `spikes`/);
});
test("observer view fits large screens and bounds zoom", () => {
assert.equal(player.observerFitScale(1024, 600, 32, 32), 2.25);
assert.equal(player.clampObserverZoom(.01), .25);
assert.equal(player.clampObserverZoom(20), 6);
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
assert.match(html, /Режим наблюдателя · только чтение/);
assert.match(html, /Нужен экран побольше/);
assert.match(html, /Зрительный зал открывается от 1024 × 768/);
assert.match(css, /@media \(max-width: 1023px\), \(max-height: 767px\)/);
assert.match(css, /\.observer-stage \{[^}]*overflow: hidden/);
assert.match(script, /snapshot\.role === "observer"/);
assert.match(script, /state\.session\.role === "observer" \? "observer" : "match"/);
assert.match(script, /!player\.connected \? "нет связи"/);
assert.match(script, /addEventListener\("wheel"/);
assert.match(script, /addEventListener\("pointermove"/);
});
test("observer protocol exposes no mutation control path", () => {
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
const observerSection = html.match(/<section class="screen observer-screen"[\s\S]*?<\/section>/)[0];
assert.doesNotMatch(observerSection, /data-direction|drop-key|start-match/);
const observer = {v: 1, type: "view", seq: 30, payload: {role: "observer", phase: "active", result: "none", width: 2, height: 2, players: [{cell: 0, key: null, exited: 0, connected: 1}, {cell: 3, key: 1, exited: 0, connected: 1}], chunk: 0, chunks: 1, cells: [[0, 6, 0], [1, 12, 0], [2, 3, 0], [3, 9, 0]], objects: []}};
const snapshot = new player.SnapshotAssembler().accept(observer);
assert.equal(snapshot.role, "observer");
assert.deepEqual(snapshot.players.map(item => item.cell), [0, 3]);
assert.equal("self" in snapshot, false);
});
test("finished matches expose only an owner restart intention", () => {
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
assert.match(script, /send\("restart", \{\}, byId\("modal-action"\)\)/);
assert.match(script, /message\.type === "reset"/);
assert.doesNotMatch(script, /restart[^\n]*(position|inventory|result):/);
});
test("finished-player exit returns remaining browsers to the waiting lobby", () => {
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
const gateway = fs.readFileSync(path.join(project, "components/maze32_web/maze32_gateway.c"), "utf8");
const leaveHandler = gateway.match(/if \(message->type == MAZE32_MESSAGE_LEAVE\) \{[\s\S]*?if \(message->type == MAZE32_MESSAGE_START\)/)[0];
assert.match(html, /id="settings-leave"/);
assert.match(html, /id="leave-confirmation"/);
assert.match(script, /\["countdown", "active", "finish_pending", "paused", "finished"\]\.includes\(phase\)/);
assert.match(script, /message\.type === "reset"[\s\S]*?hideModal\(\); setScreen\("lobby"\)/);
assert.match(script, /message\.payload\.command === "leave" && message\.requestId === state\.leaveRequest/);
assert.match(script, /windowObject\.localStorage\.removeItem\(TOKEN_KEY\)/);
assert.match(script, /setScreen\("welcome"\)/);
assert.match(leaveHandler, /leaving_role == MAZE32_ROLE_PLAYER &&\s*gateway->lobby\.phase != MAZE32_LOBBY_WAITING;/);
assert.doesNotMatch(leaveHandler, /gateway->game\.phase != MAZE32_GAME_PHASE_FINISHED/);
});
test("authoritative trail masks render bounded yellow, blue, and shared paths", () => {
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
assert.equal(player.snapshotIsValid({width: 2, height: 2, cells: [[0, 6, 0, 3, 0]], objects: []}), true);
assert.equal(player.snapshotIsValid({width: 2, height: 2, cells: [[0, 6, 0, 4, 0]], objects: []}), false);
assert.match(script, /function renderTrails\(/);
assert.match(script, /if \(owners & 1\) paths\[0\]\.push/);
assert.match(script, /if \(owners & 2\) paths\[1\]\.push/);
assert.match(script, /renderTrails\(maze, snapshot\.cells/);
assert.match(css, /\.trail-layer \{[^}]*pointer-events: none/);
assert.match(css, /\.trail-player-one \{ stroke: var\(--p1\); stroke-dasharray/);
assert.match(css, /\.trail-player-two \{ stroke: var\(--p2\); stroke-dasharray/);
});
+1
View File
@@ -0,0 +1 @@
0.14.0
+22
View File
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 40" aria-hidden="true">
<defs>
<g id="leaf-cluster-a" stroke="#173d2a" stroke-width="1.5" stroke-linejoin="round"><path fill="#296443" d="M2 13C0 6 7 0 13 3c2-7 12-5 14 2 8-1 11 8 5 13 3 8-6 13-12 8-6 5-14 1-13-6-7 0-9-5-5-7Z"/><path fill="#4f9b55" d="M7 13c2-5 7-8 12-7 5 0 8 4 9 8-3 5-8 8-14 7-5 0-8-3-7-8Z"/><path fill="#c7e68a" d="M12 9c3-3 8-3 10 0-2 3-7 4-10 0Z"/></g>
<g id="leaf-cluster-b" stroke="#173d2a" stroke-width="1.5" stroke-linejoin="round"><path fill="#296443" d="M1 16C-1 8 5 2 11 4c1-7 11-7 14-1 7-3 13 5 8 11 5 6-2 14-9 11-4 7-15 4-15-3-8 2-12-4-8-6Z"/><path fill="#8cc968" d="M8 14c2-5 8-7 13-5 4 2 6 7 4 11-5 4-13 3-17-2Z"/></g>
<g id="twig-fork" fill="none" stroke="#765033" stroke-width="2" stroke-linecap="round"><path d="M3 20 16 8 29 3M16 8l2 12M16 8 7 4"/></g>
<g id="hedge-base"><path d="M0 11C9 2 20 4 30 9c12-8 23-7 34 1 10-5 22-5 32 1 11-7 23-6 34 1 9-5 22-4 30 4v17c-10 5-22 5-32 0-11 7-23 6-34 0-10 6-22 6-32 0-11 6-23 6-34 0-10 5-20 4-30-3Z" fill="#173d2a"/><use href="#twig-fork" x="5" y="9"/><use href="#twig-fork" x="54" y="8"/><use href="#twig-fork" x="105" y="9"/><use href="#leaf-cluster-a" x="1" y="5"/><use href="#leaf-cluster-b" x="27" y="4"/><use href="#leaf-cluster-a" x="54" y="6"/><use href="#leaf-cluster-b" x="80" y="4"/><use href="#leaf-cluster-a" x="107" y="5"/><use href="#leaf-cluster-b" x="132" y="5"/></g>
<g id="hedge-intact-1"><use href="#hedge-base"/></g>
<g id="hedge-intact-2"><use href="#hedge-base"/><path fill="#8cc968" d="M20 7l8-3 4 4-8 4Zm61 3 8-4 4 4-7 4Zm48 12 7-3 3 5-7 3Z"/></g>
<g id="hedge-intact-3"><use href="#hedge-base"/><path fill="none" stroke="#765033" stroke-width="2" d="M38 27 49 9m36 18L73 7m47 20 13-17"/></g>
<g id="hedge-intact-4"><use href="#hedge-base"/><path fill="#4f9b55" d="M6 27c12-5 20 6 31 0 11-6 20 6 32 0 11-6 21 5 33-1 10-5 19 3 29 0v8H6Z"/></g>
<g id="hedge-intact-5"><use href="#hedge-base"/><path fill="#c7e68a" d="m10 17 5-4 4 3-5 4Zm42-8 5-4 4 3-5 4Zm46 11 5-4 4 3-5 4Zm38-8 5-4 4 3-5 4Z"/></g>
<g id="hedge-intact-6"><use href="#hedge-base"/><path fill="none" stroke="#173d2a" stroke-width="2" d="M19 30 32 14l14 13m33 3 12-15 13 14m17 1 11-12 12 10"/></g>
<g id="hedge-impact-1"><use href="#hedge-intact-1"/><path fill="#8cc968" d="m70 2 8 3-3 6-8-3Zm12 28 7 2-3 5-7-2Z"/></g>
<g id="hedge-impact-2"><use href="#hedge-intact-1"/><path fill="#173d2a" d="M64 0h30v40H64Z" opacity=".18"/></g>
<g id="hedge-impact-3"><use href="#hedge-intact-1" transform="skewX(-4)"/><path fill="none" stroke="#765033" stroke-width="3" d="m71 28 11-18m1 16 11-13"/></g>
<g id="hedge-impact-4"><use href="#hedge-intact-1" transform="translate(0 4) scale(1 .86)"/><path fill="#c7e68a" d="m61 2 8 3-3 6-8-3Zm13 7 8 3-3 6-8-3Zm14-4 8 3-3 6-8-3Z"/></g>
<g id="hedge-breakthrough"><use href="#hedge-intact-1"/><path fill="#11131a" stroke="#173d2a" stroke-width="2" d="M62 0h36v40H62Z"/><path fill="#765033" d="m60 6 10 8-4 5-11-8Zm40 12 10-8 5 5-10 9Z"/></g>
<g id="hedge-breach-1"><use href="#hedge-intact-1"/><path fill="#11131a" d="M32 0h96v40H32Z"/><path fill="#296443" d="M0 8c12-9 23-6 32 2v22c-11 6-22 4-32-3Zm128 2c11-8 22-11 32-2v21c-10 7-21 9-32 3Z"/><path fill="none" stroke="#765033" stroke-width="3" d="m8 30 17-15m127 15-17-15"/><path fill="#4f9b55" d="m27 30 6-5 5 7-7 4Zm99 2 7-7 6 5-5 6Z"/></g>
<g id="hedge-breach-2"><use href="#hedge-intact-3"/><path fill="#11131a" d="M32 0h96v40H32Z"/><path fill="#173d2a" d="M0 5c14-7 25-5 32 5v22c-10 5-22 4-32-2Zm128 5c8-10 19-12 32-5v25c-11 6-22 7-32 2Z"/><path fill="none" stroke="#765033" stroke-width="4" d="M4 32 21 9m7 25L15 19m141 13-17-23m-7 25 13-15"/><path fill="#8cc968" d="m25 28 7-6 5 7-8 5Zm98 1 7-7 6 6-7 5Z"/></g>
<g id="hedge-breach-3"><use href="#hedge-intact-5"/><path fill="#11131a" d="M32 0h96v40H32Z"/><path fill="#4f9b55" d="M0 10c10-10 22-8 32 0v21c-12 6-23 3-32-4Zm128 0c10-8 22-10 32 0v17c-10 9-21 10-32 4Z"/><path fill="none" stroke="#765033" stroke-width="3" d="M5 31 23 12m-9 22 14-13m132-10-18-19m9 22-14-13"/><path fill="#296443" d="m29 30 7-5 4 7-7 3Zm94 3 6-8 7 5-5 7Z"/></g>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg">
<!-- Reference-derived gameplay symbols use a shared 64 by 64 view box and currentColor. -->
<symbol id="player" viewBox="0 0 64 64"><path fill="currentColor" d="M37 5a9 9 0 1 1 0 18 9 9 0 0 1 0-18Zm-8 20 10-2 10 7 8-5a6 6 0 1 1 6 10l-12 8a6 6 0 0 1-7 0l-7-5-3 11 12 8a6 6 0 0 1-7 10L24 57a6 6 0 0 1-2-7l4-15-7 4-4 10a6 6 0 1 1-11-4l5-14a6 6 0 0 1 3-3l13-7a6 6 0 0 1 4 4Z"/></symbol>
<symbol id="key" viewBox="0 0 64 64"><path fill="currentColor" fill-rule="evenodd" d="M43 5a17 17 0 0 0-14 27L5 56l3 3 5-2 3 4 5-2 3 4 6-6-4-4 7-7A17 17 0 1 0 43 5Zm0 11a6 6 0 1 1 0 12 6 6 0 0 1 0-12Z" clip-rule="evenodd"/></symbol>
<symbol id="exit-closed" viewBox="0 0 64 64"><path fill="currentColor" d="M15 59V29C15 10 49 10 49 29v30H15Zm10-30v30h14V29c0-8-14-8-14 0Zm-17-8 6 2-2 10-7-2 3-10Zm42 2 6-2 3 10-7 2-2-10ZM7 38h7v11H7V38Zm43 0h7v11h-7V38ZM14 9l7-5 4 9-7 3-4-7Zm32-5 7 5-4 7-7-3 4-9Z"/></symbol>
<symbol id="exit-open" viewBox="0 0 64 64"><path fill="currentColor" d="M15 59V29C15 10 49 10 49 29v30H15Zm10-30v30h14V29c0-8-14-8-14 0ZM7 38h7v11H7V38Zm43 0h7v11h-7V38ZM14 9l7-5 4 9-7 3-4-7Zm32-5 7 5-4 7-7-3 4-9Z"/><path fill="currentColor" d="M25 36h10v-7l14 12-14 12v-7H25V36Z"/></symbol>
<symbol id="crystal" viewBox="0 0 64 64"><path fill="currentColor" fill-rule="evenodd" d="m32 5 19 20-19 34L13 25 32 5Zm0 12-8 9h16l-8-9Zm-11 17 11 17 11-17H21Z" clip-rule="evenodd"/><path fill="currentColor" d="M32 1h4v8h-4V1ZM5 29h9v5H5v-5Zm45 0h9v5h-9v-5ZM10 11l6-6 4 4-6 6-4-4Zm34-2 4-4 6 6-4 4-6-6ZM10 53l4-4 6 6-4 4-6-6Zm34 2 6-6 4 4-6 6-4-4Z"/></symbol>
<symbol id="spikes" viewBox="0 0 64 64"><path fill="currentColor" d="m9 47 9-32 9 32H9Zm18 0 9-32 9 32H27Zm18 0 9-32 9 32H45ZM5 51h54v9H5v-9Z"/></symbol>
<symbol id="eye" viewBox="0 0 24 24"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12Z" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="3" fill="currentColor"/></symbol>
<symbol id="arrow" viewBox="0 0 24 24"><path d="M12 2l7 9h-4v11H9V11H5l7-9Z" fill="currentColor"/></symbol>
<symbol id="signal" viewBox="0 0 24 24"><path d="M3 9a14 14 0 0 1 18 0M6 13a9 9 0 0 1 12 0m-9 4a4 4 0 0 1 6 0" fill="none" stroke="currentColor" stroke-width="2.5"/><circle cx="12" cy="21" r="1.5" fill="currentColor"/></symbol>
<symbol id="gear" viewBox="0 0 24 24"><path d="M10 2h4l.7 3 2.6 1.1 2.7-1.5 2 3.5-2.2 2 .1 3 2.1 1.9-2 3.5-2.7-1.5-2.6 1.1-.7 3h-4l-.7-3-2.6-1.1-2.7 1.5-2-3.5 2.1-1.9.1-3-2.2-2 2-3.5 2.7 1.5L9.3 5Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="1.8"/></symbol>
<symbol id="radar" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 12 20 6" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="2" fill="currentColor"/></symbol>
<symbol id="hourglass" viewBox="0 0 24 24"><path d="M6 3h12M6 21h12M7 4c0 5 4 5 5 8-1 3-5 3-5 8m10-16c0 5-4 5-5 8 1 3 5 3 5 8" fill="none" stroke="currentColor" stroke-width="2"/></symbol>
<symbol id="sound" viewBox="0 0 24 24"><path d="M4 10h4l5-4v12l-5-4H4zM17 9c2 2 2 4 0 6M20 6c4 4 4 8 0 12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></symbol>
<symbol id="motion" viewBox="0 0 24 24"><path d="M4 12h6m4 0h6M8 7l5 5-5 5M16 7l-5 5 5 5" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></symbol>
<symbol id="help" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2.5"/><path d="M9.5 9a2.7 2.7 0 1 1 4.2 2.2c-1.4.9-1.7 1.4-1.7 2.8M12 17h.01" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></symbol>
<symbol id="hand" viewBox="0 0 24 24"><path d="M8 12V5a1.5 1.5 0 0 1 3 0v5V3.5a1.5 1.5 0 0 1 3 0V10V5a1.5 1.5 0 0 1 3 0v6V8a1.5 1.5 0 0 1 3 0v6c0 4-3 7-7 7-3 0-5-2-6-5l-1-3a1.5 1.5 0 0 1 2-1z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></symbol>
<symbol id="friends" viewBox="0 0 24 24"><circle cx="8" cy="8" r="3" fill="none" stroke="currentColor" stroke-width="2.5"/><circle cx="17" cy="10" r="3" fill="none" stroke="currentColor" stroke-width="2.5"/><path d="M3 21c0-4 2-7 5-7s5 3 5 7m1-1c.5-3 1.7-5 4-5 2 0 3 2 3 5" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></symbol>
<symbol id="maze" viewBox="0 0 160 112"><path d="M8 8h144v96H8V8Zm20 0v24H8m44-24v20h28v28H52v24H28v24m52-48h28V32h24v48h20M80 80v24m28-48v48" fill="none" stroke="currentColor" stroke-width="8" stroke-linecap="square"/><circle cx="20" cy="92" r="7" fill="currentColor"/><path d="m135 89 9 7-9 7" fill="none" stroke="currentColor" stroke-width="6"/></symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

+95
View File
@@ -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
View File
@@ -0,0 +1,346 @@
:root {
color-scheme: dark;
font-family: ui-monospace, "Cascadia Mono", "SFMono-Regular", Menlo, Consolas, monospace;
--bg: #090711;
--panel: #1d1230;
--panel-deep: #110a20;
--edge: #674990;
--ink: #f6efff;
--muted: #b7a8ca;
--aqua: #43ead2;
--p1: #ffd33d;
--p2: #41b6ff;
--plant: #45b86b;
--plant-dark: #176638;
--success: #72e48c;
--warning: #ffad38;
--vision: #37c9ff;
--error: #ff647a;
--pause: #9b8cff;
--disconnect: #ff9f43;
--cell: 40px;
--shadow: #040207;
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--ink); }
body { overflow-x: hidden; background-image: radial-gradient(circle at 50% -20%, #33194c 0, transparent 42%), linear-gradient(#090711dd, #090711f5), repeating-linear-gradient(0deg, transparent 0 3px, #ffffff05 3px 4px); }
button, input { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
button { color: inherit; }
[hidden] { display: none !important; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
#app { min-height: 100dvh; width: min(1180px, 100%); margin: 0 auto; padding: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(12px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left)); }
.topbar { position: relative; z-index: 20; min-height: 54px; display: flex; align-items: center; gap: 12px; border-bottom: 2px solid #342347; }
.brand { color: var(--ink); text-decoration: none; font-weight: 1000; font-size: clamp(1.1rem, 3vw, 1.45rem); letter-spacing: .14em; text-shadow: 3px 3px 0 #000; }
.brand span { color: var(--aqua); }
.connection-pill { margin-left: auto; display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; }
.quality-pill { display: flex; align-items: center; gap: 4px; min-width: 48px; color: var(--muted); font-size: .68rem; font-weight: 800; white-space: nowrap; }
.quality-pill svg { width: 17px; height: 17px; }
.quality-pill[data-state="good"] { color: var(--success); }
.quality-pill[data-state="delayed"] { color: var(--warning); }
.quality-pill[data-state="slow"], .quality-pill[data-state="offline"] { color: var(--error); }
.signal { width: 9px; height: 9px; border-radius: 50%; background: var(--warning); box-shadow: 0 0 10px var(--warning); }
.connection-pill[data-state="online"] .signal { background: var(--success); box-shadow: 0 0 10px var(--success); }
.connection-pill[data-state="offline"] .signal { background: var(--disconnect); box-shadow: 0 0 10px var(--disconnect); }
.icon-button { width: 38px; height: 38px; padding: 8px; border: 1px solid var(--edge); background: var(--panel-deep); cursor: pointer; }
.icon-button svg { width: 100%; height: 100%; color: var(--aqua); }
.settings-menu { position: fixed; z-index: 40; top: max(58px, calc(env(safe-area-inset-top) + 58px)); right: max(12px, env(safe-area-inset-right)); display: grid; gap: 8px; width: min(300px, calc(100% - 24px)); max-height: calc(100dvh - max(70px, env(safe-area-inset-top)) - max(12px, env(safe-area-inset-bottom))); overflow-y: auto; padding: 14px; border: 2px solid var(--edge); background: #120b21f5; box-shadow: 6px 6px 0 var(--shadow); font-size: .8rem; }
.settings-details { color: var(--muted); line-height: 1.45; }
.settings-quality { color: var(--muted); line-height: 1.45; }
.settings-item { min-height: 42px; display: flex; align-items: center; justify-content: space-between; border: 1px solid var(--edge); background: #0c0815; padding: 8px 10px; color: var(--ink); font: inherit; text-align: left; text-decoration: none; cursor: pointer; }
.settings-item:hover, .settings-item:focus-visible { border-color: var(--aqua); background: #164049; }
.settings-item[aria-pressed="true"] { border-color: var(--warning); color: var(--warning); }
.settings-exit { border-color: var(--error); color: var(--error); }
.screen { min-height: calc(100dvh - 90px); animation: screen-in .28s steps(5, end); }
.welcome-screen { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(320px, 500px); align-items: center; gap: clamp(24px, 6vw, 90px); padding: 5vh 4vw; }
.hero-art { position: relative; min-height: 330px; display: grid; place-items: center; color: var(--aqua); }
.hero-art svg { width: min(100%, 460px); filter: drop-shadow(10px 10px 0 #030204); animation: hover 2.4s steps(4, end) infinite; }
.pixel-bush { position: absolute; width: 76px; height: 56px; background: var(--plant); box-shadow: 18px -18px 0 var(--plant), 36px 0 0 var(--plant), 18px 18px 0 var(--plant-dark); image-rendering: pixelated; opacity: .8; }
.bush-one { left: 3%; bottom: 12%; transform: scale(.8); }
.bush-two { right: 4%; top: 12%; transform: scale(.55); }
.panel { border: 2px solid var(--edge); background: linear-gradient(145deg, #25163a, var(--panel)); box-shadow: 7px 7px 0 var(--shadow); }
.welcome-panel { padding: clamp(22px, 4vw, 42px); }
.eyebrow { margin: 0 0 8px; color: var(--aqua); text-transform: uppercase; letter-spacing: .12em; font-size: .72rem; }
h1, h2, p { text-wrap: balance; }
h1, h2 { margin: 0; text-transform: uppercase; line-height: 1.04; text-shadow: 3px 3px 0 #000; }
h1 { font-size: clamp(2rem, 6vw, 4.6rem); }
h1 span { color: var(--warning); }
h2 { font-size: 1rem; letter-spacing: .06em; }
p { color: var(--muted); line-height: 1.55; }
label { display: block; margin: 24px 0 7px; color: var(--ink); font-size: .8rem; font-weight: 800; text-transform: uppercase; }
input { width: 100%; min-height: 50px; border: 2px solid var(--edge); border-radius: 0; outline: 0; background: #0c0815; padding: 0 14px; color: var(--ink); }
input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.button-row { display: flex; gap: 10px; margin-top: 12px; }
.button { min-height: 48px; padding: 0 17px; border: 2px solid var(--aqua); border-radius: 0; background: #0f2026; cursor: pointer; font-weight: 900; text-transform: uppercase; box-shadow: 4px 4px 0 var(--shadow); }
.button:hover:not(:disabled) { background: #164049; }
.button:active:not(:disabled) { transform: translate(3px, 3px); box-shadow: 1px 1px 0 var(--shadow); }
.button:disabled { opacity: .4; cursor: not-allowed; }
.button.primary { background: var(--aqua); color: #051411; }
.button.primary:hover:not(:disabled) { background: #87f6e6; }
.button.pending, .dpad button.pending, .drop-key.pending { animation: pending .5s steps(2, end) infinite; }
.text-button { margin-top: 20px; border: 0; background: transparent; color: var(--muted); text-decoration: underline; cursor: pointer; }
.lobby-screen { padding: clamp(28px, 5vw, 70px) 2vw; }
.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 18px; margin-bottom: 24px; }
.section-heading h1 { font-size: clamp(1.8rem, 5vw, 3.8rem); }
.phase-chip { padding: 9px 12px; border: 1px solid var(--warning); color: var(--warning); background: #2b220b; font-size: .72rem; text-transform: uppercase; }
.lobby-grid { display: grid; grid-template-columns: 1.4fr .8fr; gap: 18px; }
.lobby-grid .panel { padding: 22px; }
.roster-panel { grid-row: span 2; }
.player-card { display: grid; grid-template-columns: 54px 1fr 12px; align-items: center; gap: 13px; margin-top: 16px; padding: 14px; border: 1px solid #51386e; background: #100a1b; }
.player-card.player-one { border-left: 5px solid var(--p1); }
.player-card.player-two { border-left: 5px solid var(--p2); }
.avatar { width: 46px; height: 46px; display: grid; place-items: center; border: 2px solid currentColor; background: #0b0712; font-weight: 1000; }
.avatar svg { width: 82%; height: 82%; }
.player-one .avatar, .identity.player-one .avatar { color: var(--p1); }
.player-two .avatar, .identity.player-two .avatar { color: var(--p2); }
.player-two .avatar { border-style: dashed; }
.player-card strong, .player-card small, .identity strong, .identity small, .stat small, .stat strong { display: block; }
.player-card small, .identity small, .stat small { margin-top: 4px; color: var(--muted); font-size: .66rem; }
.status-dot { width: 10px; height: 10px; background: var(--error); }
.player-card.online .status-dot { background: var(--success); box-shadow: 0 0 9px var(--success); }
.observer-panel svg { width: 46px; height: 46px; color: var(--p2); }
.observer-panel strong { color: var(--p2); font-size: 1.7rem; }
.launch-button { width: 100%; margin-top: 10px; }
.match-screen { position: relative; height: calc(100dvh - 78px); min-height: 520px; overflow: hidden; padding-top: 84px; }
.player-status { position: absolute; z-index: 8; inset: 10px 0 auto; display: grid; grid-template-columns: minmax(190px, 1.4fr) repeat(3, minmax(120px, .7fr)); gap: 7px; }
.identity, .stat { min-height: 68px; display: flex; align-items: center; gap: 10px; padding: 8px 12px; border: 1px solid #543a72; background: #171027eb; backdrop-filter: blur(8px); }
.identity .avatar { flex: 0 0 46px; }
.stat svg { width: 29px; height: 29px; flex: 0 0 29px; color: var(--aqua); }
.stat strong { font-size: .75rem; }
.vision-active { border-color: var(--warning); box-shadow: inset 0 0 18px #ffd65a22; }
.vision-active svg { color: var(--warning); animation: eye-pulse 1s steps(3, end) infinite; }
.compass-stat[data-direction]:not([data-direction="none"]) { border-color: var(--aqua); }
#compass-arrow { transition: transform .15s steps(3, end); }
.map-shell { position: absolute; inset: 84px 0 0; overflow: hidden; outline: 0; border: 2px solid #2f2041; background-color: #050408; background-image: radial-gradient(circle at center, #161024 0, #050408 72%); touch-action: none; user-select: none; }
.map-shell:focus-visible { border-color: var(--aqua); }
.unknown-message { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 12px; padding: 24px; color: var(--muted); text-align: center; }
.unknown-message svg { width: 54px; height: 54px; color: var(--aqua); animation: radar 1.8s linear infinite; }
.maze { position: absolute; left: 50%; top: 50%; display: grid; transform: translate(calc(-50% + var(--map-offset-x, 0px)), calc(-50% + var(--map-offset-y, 0px))); }
.cell { position: relative; width: var(--cell); height: var(--cell); background: #11131a; }
.cell::after { content: ""; position: absolute; inset: 7px; background: #1c1b27; opacity: .55; }
.trail-layer { position: absolute; z-index: 0; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
.trail-path { fill: none; stroke-width: .065; stroke-linecap: round; opacity: .58; }
.trail-player-one { stroke: var(--p1); stroke-dasharray: .06 .20; }
.trail-player-two { stroke: var(--p2); stroke-dasharray: .03 .16; stroke-dashoffset: .08; }
.hedge-layer { position: absolute; z-index: 1; inset: 0; pointer-events: none; overflow: visible; }
.hedge-edge { position: absolute; width: var(--cell); height: calc(var(--cell) * .2); left: calc(var(--hedge-x) * var(--cell)); top: calc(var(--hedge-y) * var(--cell) - var(--cell) * .1); transform-origin: 50% 50%; pointer-events: none; }
.hedge-edge svg { display: block; width: 100%; height: 100%; overflow: visible; }
.hedge-edge.hedge-vertical { left: calc(var(--hedge-x) * var(--cell) - var(--cell) * .5); top: calc(var(--hedge-y) * var(--cell) + var(--cell) * .4); transform: rotate(90deg); }
.cell.bush-impact { z-index: 5; animation: bush-resist .34s steps(3, end); }
.cell.bush-stage-2 { filter: saturate(1.25); }
.cell.bush-stage-3 { filter: brightness(1.2) saturate(1.4); }
.cell.bush-stage-4 { filter: brightness(1.35) saturate(1.65); }
.object, .pawn { position: absolute; z-index: 3; inset: 16%; display: grid; place-items: center; }
.object svg, .pawn svg { width: 100%; height: 100%; }
.object-key { color: var(--p1); }
.object-key[data-owner="two"] { color: var(--p2); }
.object-key::after { content: ""; position: absolute; right: -4px; bottom: -4px; width: 8px; height: 8px; border: 2px solid currentColor; border-radius: 50%; background: #090711; }
.object-key[data-owner="two"]::after { border-radius: 0; transform: rotate(45deg); }
.object-exit { inset: 8%; background: #0b0911; }
.object-exit[data-owner="one"] { color: var(--p1); }
.object-exit[data-owner="two"] { color: var(--p2); }
.object-exit[data-owner="two"]::after { content: ""; position: absolute; inset: 6%; border: 2px dashed currentColor; }
.object-exit[data-open="1"] { background: #72e48c35; animation: exit-open .8s steps(2, end) infinite; }
.object-compass { color: var(--aqua); border: 2px dashed currentColor; border-radius: 50%; }
.object-landmark { color: var(--plant); font-size: 1.15rem; text-shadow: 2px 2px #000; }
.object-vision { color: var(--vision); border-radius: 50%; background: #37c9ff30; box-shadow: 0 0 10px var(--vision); animation: artifact 1.2s steps(4, end) infinite; }
.pawn { inset: 12%; padding: 5%; border: 3px solid currentColor; background: #0b0712; box-shadow: 3px 3px 0 #000; animation: pawn-in .16s steps(2, end); }
.pawn-self { color: var(--p1); }
.match-screen.player-two-mode .pawn-self { color: var(--p2); }
.pawn-opponent { color: var(--p2); }
.match-screen.player-two-mode .pawn-opponent { color: var(--p1); }
.pawn-owner-two { border-style: dashed; }
.wall-bump { position: absolute; inset: 0; z-index: 6; border: 0 solid transparent; pointer-events: none; }
.wall-bump.show { border-color: var(--error); }
.maze.impact-north { animation: impact-north .28s steps(3, end); }
.maze.impact-east { animation: impact-east .28s steps(3, end); }
.maze.impact-south { animation: impact-south .28s steps(3, end); }
.maze.impact-west { animation: impact-west .28s steps(3, end); }
.maze.rate-limited { filter: brightness(.48) blur(2px); transition: filter .12s linear; }
[data-reduced-motion="true"] .maze[class*="impact-"] { animation: none; }
[data-reduced-motion="true"] .maze.rate-limited { transition: none; }
[data-reduced-motion="true"] .breakthrough-runner, [data-reduced-motion="true"] .breakthrough-accent { display: none; }
[data-reduced-motion="true"] .wall-bump.show { border-width: 4px; }
[data-reduced-motion="true"] .wall-bump.impact-north { border-top-color: var(--error); }
[data-reduced-motion="true"] .wall-bump.impact-east { border-right-color: var(--error); }
[data-reduced-motion="true"] .wall-bump.impact-south { border-bottom-color: var(--error); }
[data-reduced-motion="true"] .wall-bump.impact-west { border-left-color: var(--error); }
.avatar-control { position: absolute; z-index: 8; left: var(--player-focal-x, 50%); top: var(--player-focal-y, 50%); width: 96px; height: 96px; margin: -48px; padding: 0; border: 0; border-radius: 50%; background: transparent; cursor: grab; touch-action: none; user-select: none; }
.avatar-control:active, .avatar-control.active { cursor: grabbing; }
.avatar-handle { position: absolute; left: 50%; top: 50%; width: 52px; height: 52px; display: grid; place-items: center; border: 3px solid currentColor; border-radius: 50%; background: transparent; color: var(--p1); box-shadow: 4px 4px 0 #000, 0 0 0 3px #43ead236; font-size: 1.25rem; font-weight: 1000; transform: translate(-50%, -50%); transition: transform .12s steps(3, end); }
.player-two-mode .avatar-handle { color: var(--p2); }
.avatar-control.active .avatar-handle { transition: none; }
.avatar-control.breakthrough-travelling { opacity: 0; }
.breakthrough-runner { position: absolute; z-index: 9; left: var(--player-focal-x, 50%); top: var(--player-focal-y, 50%); width: 52px; height: 52px; display: grid; place-items: center; color: var(--p1); pointer-events: none; animation: breakthrough-run 480ms steps(6, end) forwards; }
.breakthrough-runner.player-two { color: var(--p2); }
.breakthrough-runner svg { width: 100%; height: 100%; padding: 5%; border: 3px solid currentColor; background: #0b0712; box-shadow: 3px 3px 0 #000; }
.breakthrough-accent { position: absolute; width: 8px; height: 8px; border: 2px solid #c7e68a; background: var(--plant); box-shadow: 1px 1px 0 #173d2a; animation: breakthrough-accent 480ms steps(5, end) forwards; }
.accent-1 { left: 4px; top: 6px; }
.accent-2 { right: 2px; top: 12px; background: var(--aqua); }
.accent-3 { left: 12px; bottom: 1px; background: #8cc968; }
.accent-4 { right: 9px; bottom: 5px; background: var(--warning); }
.avatar-player-icon { width: 74%; height: 74%; }
.avatar-direction-icon { position: absolute; width: 19px; height: 19px; right: -13px; top: -13px; display: none; padding: 2px; border: 1px solid currentColor; background: #08050b; color: var(--aqua); }
.avatar-control[data-direction] .avatar-direction-icon { display: block; }
.avatar-control[data-direction="north"] .avatar-direction-icon { transform: rotate(0deg); }
.avatar-control[data-direction="east"] .avatar-direction-icon { transform: rotate(90deg); }
.avatar-control[data-direction="south"] .avatar-direction-icon { transform: rotate(180deg); }
.avatar-control[data-direction="west"] .avatar-direction-icon { transform: rotate(270deg); }
.avatar-control[data-speed="fast"] .avatar-handle { box-shadow: 4px 4px 0 #000, 0 0 0 4px var(--aqua), 0 0 18px #43ead299; }
.avatar-control[data-speed="slow"] .avatar-handle { box-shadow: 4px 4px 0 #000, 0 0 0 3px #43ead270; }
.child-controls { position: absolute; z-index: 12; inset: auto 0 12px; display: flex; align-items: end; justify-content: space-between; padding: 0 16px; pointer-events: none; }
.child-controls > * { pointer-events: auto; }
.onboarding { position: absolute; z-index: 30; inset: 0; display: grid; place-items: center; padding: 18px; background: #08050bd9; }
.onboarding-card { width: min(340px, 92%); padding: 20px; border: 3px solid var(--aqua); background: var(--panel-deep); box-shadow: 7px 7px 0 #020103; text-align: center; }
.onboarding-icon { width: 104px; height: 104px; color: var(--aqua); animation: guide-hand 1.2s ease-in-out infinite; }
.onboarding-card h2 { margin: 12px 0 6px; font-size: 1.35rem; }
.onboarding-card p { min-height: 42px; margin: 0; color: var(--muted); }
.onboarding-dots { display: flex; justify-content: center; gap: 8px; margin: 10px; }
.onboarding-dots span { width: 10px; height: 10px; border: 2px solid var(--aqua); border-radius: 50%; }
.onboarding-dots .active { background: var(--aqua); }
#onboarding-next { min-width: 108px; min-height: 52px; border: 2px solid var(--aqua); background: var(--aqua); color: #05201c; }
#onboarding-skip { display: block; margin: 12px auto 0; border: 0; background: transparent; color: var(--muted); text-decoration: underline; }
[data-reduced-motion="true"] * { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: .01ms !important; }
@media (prefers-reduced-motion: reduce) { * { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: .01ms !important; } }
.drop-key { position: fixed; z-index: 12; right: max(22px, env(safe-area-inset-right)); top: max(166px, calc(env(safe-area-inset-top) + 150px)); width: 78px; height: 78px; display: grid; grid-template-columns: 26px 1fr; place-content: center; align-items: center; border: 3px solid var(--warning); background: #392c0d; color: var(--warning); box-shadow: 5px 5px 0 #050301; cursor: pointer; }
.drop-key svg { width: 25px; height: 25px; }
.drop-key strong { font-size: 1.2rem; }
.drop-key span { grid-column: 1 / -1; font-size: .56rem; text-transform: uppercase; }
.drop-key:disabled { opacity: .48; }
.dpad { position: fixed; z-index: 16; right: max(18px, env(safe-area-inset-right)); bottom: max(18px, env(safe-area-inset-bottom)); display: grid; grid-template-columns: repeat(3, 58px); grid-template-rows: repeat(3, 58px); gap: 5px; touch-action: none; user-select: none; -webkit-user-select: none; }
.dpad button { min-width: 58px; min-height: 58px; border: 2px solid var(--aqua); background: #102a30df; color: var(--aqua); box-shadow: 4px 4px 0 #020506; cursor: pointer; touch-action: none; user-select: none; -webkit-user-select: none; }
.dpad button.active, .dpad button:active { transform: translate(3px, 3px); border-style: dashed; box-shadow: 1px 1px 0 #020506; }
.dpad button:disabled { border-color: var(--edge); background: #171027c9; color: var(--muted); opacity: .55; cursor: not-allowed; }
.dpad button.pending { outline: 3px solid var(--warning); outline-offset: -5px; }
.dpad button:first-child { grid-column: 2; }
.dpad button:nth-child(2) { grid-column: 1; grid-row: 2; }
.dpad-center { grid-column: 2; grid-row: 2; display: grid; place-items: center; border: 2px solid #3b2951; background: #0d0915d9; color: #5a4174; pointer-events: none; }
.dpad button:nth-child(4) { grid-column: 3; grid-row: 2; }
.dpad button:last-child { grid-column: 2; grid-row: 3; }
.message-rail { position: fixed; top: max(4px, env(safe-area-inset-top)); z-index: 45; left: 50%; width: min(620px, calc(100% - 20px)); transform: translateX(-50%); padding: 8px 14px; border: 2px solid var(--warning); background: #191020f2; color: var(--ink); box-shadow: 4px 4px 0 #020104; font-size: .76rem; text-align: center; pointer-events: none; }
.message-rail.error { border-color: var(--error); }
.message-rail.success { border-color: var(--success); }
.modal { position: fixed; z-index: 50; inset: 0; display: grid; place-items: center; padding: 18px; background: #050308dc; backdrop-filter: blur(5px); }
.modal-card { width: min(440px, 100%); padding: 30px; border: 3px solid var(--aqua); background: #130b22; text-align: center; box-shadow: 9px 9px 0 #020103; animation: modal-in .28s steps(4, end); }
.modal-card > svg { width: 54px; height: 54px; color: var(--aqua); }
.modal-card h2 { font-size: clamp(1.5rem, 7vw, 2.7rem); }
.countdown { color: var(--warning); font-size: clamp(5rem, 24vw, 10rem); font-weight: 1000; line-height: 1; text-shadow: 7px 7px 0 #000; animation: count .65s steps(2, end) infinite; }
.observer-screen { height: calc(100dvh - 78px); min-height: 678px; overflow: hidden; padding-top: 92px; }
.observer-toolbar { position: absolute; z-index: 8; inset: 8px 0 auto; min-height: 76px; display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 18px; padding: 9px 12px; border: 1px solid var(--edge); background: #171027eb; }
.observer-toolbar h1 { font-size: clamp(1.15rem, 2.8vw, 2.1rem); }
.observer-toolbar .eyebrow { margin-bottom: 3px; }
.observer-players { display: flex; align-items: center; gap: 12px; color: var(--muted); font-size: .72rem; }
.observer-players span { padding: 7px; border: 1px solid #51386e; }
.observer-players .player-one { border-color: var(--p1); }
.observer-players .player-two { border-color: var(--p2); border-style: dashed; }
.observer-players svg { width: 16px; height: 16px; vertical-align: middle; }
.zoom-controls { display: flex; gap: 5px; }
.zoom-controls button { min-width: 42px; min-height: 38px; border: 1px solid var(--aqua); background: #102a30; color: var(--aqua); cursor: pointer; }
.observer-stage { position: absolute; inset: 92px 0 0; overflow: hidden; border: 2px solid #2f2041; background: #050408; cursor: grab; touch-action: none; user-select: none; }
.observer-stage.dragging { cursor: grabbing; }
.observer-maze { position: absolute; left: 50%; top: 50%; display: grid; transform-origin: center; will-change: transform; }
.observer-maze .cell { width: 8px; height: 8px; }
.observer-maze .cell::after { inset: 2px; }
.observer-maze .hedge-edge { height: 2px; top: calc(var(--hedge-y) * var(--cell) - 1px); }
.observer-maze .hedge-edge.hedge-vertical { left: calc(var(--hedge-x) * var(--cell) - var(--cell) * .5); top: calc(var(--hedge-y) * var(--cell) + var(--cell) * .5 - 1px); }
.observer-maze .object { inset: 5%; }
.observer-maze .object-key::after { display: none; }
.observer-maze .object-landmark { font-size: 6px; }
.observer-maze .pawn { inset: -30%; z-index: 5; padding: 1px; border-width: 1px; box-shadow: 1px 1px 0 #000; }
.observer-maze .pawn-player-one { color: var(--p1); }
.observer-maze .pawn-player-two { color: var(--p2); }
.observer-requirement { display: none; position: absolute; inset: 92px 0 0; place-content: center; justify-items: center; gap: 12px; padding: 24px; color: var(--muted); text-align: center; border: 2px solid var(--edge); background: var(--panel-deep); }
.observer-requirement svg { width: 58px; height: 58px; color: var(--p2); }
.observer-requirement strong { color: var(--ink); font-size: 1.2rem; text-transform: uppercase; }
@media (max-width: 1023px), (max-height: 767px) {
.observer-toolbar { grid-template-columns: 1fr; }
.observer-toolbar > :not(:first-child) { display: none; }
.observer-stage { display: none; }
.observer-requirement { display: grid; }
}
@media (max-width: 720px) {
:root { --cell: 46px; }
body.match-active { height: 100%; overflow: hidden; }
#app { padding: max(8px, env(safe-area-inset-top)) max(8px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left)); }
body.match-active #app { height: 100dvh; min-height: 0; overflow: hidden; }
.welcome-screen { grid-template-columns: 1fr; padding: 28px 2px; }
.hero-art { display: none; }
.welcome-panel { padding: 22px 18px; }
.button-row { flex-direction: column; }
.lobby-grid { grid-template-columns: 1fr; }
.lobby-screen { padding: 18px 2px; }
.lobby-grid { gap: 10px; }
.lobby-grid .panel { padding: 14px; }
.lobby-screen .section-heading { margin-bottom: 12px; }
.player-card { margin-top: 8px; padding: 8px; }
.roster-panel { grid-row: auto; }
.section-heading { align-items: start; flex-direction: column; }
.match-screen { height: calc(100dvh - 70px); min-height: 0; padding-top: 82px; }
.player-status { grid-template-columns: 1.25fr repeat(3, .75fr); inset: 6px 0 auto; }
.identity, .stat { min-width: 0; min-height: 72px; padding: 5px; flex-direction: column; justify-content: center; text-align: center; gap: 1px; }
.identity .avatar { flex-basis: 34px; width: 34px; height: 34px; font-size: 1.4rem; }
.identity strong { max-width: 88px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .62rem; }
.identity small, .stat small { font-size: .48rem; }
.stat strong { font-size: .54rem; }
.stat svg { width: 27px; height: 27px; flex-basis: 27px; }
.map-shell { inset: 82px 0 0; }
.child-controls { bottom: max(14px, env(safe-area-inset-bottom)); padding: 0 10px; }
.drop-key { position: relative; top: auto; right: auto; bottom: auto; width: 78px; height: 78px; }
.settings-menu { top: max(52px, calc(env(safe-area-inset-top) + 52px)); }
.dpad { right: max(12px, env(safe-area-inset-right)); bottom: max(12px, env(safe-area-inset-bottom)); grid-template-columns: repeat(3, 62px); grid-template-rows: repeat(3, 62px); gap: 5px; }
.dpad button { min-width: 62px; min-height: 62px; font-size: 1.3rem; }
.connection-pill { font-size: .58rem; }
.quality-pill { min-width: 40px; font-size: .6rem; }
.connection-pill #connection-text { max-width: 145px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}
@media (min-width: 721px) and (max-width: 980px) {
:root { --cell: 34px; }
.player-status { grid-template-columns: 1.2fr repeat(3, .7fr); }
}
@media (orientation: landscape) and (max-height: 620px) {
.topbar { min-height: 44px; }
.match-screen { height: calc(100dvh - 68px); padding-top: 64px; }
.player-status { inset: 5px 98px auto 0; }
.identity, .stat { min-height: 55px; }
.map-shell { inset: 64px 0 0; }
.drop-key { top: 76px; right: 14px; width: 70px; height: 70px; }
.dpad { right: max(12px, env(safe-area-inset-right)); bottom: max(8px, env(safe-area-inset-bottom)); grid-template-columns: repeat(3, 52px); grid-template-rows: repeat(3, 52px); }
.dpad button { min-width: 52px; min-height: 52px; }
}
@media (hover: none) and (pointer: coarse) and (max-height: 720px) {
.drop-key { top: auto; bottom: max(14px, env(safe-area-inset-bottom)); }
}
@keyframes screen-in { from { opacity: 0; transform: translateY(10px); } }
@keyframes hover { 50% { transform: translateY(-8px); } }
@keyframes pending { 50% { filter: brightness(1.8); } }
@keyframes eye-pulse { 50% { transform: scale(1.15); } }
@keyframes radar { to { transform: rotate(360deg); } }
@keyframes artifact { 50% { transform: scale(.75); box-shadow: 0 0 18px var(--warning); } }
@keyframes exit-open { 50% { outline: 3px solid var(--success); } }
@keyframes pawn-in { from { transform: scale(.6); } }
@keyframes bush-resist { 50% { transform: translateX(3px) rotate(1deg); } }
@keyframes impact-north { 50% { transform: translate(calc(-50% + var(--map-offset-x, 0px)), calc(-50% + var(--map-offset-y, 0px) - 10px)); } }
@keyframes impact-east { 50% { transform: translate(calc(-50% + var(--map-offset-x, 0px) + 10px), calc(-50% + var(--map-offset-y, 0px))); } }
@keyframes impact-south { 50% { transform: translate(calc(-50% + var(--map-offset-x, 0px)), calc(-50% + var(--map-offset-y, 0px) + 10px)); } }
@keyframes impact-west { 50% { transform: translate(calc(-50% + var(--map-offset-x, 0px) - 10px), calc(-50% + var(--map-offset-y, 0px))); } }
@keyframes bump { 0%, 100% { opacity: 0; } 20%, 75% { opacity: 1; transform: translateX(-50%) translateX(4px); } 45% { transform: translateX(-50%) translateX(-4px); } }
@keyframes modal-in { from { opacity: 0; transform: scale(.82); } }
@keyframes count { 50% { transform: scale(1.08); } }
@keyframes guide-hand { 50% { transform: translate(18px, -12px); } }
@keyframes breakthrough-run { 0% { transform: translate(calc(-50% + var(--breakthrough-start-x)), calc(-50% + var(--breakthrough-start-y))) scale(.78, 1.16); } 42% { transform: translate(calc(-50% + var(--breakthrough-mid-x)), calc(-50% + var(--breakthrough-mid-y))) scale(1.16, .78); } 100% { transform: translate(-50%, -50%) scale(1); } }
@keyframes breakthrough-accent { 0% { opacity: 0; transform: translate(0, 0) scale(.4); } 25% { opacity: 1; } 100% { opacity: 0; transform: translate(var(--breakthrough-start-x, 12px), -18px) scale(1.35); } }
+1289
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -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>