Compare commits

...

7 Commits

70 changed files with 7200 additions and 152 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
+32 -3
View File
@@ -12,8 +12,10 @@ This directory is a local mirror of the ChatGPT project “maze32”.
### Language ### Language
- Write all canonical documentation in English. - Write all canonical documentation in English.
- Write source comments, test names, protocol identifiers, log messages, and - Write source comments, test names, protocol identifiers, and system log
user-facing diagnostic text in English. messages in English.
- Write all player-facing browser UI copy, game messages, and user-visible
browser errors in Russian.
### Canonical documents ### 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. - `PLANS.md` defines the current implementation milestone and its evidence.
- `docs/BOARD_PASSPORT.md` defines confirmed hardware facts and explicit - `docs/BOARD_PASSPORT.md` defines confirmed hardware facts and explicit
unknowns. 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 - Files under `sources/` remain read-only references and are never canonical
implementation files. 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 - Do not mark a milestone `COMPLETE` without concrete recorded evidence for
every acceptance criterion. 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 ### Locked platform baseline
- Target ESP32-C6FH4 with 4 MB Flash. - Target ESP32-C6FH4 with 4 MB Flash.
@@ -65,6 +85,16 @@ Do not silently choose one requirement over another.
- Active game state need not survive reboot; Wi-Fi configuration must persist - Active game state need not survive reboot; Wi-Fi configuration must persist
in NVS. 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 ### Build and verification
- Parse the project configuration with - Parse the project configuration with
@@ -78,4 +108,3 @@ Do not silently choose one requirement over another.
- Test on the physical board before recording hardware acceptance evidence. - Test on the physical board before recording hardware acceptance evidence.
- Preserve exact commands and relevant outputs in the active milestone's - Preserve exact commands and relevant outputs in the active milestone's
evidence section. evidence section.
+11
View File
@@ -320,6 +320,17 @@ drop-key action. The UI renders only server-provided visible state. The observer
view supports fitting, panning, or zooming the complete `128 x 64` map without view supports fitting, panning, or zooming the complete `128 x 64` map without
changing server authority. changing server authority.
`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.
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 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 can reattach to the existing slot. It is not a permanent account credential and
does not need to survive an ESP reboot. does not need to survive an ESP reboot.
+36 -4
View File
@@ -215,7 +215,34 @@ Browser controls must support:
- If the player does not reconnect within 60 seconds, the match ends in a draw. - If the player does not reconnect within 60 seconds, the match ends in a draw.
- Reloading a page is treated as a reconnect, not as leaving the match. - Reloading a page is treated as a reconnect, not as leaving the match.
## 11. Technical constraints ## 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, blue for Player 1, pink for Player 2, and plant-like green maze
walls. Important state is distinguished by icons, labels, outlines, or
shapes in addition to color.
- 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.
- Computers support keyboard movement and on-screen arcade controls. Tablets
support swipes and on-screen controls. Phones use directional swipes without
on-screen directional buttons.
- 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. - Target: ESP32-C6FH4, 4 MB Flash, no required PSRAM.
- Build: PlatformIO, `espressif32@7.0.1`, ESP-IDF, compatible - Build: PlatformIO, `espressif32@7.0.1`, ESP-IDF, compatible
@@ -224,6 +251,8 @@ Browser controls must support:
MVP. MVP.
- Static browser assets reside in LittleFS and should be compressed for the - Static browser assets reside in LittleFS and should be compressed for the
production image. 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 - HTTP serves the application and provisioning pages; WebSocket carries live
lobby, control, and match messages. lobby, control, and match messages.
- The initial LwIP socket limit is 16. - The initial LwIP socket limit is 16.
@@ -232,7 +261,7 @@ Browser controls must support:
- Active lobby and match state may live in RAM and need not survive an ESP - Active lobby and match state may live in RAM and need not survive an ESP
reboot. Wi-Fi settings must survive reboot. 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 The MVP is accepted when all of the following are demonstrated on the target
board: board:
@@ -263,8 +292,12 @@ board:
60-second reconnect timeout ends it as a draw. 60-second reconnect timeout ends it as a draw.
12. A representative match normally completes in approximately five minutes 12. A representative match normally completes in approximately five minutes
after balance tuning, without imposing a hard match timer. 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 two active players or more than one concurrent match.
- More than four observers. - More than four observers.
@@ -277,4 +310,3 @@ board:
- Persistent match recovery after ESP reboot. - Persistent match recovery after ESP reboot.
- OTA firmware partitions and OTA update flow. - OTA firmware partitions and OTA update flow.
- Dependence on PSRAM, an unverified LED, or an assumed board pinout. - Dependence on PSRAM, an unverified LED, or an assumed board pinout.
+311 -35
View File
@@ -23,8 +23,10 @@
not yet available. Expected evidence may be listed in advance. not yet available. Expected evidence may be listed in advance.
11. If later work invalidates recorded evidence, reopen the affected milestone 11. If later work invalidates recorded evidence, reopen the affected milestone
and change its status before continuing. and change its status before continuing.
12. All project documentation, milestone text, source comments, test names, and 12. All project documentation, milestone text, source comments, test names,
user-facing diagnostic messages are written in English. protocol identifiers, and system log messages are written in English.
Player-facing browser UI copy, game messages, and browser errors are
written in Russian.
## Milestone 001 — Requirements and technical baseline ## Milestone 001 — Requirements and technical baseline
@@ -78,17 +80,19 @@ the read-only project sources.
- `MVP.md` contains the complete English product and acceptance specification. - `MVP.md` contains the complete English product and acceptance specification.
- `ARCHITECTURE.md` contains the English runtime, component, state-machine, - `ARCHITECTURE.md` contains the English runtime, component, state-machine,
protocol, memory, failure, and verification contracts. protocol, memory, failure, and verification contracts.
- `platformio.ini` resolves the environment `esp32-c6-mini` with - On 2026-09-03, `/home/sasa/.platformio/penv/bin/pio project config` exited
`espressif32@7.0.1`, ESP-IDF, explicit 4 MB build/upload size, `partitions.csv`, successfully and resolved environment `esp32-c6-devkitm-1` with
LittleFS, and `sdkconfig.defaults`. `espressif32@7.0.1`, ESP-IDF, explicit 4 MB build/upload sizes,
- Running `/home/sasa/.platformio/penv/bin/pio project config` successfully `partitions.csv`, LittleFS, and `sdkconfig.defaults`. PlatformIO subsequently
parsed and printed the canonical configuration. PlatformIO subsequently emitted a telemetry-cache permission warning during process shutdown; it did
emitted a sandbox-local telemetry cache permission warning during process not prevent configuration parsing.
shutdown; it did not prevent configuration parsing. - On 2026-09-03, an `awk` continuity and end-address check over
- Static partition arithmetic verified that the 2 MiB factory application ends `partitions.csv` reported the factory partition ending at `0x210000`, the
at `0x210000` and the 1,984 KiB LittleFS partition ends at `0x400000`. LittleFS partition ending at `0x400000`, and no gaps or overlaps from NVS
- `sdkconfig.defaults` contains `CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y` and through the end of Flash.
`CONFIG_LWIP_MAX_SOCKETS=16`. - On 2026-09-03, `rg` confirmed
`CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y` and `CONFIG_LWIP_MAX_SOCKETS=16` in both
`sdkconfig.defaults` and the generated ESP-IDF configuration.
- `docs/BOARD_PASSPORT.md` records the confirmed ESP32-C6FH4, 4 MB Flash, - `docs/BOARD_PASSPORT.md` records the confirmed ESP32-C6FH4, 4 MB Flash,
no-required-PSRAM baseline and the remaining explicit hardware unknowns. no-required-PSRAM baseline and the remaining explicit hardware unknowns.
- Canonical files were created outside `sources/`; the reference files under - Canonical files were created outside `sources/`; the reference files under
@@ -101,9 +105,15 @@ claim that firmware source code exists, that a firmware image builds, or that
the target board has passed runtime tests. Those claims require evidence in the target board has passed runtime tests. Those claims require evidence in
later milestones. later milestones.
Milestone 001 was reopened on 2026-09-03 after its recorded evidence was found
to disagree with the checked-in canonical files. The canonical `MVP.md`
location, explicit Flash configuration, generated SDK configuration, and board
passport were repaired and reverified before returning the milestone to
`COMPLETE`. No Milestone 002 implementation work was started.
## Milestone 002 — Firmware skeleton and reproducible build ## Milestone 002 — Firmware skeleton and reproducible build
**Status:** `NOT_STARTED` **Status:** `COMPLETE`
### Objective ### Objective
@@ -138,13 +148,52 @@ builds reproducibly with the locked platform and partition configuration.
### Evidence ### Evidence
Completion evidence is not yet available because this milestone has not - On 2026-09-03, `/home/sasa/.platformio/penv/bin/pio project config` parsed
started. Required evidence includes clean build output, image sizes, asset the updated configuration successfully with the locked platform, ESP-IDF,
reproducibility checks, and a target-board boot log. 4 MB Flash, root partition table, LittleFS, pinned `esp_littlefs`, bounded
LittleFS settings, and the build hook.
- On 2026-09-03, `./scripts/run_host_tests.sh` reported
`health state tests: PASS` and
`web asset reproducibility: PASS
(611eacdb06b81bdb064a9074352ff4c6f82d3af3ad3d01e65705387744b86b21)`.
- After `/home/sasa/.platformio/penv/bin/pio run --target clean`, running
`/home/sasa/.platformio/penv/bin/pio run` on 2026-09-03 completed with
`[SUCCESS]` in 43.10 seconds. PlatformIO reported 14,084 bytes of RAM and
350,232 of 2,097,152 application bytes in use. The independent build hook
reported `firmware.bin uses 350864/2097152 bytes in partition factory`.
- Running `/home/sasa/.platformio/penv/bin/pio run --target buildfs` on
2026-09-03 regenerated `data/index.html.gz`, built the LittleFS image, and
reported `[SUCCESS]`. The independent build hook reported
`littlefs.bin uses 2031616/2031616 bytes in partition littlefs`; the image is
intentionally emitted at full partition size and contains the 683-byte
compressed application asset.
- Two LittleFS builds separated by a clean produced the identical SHA-256
`ad203373be69f4541106b2d0dfd7096adb8132967ab49e2d23c391ede8f4d4f5`.
The generated gzip asset remained
`950c60077c381d4484c11b43762bc5c8378587b6cd91924c344cfbc406771bb9`.
Listing the image showed both file and image creation times normalized to
1970-01-01, demonstrating deterministic compression and packaging.
- `strings .pio/build/esp32-c6-devkitm-1/firmware.bin` found firmware version
`0.2.0` and the startup log formats for version, LittleFS capacity, and health
state. This verifies their presence in the built artifact but is not a
substitute for target-board execution.
- On 2026-09-03, `/home/sasa/.platformio/penv/bin/pio run --target uploadfs
--upload-port /dev/ttyACM0` wrote and hash-verified the 2,031,616-byte
LittleFS image at `0x210000`. A subsequent application upload identified the
target as `ESP32-C6FH4 (QFN32) (revision v0.2)` with embedded 4 MB Flash and
USB Serial/JTAG, wrote the 350,864-byte application image, and verified its
hash.
- A target-board boot capture on 2026-09-03 reported `App version: 0.2.0`,
`SPI Flash Size : 4MB`,
`LittleFS mounted: total=2031616 bytes, used=12288 bytes`,
`HTTP server ready with static and health endpoints`, and
`Health state: serving`. The capture contained no application error after
startup. This supplies the physical-board evidence for acceptance criterion
5.
## Milestone 003 — Wi-Fi provisioning and persistent configuration ## Milestone 003 — Wi-Fi provisioning and persistent configuration
**Status:** `NOT_STARTED` **Status:** `COMPLETE`
### Objective ### Objective
@@ -176,13 +225,60 @@ access-point provisioning flow.
### Evidence ### Evidence
Completion evidence is not yet available because this milestone has not - On 2026-09-03, `./scripts/run_host_tests.sh` reported
started. Required evidence includes NVS tests and target-board logs for first `health state tests: PASS`, `wifi configuration tests: PASS`,
boot, successful station connection, reboot persistence, and fallback recovery. `configuration NVS tests: PASS`, `provisioning protocol tests: PASS`, and
`web asset reproducibility: PASS
(b36a6203a78964a324e3d56986a99abb7e36a48a84688cb4ecfe4ac1934b9f92)`.
The tests exercise credential length and control-byte bounds, invalid-input
non-mutation, mandatory reconfiguration guards, real store code across an
NVS reinitialization, schema mismatch, reset, strict two-field JSON parsing,
and malformed or duplicated request fields.
- After a clean on 2026-09-03,
`/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1` completed
with `[SUCCESS]` in 46.25 seconds. PlatformIO reported 37,452 bytes of RAM
and 985,280 of 2,097,152 application bytes in use; the independent size hook
reported `firmware.bin uses 985920/2097152 bytes in partition factory`.
- `/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1 --target
buildfs` completed with `[SUCCESS]` and reported
`littlefs.bin uses 2031616/2031616 bytes in partition littlefs`. The image
contains deterministic compressed application and provisioning pages; its
SHA-256 was
`f3e529542737221d46146f46ff57eb3a88a1b3c5b25c9f4298b9d3e3e22a7d3a`.
- The application and filesystem images were written to the physical
ESP32-C6FH4 and hash-verified. A production first boot with empty
configuration reported `App version: 0.3.0`,
`No usable saved station configuration; enabling fallback`,
`Fallback access point ready: SSID=maze32-F7B9,
URL=http://192.168.4.1/provision`, DHCP at `192.168.4.1`,
`Provisioning endpoints ready`, and `Health state: serving`.
- The isolated `milestone-003-device-test` environment exercised the real NVS
and network manager without accessing local Wi-Fi secrets. One continuous
target log reported `credentials saved; restarting`, reset reason `SW_CPU`,
`credentials survived restart`, station connection to the persisted test
SSID with three bounded retries, `Station connection timed out or exhausted
retries; enabling fallback`, and `failed station returned to fallback;
credentials cleared`. Normal production firmware was restored afterward and
its boot log confirmed the test credentials were absent.
- On 2026-09-03, the project owner used the device-hosted provisioning page
from a separate client to save credentials for `Mazda7`; no credential was
exposed in the test record. A subsequent physical-board reboot loaded that
persisted SSID, associated using WPA2, received `192.168.1.50`, reported
`Network state: station_connected` and `Station connected:
IP=192.168.1.50`, registered the HTTP and provisioning routes, and reached
`Health state: serving`.
- Live requests over `Mazda7` returned
`{"state":"station_connected",...,"ip":"192.168.1.50"}` from
`/api/wifi/status`, a healthy version `0.3.0` response from `/health`, and
the decompressed Russian provisioning page from `/provision`. A deliberately
invalid two-field configuration request returned HTTP 400. A final board
reboot again loaded and connected to `Mazda7`, proving the rejected request
did not alter the persisted valid configuration.
- All five acceptance criteria therefore have recorded evidence.
## Milestone 004 — HTTP, WebSocket, sessions, and lobby ## Milestone 004 — HTTP, WebSocket, sessions, and lobby
**Status:** `NOT_STARTED` **Status:** `COMPLETE`
### Objective ### Objective
@@ -216,13 +312,61 @@ observers, and an owner-controlled lobby.
### Evidence ### Evidence
Completion evidence is not yet available because this milestone has not - On 2026-09-04, `./scripts/run_host_tests.sh` reported
started. Required evidence includes protocol-test output, six-client connection `health state tests: PASS`, `wifi configuration tests: PASS`,
results, session restoration tests, and lobby authorization tests. `configuration NVS tests: PASS`, `provisioning protocol tests: PASS`,
`lobby and protocol tests: PASS`, and `web asset reproducibility: PASS
(6bdc7d380994cd302019c8634570953881cda035757d577fe2b1f54742a95f33)`.
The deterministic lobby tests cover the two-player and four-observer bounds,
duplicate-name and excess-role rejection, Player 2 start rejection, Player 1
readiness enforcement, restoration to the original slot, and the shared
countdown activation instant. Protocol tests cover valid envelopes plus
malformed, oversized, duplicate-field, unsupported-version, unknown-type,
invalid-payload, and stale-sequence handling without state mutation.
- A clean production build followed by
`/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1 --target
upload --upload-port /dev/ttyACM0` completed with `[SUCCESS]` in 50.31
seconds. PlatformIO reported 47,508 bytes of RAM and 1,005,274 of 2,097,152
application bytes in use; the independent partition check reported
`firmware.bin uses 1005904/2097152 bytes in partition factory`. The upload
identified the physical target as an ESP32-C6FH4 with 4 MB embedded Flash,
wrote the bootloader, partition table, and application, and hash-verified all
three images.
- `/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1 --target
buildfs` completed with `[SUCCESS]` and reported
`littlefs.bin uses 2031616/2031616 bytes in partition littlefs`. The two
deterministic compressed browser assets total 5,970 bytes, below the 1.5 MiB
web-payload limit and within the unchanged 1,984 KiB LittleFS partition.
- The final firmware booted through the bounded station retry policy and served
its fallback network while the configured `Mazda7` access point was
unavailable. A live `GET /health` returned
`{"status":"serving","healthy":true,"version":"0.4.0","errorCode":0}`.
The host retained its independent Ethernet route while its Wi-Fi interface
joined the device access point, so device testing did not interrupt the
development connection.
- On the physical board,
`python3 scripts/ws_protocol_client.py 192.168.4.1 --verify` reported
`malformed and stale message rejection: PASS`,
`six-client capacity: PASS (2 players + 4 observers)`,
`duplicate player name: PASS`, `seventh connection rejection: PASS`,
`Player 2 start authorization: PASS`,
`session refresh restoration: PASS (Player 2 retained slot 2)`,
`authoritative countdown: PASS (activationAtMs=72223)`, and
`oversized message rejection: PASS`. The countdown payload received by both
player connections was byte-for-byte equivalent, and both subsequent active
events carried the same server activation timestamp.
- During a six-client physical run, the gateway logged all six bounded
admissions and `Six-client heap: free=305388 bytes, largest=278528 bytes`;
it rejected a seventh connection and a 513-byte frame. Session tokens were
returned only in private session messages and were absent from shared lobby
snapshots. The observer protocol exposes no mutating command, while start
authorization is resolved from the server-owned session and player slot.
- These results provide concrete host and physical-device evidence for all six
acceptance criteria.
## Milestone 005 — Symmetric maze generation and validation ## Milestone 005 — Symmetric maze generation and validation
**Status:** `NOT_STARTED` **Status:** `COMPLETE`
### Objective ### Objective
@@ -256,13 +400,70 @@ object placement and bounded memory use.
### Evidence ### Evidence
Completion evidence is not yet available because this milestone has not - On 2026-09-04, `/home/sasa/.platformio/penv/bin/pio project config` exited
started. Required evidence includes deterministic seed-test results, validator successfully and resolved the production, Milestone 003 device-test, and
statistics, memory measurements, and representative generated maps. isolated `milestone-005-device-test` environments from the locked
`espressif32@7.0.1`, ESP-IDF, 4 MB Flash, root partition, and root sdkconfig
baseline.
- On 2026-09-04, `./scripts/run_host_tests.sh` reported all preceding tests as
`PASS`, `maze bounded regeneration failure: PASS (3/3 attempts)`, and
`maze seed sweep: PASS (1024 seeds, combined hash=a264b7d133f8bad4)`.
Generating every default `128 x 64` seed twice produced identical hashes and
valid results. All 1,024 maps contained 8,192 reachable cells, exactly 133
cycles, six cross-half connections, 704–854 dead ends, and symmetric object
and route metrics. Start-to-key distances were 416–1,442, key-to-exit
distances were 346–1,244, and start-to-exit distances were 102–992; every
tested candidate succeeded on its first bounded attempt and key-search route
distance was never shorter than the following exit-search route distance.
- The same host suite reported a maximum 5.127 ms generation time and
`maze fixed memory: PASS (42332/65536 bytes, no heap API)`. The portable
component uses one byte of passage flags per cell plus fixed bit-set,
traversal, distance, and placement arrays. A separate build with
`-fsanitize=address,undefined` and `ASAN_OPTIONS=detect_leaks=0` repeated the
1,024-seed suite without an address or undefined-behavior finding; leak
detection was disabled because the sandbox executes under `ptrace`.
- Validator corruption tests independently rejected a broken reciprocal or
rotational passage and a colliding key placement. The sweep also verified
boundary starts and exits, keys outside each owner's Chebyshev-radius-three
initial view, corresponding distances for keys, exits, compasses, all eight
landmark pairs, and all four vision-artifact pairs. The forced impossible
candidate stopped after the configured three attempts and returned a
diagnostic failure instead of retrying indefinitely.
- The deterministic small-map diagnostic reported representative map hash
`7396c6426181da3e` and printed a complete `16 x 8` ASCII maze with walls,
opposite starts, symmetric keys and exits, compasses, landmarks, and four
vision artifacts. `docs/MAZE_GENERATION.md` records the construction,
validation, configuration, reproducibility, and resource contracts.
- `/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1` completed
with `[SUCCESS]`. PlatformIO reported 47,508 of 327,680 RAM bytes and
1,005,274 of 2,097,152 application bytes in use; the production upload's
independent partition check reported
`firmware.bin uses 1005904/2097152 bytes in partition factory`.
`/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1 --target
buildfs` also completed with `[SUCCESS]` and reported
`littlefs.bin uses 2031616/2031616 bytes in partition littlefs`.
- The isolated physical-board image was built and hash-verified with
`/home/sasa/.platformio/penv/bin/pio run -e milestone-005-device-test
--target upload --upload-port /dev/ttyACM0`. The upload identified the target
as an ESP32-C6FH4 with 4 MB embedded Flash. On target, requested seed
`1513226245` generated in one attempt with hash `485280a746e8900c` and logged
`cells=8192, reachable=8192, edges=8324, cycles=133, crossings=6,
dead_ends=778, branches=1024`. Paired route metrics were equal:
`start_key=804/804`, `key_exit=773/773`, `start_exit=191/191`,
`compass=223/223`, `landmarks=3099/3099`, and `artifacts=1085/1085`.
- The same physical run logged
`fixed=42332/65536 bytes, runtime=183/250 ms, heap_delta=0` followed by
`M005 physical maze test: PASS`. Production firmware was restored and
hash-verified afterward; a live `GET /health` over the configured station
network returned
`{"status":"serving","healthy":true,"version":"0.5.0","errorCode":0}`.
- These deterministic, validator, resource, representative-map, build, and
physical-board results provide concrete evidence for all six acceptance
criteria and completed Milestone 005.
## Milestone 006 — Authoritative movement and visibility core ## Milestone 006 — Authoritative movement and visibility core
**Status:** `NOT_STARTED` **Status:** `COMPLETE`
### Objective ### Objective
@@ -296,9 +497,66 @@ server-enforced role-specific visibility.
### Evidence ### Evidence
Completion evidence is not yet available because this milestone has not - On 2026-09-04,
started. Required evidence includes host-test output, serialized payload `PLATFORMIO_SETTING_ENABLE_TELEMETRY=no /home/sasa/.platformio/penv/bin/pio
inspection, and deterministic command-order tests. project config` exited successfully and resolved the production and isolated
`milestone-006-device-test` environments from `espressif32@7.0.1`, ESP-IDF,
the `esp32-c6-devkitm-1` profile, 4 MB Flash, and the root partition and
sdkconfig files.
- `./scripts/run_host_tests.sh` passed every preceding suite and reported
`game movement, cooldown, and FIFO ordering: PASS (commands 1-7, states
0-3)`, `game bounded command queue: PASS (16 entries)`, `game shared-cell
meeting event: PASS (one event)`, and `game visibility and payload security:
PASS (r3=49/16, r6=64/49, observer=64)`. The same suite serialized a complete
default `128 x 64` observer snapshot as 345 chunks and reported a maximum
chunk size of 405 bytes against the 512-byte limit. A separate build with
`-fsanitize=address,undefined` repeated the game-core suite without an
address or undefined-behavior finding; leak detection was disabled because
the sandbox executes under `ptrace`.
- The deterministic movement cases proved that queued commands are processed
in assigned FIFO order, each accepted direction changes exactly one
coordinate by one cell, and Player 1 and Player 2 may act at the same server
timestamp. Countdown, cooldown, out-of-order timestamp, closed-wall,
malformed-direction, observer, and full-queue rejections left player state,
accepted-move counters, cooldown clocks, and the authoritative state
sequence unchanged. Entering the other player's occupied cell left both
players there and emitted one meeting event.
- Projection tests covered both player roles and reused a snapshot after an
opponent changed from visible to hidden. Radius-three squares contained 49
cells at an interior position and 16 at a corner; radius-six squares
contained 64 and 49 respectively on the `8 x 8` fixture. An internal wall
did not remove cells. Player structures and every serialized 512-byte-bounded
JSON chunk omitted hidden cell 63, its hidden key record, and stale opponent
state, while the observer contained all 64 cells, all ten fixture objects,
and both player positions. The serializer also emitted the versioned
envelope, authoritative sequence, phase, meeting sequence, and chunk
metadata.
- `/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1` completed
with `[SUCCESS]` and reported 47,508 of 327,680 RAM bytes and 1,005,274 of
2,097,152 application bytes in use. The production `firmware.bin` measured
1,005,904 bytes against the 2,097,152-byte factory partition.
`/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1 --target
buildfs` completed with `[SUCCESS]`; the independent partition check reported
`littlefs.bin uses 2031616/2031616 bytes in partition littlefs`.
- The isolated device image built with `[SUCCESS]`, using 124,468 of 327,680
RAM bytes and 1,016,242 of 2,097,152 application bytes. Its independent
partition check reported `firmware.bin uses 1016880/2097152 bytes in
partition factory`. Upload with `/home/sasa/.platformio/penv/bin/pio run -e
milestone-006-device-test --target upload --upload-port /dev/ttyACM0`
identified an ESP32-C6FH4 revision 0.2 with 4 MB embedded Flash and
hash-verified every written image.
- The physical board logged an accepted command with command sequence one,
state sequence one, and the new position. It projected 28 player cells into
two bounded chunks and the complete 8,192-cell, 30-object observer state into
345 bounded chunks; the first serialized chunk was 372 bytes. It then logged
`fixed=34612/40960 bytes, runtime=5/50 ms, heap_delta=0` and
`M006 physical game test: PASS`.
- The production image was restored and hash-verified after the isolated test.
A live `GET /health` returned
`{"status":"serving","healthy":true,"version":"0.6.0","errorCode":0}`.
These host, serialization, build, resource, and physical-board results
provide concrete evidence for all six acceptance criteria. Milestone 007
remains `NOT_STARTED`.
## Milestone 007 — Keys, artifacts, compasses, exits, and results ## Milestone 007 — Keys, artifacts, compasses, exits, and results
@@ -350,15 +608,20 @@ into the browser.
### Scope ### Scope
- Name selection, lobby, countdown, running, paused, and result screens. - Name selection, lobby, countdown, running, paused, and result screens.
- Dark, humorous retro arcade presentation following `docs/UI_STYLE.md`.
- Russian player-facing copy and locally served pixel-styled graphics.
- Maze viewport and visual landmarks. - Maze viewport and visual landmarks.
- Keyboard movement and touchscreen swipes. - Keyboard movement, touchscreen swipes, and responsive on-screen arcade
controls.
- Accessible drop-key action. - Accessible drop-key action.
- Inventory, vision duration, compass hint, connection, and error feedback. - Inventory, vision duration, compass hint, connection, and error feedback.
- Default sound, supported-device vibration, and animated game feedback.
- Session restoration after page refresh. - Session restoration after page refresh.
### Deliverables ### Deliverables
- Responsive browser application and compressed production assets. - Responsive browser application and compressed production assets.
- Implemented visual tokens, SVG icon set, and shared control styles.
- Browser interaction tests for desktop and mobile input. - Browser interaction tests for desktop and mobile input.
- Accessibility and viewport behavior checks. - Accessibility and viewport behavior checks.
@@ -369,7 +632,17 @@ into the browser.
3. Hidden and previously seen cells are not retained on screen. 3. Hidden and previously seen cells are not retained on screen.
4. Inventory, effects, hints, pause, and results are unambiguous. 4. Inventory, effects, hints, pause, and results are unambiguous.
5. Refresh restores the active player without changing position or inventory. 5. Refresh restores the active player without changing position or inventory.
6. The production asset image fits its LittleFS partition. 6. The player flow remains usable without page scrolling at a `412 x 916`
CSS-pixel viewport and at representative tablet and desktop viewports.
7. Player-facing UI copy and browser errors are in Russian while protocol and
system-log text remain in English.
8. The application requires no runtime internet access or third-party UI
dependency.
9. The complete compressed web payload is no larger than 1.5 MiB and fits the
unchanged LittleFS partition.
10. The current stable Chrome, Safari, Firefox, and Edge releases pass the
applicable browser interaction checks, with versions and test date
recorded in the milestone evidence.
### Evidence ### Evidence
@@ -389,6 +662,7 @@ and reconnect behavior.
### Scope ### Scope
- Full-map observer rendering with player and object updates. - Full-map observer rendering with player and object updates.
- Large-screen observer layout with initial full-map fit, zoom, and pan.
- Observer access without a password and strict read-only authorization. - Observer access without a password and strict read-only authorization.
- Player disconnect detection and authoritative pause. - Player disconnect detection and authoritative pause.
- Session reattachment and 60-second monotonic deadline. - Session reattachment and 60-second monotonic deadline.
@@ -408,6 +682,8 @@ and reconnect behavior.
4. Reconnection within 60 seconds restores and resumes the same state. 4. Reconnection within 60 seconds restores and resumes the same state.
5. Reconnection timeout ends the match in a draw. 5. Reconnection timeout ends the match in a draw.
6. Observer attempts to send mutation commands are rejected and audited. 6. Observer attempts to send mutation commands are rejected and audited.
7. Observer mode works at `1024 x 768` CSS pixels and larger, while smaller
viewports show the required Russian large-screen message.
### Evidence ### Evidence
+97
View File
@@ -0,0 +1,97 @@
# 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/sasa/.platformio/penv/bin/pio`;
- a C11 host compiler available as `cc`;
- Python 3.
## Build and verify
Parse the locked PlatformIO configuration:
```sh
/home/sasa/.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/sasa/.platformio/penv/bin/pio run
```
Generate compressed assets explicitly when needed:
```sh
/home/sasa/.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/sasa/.platformio/penv/bin/pio run --target buildfs
```
## Device commands
Upload the application and LittleFS image:
```sh
/home/sasa/.platformio/penv/bin/pio run --target upload
/home/sasa/.platformio/penv/bin/pio run --target uploadfs
```
Monitor startup at 115200 baud:
```sh
/home/sasa/.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`.
## 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/sasa/.platformio/penv/bin/pio run -e milestone-003-device-test
/home/sasa/.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,146 @@
#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_COMMAND_CAPACITY 16U
#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 24U
#define MAZE32_GAME_VIEW_OBJECTS_PER_CHUNK 10U
#define MAZE32_GAME_VIEW_CHUNK_BYTES 512U
#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_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_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_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 has_moved;
} maze32_game_player_t;
typedef struct {
uint32_t command_sequence;
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;
} 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_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_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;
} 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;
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 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_command_result_t maze32_game_process_next(maze32_game_t* game);
maze32_game_result_t maze32_game_project_view(const maze32_game_t* game, maze32_game_role_t role,
uint8_t radius, maze32_view_t* view);
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
+380
View File
@@ -0,0 +1,380 @@
#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_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 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;
}
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->players[0].position = maze->starts[0];
game->players[1].position = maze->starts[1];
game->next_command_sequence = 1U;
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_ACTIVE)) {
game->phase = 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) {
if (game == NULL || command_sequence == NULL || !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->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_command_result_t maze32_game_process_next(maze32_game_t* game) {
maze32_game_command_result_t processed = {.result = MAZE32_GAME_RESULT_INVALID_ARGUMENT};
if (game == NULL) {
return processed;
}
processed.state_sequence = game->state_sequence;
processed.meeting_sequence = game->meeting_sequence;
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 (game->phase != MAZE32_GAME_PHASE_ACTIVE) {
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->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;
}
maze32_passage_t passage = direction_passage(command.direction);
if (!maze32_maze_has_passage(game->maze, player->position, passage)) {
processed.result = MAZE32_GAME_RESULT_BLOCKED;
return processed;
}
player->position = moved_cell(player->position, command.direction);
player->last_move_ms = command.submitted_ms;
player->accepted_moves++;
player->has_moved = 1U;
game->state_sequence++;
processed.result = MAZE32_GAME_RESULT_ACCEPTED;
processed.state_sequence = game->state_sequence;
uint8_t opponent_index = (uint8_t)(1U - player_index);
if (player->position.x == game->players[opponent_index].position.x &&
player->position.y == game->players[opponent_index].position.y) {
game->meeting_sequence++;
processed.meeting = 1U;
processed.meeting_sequence = game->meeting_sequence;
}
return processed;
}
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) {
add_object(game, view, game->maze->keys[owner], MAZE32_VIEW_OBJECT_KEY, owner, 0U, center,
radius, reveal_all);
add_object(game, view, game->maze->exits[owner], MAZE32_VIEW_OBJECT_EXIT, owner, 0U, 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) {
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,
uint8_t radius, maze32_view_t* view) {
if (game == NULL || game->maze == NULL || view == NULL ||
(!role_is_player(role) && role != MAZE32_GAME_ROLE_OBSERVER) ||
(role_is_player(role) && radius != 3U && radius != 6U)) {
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->opponent_cell_index = UINT16_MAX;
int reveal_all = role == MAZE32_GAME_ROLE_OBSERVER;
maze32_cell_t center = {0U, 0U};
if (role_is_player(role)) {
uint8_t player_index = (uint8_t)role;
center = game->players[player_index].position;
view->self_cell_index = cell_index(game->maze, center);
maze32_cell_t opponent = game->players[1U - player_index].position;
if (is_visible(center, opponent, radius)) {
view->opponent_visible = 1U;
view->opponent_cell_index = cell_index(game->maze, opponent);
}
} else {
view->self_cell_index = UINT16_MAX;
view->observer_player_cell_indices[0] = cell_index(game->maze, game->players[0].position);
view->observer_player_cell_indices[1] = cell_index(game->maze, game->players[1].position);
}
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->maze->passages[index];
view->cell_count++;
}
}
}
project_objects(game, view, center, radius, reveal_all);
return MAZE32_GAME_RESULT_ACCEPTED;
}
size_t maze32_game_view_chunk_count(const maze32_view_t* view) {
if (view == NULL) {
return 0U;
}
size_t cell_chunks = (view->cell_count + MAZE32_GAME_VIEW_CELLS_PER_CHUNK - 1U) /
MAZE32_GAME_VIEW_CELLS_PER_CHUNK;
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) {
return phase == MAZE32_GAME_PHASE_ACTIVE ? "active" : "countdown";
}
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) {
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\",\"meetingSequence\":%lu,\"chunk\":%u,\"chunks\":%u",
(unsigned long)view->state_sequence, role_name(view->role),
phase_name(view->phase), (unsigned long)view->meeting_sequence,
(unsigned int)chunk_index, (unsigned int)total_chunks)) {
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
}
if (view->role == MAZE32_GAME_ROLE_OBSERVER) {
if (!append_text(output, output_size, &offset, ",\"players\":[%u,%u]",
view->observer_player_cell_indices[0],
view->observer_player_cell_indices[1])) {
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
}
} else if (!append_text(output, output_size, &offset, ",\"self\":%u",
(unsigned int)view->self_cell_index)) {
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
}
if (view->role != MAZE32_GAME_ROLE_OBSERVER && view->opponent_visible &&
!append_text(output, output_size, &offset, ",\"opponent\":%u",
(unsigned int)view->opponent_cell_index)) {
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
}
if (!append_text(output, output_size, &offset, ",\"cells\":[")) {
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
}
size_t cell_chunks = (view->cell_count + MAZE32_GAME_VIEW_CELLS_PER_CHUNK - 1U) /
MAZE32_GAME_VIEW_CELLS_PER_CHUNK;
if (chunk_index < cell_chunks) {
size_t first = chunk_index * MAZE32_GAME_VIEW_CELLS_PER_CHUNK;
size_t end = first + MAZE32_GAME_VIEW_CELLS_PER_CHUNK;
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]", index == first ? "" : ",",
cell->cell_index, cell->passages)) {
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 (!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"};
if (result < MAZE32_GAME_RESULT_ACCEPTED || result > MAZE32_GAME_RESULT_BUFFER_TOO_SMALL) {
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,82 @@
#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);
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);
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,52 @@
#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_HEARTBEAT,
MAZE32_MESSAGE_SUGGEST_NAME,
} 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];
} 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
+263
View File
@@ -0,0 +1,263 @@
#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;
}
}
}
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;
}
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";
}
}
+280
View File
@@ -0,0 +1,280 @@
#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;
}
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;
}
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, "heartbeat") == 0) {
return MAZE32_MESSAGE_HEARTBEAT;
}
if (strcmp(value, "suggest_name") == 0) {
return MAZE32_MESSAGE_SUGGEST_NAME;
}
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 128U
#define MAZE32_MAZE_MAX_HEIGHT 64U
#define MAZE32_MAZE_MAX_CELLS (MAZE32_MAZE_MAX_WIDTH * MAZE32_MAZE_MAX_HEIGHT)
#define MAZE32_MAZE_DEFAULT_WIDTH 128U
#define MAZE32_MAZE_DEFAULT_HEIGHT 64U
#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 = 3U,
.extra_cycle_pairs = 64U,
.minimum_exit_separation = 64U,
.landmark_pairs = 8U,
.artifact_pairs = 4U,
.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,73 @@
#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;
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
+352
View File
@@ -0,0 +1,352 @@
#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) {
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 {
xEventGroupSetBits(network->events, MAZE32_FAILED_BIT);
}
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
xEventGroupSetBits(network->events, MAZE32_CONNECTED_BIT);
}
}
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) {
result = esp_wifi_start();
}
if (result != ESP_OK) {
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));
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_lobby maze32_platform
)
@@ -0,0 +1,57 @@
#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_lobby.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
typedef struct {
int active;
int socket_fd;
uint32_t last_client_sequence;
uint64_t rate_window_started_ms;
unsigned int messages_in_window;
} maze32_gateway_connection_t;
typedef struct {
int socket_fd;
size_t length;
char payload[MAZE32_GATEWAY_OUTGOING_LIMIT];
} maze32_gateway_outgoing_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;
unsigned int suggestion_index;
esp_timer_handle_t countdown_timer;
} 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);
#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
+663
View File
@@ -0,0 +1,663 @@
#include "maze32_gateway.h"
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "esp_random.h"
#include "maze32_protocol.h"
static const char* TAG = "maze32_gateway";
static const char* NAME_SUGGESTIONS[] = {
"Байт-Беглец", "Кот-Навигатор", "Сэр Тупичок", "Пиксельный Ёж", "Лорд Лабиринт",
};
static uint64_t now_ms(void) {
return (uint64_t)(esp_timer_get_time() / 1000);
}
static maze32_gateway_connection_t* find_connection(maze32_gateway_t* gateway, int socket_fd) {
for (size_t index = 0U; index < MAZE32_GATEWAY_CONNECTION_LIMIT; ++index) {
if (gateway->connections[index].active &&
gateway->connections[index].socket_fd == socket_fd) {
return &gateway->connections[index];
}
}
return NULL;
}
static maze32_gateway_connection_t* open_connection(maze32_gateway_t* gateway, int socket_fd) {
maze32_gateway_connection_t* existing = find_connection(gateway, socket_fd);
if (existing != NULL) {
return existing;
}
for (size_t index = 0U; index < MAZE32_GATEWAY_CONNECTION_LIMIT; ++index) {
maze32_gateway_connection_t* connection = &gateway->connections[index];
if (!connection->active) {
memset(connection, 0, sizeof(*connection));
connection->active = 1;
connection->socket_fd = socket_fd;
connection->rate_window_started_ms = now_ms();
unsigned int active_count = 0U;
for (size_t count_index = 0U; count_index < MAZE32_GATEWAY_CONNECTION_LIMIT;
++count_index) {
if (gateway->connections[count_index].active) {
active_count++;
}
}
ESP_LOGI(TAG, "WebSocket connected: fd=%d, active=%u/%u", socket_fd, active_count,
(unsigned int)MAZE32_GATEWAY_CONNECTION_LIMIT);
if (active_count == MAZE32_GATEWAY_CONNECTION_LIMIT) {
ESP_LOGI(TAG, "Six-client heap: free=%u bytes, largest=%u bytes",
(unsigned int)heap_caps_get_free_size(MALLOC_CAP_8BIT),
(unsigned int)heap_caps_get_largest_free_block(MALLOC_CAP_8BIT));
}
return connection;
}
}
return NULL;
}
static int append_text(char* output, size_t capacity, size_t* length, const char* text) {
size_t text_length = strlen(text);
if (*length + text_length >= capacity) {
return 0;
}
memcpy(output + *length, text, text_length);
*length += text_length;
output[*length] = '\0';
return 1;
}
static int append_json_string(char* output, size_t capacity, size_t* length, const char* value) {
if (!append_text(output, capacity, length, "\"")) {
return 0;
}
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 == '\n') {
escape = "\\n";
} else if (*cursor == '\r') {
escape = "\\r";
} else if (*cursor == '\t') {
escape = "\\t";
}
if (escape != NULL) {
if (!append_text(output, capacity, length, escape)) {
return 0;
}
} else {
char byte[2] = {(char)*cursor, '\0'};
if (!append_text(output, capacity, length, byte)) {
return 0;
}
}
}
return append_text(output, capacity, length, "\"");
}
static uint32_t next_server_sequence(maze32_gateway_t* gateway) {
gateway->server_sequence++;
if (gateway->server_sequence == 0U) {
gateway->server_sequence = 1U;
}
return gateway->server_sequence;
}
static int enqueue(maze32_gateway_t* gateway, int socket_fd, const char* payload) {
size_t length = strlen(payload);
if (gateway->outgoing_count >= MAZE32_GATEWAY_QUEUE_LIMIT ||
length >= MAZE32_GATEWAY_OUTGOING_LIMIT) {
ESP_LOGW(TAG, "Bounded outgoing queue rejected a message for fd=%d", socket_fd);
return 0;
}
size_t index = (gateway->outgoing_head + gateway->outgoing_count) % MAZE32_GATEWAY_QUEUE_LIMIT;
maze32_gateway_outgoing_t* entry = &gateway->outgoing[index];
entry->socket_fd = socket_fd;
entry->length = length;
memcpy(entry->payload, payload, length + 1U);
gateway->outgoing_count++;
return 1;
}
static void drain_queue(maze32_gateway_t* gateway) {
while (gateway->outgoing_count > 0U) {
maze32_gateway_outgoing_t* entry = &gateway->outgoing[gateway->outgoing_head];
httpd_ws_frame_t frame = {
.final = true,
.fragmented = false,
.type = HTTPD_WS_TYPE_TEXT,
.payload = (uint8_t*)entry->payload,
.len = entry->length,
};
esp_err_t result = httpd_ws_send_frame_async(gateway->server, entry->socket_fd, &frame);
if (result != ESP_OK) {
ESP_LOGW(TAG, "WebSocket send failed for fd=%d: %s", entry->socket_fd,
esp_err_to_name(result));
}
gateway->outgoing_head = (gateway->outgoing_head + 1U) % MAZE32_GATEWAY_QUEUE_LIMIT;
gateway->outgoing_count--;
}
}
static void send_error(maze32_gateway_t* gateway, int socket_fd, const char* request_id,
const char* code, const char* message) {
char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = "";
size_t length = 0U;
char prefix[128];
int written = snprintf(prefix, sizeof(prefix), "{\"v\":1,\"type\":\"error\",\"seq\":%" PRIu32,
next_server_sequence(gateway));
if (written < 0 || (size_t)written >= sizeof(prefix) ||
!append_text(response, sizeof(response), &length, prefix)) {
return;
}
if (request_id != NULL && request_id[0] != '\0') {
if (!append_text(response, sizeof(response), &length, ",\"requestId\":") ||
!append_json_string(response, sizeof(response), &length, request_id)) {
return;
}
}
if (!append_text(response, sizeof(response), &length, ",\"payload\":{\"code\":") ||
!append_json_string(response, sizeof(response), &length, code) ||
!append_text(response, sizeof(response), &length, ",\"message\":") ||
!append_json_string(response, sizeof(response), &length, message) ||
!append_text(response, sizeof(response), &length, "}}")) {
return;
}
enqueue(gateway, socket_fd, response);
}
static const maze32_session_t* player_for_slot(const maze32_lobby_t* lobby, unsigned int slot) {
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) {
return session;
}
}
return NULL;
}
static int build_snapshot(maze32_gateway_t* gateway, char* response, size_t capacity) {
uint32_t sequence = next_server_sequence(gateway);
int written =
snprintf(response, capacity,
"{\"v\":1,\"type\":\"lobby\",\"seq\":%" PRIu32
",\"payload\":{\"revision\":%" PRIu32 ",\"phase\":\"%s\","
"\"ready\":%s,\"ownerSlot\":1,\"players\":[",
sequence, gateway->lobby.revision, maze32_lobby_phase_name(gateway->lobby.phase),
maze32_lobby_ready(&gateway->lobby) ? "true" : "false");
if (written < 0 || (size_t)written >= capacity) {
return 0;
}
size_t length = (size_t)written;
for (unsigned int slot = 1U; slot <= MAZE32_PLAYER_LIMIT; ++slot) {
const maze32_session_t* player = player_for_slot(&gateway->lobby, slot);
char prefix[96];
written = snprintf(prefix, sizeof(prefix),
"%s{\"slot\":%u,\"name\":", slot == 1U ? "" : ",", slot);
if (written < 0 || (size_t)written >= sizeof(prefix) ||
!append_text(response, capacity, &length, prefix) ||
!append_json_string(response, capacity, &length, player == NULL ? "" : player->name)) {
return 0;
}
written = snprintf(prefix, sizeof(prefix), ",\"occupied\":%s,\"connected\":%s}",
player == NULL ? "false" : "true",
player != NULL && player->connected ? "true" : "false");
if (written < 0 || (size_t)written >= sizeof(prefix) ||
!append_text(response, capacity, &length, prefix)) {
return 0;
}
}
char suffix[128];
written = snprintf(suffix, sizeof(suffix), "],\"observers\":%u,\"observerLimit\":%u}}",
maze32_lobby_connected_observers(&gateway->lobby),
(unsigned int)MAZE32_OBSERVER_LIMIT);
return written >= 0 && (size_t)written < sizeof(suffix) &&
append_text(response, capacity, &length, suffix);
}
static void broadcast_snapshot(maze32_gateway_t* gateway) {
char response[MAZE32_GATEWAY_OUTGOING_LIMIT];
if (!build_snapshot(gateway, response, sizeof(response))) {
ESP_LOGE(TAG, "Lobby snapshot exceeded its fixed buffer");
return;
}
for (size_t index = 0U; index < MAZE32_GATEWAY_CONNECTION_LIMIT; ++index) {
maze32_gateway_connection_t* connection = &gateway->connections[index];
if (connection->active &&
maze32_lobby_session_for_connection(&gateway->lobby, connection->socket_fd) != NULL) {
enqueue(gateway, connection->socket_fd, response);
}
}
}
static void send_session(maze32_gateway_t* gateway, int socket_fd, const maze32_session_t* session,
const char* request_id) {
char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = "";
size_t length = 0U;
char prefix[160];
int written = snprintf(prefix, sizeof(prefix), "{\"v\":1,\"type\":\"session\",\"seq\":%" PRIu32,
next_server_sequence(gateway));
if (written < 0 || (size_t)written >= sizeof(prefix) ||
!append_text(response, sizeof(response), &length, prefix)) {
return;
}
if (request_id[0] != '\0' &&
(!append_text(response, sizeof(response), &length, ",\"requestId\":") ||
!append_json_string(response, sizeof(response), &length, request_id))) {
return;
}
written =
snprintf(prefix, sizeof(prefix), ",\"payload\":{\"role\":\"%s\",\"slot\":%u,\"token\":",
session->role == MAZE32_ROLE_PLAYER ? "player" : "observer", session->player_slot);
if (written < 0 || (size_t)written >= sizeof(prefix) ||
!append_text(response, sizeof(response), &length, prefix) ||
!append_json_string(response, sizeof(response), &length, session->token) ||
!append_text(response, sizeof(response), &length, "}}")) {
return;
}
enqueue(gateway, socket_fd, response);
}
static void generate_token(maze32_gateway_t* gateway, char* token) {
static const char HEX[] = "0123456789abcdef";
uint8_t bytes[MAZE32_SESSION_TOKEN_BYTES];
for (;;) {
esp_fill_random(bytes, sizeof(bytes));
for (size_t index = 0U; index < sizeof(bytes); ++index) {
token[index * 2U] = HEX[bytes[index] >> 4U];
token[index * 2U + 1U] = HEX[bytes[index] & 0x0fU];
}
token[MAZE32_SESSION_TOKEN_HEX_LENGTH] = '\0';
int collision = 0;
for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) {
if (gateway->lobby.sessions[index].role != MAZE32_ROLE_NONE &&
strcmp(gateway->lobby.sessions[index].token, token) == 0) {
collision = 1;
break;
}
}
if (!collision) {
return;
}
}
}
static void send_lobby_error(maze32_gateway_t* gateway, int socket_fd, const char* request_id,
maze32_lobby_result_t result) {
switch (result) {
case MAZE32_LOBBY_INVALID_NAME:
send_error(gateway, socket_fd, request_id, "invalid_name",
"Имя должно быть длиной от 1 до 32 байт без пробелов по краям");
break;
case MAZE32_LOBBY_DUPLICATE_NAME:
send_error(gateway, socket_fd, request_id, "duplicate_name", "Это имя уже занято");
break;
case MAZE32_LOBBY_ROLE_FULL:
send_error(gateway, socket_fd, request_id, "role_full", "Свободных мест больше нет");
break;
case MAZE32_LOBBY_INVALID_TOKEN:
send_error(gateway, socket_fd, request_id, "invalid_token", "Сессия не найдена");
break;
case MAZE32_LOBBY_SESSION_CONNECTED:
send_error(gateway, socket_fd, request_id, "session_connected",
"Эта сессия уже подключена");
break;
case MAZE32_LOBBY_NOT_OWNER:
send_error(gateway, socket_fd, request_id, "not_owner",
"Запустить игру может только Игрок 1");
break;
case MAZE32_LOBBY_NOT_READY:
send_error(gateway, socket_fd, request_id, "not_ready",
"Для старта нужны оба игрока онлайн");
break;
case MAZE32_LOBBY_INVALID_PHASE:
send_error(gateway, socket_fd, request_id, "invalid_phase",
"Лобби уже запустило обратный отсчёт");
break;
default:
send_error(gateway, socket_fd, request_id, "request_rejected", "Команда отклонена");
break;
}
}
static void send_suggestion(maze32_gateway_t* gateway, int socket_fd, const char* request_id) {
const char* suggestion = NULL;
size_t suggestion_count = sizeof(NAME_SUGGESTIONS) / sizeof(NAME_SUGGESTIONS[0]);
for (size_t attempt = 0U; attempt < suggestion_count; ++attempt) {
size_t index = gateway->suggestion_index++ % suggestion_count;
int occupied = 0;
for (size_t session_index = 0U; session_index < MAZE32_SESSION_LIMIT; ++session_index) {
const maze32_session_t* session = &gateway->lobby.sessions[session_index];
if (session->role == MAZE32_ROLE_PLAYER &&
strcmp(session->name, NAME_SUGGESTIONS[index]) == 0) {
occupied = 1;
break;
}
}
if (!occupied) {
suggestion = NAME_SUGGESTIONS[index];
break;
}
}
if (suggestion == NULL) {
send_error(gateway, socket_fd, request_id, "no_suggestion",
"Все смешные имена уже разобрали");
return;
}
char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = "";
size_t length = 0U;
char prefix[128];
int written =
snprintf(prefix, sizeof(prefix), "{\"v\":1,\"type\":\"name_suggestion\",\"seq\":%" PRIu32,
next_server_sequence(gateway));
if (written < 0 || (size_t)written >= sizeof(prefix) ||
!append_text(response, sizeof(response), &length, prefix)) {
return;
}
if (request_id[0] != '\0' &&
(!append_text(response, sizeof(response), &length, ",\"requestId\":") ||
!append_json_string(response, sizeof(response), &length, request_id))) {
return;
}
if (append_text(response, sizeof(response), &length, ",\"payload\":{\"name\":") &&
append_json_string(response, sizeof(response), &length, suggestion) &&
append_text(response, sizeof(response), &length, "}}")) {
enqueue(gateway, socket_fd, response);
}
}
static void broadcast_countdown(maze32_gateway_t* gateway) {
char response[MAZE32_GATEWAY_OUTGOING_LIMIT];
int written = snprintf(response, sizeof(response),
"{\"v\":1,\"type\":\"countdown\",\"seq\":%" PRIu32
",\"payload\":{\"steps\":[3,2,1],\"startedAtMs\":%" PRIu64
",\"activationAtMs\":%" PRIu64 "}}",
next_server_sequence(gateway), gateway->lobby.countdown_started_ms,
gateway->lobby.activation_ms);
if (written < 0 || (size_t)written >= sizeof(response)) {
return;
}
for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) {
const maze32_session_t* session = &gateway->lobby.sessions[index];
if (session->role == MAZE32_ROLE_PLAYER && session->connected) {
enqueue(gateway, session->connection_id, response);
}
}
}
static void countdown_finished_work(void* context) {
maze32_gateway_t* gateway = context;
if (!maze32_lobby_activate_if_due(&gateway->lobby, now_ms())) {
return;
}
char response[MAZE32_GATEWAY_OUTGOING_LIMIT];
int written = snprintf(response, sizeof(response),
"{\"v\":1,\"type\":\"active\",\"seq\":%" PRIu32
",\"payload\":{\"activatedAtMs\":%" PRIu64 "}}",
next_server_sequence(gateway), gateway->lobby.activation_ms);
if (written >= 0 && (size_t)written < sizeof(response)) {
for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) {
const maze32_session_t* session = &gateway->lobby.sessions[index];
if (session->role != MAZE32_ROLE_NONE && session->connected) {
enqueue(gateway, session->connection_id, response);
}
}
}
broadcast_snapshot(gateway);
drain_queue(gateway);
ESP_LOGI(TAG, "Lobby became active at server time %" PRIu64 " ms",
gateway->lobby.activation_ms);
}
static void countdown_timer_callback(void* context) {
maze32_gateway_t* gateway = context;
if (gateway->server != NULL) {
esp_err_t result = httpd_queue_work(gateway->server, countdown_finished_work, gateway);
if (result != ESP_OK) {
ESP_LOGE(TAG, "Could not queue countdown activation: %s", esp_err_to_name(result));
}
}
}
static int rate_limit_allows(maze32_gateway_connection_t* connection, uint64_t current_ms) {
if (current_ms - connection->rate_window_started_ms >= 1000U) {
connection->rate_window_started_ms = current_ms;
connection->messages_in_window = 0U;
}
connection->messages_in_window++;
return connection->messages_in_window <= MAZE32_GATEWAY_RATE_LIMIT_PER_SECOND;
}
static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_t* connection,
const maze32_client_message_t* message) {
int socket_fd = connection->socket_fd;
if (message->type == MAZE32_MESSAGE_SUGGEST_NAME) {
send_suggestion(gateway, socket_fd, message->request_id);
return;
}
if (message->type == MAZE32_MESSAGE_JOIN) {
if (maze32_lobby_session_for_connection(&gateway->lobby, socket_fd) != NULL) {
send_error(gateway, socket_fd, message->request_id, "already_joined",
"Сессия уже выбрала роль");
return;
}
char token[MAZE32_SESSION_TOKEN_HEX_LENGTH + 1U];
generate_token(gateway, token);
maze32_session_t* session = NULL;
maze32_lobby_result_t result =
message->role == MAZE32_ROLE_PLAYER
? maze32_lobby_join_player(&gateway->lobby, socket_fd, message->name, token,
&session)
: maze32_lobby_join_observer(&gateway->lobby, socket_fd, token, &session);
if (result != MAZE32_LOBBY_OK) {
send_lobby_error(gateway, socket_fd, message->request_id, result);
return;
}
send_session(gateway, socket_fd, session, message->request_id);
broadcast_snapshot(gateway);
return;
}
if (message->type == MAZE32_MESSAGE_RESTORE) {
if (maze32_lobby_session_for_connection(&gateway->lobby, socket_fd) != NULL) {
send_error(gateway, socket_fd, message->request_id, "already_joined",
"Сессия уже восстановлена");
return;
}
maze32_session_t* session = NULL;
maze32_lobby_result_t result =
maze32_lobby_restore(&gateway->lobby, socket_fd, message->token, &session);
if (result != MAZE32_LOBBY_OK) {
send_lobby_error(gateway, socket_fd, message->request_id, result);
return;
}
send_session(gateway, socket_fd, session, message->request_id);
broadcast_snapshot(gateway);
return;
}
const maze32_session_t* session =
maze32_lobby_session_for_connection(&gateway->lobby, socket_fd);
if (session == NULL) {
send_error(gateway, socket_fd, message->request_id, "unauthorized",
"Сначала выберите роль или восстановите сессию");
return;
}
if (message->type == MAZE32_MESSAGE_START) {
maze32_lobby_result_t result = maze32_lobby_start(&gateway->lobby, socket_fd, now_ms());
if (result != MAZE32_LOBBY_OK) {
send_lobby_error(gateway, socket_fd, message->request_id, result);
return;
}
broadcast_snapshot(gateway);
broadcast_countdown(gateway);
esp_err_t timer_result =
esp_timer_start_once(gateway->countdown_timer, MAZE32_COUNTDOWN_DURATION_MS * 1000U);
if (timer_result != ESP_OK) {
ESP_LOGE(TAG, "Countdown timer could not start: %s", esp_err_to_name(timer_result));
}
return;
}
if (message->type == MAZE32_MESSAGE_HEARTBEAT) {
char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = "";
size_t length = 0U;
char prefix[128];
int written = snprintf(prefix, sizeof(prefix), "{\"v\":1,\"type\":\"ack\",\"seq\":%" PRIu32,
next_server_sequence(gateway));
if (written >= 0 && (size_t)written < sizeof(prefix) &&
append_text(response, sizeof(response), &length, prefix) &&
append_text(response, sizeof(response), &length, ",\"requestId\":") &&
append_json_string(response, sizeof(response), &length, message->request_id) &&
append_text(response, sizeof(response), &length,
",\"payload\":{\"command\":\"heartbeat\"}}")) {
enqueue(gateway, socket_fd, response);
}
}
}
static esp_err_t websocket_handler(httpd_req_t* request) {
maze32_gateway_t* gateway = request->user_ctx;
int socket_fd = httpd_req_to_sockfd(request);
maze32_gateway_connection_t* connection = find_connection(gateway, socket_fd);
if (connection == NULL) {
connection = open_connection(gateway, socket_fd);
if (connection == NULL) {
ESP_LOGW(TAG, "WebSocket connection limit reached; rejecting fd=%d", socket_fd);
shutdown(socket_fd, SHUT_RDWR);
return ESP_OK;
}
}
httpd_ws_frame_t frame = {0};
esp_err_t result = httpd_ws_recv_frame(request, &frame, 0U);
if (result != ESP_OK) {
return result;
}
if (frame.len > MAZE32_PROTOCOL_MESSAGE_LIMIT) {
ESP_LOGW(TAG, "Oversized WebSocket frame rejected: fd=%d, length=%u", socket_fd,
(unsigned int)frame.len);
shutdown(socket_fd, SHUT_RDWR);
return ESP_OK;
}
uint8_t payload[MAZE32_PROTOCOL_MESSAGE_LIMIT + 1U];
frame.payload = payload;
result = httpd_ws_recv_frame(request, &frame, MAZE32_PROTOCOL_MESSAGE_LIMIT);
if (result != ESP_OK) {
return result;
}
if (frame.type == HTTPD_WS_TYPE_CLOSE) {
return ESP_OK;
}
if (frame.type != HTTPD_WS_TYPE_TEXT) {
send_error(gateway, socket_fd, "", "text_required", "Нужен текстовый JSON-пакет");
drain_queue(gateway);
return ESP_OK;
}
payload[frame.len] = '\0';
if (!rate_limit_allows(connection, now_ms())) {
send_error(gateway, socket_fd, "", "rate_limited", "Слишком много команд");
drain_queue(gateway);
return ESP_OK;
}
maze32_client_message_t message;
maze32_protocol_result_t parse_result =
maze32_protocol_parse((const char*)payload, frame.len, &message);
if (parse_result != MAZE32_PROTOCOL_OK) {
send_error(gateway, socket_fd, "", maze32_protocol_result_name(parse_result),
"Пакет не прошёл проверку протокола");
drain_queue(gateway);
return ESP_OK;
}
if (message.sequence <= connection->last_client_sequence) {
send_error(gateway, socket_fd, message.request_id, "stale_sequence",
"Команда устарела или уже обработана");
drain_queue(gateway);
return ESP_OK;
}
connection->last_client_sequence = message.sequence;
handle_message(gateway, connection, &message);
drain_queue(gateway);
return ESP_OK;
}
static esp_err_t websocket_pre_handshake(httpd_req_t* request) {
maze32_gateway_t* gateway = request->user_ctx;
int socket_fd = httpd_req_to_sockfd(request);
if (open_connection(gateway, socket_fd) == NULL) {
ESP_LOGW(TAG, "WebSocket connection limit reached; rejecting fd=%d", socket_fd);
return ESP_ERR_NO_MEM;
}
return ESP_OK;
}
esp_err_t maze32_gateway_init(maze32_gateway_t* gateway) {
if (gateway == NULL) {
return ESP_ERR_INVALID_ARG;
}
memset(gateway, 0, sizeof(*gateway));
maze32_lobby_init(&gateway->lobby);
const esp_timer_create_args_t timer_arguments = {
.callback = countdown_timer_callback,
.arg = gateway,
.dispatch_method = ESP_TIMER_TASK,
.name = "lobby_countdown",
.skip_unhandled_events = true,
};
return esp_timer_create(&timer_arguments, &gateway->countdown_timer);
}
void maze32_gateway_set_server(maze32_gateway_t* gateway, httpd_handle_t server) {
if (gateway != NULL) {
gateway->server = server;
}
}
esp_err_t maze32_gateway_register(httpd_handle_t server, maze32_gateway_t* gateway) {
if (server == NULL || gateway == NULL) {
return ESP_ERR_INVALID_ARG;
}
maze32_gateway_set_server(gateway, server);
const httpd_uri_t websocket = {
.uri = "/ws",
.method = HTTP_GET,
.handler = websocket_handler,
.user_ctx = gateway,
.is_websocket = true,
.ws_pre_handshake_cb = websocket_pre_handshake,
};
esp_err_t result = httpd_register_uri_handler(server, &websocket);
if (result == ESP_OK) {
ESP_LOGI(TAG, "WebSocket gateway ready: connections=%u, message=%u, queue=%u",
(unsigned int)MAZE32_GATEWAY_CONNECTION_LIMIT,
(unsigned int)MAZE32_PROTOCOL_MESSAGE_LIMIT,
(unsigned int)MAZE32_GATEWAY_QUEUE_LIMIT);
}
return result;
}
void maze32_gateway_close_socket(httpd_handle_t server, int socket_fd) {
maze32_gateway_t* gateway = httpd_get_global_user_ctx(server);
if (gateway != NULL) {
maze32_gateway_connection_t* connection = find_connection(gateway, socket_fd);
if (connection != NULL) {
maze32_lobby_disconnect(&gateway->lobby, socket_fd);
memset(connection, 0, sizeof(*connection));
broadcast_snapshot(gateway);
drain_queue(gateway);
ESP_LOGI(TAG, "WebSocket disconnected: fd=%d", socket_fd);
}
}
close(socket_fd);
}
int maze32_gateway_can_reconfigure(const maze32_gateway_t* gateway) {
return gateway != NULL && maze32_lobby_can_reconfigure(&gateway->lobby);
}
+125
View File
@@ -0,0 +1,125 @@
#include "maze32_web.h"
#include <stdio.h>
#include "esp_log.h"
#define MAZE32_STATIC_CHUNK_SIZE 1024
static const char* TAG = "maze32_web";
static const char* INDEX_PATH = "/littlefs/index.html.gz";
static void preserve_static_context(void* context) {
(void)context;
}
static esp_err_t send_index(httpd_req_t* request) {
FILE* file = fopen(INDEX_PATH, "rb");
if (file == NULL) {
ESP_LOGE(TAG, "Static asset is unavailable: %s", INDEX_PATH);
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR,
"Приложение временно недоступно");
return ESP_FAIL;
}
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[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", INDEX_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);
}
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 = 9;
configuration.max_open_sockets = 9;
configuration.lru_purge_enable = false;
configuration.recv_wait_timeout = 5;
configuration.send_wait_timeout = 1;
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_index,
.user_ctx = NULL,
};
const httpd_uri_t index = {
.uri = "/index.html",
.method = HTTP_GET,
.handler = send_index,
.user_ctx = NULL,
};
const httpd_uri_t health_uri = {
.uri = "/health",
.method = HTTP_GET,
.handler = send_health,
.user_ctx = health,
};
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, &health_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.
+48 -66
View File
@@ -1,84 +1,66 @@
# Board passport # Board passport
## Identification status ## Confirmed hardware
The target PCB has not been identified from physical markings. No photograph The target contains an ESP32-C6FH4 (chip revision 0.2) with 4 MB of embedded
of the markings is available in the repository and no `sources/` directory is Flash and a 40 MHz crystal. It was identified electronically on 2026-08-27 with
present. A read-only `esptool.py flash_id` probe did identify the installed SoC `esptool.py` 4.11.0 through the native Espressif USB Serial/JTAG interface at
and flash, but it cannot identify the PCB manufacturer, board model/revision, `/dev/ttyACM0` (USB VID:PID `303a:1001`). No PSRAM was reported or is required.
built-in LED, or board-specific pinout.
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 ## Locked project baseline
markings;
- the manufacturer's product page, schematic, and pinout matching those
markings;
- the PCB/model ordering code that matches the photographs.
## Confirmed electronically The canonical build uses:
On 2026-08-27, the board appeared as `/dev/ttyACM0` with USB VID:PID - PlatformIO with `platformio/espressif32@7.0.1`;
`303a:1001` and product name `Espressif USB JTAG/serial debug unit`. - ESP-IDF;
`esptool.py` 4.11.0 reported: - 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; The PlatformIO board profile is a compatible compilation target. It is not
- 4 MB embedded flash, with 4 MB detected; evidence that the physical carrier is an Espressif ESP32-C6-DevKitM-1.
- 40 MHz crystal;
- native USB-Serial/JTAG mode.
The ESP32-C6FH4 ordering code and probe establish the 4 MB in-package flash. ## Canonical Flash layout
No PSRAM was reported. These are chip-level facts; they do not establish the
carrier PCB model or its LED and pin routing.
## 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 | 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
| PlatformIO Core | 6.1.19 | Installed locally | layout ends exactly at the confirmed 4 MB boundary. The area below `0x9000` is
| PlatformIO platform | `platformio/espressif32` 7.0.1 | Installed locally; `platformio.ini` is not version-pinned | reserved for the bootloader and partition table by the ESP-IDF layout.
| 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 current configuration is not yet a reproducible baseline: its generated ## Explicit unknowns
SDK configuration must be changed from 2 MB to the confirmed 4 MB and its
partition table must add LittleFS before firmware is uploaded.
## 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 - carrier PCB manufacturer, model, or revision;
ESP32-C6-DevKitM-1, the matching authoritative references are: - 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 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-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) - [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.
+45
View File
@@ -0,0 +1,45 @@
# Authoritative game core
Milestone 006 provides the portable movement engine and view projector. The
ESP remains the only owner of mutable game state. Object interaction, item
effects, exits, results, reconnect behavior, and browser controls remain out of
scope until their later milestones.
## 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 active radius:
`max(abs(x - player_x), abs(y - player_y)) <= radius`
The projector supports the normal radius three and vision-effect radius six.
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, meeting sequence, chunk index,
and chunk count. 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`.
Static maze objects are projected for visibility testing, but Milestone 006
does not pick up, consume, transfer, or otherwise mutate them.
+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 `128 x 64` and contain at most
8,192 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 `128 x 64`
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 64 cells of Manhattan separation between each start and its
exit. Keys are selected outside the owners' initial Chebyshev-radius-three
views. The default places eight landmark pairs and four vision-artifact pairs,
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.
+56
View File
@@ -0,0 +1,56 @@
# 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"}`.
- `suggest_name`: `{}`.
- `start`: `{}`. Only connected Player 1 may use it, and both player slots must
be connected.
- `heartbeat`: `{}` after a session has been joined or restored.
No Milestone 005 maze or movement messages are implemented by this milestone.
## Server messages
- `session`: the private role result containing `role`, `slot`, and temporary
`token`. The token is never included in a shared snapshot.
- `name_suggestion`: an available humorous Russian player name.
- `lobby`: the authoritative phase, revision, readiness, public player slots,
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.
- `ack`: successful heartbeat correlation.
- `error`: a stable English `code` and Russian player-facing `message`.
An observer session can receive snapshots but cannot issue `start`. Player
tokens reserve their original slot after disconnection so a refresh can restore
it. A disconnected observer token may be restored until its bounded slot is
reused by a new observer.
+145
View File
@@ -0,0 +1,145 @@
# 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;
- blue for Player 1 and pink for Player 2 across every screen, map object, and
message;
- green for success, yellow for warnings, red for errors, blue 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.
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 reduced-motion mode is not required for the MVP.
- 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.
- Dedicated sound and vibration toggles are not required for the MVP.
## 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 tablets, an on-screen directional control cluster is fixed in the
lower-right corner for right-handed use. Touch swipes remain supported.
- On phones, movement uses directional swipes and no on-screen directional
buttons are shown.
- 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.
- Connection details and the Wi-Fi settings link belong in a secondary panel,
not in the persistent status row.
- Countdown, pause, and result states appear as modal overlays above the map.
- Errors and rejected actions appear as contextual tooltips or notices near
the relevant control or status area.
- 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 player near the center of the map viewport except where maze
boundaries prevent it.
- Render unknown space as a solid background. Never infer, retain, animate, or
decorate maze geometry outside the server-provided visible projection.
- Use a nominal cell size of 20 CSS pixels on phones and 40 CSS pixels on
tablets and computers. Minor adjustment is allowed to fit browser chrome and
safe areas without making the map unusable.
- 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.
- Give immediate visual feedback when movement is rejected by a wall without
revealing hidden state.
## 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.
- 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 `128 x 64` 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.
+35 -1
View File
@@ -8,15 +8,49 @@
; Please visit documentation for the other options and examples ; Please visit documentation for the other options and examples
; https://docs.platformio.org/page/projectconf.html ; https://docs.platformio.org/page/projectconf.html
[platformio]
default_envs = esp32-c6-devkitm-1
[env:esp32-c6-devkitm-1] [env:esp32-c6-devkitm-1]
platform = espressif32 @ 7.0.1 platform = espressif32 @ 7.0.1
board = esp32-c6-devkitm-1 board = esp32-c6-devkitm-1
framework = espidf framework = espidf
board_build.flash_size = 4MB
board_build.partitions = partitions.csv board_build.partitions = partitions.csv
board_build.filesystem = littlefs board_build.filesystem = littlefs
board_build.sdkconfig_defaults = sdkconfig.defaults board_build.sdkconfig_defaults = sdkconfig.defaults
board_upload.flash_size = 4MB
lib_deps = https://github.com/joltwallet/esp_littlefs.git#v1.20.4 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_port = /dev/ttyACM0
monitor_speed = 115200 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
+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);
}
+71
View File
@@ -0,0 +1,71 @@
#!/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"
python3 "$project_directory/scripts/verify_web_assets.py"
+59
View File
@@ -0,0 +1,59 @@
#!/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
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")
index_source = (source_directory / "index.html").read_bytes()
index_compressed = (first / "index.html.gz").read_bytes()
if gzip.decompress(index_compressed) != index_source:
raise AssertionError("Compressed index does not reproduce its source")
provision_source = (source_directory / "provision.html").read_bytes()
provision_compressed = (first / "provision.html.gz").read_bytes()
if gzip.decompress(provision_compressed) != provision_source:
raise AssertionError("Compressed provisioning page does not reproduce its source")
print(f"web asset reproducibility: PASS ({first_digest})")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+237
View File
@@ -0,0 +1,237 @@
#!/usr/bin/env python3
"""Dependency-free maze32 WebSocket protocol and six-client verifier."""
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import os
import socket
import struct
import time
from typing import Any
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 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")
arguments = parser.parse_args()
if arguments.verify:
verify(arguments.host)
else:
client = WebSocket(arguments.host)
client.send("suggest_name", {}, "manual")
print(json.dumps(client.receive(), ensure_ascii=False, indent=2))
client.close()
if __name__ == "__main__":
main()
+5
View File
@@ -1 +1,6 @@
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_LWIP_MAX_SOCKETS=16 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 # CONFIG_ESPTOOLPY_FLASHFREQ_20M is not set
# default: # default:
CONFIG_ESPTOOLPY_FLASHFREQ="80m" CONFIG_ESPTOOLPY_FLASHFREQ="80m"
# default:
# CONFIG_ESPTOOLPY_FLASHSIZE_1MB is not set # CONFIG_ESPTOOLPY_FLASHSIZE_1MB is not set
# default: # CONFIG_ESPTOOLPY_FLASHSIZE_2MB is not set
CONFIG_ESPTOOLPY_FLASHSIZE_2MB=y CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
# default:
# CONFIG_ESPTOOLPY_FLASHSIZE_4MB is not set
# default:
# CONFIG_ESPTOOLPY_FLASHSIZE_8MB is not set # CONFIG_ESPTOOLPY_FLASHSIZE_8MB is not set
# default:
# CONFIG_ESPTOOLPY_FLASHSIZE_16MB is not set # CONFIG_ESPTOOLPY_FLASHSIZE_16MB is not set
# default:
# CONFIG_ESPTOOLPY_FLASHSIZE_32MB is not set # CONFIG_ESPTOOLPY_FLASHSIZE_32MB is not set
# default:
# CONFIG_ESPTOOLPY_FLASHSIZE_64MB is not set # CONFIG_ESPTOOLPY_FLASHSIZE_64MB is not set
# default:
# CONFIG_ESPTOOLPY_FLASHSIZE_128MB is not set # CONFIG_ESPTOOLPY_FLASHSIZE_128MB is not set
# default: # default:
CONFIG_ESPTOOLPY_FLASHSIZE="2MB" CONFIG_ESPTOOLPY_FLASHSIZE="4MB"
# default: # default:
# CONFIG_ESPTOOLPY_HEADER_FLASHSIZE_UPDATE is not set # CONFIG_ESPTOOLPY_HEADER_FLASHSIZE_UPDATE is not set
# default: # default:
@@ -1567,12 +1559,14 @@ CONFIG_HTTPD_ERR_RESP_NO_DELAY=y
CONFIG_HTTPD_PURGE_BUF_LEN=32 CONFIG_HTTPD_PURGE_BUF_LEN=32
# default: # default:
# CONFIG_HTTPD_LOG_PURGE_DATA is not set # CONFIG_HTTPD_LOG_PURGE_DATA is not set
# default: CONFIG_HTTPD_WS_SUPPORT=y
# CONFIG_HTTPD_WS_SUPPORT is not set
# default: # default:
# CONFIG_HTTPD_QUEUE_WORK_BLOCKING is not set # CONFIG_HTTPD_QUEUE_WORK_BLOCKING is not set
# default: # default:
CONFIG_HTTPD_SERVER_EVENT_POST_TIMEOUT=2000 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 # end of HTTP Server
# #
@@ -1950,28 +1944,17 @@ CONFIG_ESP_CRYPTO_DPA_PROTECTION_LEVEL=1
# #
# ESP-STDIO # ESP-STDIO
# #
# default: # CONFIG_ESP_CONSOLE_UART_DEFAULT is not set
CONFIG_ESP_CONSOLE_UART_DEFAULT=y CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
# default:
# CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG is not set
# default:
# CONFIG_ESP_CONSOLE_UART_CUSTOM is not set # CONFIG_ESP_CONSOLE_UART_CUSTOM is not set
# default:
# CONFIG_ESP_CONSOLE_NONE is not set # CONFIG_ESP_CONSOLE_NONE is not set
# default: CONFIG_ESP_CONSOLE_SECONDARY_NONE=y
# CONFIG_ESP_CONSOLE_SECONDARY_NONE is not set
# default:
CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y
# default: # default:
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG_ENABLED=y CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG_ENABLED=y
# default: # default:
CONFIG_ESP_CONSOLE_UART=y CONFIG_ESP_CONSOLE_UART_NUM=-1
# default: # default:
CONFIG_ESP_CONSOLE_UART_NUM=0 CONFIG_ESP_CONSOLE_ROM_SERIAL_PORT_NUM=3
# default:
CONFIG_ESP_CONSOLE_ROM_SERIAL_PORT_NUM=0
# default:
CONFIG_ESP_CONSOLE_UART_BAUDRATE=115200
# end of ESP-STDIO # end of ESP-STDIO
# #
@@ -3397,11 +3380,11 @@ CONFIG_SD_ENABLE_SDIO_SUPPORT=y
# MMU Config # MMU Config
# #
# default: # default:
CONFIG_MMU_PAGE_SIZE_32KB=y CONFIG_MMU_PAGE_SIZE_64KB=y
# default: # default:
CONFIG_MMU_PAGE_MODE="32KB" CONFIG_MMU_PAGE_MODE="64KB"
# default: # default:
CONFIG_MMU_PAGE_SIZE=0x8000 CONFIG_MMU_PAGE_SIZE=0x10000
# end of MMU Config # end of MMU Config
# #
@@ -3717,13 +3700,11 @@ CONFIG_ESP32_PHY_MAX_TX_POWER=20
# CONFIG_REDUCE_PHY_TX_POWER is not set # CONFIG_REDUCE_PHY_TX_POWER is not set
# CONFIG_ESP32_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_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_CUSTOM is not set
# CONFIG_CONSOLE_UART_NONE is not set # CONFIG_CONSOLE_UART_NONE is not set
# CONFIG_ESP_CONSOLE_UART_NONE is not set # CONFIG_ESP_CONSOLE_UART_NONE is not set
CONFIG_CONSOLE_UART=y CONFIG_CONSOLE_UART_NUM=-1
CONFIG_CONSOLE_UART_NUM=0
CONFIG_CONSOLE_UART_BAUDRATE=115200
CONFIG_ESP_SYSTEM_PMP_IDRAM_SPLIT=y CONFIG_ESP_SYSTEM_PMP_IDRAM_SPLIT=y
CONFIG_ESP_SYSTEM_MEMPROT_FEATURE_VIA_TEE=y CONFIG_ESP_SYSTEM_MEMPROT_FEATURE_VIA_TEE=y
CONFIG_ESP_SYSTEM_MEMPROT_FEATURE=y CONFIG_ESP_SYSTEM_MEMPROT_FEATURE=y
+4 -6
View File
@@ -1,6 +1,4 @@
# This file was automatically generated for projects idf_component_register(
# without default 'CMakeLists.txt' file. SRCS "main.c"
REQUIRES esp_event esp_netif esp_timer heap maze32_config maze32_game maze32_maze maze32_network maze32_platform maze32_web
FILE(GLOB_RECURSE app_sources ${CMAKE_SOURCE_DIR}/src/*.*) )
idf_component_register(SRCS ${app_sources})
+359 -1
View File
@@ -1 +1,359 @@
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_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, 3U,
&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, 0U,
&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
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;
}
+318
View File
@@ -0,0 +1,318 @@
#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);
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, 3U, &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, 3U, &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, 3U, &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);
}
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, 6U, &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, 6U, &view) ==
MAZE32_GAME_RESULT_ACCEPTED);
assert(view.cell_count == 64U);
assert(view.opponent_visible);
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, 0U, &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);
maze32_view_t view;
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, 0U, &view) ==
MAZE32_GAME_RESULT_ACCEPTED);
assert(view.cell_count == 8192U && view.object_count == 30U);
size_t chunks = maze32_game_view_chunk_count(&view);
assert(chunks == 345U);
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 (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);
}
int main(void) {
test_movement_cooldown_and_ordering();
test_bounded_queue();
test_shared_cell_meeting();
test_visibility_and_serialization();
test_default_map_chunk_bounds();
puts("game core 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;
}
+102
View File
@@ -0,0 +1,102 @@
#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* 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;
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_session_t* extra = NULL;
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);
}
int main(void) {
test_protocol_schema();
test_lobby_limits_restoration_and_authority();
puts("lobby and protocol tests: PASS");
return 0;
}
+234
View File
@@ -0,0 +1,234 @@
#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();
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;
}
+1
View File
@@ -0,0 +1 @@
0.6.0
+130
View File
@@ -0,0 +1,130 @@
<!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; font-family: ui-monospace, "Cascadia Mono", monospace; --ink:#f5edff; --bg:#090711; --panel:#1d1230; --edge:#65488a; --aqua:#42ead2; --p1:#59a9ff; --p2:#ff73bf; --ok:#72e28b; --warn:#ffd75a; --bad:#ff657a; }
* { box-sizing: border-box; }
body { min-height:100vh; margin:0; padding:max(1rem,env(safe-area-inset-top)) max(1rem,env(safe-area-inset-right)) max(1rem,env(safe-area-inset-bottom)) max(1rem,env(safe-area-inset-left)); background:radial-gradient(circle at top,#24113a,var(--bg) 55%); color:var(--ink); }
main { width:min(70rem,100%); margin:auto; }
header { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:.75rem; margin-bottom:1rem; }
h1,h2 { margin:.2rem 0; text-transform:uppercase; letter-spacing:.09em; text-shadow:2px 2px #000; }
h1 { color:var(--aqua); }
.status { padding:.45rem .7rem; border:1px solid var(--edge); background:#10091d; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(17rem,1fr)); gap:1rem; }
section { min-height:13rem; padding:1rem; border:2px solid var(--edge); box-shadow:5px 5px 0 #050307; background:var(--panel); }
.player { display:grid; grid-template-columns:2.2rem 1fr auto; gap:.7rem; align-items:center; margin:.75rem 0; padding:.75rem; border:1px solid #493563; background:#130c20; }
.p1 { border-left:5px solid var(--p1); } .p2 { border-left:5px solid var(--p2); }
.lamp { color:var(--bad); } .online .lamp { color:var(--ok); }
label { display:block; margin:.8rem 0 .3rem; }
input,button { min-height:2.8rem; border:2px solid var(--aqua); border-radius:0; background:#0c1520; color:var(--ink); font:inherit; }
input { width:100%; padding:.55rem; }
button { padding:.5rem .8rem; cursor:pointer; box-shadow:3px 3px 0 #020506; }
button:hover:not(:disabled) { background:#123a40; } button:active:not(:disabled) { transform:translate(2px,2px); box-shadow:1px 1px 0 #020506; }
button:disabled { opacity:.45; cursor:not-allowed; }
.actions { display:flex; flex-wrap:wrap; gap:.6rem; margin-top:.7rem; }
#notice { min-height:2.8rem; margin-top:1rem; padding:.7rem; border-left:4px solid var(--warn); background:#17101f; }
#notice.error { border-color:var(--bad); color:#ffc0c8; }
#notice.good { border-color:var(--ok); color:#b5ffc4; }
dialog { width:min(28rem,calc(100% - 2rem)); border:3px solid var(--aqua); background:#0b0712ef; color:var(--ink); text-align:center; }
dialog::backdrop { background:#030205d9; }
#count { margin:1rem; color:var(--warn); font-size:clamp(5rem,30vw,12rem); animation:pulse .65s steps(2,end) infinite; }
.observer-warning { display:none; color:var(--warn); }
body.observer-mode .observer-warning { display:block; }
@media (min-width:1024px) and (min-height:768px) { body.observer-mode .observer-warning { display:none; } }
@keyframes pulse { 50% { transform:scale(1.08); } }
</style>
</head>
<body>
<main>
<header><h1>maze32</h1><span class="status" id="connection">Подключаем провода к воздуху…</span></header>
<div class="grid">
<section>
<h2>Игроки</h2>
<div class="player p1" id="p1"><b>P1</b><span class="name">Свободно</span><span class="lamp">●</span></div>
<div class="player p2" id="p2"><b>P2</b><span class="name">Свободно</span><span class="lamp">●</span></div>
</section>
<section>
<h2>Наблюдатели</h2>
<p><strong id="observers">0</strong> / 4 смотрят очень внимательно.</p>
<p class="observer-warning">Для режима наблюдателя нужен экран не меньше 1024 × 768.</p>
<button id="observe">Смотреть лабиринт</button>
</section>
<section>
<h2>Пульт запуска</h2>
<div id="join">
<label for="name">Позывной игрока</label>
<input id="name" maxlength="32" autocomplete="nickname" placeholder="Например, Байт-Беглец">
<div class="actions"><button id="suggest">Подкинуть имя</button><button id="play">Занять слот</button></div>
</div>
<div class="actions"><button id="start" disabled>Начать 3-2-1</button><a href="/provision"><button>Wi‑Fi</button></a></div>
</section>
</div>
<div id="notice" role="status">Сервер решает всё. Браузер только красиво моргает.</div>
</main>
<dialog id="overlay"><h2 id="overlay-title">Приготовились!</h2><div id="count">3</div><p id="overlay-text">Сервер синхронизирует старт.</p></dialog>
<script>
(() => {
const $ = id => document.getElementById(id);
const state = { socket:null, clientSeq:0, serverSeq:0, session:null, lobby:null, reconnect:null };
const tokenKey = 'maze32.session.v1';
const notice = (text, kind='') => { $('notice').textContent=text; $('notice').className=kind; };
const send = (type,payload={},requestId=type) => {
if (!state.socket || state.socket.readyState !== WebSocket.OPEN) return;
state.socket.send(JSON.stringify({v:1,type,seq:++state.clientSeq,requestId,payload}));
};
const render = () => {
if (!state.lobby) return;
state.lobby.players.forEach(player => {
const row = $('p'+player.slot);
row.querySelector('.name').textContent = player.occupied ? player.name : 'Свободно';
row.classList.toggle('online',player.connected);
});
$('observers').textContent=state.lobby.observers;
$('join').hidden=!!state.session;
$('observe').hidden=!!state.session;
const owner=state.session?.role==='player' && state.session.slot===1;
$('start').disabled=!(owner && state.lobby.ready && state.lobby.phase==='waiting');
document.body.classList.toggle('observer-mode',state.session?.role==='observer');
};
const countdown = () => {
const dialog=$('overlay'); $('overlay-title').textContent='Приготовились!'; $('overlay-text').textContent='Один сервер — один момент старта.';
if (!dialog.open) dialog.showModal();
let value=3; $('count').textContent=value;
const timer=setInterval(() => { value--; if (value>0) $('count').textContent=value; else clearInterval(timer); },1000);
};
const handle = event => {
let message; try { message=JSON.parse(event.data); } catch { notice('Сервер прислал нечитаемый пакет.','error'); return; }
if (message.v!==1 || !Number.isInteger(message.seq) || message.seq<=state.serverSeq) return;
state.serverSeq=message.seq;
if (message.type==='session') {
state.session=message.payload; localStorage.setItem(tokenKey,message.payload.token);
notice(message.payload.role==='player' ? `Слот P${message.payload.slot} закреплён. Токен спрятан в этом браузере.` : 'Вы в зрительном зале. Руками не трогаем!','good');
} else if (message.type==='lobby') { state.lobby=message.payload; render();
} else if (message.type==='name_suggestion') { $('name').value=message.payload.name;
} else if (message.type==='countdown') { countdown();
} else if (message.type==='active') { $('count').textContent='GO'; $('overlay-title').textContent='Старт!'; $('overlay-text').textContent='Лобби активировано. Лабиринт появится в следующем этапе.'; setTimeout(()=>$('overlay').close(),1200);
} else if (message.type==='error') {
if (message.payload.code==='invalid_token') { localStorage.removeItem(tokenKey); state.session=null; render(); }
notice(message.payload.message || 'Команда отклонена сервером.','error');
}
};
const connect = () => {
clearTimeout(state.reconnect); state.clientSeq=0; state.serverSeq=0;
const scheme=location.protocol==='https:'?'wss':'ws'; state.socket=new WebSocket(`${scheme}://${location.host}/ws`);
state.socket.onopen=() => { $('connection').textContent='Связь с ESP: есть'; const token=localStorage.getItem(tokenKey); if(token) send('restore',{token},'refresh'); };
state.socket.onmessage=handle;
state.socket.onclose=() => { $('connection').textContent='Связь потеряна, переподключаемся…'; state.reconnect=setTimeout(connect,1000); };
state.socket.onerror=() => notice('WebSocket споткнулся. Пробуем снова…','error');
};
$('suggest').onclick=()=>send('suggest_name',{},'suggest');
$('play').onclick=()=>send('join',{role:'player',name:$('name').value},'join-player');
$('observe').onclick=()=>send('join',{role:'observer'},'join-observer');
$('start').onclick=()=>send('start',{},'start');
connect();
})();
</script>
</body>
</html>
+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>