Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2aaa1650ae | |||
| 5d780def9d | |||
| 996bda9567 | |||
| b10c7f5972 | |||
| 8ffd6dabcf | |||
| 9c274800b5 | |||
| dd16bc7ade |
@@ -0,0 +1,25 @@
|
||||
BasedOnStyle: LLVM
|
||||
|
||||
UseTab: Never
|
||||
IndentWidth: 4
|
||||
ContinuationIndentWidth: 4
|
||||
IndentCaseLabels: true
|
||||
|
||||
ColumnLimit: 100
|
||||
|
||||
BreakBeforeBraces: Attach
|
||||
|
||||
AllowShortFunctionsOnASingleLine: None
|
||||
AllowShortIfStatementsOnASingleLine: Never
|
||||
AllowShortLoopsOnASingleLine: false
|
||||
AllowShortBlocksOnASingleLine: Never
|
||||
|
||||
DerivePointerAlignment: false
|
||||
PointerAlignment: Left
|
||||
|
||||
SpaceBeforeParens: ControlStatements
|
||||
|
||||
AlignConsecutiveAssignments: None
|
||||
AlignConsecutiveDeclarations: None
|
||||
|
||||
SortIncludes: CaseSensitive
|
||||
@@ -12,8 +12,10 @@ This directory is a local mirror of the ChatGPT project “maze32”.
|
||||
### Language
|
||||
|
||||
- Write all canonical documentation in English.
|
||||
- Write source comments, test names, protocol identifiers, log messages, and
|
||||
user-facing diagnostic text in English.
|
||||
- Write source comments, test names, protocol identifiers, and system log
|
||||
messages in English.
|
||||
- Write all player-facing browser UI copy, game messages, and user-visible
|
||||
browser errors in Russian.
|
||||
|
||||
### Canonical documents
|
||||
|
||||
@@ -22,6 +24,8 @@ This directory is a local mirror of the ChatGPT project “maze32”.
|
||||
- `PLANS.md` defines the current implementation milestone and its evidence.
|
||||
- `docs/BOARD_PASSPORT.md` defines confirmed hardware facts and explicit
|
||||
unknowns.
|
||||
- `docs/UI_STYLE.md` defines the canonical browser visual language, responsive
|
||||
layout, controls, and presentation behavior.
|
||||
- Files under `sources/` remain read-only references and are never canonical
|
||||
implementation files.
|
||||
|
||||
@@ -44,6 +48,22 @@ Do not silently choose one requirement over another.
|
||||
- Do not mark a milestone `COMPLETE` without concrete recorded evidence for
|
||||
every acceptance criterion.
|
||||
|
||||
### Commit message handoff
|
||||
|
||||
- After completing every user prompt, provide a proposed commit message for
|
||||
the resulting changes. This is mandatory when completing a milestone.
|
||||
- Use the Conventional Commits format `type(scope): imperative summary`, or
|
||||
`type: imperative summary` when no meaningful scope exists.
|
||||
- Choose exactly one appropriate type from `feat`, `fix`, `refactor`, `perf`,
|
||||
`test`, `docs`, `style`, `build`, `ci`, `chore`, or `revert`.
|
||||
- Write the commit message in English, use an imperative summary beginning
|
||||
with a lowercase verb, keep the subject at 72 characters or fewer when
|
||||
practical, and do not end it with a period.
|
||||
- Base the message on the actual completed changes and milestone outcome; do
|
||||
not use vague summaries such as `update files` or `make changes`.
|
||||
- Present the proposed commit message in the final response, but do not create
|
||||
the Git commit unless the user explicitly requests it.
|
||||
|
||||
### Locked platform baseline
|
||||
|
||||
- Target ESP32-C6FH4 with 4 MB Flash.
|
||||
@@ -65,6 +85,16 @@ Do not silently choose one requirement over another.
|
||||
- Active game state need not survive reboot; Wi-Fi configuration must persist
|
||||
in NVS.
|
||||
|
||||
### Code style
|
||||
|
||||
- Follow the repository `.clang-format`.
|
||||
- Format all modified C and C++ files with `clang-format`.
|
||||
- Prefer simple, explicit code that is easy for beginners to read.
|
||||
- Avoid clever one-liners and unnecessary abstractions.
|
||||
- Use braces for all non-trivial control-flow blocks.
|
||||
- Prefer descriptive variable and function names.
|
||||
- Keep functions reasonably small.
|
||||
|
||||
### Build and verification
|
||||
|
||||
- Parse the project configuration with
|
||||
@@ -78,4 +108,3 @@ Do not silently choose one requirement over another.
|
||||
- Test on the physical board before recording hardware acceptance evidence.
|
||||
- Preserve exact commands and relevant outputs in the active milestone's
|
||||
evidence section.
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
can reattach to the existing slot. It is not a permanent account credential and
|
||||
does not need to survive an ESP reboot.
|
||||
|
||||
+36
-4
@@ -215,7 +215,34 @@ Browser controls must support:
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
- Build: PlatformIO, `espressif32@7.0.1`, ESP-IDF, compatible
|
||||
@@ -224,6 +251,8 @@ Browser controls must support:
|
||||
MVP.
|
||||
- Static browser assets reside in LittleFS and should be compressed for the
|
||||
production image.
|
||||
- The complete compressed web payload must not exceed 1.5 MiB and must fit the
|
||||
unchanged 1,984 KiB LittleFS partition.
|
||||
- HTTP serves the application and provisioning pages; WebSocket carries live
|
||||
lobby, control, and match messages.
|
||||
- The initial LwIP socket limit is 16.
|
||||
@@ -232,7 +261,7 @@ Browser controls must support:
|
||||
- Active lobby and match state may live in RAM and need not survive an ESP
|
||||
reboot. Wi-Fi settings must survive reboot.
|
||||
|
||||
## 12. MVP acceptance criteria
|
||||
## 13. MVP acceptance criteria
|
||||
|
||||
The MVP is accepted when all of the following are demonstrated on the target
|
||||
board:
|
||||
@@ -263,8 +292,12 @@ board:
|
||||
60-second reconnect timeout ends it as a draw.
|
||||
12. A representative match normally completes in approximately five minutes
|
||||
after balance tuning, without imposing a hard match timer.
|
||||
13. The Russian retro arcade UI follows `docs/UI_STYLE.md`, works at the
|
||||
required player and observer viewport sizes in the current stable Chrome,
|
||||
Safari, Firefox, and Edge releases, requires no runtime internet access,
|
||||
and stays within the 1.5 MiB compressed web-asset budget.
|
||||
|
||||
## 13. Explicitly outside MVP
|
||||
## 14. Explicitly outside MVP
|
||||
|
||||
- More than two active players or more than one concurrent match.
|
||||
- More than four observers.
|
||||
@@ -277,4 +310,3 @@ board:
|
||||
- Persistent match recovery after ESP reboot.
|
||||
- OTA firmware partitions and OTA update flow.
|
||||
- Dependence on PSRAM, an unverified LED, or an assumed board pinout.
|
||||
|
||||
@@ -23,8 +23,10 @@
|
||||
not yet available. Expected evidence may be listed in advance.
|
||||
11. If later work invalidates recorded evidence, reopen the affected milestone
|
||||
and change its status before continuing.
|
||||
12. All project documentation, milestone text, source comments, test names, and
|
||||
user-facing diagnostic messages are written in English.
|
||||
12. All project documentation, milestone text, source comments, test names,
|
||||
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
|
||||
|
||||
@@ -78,17 +80,19 @@ the read-only project sources.
|
||||
- `MVP.md` contains the complete English product and acceptance specification.
|
||||
- `ARCHITECTURE.md` contains the English runtime, component, state-machine,
|
||||
protocol, memory, failure, and verification contracts.
|
||||
- `platformio.ini` resolves the environment `esp32-c6-mini` with
|
||||
`espressif32@7.0.1`, ESP-IDF, explicit 4 MB build/upload size, `partitions.csv`,
|
||||
LittleFS, and `sdkconfig.defaults`.
|
||||
- Running `/home/sasa/.platformio/penv/bin/pio project config` successfully
|
||||
parsed and printed the canonical configuration. PlatformIO subsequently
|
||||
emitted a sandbox-local telemetry cache permission warning during process
|
||||
shutdown; it did not prevent configuration parsing.
|
||||
- Static partition arithmetic verified that the 2 MiB factory application ends
|
||||
at `0x210000` and the 1,984 KiB LittleFS partition ends at `0x400000`.
|
||||
- `sdkconfig.defaults` contains `CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y` and
|
||||
`CONFIG_LWIP_MAX_SOCKETS=16`.
|
||||
- On 2026-09-03, `/home/sasa/.platformio/penv/bin/pio project config` exited
|
||||
successfully and resolved environment `esp32-c6-devkitm-1` with
|
||||
`espressif32@7.0.1`, ESP-IDF, explicit 4 MB build/upload sizes,
|
||||
`partitions.csv`, LittleFS, and `sdkconfig.defaults`. PlatformIO subsequently
|
||||
emitted a telemetry-cache permission warning during process shutdown; it did
|
||||
not prevent configuration parsing.
|
||||
- On 2026-09-03, an `awk` continuity and end-address check over
|
||||
`partitions.csv` reported the factory partition ending at `0x210000`, the
|
||||
LittleFS partition ending at `0x400000`, and no gaps or overlaps from NVS
|
||||
through the end of Flash.
|
||||
- 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,
|
||||
no-required-PSRAM baseline and the remaining explicit hardware unknowns.
|
||||
- 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
|
||||
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
|
||||
|
||||
**Status:** `NOT_STARTED`
|
||||
**Status:** `COMPLETE`
|
||||
|
||||
### Objective
|
||||
|
||||
@@ -138,13 +148,52 @@ builds reproducibly with the locked platform and partition configuration.
|
||||
|
||||
### Evidence
|
||||
|
||||
Completion evidence is not yet available because this milestone has not
|
||||
started. Required evidence includes clean build output, image sizes, asset
|
||||
reproducibility checks, and a target-board boot log.
|
||||
- On 2026-09-03, `/home/sasa/.platformio/penv/bin/pio project config` parsed
|
||||
the updated configuration successfully with the locked platform, ESP-IDF,
|
||||
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
|
||||
|
||||
**Status:** `NOT_STARTED`
|
||||
**Status:** `COMPLETE`
|
||||
|
||||
### Objective
|
||||
|
||||
@@ -176,13 +225,60 @@ access-point provisioning flow.
|
||||
|
||||
### Evidence
|
||||
|
||||
Completion evidence is not yet available because this milestone has not
|
||||
started. Required evidence includes NVS tests and target-board logs for first
|
||||
boot, successful station connection, reboot persistence, and fallback recovery.
|
||||
- On 2026-09-03, `./scripts/run_host_tests.sh` reported
|
||||
`health state tests: PASS`, `wifi configuration tests: PASS`,
|
||||
`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
|
||||
|
||||
**Status:** `NOT_STARTED`
|
||||
**Status:** `COMPLETE`
|
||||
|
||||
### Objective
|
||||
|
||||
@@ -216,13 +312,61 @@ observers, and an owner-controlled lobby.
|
||||
|
||||
### Evidence
|
||||
|
||||
Completion evidence is not yet available because this milestone has not
|
||||
started. Required evidence includes protocol-test output, six-client connection
|
||||
results, session restoration tests, and lobby authorization tests.
|
||||
- On 2026-09-04, `./scripts/run_host_tests.sh` reported
|
||||
`health state tests: PASS`, `wifi configuration tests: PASS`,
|
||||
`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
|
||||
|
||||
**Status:** `NOT_STARTED`
|
||||
**Status:** `COMPLETE`
|
||||
|
||||
### Objective
|
||||
|
||||
@@ -256,13 +400,70 @@ object placement and bounded memory use.
|
||||
|
||||
### Evidence
|
||||
|
||||
Completion evidence is not yet available because this milestone has not
|
||||
started. Required evidence includes deterministic seed-test results, validator
|
||||
statistics, memory measurements, and representative generated maps.
|
||||
- On 2026-09-04, `/home/sasa/.platformio/penv/bin/pio project config` exited
|
||||
successfully and resolved the production, Milestone 003 device-test, and
|
||||
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
|
||||
|
||||
**Status:** `NOT_STARTED`
|
||||
**Status:** `COMPLETE`
|
||||
|
||||
### Objective
|
||||
|
||||
@@ -296,9 +497,66 @@ server-enforced role-specific visibility.
|
||||
|
||||
### Evidence
|
||||
|
||||
Completion evidence is not yet available because this milestone has not
|
||||
started. Required evidence includes host-test output, serialized payload
|
||||
inspection, and deterministic command-order tests.
|
||||
- On 2026-09-04,
|
||||
`PLATFORMIO_SETTING_ENABLE_TELEMETRY=no /home/sasa/.platformio/penv/bin/pio
|
||||
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
|
||||
|
||||
@@ -350,15 +608,20 @@ into the browser.
|
||||
### Scope
|
||||
|
||||
- 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.
|
||||
- Keyboard movement and touchscreen swipes.
|
||||
- Keyboard movement, touchscreen swipes, and responsive on-screen arcade
|
||||
controls.
|
||||
- Accessible drop-key action.
|
||||
- Inventory, vision duration, compass hint, connection, and error feedback.
|
||||
- Default sound, supported-device vibration, and animated game feedback.
|
||||
- Session restoration after page refresh.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- 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.
|
||||
- Accessibility and viewport behavior checks.
|
||||
|
||||
@@ -369,7 +632,17 @@ into the browser.
|
||||
3. Hidden and previously seen cells are not retained on screen.
|
||||
4. Inventory, effects, hints, pause, and results are unambiguous.
|
||||
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
|
||||
|
||||
@@ -389,6 +662,7 @@ and reconnect behavior.
|
||||
### Scope
|
||||
|
||||
- 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.
|
||||
- Player disconnect detection and authoritative pause.
|
||||
- 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.
|
||||
5. Reconnection timeout ends the match in a draw.
|
||||
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
idf_component_register(
|
||||
SRCS "maze32_config_store.c" "maze32_wifi_config.c"
|
||||
INCLUDE_DIRS "include"
|
||||
REQUIRES nvs_flash
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
#ifndef MAZE32_CONFIG_STORE_H
|
||||
#define MAZE32_CONFIG_STORE_H
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "maze32_wifi_config.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
esp_err_t maze32_config_store_init(void);
|
||||
esp_err_t maze32_config_store_load(maze32_wifi_credentials_t *credentials);
|
||||
esp_err_t maze32_config_store_save(const maze32_wifi_credentials_t *credentials);
|
||||
esp_err_t maze32_config_store_clear(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,45 @@
|
||||
#ifndef MAZE32_WIFI_CONFIG_H
|
||||
#define MAZE32_WIFI_CONFIG_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define MAZE32_WIFI_CONFIG_SCHEMA_VERSION 1U
|
||||
#define MAZE32_WIFI_SSID_MAX_BYTES 32U
|
||||
#define MAZE32_WIFI_PASSWORD_MAX_BYTES 63U
|
||||
|
||||
typedef struct {
|
||||
char ssid[MAZE32_WIFI_SSID_MAX_BYTES + 1U];
|
||||
char password[MAZE32_WIFI_PASSWORD_MAX_BYTES + 1U];
|
||||
} maze32_wifi_credentials_t;
|
||||
|
||||
typedef enum {
|
||||
MAZE32_WIFI_CONFIG_VALID = 0,
|
||||
MAZE32_WIFI_CONFIG_INVALID_SSID,
|
||||
MAZE32_WIFI_CONFIG_INVALID_PASSWORD,
|
||||
} maze32_wifi_config_validation_t;
|
||||
|
||||
typedef int (*maze32_reconfiguration_guard_fn)(void *context);
|
||||
typedef int (*maze32_credentials_save_fn)(const maze32_wifi_credentials_t *credentials,
|
||||
void *context);
|
||||
typedef int (*maze32_credentials_clear_fn)(void *context);
|
||||
|
||||
maze32_wifi_config_validation_t maze32_wifi_credentials_validate(
|
||||
const maze32_wifi_credentials_t *credentials);
|
||||
int maze32_wifi_config_apply(const maze32_wifi_credentials_t *credentials,
|
||||
maze32_reconfiguration_guard_fn guard,
|
||||
maze32_credentials_save_fn save,
|
||||
void *context);
|
||||
int maze32_wifi_config_reset(maze32_reconfiguration_guard_fn guard,
|
||||
maze32_credentials_clear_fn clear,
|
||||
void *context);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,110 @@
|
||||
#include "maze32_config_store.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include "nvs.h"
|
||||
#include "nvs_flash.h"
|
||||
|
||||
static const char *NAMESPACE = "maze32";
|
||||
static const char *SCHEMA_KEY = "cfg_ver";
|
||||
static const char *SSID_KEY = "wifi_ssid";
|
||||
static const char *PASSWORD_KEY = "wifi_pass";
|
||||
|
||||
esp_err_t maze32_config_store_init(void)
|
||||
{
|
||||
esp_err_t result = nvs_flash_init();
|
||||
if (result == ESP_ERR_NVS_NO_FREE_PAGES || result == ESP_ERR_NVS_NEW_VERSION_FOUND) {
|
||||
result = nvs_flash_erase();
|
||||
if (result == ESP_OK) {
|
||||
result = nvs_flash_init();
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
esp_err_t maze32_config_store_load(maze32_wifi_credentials_t *credentials)
|
||||
{
|
||||
if (credentials == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
nvs_handle_t handle;
|
||||
esp_err_t result = nvs_open(NAMESPACE, NVS_READONLY, &handle);
|
||||
if (result == ESP_ERR_NVS_NOT_FOUND) {
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
if (result != ESP_OK) {
|
||||
return result;
|
||||
}
|
||||
|
||||
uint32_t schema_version = 0U;
|
||||
result = nvs_get_u32(handle, SCHEMA_KEY, &schema_version);
|
||||
if (result == ESP_OK && schema_version != MAZE32_WIFI_CONFIG_SCHEMA_VERSION) {
|
||||
result = ESP_ERR_INVALID_VERSION;
|
||||
}
|
||||
|
||||
maze32_wifi_credentials_t loaded = {0};
|
||||
size_t ssid_size = sizeof(loaded.ssid);
|
||||
size_t password_size = sizeof(loaded.password);
|
||||
if (result == ESP_OK) {
|
||||
result = nvs_get_str(handle, SSID_KEY, loaded.ssid, &ssid_size);
|
||||
}
|
||||
if (result == ESP_OK) {
|
||||
result = nvs_get_str(handle, PASSWORD_KEY, loaded.password, &password_size);
|
||||
}
|
||||
nvs_close(handle);
|
||||
|
||||
if (result == ESP_ERR_NVS_NOT_FOUND) {
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
if (result == ESP_OK &&
|
||||
maze32_wifi_credentials_validate(&loaded) != MAZE32_WIFI_CONFIG_VALID) {
|
||||
result = ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (result == ESP_OK) {
|
||||
memcpy(credentials, &loaded, sizeof(*credentials));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
esp_err_t maze32_config_store_save(const maze32_wifi_credentials_t *credentials)
|
||||
{
|
||||
if (maze32_wifi_credentials_validate(credentials) != MAZE32_WIFI_CONFIG_VALID) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
nvs_handle_t handle;
|
||||
esp_err_t result = nvs_open(NAMESPACE, NVS_READWRITE, &handle);
|
||||
if (result != ESP_OK) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result = nvs_set_str(handle, SSID_KEY, credentials->ssid);
|
||||
if (result == ESP_OK) {
|
||||
result = nvs_set_str(handle, PASSWORD_KEY, credentials->password);
|
||||
}
|
||||
if (result == ESP_OK) {
|
||||
result = nvs_set_u32(handle, SCHEMA_KEY, MAZE32_WIFI_CONFIG_SCHEMA_VERSION);
|
||||
}
|
||||
if (result == ESP_OK) {
|
||||
result = nvs_commit(handle);
|
||||
}
|
||||
nvs_close(handle);
|
||||
return result;
|
||||
}
|
||||
|
||||
esp_err_t maze32_config_store_clear(void)
|
||||
{
|
||||
nvs_handle_t handle;
|
||||
esp_err_t result = nvs_open(NAMESPACE, NVS_READWRITE, &handle);
|
||||
if (result != ESP_OK) {
|
||||
return result;
|
||||
}
|
||||
result = nvs_erase_all(handle);
|
||||
if (result == ESP_OK) {
|
||||
result = nvs_commit(handle);
|
||||
}
|
||||
nvs_close(handle);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
#include "maze32_wifi_config.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
static size_t bounded_length(const char *value, size_t capacity)
|
||||
{
|
||||
size_t length = 0U;
|
||||
while (length < capacity && value[length] != '\0') {
|
||||
++length;
|
||||
}
|
||||
return length;
|
||||
}
|
||||
|
||||
static int has_control_bytes(const char *value)
|
||||
{
|
||||
const unsigned char *cursor = (const unsigned char *)value;
|
||||
while (*cursor != '\0') {
|
||||
if (*cursor < 0x20U || *cursor == 0x7fU) {
|
||||
return 1;
|
||||
}
|
||||
++cursor;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
maze32_wifi_config_validation_t maze32_wifi_credentials_validate(
|
||||
const maze32_wifi_credentials_t *credentials)
|
||||
{
|
||||
if (credentials == NULL) {
|
||||
return MAZE32_WIFI_CONFIG_INVALID_SSID;
|
||||
}
|
||||
|
||||
size_t ssid_length = bounded_length(credentials->ssid, sizeof(credentials->ssid));
|
||||
if (ssid_length == 0U || ssid_length > MAZE32_WIFI_SSID_MAX_BYTES ||
|
||||
has_control_bytes(credentials->ssid)) {
|
||||
return MAZE32_WIFI_CONFIG_INVALID_SSID;
|
||||
}
|
||||
|
||||
size_t password_length = bounded_length(credentials->password, sizeof(credentials->password));
|
||||
if (password_length > MAZE32_WIFI_PASSWORD_MAX_BYTES ||
|
||||
(password_length > 0U && password_length < 8U) ||
|
||||
has_control_bytes(credentials->password)) {
|
||||
return MAZE32_WIFI_CONFIG_INVALID_PASSWORD;
|
||||
}
|
||||
|
||||
return MAZE32_WIFI_CONFIG_VALID;
|
||||
}
|
||||
|
||||
int maze32_wifi_config_apply(const maze32_wifi_credentials_t *credentials,
|
||||
maze32_reconfiguration_guard_fn guard,
|
||||
maze32_credentials_save_fn save,
|
||||
void *context)
|
||||
{
|
||||
if (maze32_wifi_credentials_validate(credentials) != MAZE32_WIFI_CONFIG_VALID ||
|
||||
guard == NULL || save == NULL) {
|
||||
return -1;
|
||||
}
|
||||
if (guard(context) != 0) {
|
||||
return -2;
|
||||
}
|
||||
return save(credentials, context) == 0 ? 0 : -3;
|
||||
}
|
||||
|
||||
int maze32_wifi_config_reset(maze32_reconfiguration_guard_fn guard,
|
||||
maze32_credentials_clear_fn clear,
|
||||
void *context)
|
||||
{
|
||||
if (guard == NULL || clear == NULL) {
|
||||
return -1;
|
||||
}
|
||||
if (guard(context) != 0) {
|
||||
return -2;
|
||||
}
|
||||
return clear(context) == 0 ? 0 : -3;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
idf_component_register(
|
||||
SRCS "maze32_game.c"
|
||||
INCLUDE_DIRS "include"
|
||||
REQUIRES maze32_maze
|
||||
)
|
||||
@@ -0,0 +1,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
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -1,84 +1,66 @@
|
||||
# Board passport
|
||||
|
||||
## Identification status
|
||||
## Confirmed hardware
|
||||
|
||||
The target PCB has not been identified from physical markings. No photograph
|
||||
of the markings is available in the repository and no `sources/` directory is
|
||||
present. A read-only `esptool.py flash_id` probe did identify the installed SoC
|
||||
and flash, but it cannot identify the PCB manufacturer, board model/revision,
|
||||
built-in LED, or board-specific pinout.
|
||||
The target contains an ESP32-C6FH4 (chip revision 0.2) with 4 MB of embedded
|
||||
Flash and a 40 MHz crystal. It was identified electronically on 2026-08-27 with
|
||||
`esptool.py` 4.11.0 through the native Espressif USB Serial/JTAG interface at
|
||||
`/dev/ttyACM0` (USB VID:PID `303a:1001`). No PSRAM was reported or is required.
|
||||
|
||||
Required evidence to unblock Milestone 000:
|
||||
These observations establish chip-level facts only. The carrier PCB maker,
|
||||
commercial model, revision, GPIO routing, and any built-in LED remain unknown.
|
||||
Firmware must not depend on those unverified details.
|
||||
|
||||
- clear photographs of both sides of the board, including all PCB and module
|
||||
markings;
|
||||
- the manufacturer's product page, schematic, and pinout matching those
|
||||
markings;
|
||||
- the PCB/model ordering code that matches the photographs.
|
||||
## Locked project baseline
|
||||
|
||||
## Confirmed electronically
|
||||
The canonical build uses:
|
||||
|
||||
On 2026-08-27, the board appeared as `/dev/ttyACM0` with USB VID:PID
|
||||
`303a:1001` and product name `Espressif USB JTAG/serial debug unit`.
|
||||
`esptool.py` 4.11.0 reported:
|
||||
- PlatformIO with `platformio/espressif32@7.0.1`;
|
||||
- ESP-IDF;
|
||||
- the compatible `esp32-c6-devkitm-1` build profile;
|
||||
- explicit 4 MB build and upload Flash sizes;
|
||||
- `sdkconfig.defaults` with 4 MB Flash and 16 LwIP sockets;
|
||||
- the root `partitions.csv` layout;
|
||||
- LittleFS for browser assets;
|
||||
- no OTA partition and no PSRAM requirement.
|
||||
|
||||
- ESP32-C6FH4 in QFN32, chip revision 0.2;
|
||||
- 4 MB embedded flash, with 4 MB detected;
|
||||
- 40 MHz crystal;
|
||||
- native USB-Serial/JTAG mode.
|
||||
The PlatformIO board profile is a compatible compilation target. It is not
|
||||
evidence that the physical carrier is an Espressif ESP32-C6-DevKitM-1.
|
||||
|
||||
The ESP32-C6FH4 ordering code and probe establish the 4 MB in-package flash.
|
||||
No PSRAM was reported. These are chip-level facts; they do not establish the
|
||||
carrier PCB model or its LED and pin routing.
|
||||
## Canonical Flash layout
|
||||
|
||||
## Current repository baseline (not hardware confirmation)
|
||||
| Name | Type | Subtype | Offset | Size | End |
|
||||
| ---------- | ---- | -------- | --------- | ---------- | ---------- |
|
||||
| `nvs` | data | nvs | `0x9000` | `0x6000` | `0xF000` |
|
||||
| `phy_init` | data | phy | `0xF000` | `0x1000` | `0x10000` |
|
||||
| `factory` | app | factory | `0x10000` | `0x200000` | `0x210000` |
|
||||
| `littlefs` | data | littlefs | `0x210000`| `0x1F0000` | `0x400000` |
|
||||
|
||||
| Item | Observed value | Status |
|
||||
| ---------------------------- | ------------------------------- | --------------------------------------------------------------------- |
|
||||
| PlatformIO Core | 6.1.19 | Installed locally |
|
||||
| PlatformIO platform | `platformio/espressif32` 7.0.1 | Installed locally; `platformio.ini` is not version-pinned |
|
||||
| Framework | ESP-IDF 6.0.1 | Current project setting conflicts with the Arduino-first MVP baseline |
|
||||
| Arduino Core | Not installed | Cannot be pinned and verified against this board yet |
|
||||
| PlatformIO board | `esp32-c6-devkitm-1` | Unverified candidate, not evidence of the physical model |
|
||||
| Board-profile flash size | 4 MB | Unverified; profile metadata only |
|
||||
| Detected physical flash size | 4 MB | Confirmed by `esptool.py flash_id` |
|
||||
| Generated SDK flash size | 2 MB | Conflicts with the detected hardware and board profile |
|
||||
| Generated partition table | Single application, no LittleFS | Does not meet Milestone 000 |
|
||||
The partitions are contiguous from NVS through the end of Flash. The factory
|
||||
application partition is 2 MiB, the LittleFS partition is 1,984 KiB, and the
|
||||
layout ends exactly at the confirmed 4 MB boundary. The area below `0x9000` is
|
||||
reserved for the bootloader and partition table by the ESP-IDF layout.
|
||||
|
||||
The current configuration is not yet a reproducible baseline: its generated
|
||||
SDK configuration must be changed from 2 MB to the confirmed 4 MB and its
|
||||
partition table must add LittleFS before firmware is uploaded.
|
||||
## Explicit unknowns
|
||||
|
||||
## Candidate reference only
|
||||
The following facts have not been verified and must not be assumed:
|
||||
|
||||
If the physical markings prove that the board is an Espressif
|
||||
ESP32-C6-DevKitM-1, the matching authoritative references are:
|
||||
- carrier PCB manufacturer, model, or revision;
|
||||
- header and peripheral GPIO mapping;
|
||||
- presence, GPIO, polarity, or type of a built-in LED;
|
||||
- whether the carrier uses native USB only or includes another USB bridge;
|
||||
- availability of optional external RAM or peripherals.
|
||||
|
||||
Clear photographs of both sides of the board and matching manufacturer
|
||||
documentation are required before adopting any carrier-specific pin mapping.
|
||||
|
||||
## Candidate documentation
|
||||
|
||||
The `esp32-c6-devkitm-1` PlatformIO profile is used for compatibility only.
|
||||
Espressif's DevKitM-1 documentation may be consulted as a candidate reference,
|
||||
but its PCB-specific details remain noncanonical until physical markings prove
|
||||
that the target is that model:
|
||||
|
||||
- [ESP32-C6-DevKitM-1 user guide](https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32c6/esp32-c6-devkitm-1/user_guide.html)
|
||||
- [ESP32-C6-DevKitM-1 schematic](https://docs.espressif.com/projects/esp-dev-kits/en/latest/_static/esp32-c6-devkitm-1/schematics/esp32-c6-devkitm-1-schematics.pdf)
|
||||
- [ESP32-C6-MINI-1 datasheet](https://www.espressif.com/sites/default/files/documentation/esp32-c6-mini-1_mini-1u_datasheet_en.pdf)
|
||||
|
||||
For that board only, Espressif documents an ESP32-C6-MINI-1(U) module with
|
||||
4 MB in-package SPI flash, no PSRAM, a USB-C connector connected through a
|
||||
USB-to-UART bridge, UART0 on GPIO16/GPIO17, native USB D-/D+ on GPIO12/GPIO13,
|
||||
and an addressable RGB LED on GPIO8. These values are deliberately not adopted
|
||||
as the target specifications until the markings match.
|
||||
|
||||
## Memory map and partition status
|
||||
|
||||
No partition CSV has been selected yet. The confirmed 4 MB capacity permits a
|
||||
layout reserving, at minimum, NVS, PHY initialization, one application
|
||||
partition, and one LittleFS data partition. Exact offsets and sizes still need
|
||||
to be defined and checked against the framework's bootloader and partition
|
||||
alignment requirements.
|
||||
|
||||
## Risks carried to the next action
|
||||
|
||||
- A generic “ESP32-C6 Mini” may not match Espressif's DevKitM-1 pinout, LED
|
||||
polarity/type, or USB bridge. The flash capacity is confirmed independently.
|
||||
- GPIO8, GPIO12, GPIO13, GPIO16, and GPIO17 must not be assigned from the
|
||||
candidate documentation until the exact board is confirmed.
|
||||
- Official `platformio/espressif32` 7.0.1 exposes the C6 DevKitM profile only
|
||||
for ESP-IDF and its bundled Arduino Core 2.0.17 does not support ESP32-C6.
|
||||
An exact Arduino-compatible PlatformIO platform/Core pairing must be chosen
|
||||
and pinned after the board is identified, then proven by Milestone 001.
|
||||
|
||||
@@ -0,0 +1,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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -8,15 +8,49 @@
|
||||
; Please visit documentation for the other options and examples
|
||||
; https://docs.platformio.org/page/projectconf.html
|
||||
|
||||
[platformio]
|
||||
default_envs = esp32-c6-devkitm-1
|
||||
|
||||
[env:esp32-c6-devkitm-1]
|
||||
platform = espressif32 @ 7.0.1
|
||||
board = esp32-c6-devkitm-1
|
||||
framework = espidf
|
||||
board_build.flash_size = 4MB
|
||||
board_build.partitions = partitions.csv
|
||||
board_build.filesystem = littlefs
|
||||
board_build.sdkconfig_defaults = sdkconfig.defaults
|
||||
board_upload.flash_size = 4MB
|
||||
lib_deps = https://github.com/joltwallet/esp_littlefs.git#v1.20.4
|
||||
extra_scripts =
|
||||
build_flags =
|
||||
-DCONFIG_LITTLEFS_MAX_PARTITIONS=1
|
||||
-DCONFIG_LITTLEFS_PAGE_SIZE=256
|
||||
-DCONFIG_LITTLEFS_OBJ_NAME_LEN=64
|
||||
-DCONFIG_LITTLEFS_READ_SIZE=128
|
||||
-DCONFIG_LITTLEFS_WRITE_SIZE=128
|
||||
-DCONFIG_LITTLEFS_LOOKAHEAD_SIZE=128
|
||||
-DCONFIG_LITTLEFS_CACHE_SIZE=512
|
||||
-DCONFIG_LITTLEFS_BLOCK_CYCLES=512
|
||||
-DCONFIG_LITTLEFS_MALLOC_STRATEGY_DEFAULT=1
|
||||
extra_scripts = post:scripts/platformio_build.py
|
||||
monitor_port = /dev/ttyACM0
|
||||
monitor_speed = 115200
|
||||
monitor_dtr = 0
|
||||
monitor_rts = 0
|
||||
|
||||
[env:milestone-003-device-test]
|
||||
extends = env:esp32-c6-devkitm-1
|
||||
build_flags =
|
||||
${env:esp32-c6-devkitm-1.build_flags}
|
||||
-DMAZE32_MILESTONE_003_DEVICE_TEST=1
|
||||
|
||||
[env:milestone-005-device-test]
|
||||
extends = env:esp32-c6-devkitm-1
|
||||
build_flags =
|
||||
${env:esp32-c6-devkitm-1.build_flags}
|
||||
-DMAZE32_MILESTONE_005_DEVICE_TEST=1
|
||||
|
||||
[env:milestone-006-device-test]
|
||||
extends = env:esp32-c6-devkitm-1
|
||||
build_flags =
|
||||
${env:esp32-c6-devkitm-1.build_flags}
|
||||
-DMAZE32_MILESTONE_006_DEVICE_TEST=1
|
||||
|
||||
Binary file not shown.
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate deterministic gzip-compressed LittleFS web assets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SUPPORTED_SUFFIXES = {".css", ".html", ".ico", ".js", ".json", ".svg"}
|
||||
|
||||
|
||||
def generate_assets(source_directory: Path, output_directory: Path) -> list[Path]:
|
||||
source_directory = source_directory.resolve()
|
||||
output_directory = output_directory.resolve()
|
||||
if not source_directory.is_dir():
|
||||
raise ValueError(f"Web source directory does not exist: {source_directory}")
|
||||
if source_directory == output_directory:
|
||||
raise ValueError("Web source and output directories must differ")
|
||||
|
||||
source_files = sorted(
|
||||
path for path in source_directory.rglob("*")
|
||||
if path.is_file() and path.suffix.lower() in SUPPORTED_SUFFIXES
|
||||
)
|
||||
if not source_files:
|
||||
raise ValueError(f"No supported web assets found in {source_directory}")
|
||||
|
||||
if output_directory.exists():
|
||||
shutil.rmtree(output_directory)
|
||||
output_directory.mkdir(parents=True)
|
||||
|
||||
generated_files: list[Path] = []
|
||||
for source_path in source_files:
|
||||
relative_path = source_path.relative_to(source_directory)
|
||||
output_path = output_directory / f"{relative_path}.gz"
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with source_path.open("rb") as source, output_path.open("wb") as output:
|
||||
with gzip.GzipFile(
|
||||
filename="",
|
||||
mode="wb",
|
||||
compresslevel=9,
|
||||
fileobj=output,
|
||||
mtime=0,
|
||||
) as compressed:
|
||||
shutil.copyfileobj(source, compressed)
|
||||
os.utime(output_path, ns=(0, 0))
|
||||
generated_files.append(output_path)
|
||||
|
||||
for directory in sorted(
|
||||
(path for path in output_directory.rglob("*") if path.is_dir()),
|
||||
reverse=True,
|
||||
):
|
||||
os.utime(directory, ns=(0, 0))
|
||||
os.utime(output_directory, ns=(0, 0))
|
||||
|
||||
return generated_files
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--source", type=Path, default=Path("web"))
|
||||
parser.add_argument("--output", type=Path, default=Path("data"))
|
||||
arguments = parser.parse_args()
|
||||
|
||||
generated_files = generate_assets(arguments.source, arguments.output)
|
||||
for path in generated_files:
|
||||
print(path)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,93 @@
|
||||
"""PlatformIO hooks for deterministic assets and partition size limits."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
Import("env")
|
||||
|
||||
|
||||
PROJECT_DIRECTORY = Path(env.subst("$PROJECT_DIR"))
|
||||
BUILD_DIRECTORY = Path(env.subst("$BUILD_DIR"))
|
||||
PARTITION_FILE = PROJECT_DIRECTORY / "partitions.csv"
|
||||
ASSET_SCRIPT = PROJECT_DIRECTORY / "scripts" / "generate_web_assets.py"
|
||||
FILESYSTEM_WRAPPER = PROJECT_DIRECTORY / "scripts" / "reproducible_mklittlefs.py"
|
||||
|
||||
|
||||
def parse_size(value: str) -> int:
|
||||
value = value.strip()
|
||||
suffixes = {"K": 1024, "M": 1024 * 1024}
|
||||
if value[-1:].upper() in suffixes:
|
||||
return int(value[:-1], 0) * suffixes[value[-1].upper()]
|
||||
return int(value, 0)
|
||||
|
||||
|
||||
def partition_sizes() -> dict[str, int]:
|
||||
sizes: dict[str, int] = {}
|
||||
with PARTITION_FILE.open(newline="", encoding="utf-8") as partition_stream:
|
||||
for row in csv.reader(partition_stream):
|
||||
if not row or row[0].lstrip().startswith("#"):
|
||||
continue
|
||||
if len(row) < 5:
|
||||
raise RuntimeError(f"Invalid partition row: {row}")
|
||||
sizes[row[0].strip()] = parse_size(row[4])
|
||||
return sizes
|
||||
|
||||
|
||||
def generate_assets(source, target, env) -> None:
|
||||
command = [
|
||||
env.subst("$PYTHONEXE"),
|
||||
str(ASSET_SCRIPT),
|
||||
"--source",
|
||||
str(PROJECT_DIRECTORY / "web"),
|
||||
"--output",
|
||||
str(PROJECT_DIRECTORY / "data"),
|
||||
]
|
||||
subprocess.run(command, check=True)
|
||||
|
||||
|
||||
def enforce_size(path: Path, partition_name: str) -> None:
|
||||
limits = partition_sizes()
|
||||
if partition_name not in limits:
|
||||
raise RuntimeError(f"Missing required partition: {partition_name}")
|
||||
actual_size = path.stat().st_size
|
||||
maximum_size = limits[partition_name]
|
||||
if actual_size > maximum_size:
|
||||
raise RuntimeError(
|
||||
f"{path.name} is {actual_size} bytes and exceeds the "
|
||||
f"{partition_name} partition limit of {maximum_size} bytes"
|
||||
)
|
||||
print(
|
||||
f"maze32 size check: {path.name} uses {actual_size}/{maximum_size} bytes "
|
||||
f"in partition {partition_name}"
|
||||
)
|
||||
|
||||
|
||||
def check_application(source, target, env) -> None:
|
||||
enforce_size(application_image, "factory")
|
||||
|
||||
|
||||
def check_filesystem(source, target, env) -> None:
|
||||
enforce_size(filesystem_image, "littlefs")
|
||||
|
||||
|
||||
filesystem_image = BUILD_DIRECTORY / "littlefs.bin"
|
||||
application_image = BUILD_DIRECTORY / f"{env.subst('$PROGNAME')}.bin"
|
||||
real_filesystem_tool = env.subst("$MKFSTOOL")
|
||||
|
||||
env["ENV"]["MAZE32_REAL_MKFSTOOL"] = real_filesystem_tool
|
||||
env["ENV"]["MAZE32_BUILD_DIR"] = str(BUILD_DIRECTORY)
|
||||
env.Replace(MKFSTOOL=str(FILESYSTEM_WRAPPER))
|
||||
|
||||
env.AddPreAction("$BUILD_DIR/littlefs.bin", generate_assets)
|
||||
env.AddPostAction("$BUILD_DIR/littlefs.bin", check_filesystem)
|
||||
env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", check_application)
|
||||
env.AddCustomTarget(
|
||||
"webassets",
|
||||
None,
|
||||
generate_assets,
|
||||
title="Generate deterministic web assets",
|
||||
description="Compress web sources into the LittleFS data directory",
|
||||
)
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run mklittlefs with a fixed wall clock for reproducible images."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def build_time_override(output_path: Path) -> None:
|
||||
source_path = Path(__file__).with_name("reproducible_time.c")
|
||||
compiler = shutil.which("cc")
|
||||
if compiler is None:
|
||||
raise RuntimeError("A host C compiler named 'cc' is required")
|
||||
subprocess.run(
|
||||
[
|
||||
compiler,
|
||||
"-shared",
|
||||
"-fPIC",
|
||||
"-O2",
|
||||
str(source_path),
|
||||
"-o",
|
||||
str(output_path),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
real_tool = os.environ.get("MAZE32_REAL_MKFSTOOL")
|
||||
build_directory = os.environ.get("MAZE32_BUILD_DIR")
|
||||
if not real_tool or not build_directory:
|
||||
raise RuntimeError("PlatformIO did not provide the filesystem tool environment")
|
||||
|
||||
override_path = Path(build_directory) / "reproducible_time.so"
|
||||
override_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
build_time_override(override_path)
|
||||
|
||||
environment = os.environ.copy()
|
||||
existing_preload = environment.get("LD_PRELOAD")
|
||||
environment["LD_PRELOAD"] = (
|
||||
f"{override_path}:{existing_preload}" if existing_preload else str(override_path)
|
||||
)
|
||||
completed = subprocess.run([real_tool, *sys.argv[1:]], env=environment)
|
||||
return completed.returncode
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,37 @@
|
||||
#define _GNU_SOURCE
|
||||
|
||||
#include <stddef.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <sys/time.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static const time_t FIXED_TIME = 0;
|
||||
|
||||
time_t time(time_t *result)
|
||||
{
|
||||
if (result != NULL) {
|
||||
*result = FIXED_TIME;
|
||||
}
|
||||
return FIXED_TIME;
|
||||
}
|
||||
|
||||
int gettimeofday(struct timeval *value, void *timezone)
|
||||
{
|
||||
(void)timezone;
|
||||
if (value != NULL) {
|
||||
value->tv_sec = FIXED_TIME;
|
||||
value->tv_usec = 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int clock_gettime(clockid_t clock_id, struct timespec *value)
|
||||
{
|
||||
if (clock_id == CLOCK_REALTIME) {
|
||||
value->tv_sec = FIXED_TIME;
|
||||
value->tv_nsec = 0;
|
||||
return 0;
|
||||
}
|
||||
return (int)syscall(SYS_clock_gettime, clock_id, value);
|
||||
}
|
||||
Executable
+71
@@ -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"
|
||||
Executable
+59
@@ -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())
|
||||
@@ -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()
|
||||
@@ -1 +1,6 @@
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
|
||||
CONFIG_LWIP_MAX_SOCKETS=16
|
||||
CONFIG_HTTPD_WS_SUPPORT=y
|
||||
CONFIG_HTTPD_WS_PRE_HANDSHAKE_CB_SUPPORT=y
|
||||
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
|
||||
CONFIG_ESP_CONSOLE_SECONDARY_NONE=y
|
||||
|
||||
@@ -1008,24 +1008,16 @@ CONFIG_ESPTOOLPY_FLASHFREQ_80M=y
|
||||
# CONFIG_ESPTOOLPY_FLASHFREQ_20M is not set
|
||||
# default:
|
||||
CONFIG_ESPTOOLPY_FLASHFREQ="80m"
|
||||
# default:
|
||||
# CONFIG_ESPTOOLPY_FLASHSIZE_1MB is not set
|
||||
# default:
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_2MB=y
|
||||
# default:
|
||||
# CONFIG_ESPTOOLPY_FLASHSIZE_4MB is not set
|
||||
# default:
|
||||
# CONFIG_ESPTOOLPY_FLASHSIZE_2MB is not set
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
|
||||
# CONFIG_ESPTOOLPY_FLASHSIZE_8MB is not set
|
||||
# default:
|
||||
# CONFIG_ESPTOOLPY_FLASHSIZE_16MB is not set
|
||||
# default:
|
||||
# CONFIG_ESPTOOLPY_FLASHSIZE_32MB is not set
|
||||
# default:
|
||||
# CONFIG_ESPTOOLPY_FLASHSIZE_64MB is not set
|
||||
# default:
|
||||
# CONFIG_ESPTOOLPY_FLASHSIZE_128MB is not set
|
||||
# default:
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE="2MB"
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE="4MB"
|
||||
# default:
|
||||
# CONFIG_ESPTOOLPY_HEADER_FLASHSIZE_UPDATE is not set
|
||||
# default:
|
||||
@@ -1567,12 +1559,14 @@ CONFIG_HTTPD_ERR_RESP_NO_DELAY=y
|
||||
CONFIG_HTTPD_PURGE_BUF_LEN=32
|
||||
# default:
|
||||
# CONFIG_HTTPD_LOG_PURGE_DATA is not set
|
||||
# default:
|
||||
# CONFIG_HTTPD_WS_SUPPORT is not set
|
||||
CONFIG_HTTPD_WS_SUPPORT=y
|
||||
# default:
|
||||
# CONFIG_HTTPD_QUEUE_WORK_BLOCKING is not set
|
||||
# default:
|
||||
CONFIG_HTTPD_SERVER_EVENT_POST_TIMEOUT=2000
|
||||
CONFIG_HTTPD_WS_PRE_HANDSHAKE_CB_SUPPORT=y
|
||||
# default:
|
||||
# CONFIG_HTTPD_WS_POST_HANDSHAKE_CB_SUPPORT is not set
|
||||
# end of HTTP Server
|
||||
|
||||
#
|
||||
@@ -1950,28 +1944,17 @@ CONFIG_ESP_CRYPTO_DPA_PROTECTION_LEVEL=1
|
||||
#
|
||||
# ESP-STDIO
|
||||
#
|
||||
# default:
|
||||
CONFIG_ESP_CONSOLE_UART_DEFAULT=y
|
||||
# default:
|
||||
# CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG is not set
|
||||
# default:
|
||||
# CONFIG_ESP_CONSOLE_UART_DEFAULT is not set
|
||||
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
|
||||
# CONFIG_ESP_CONSOLE_UART_CUSTOM is not set
|
||||
# default:
|
||||
# CONFIG_ESP_CONSOLE_NONE is not set
|
||||
# default:
|
||||
# CONFIG_ESP_CONSOLE_SECONDARY_NONE is not set
|
||||
# default:
|
||||
CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y
|
||||
CONFIG_ESP_CONSOLE_SECONDARY_NONE=y
|
||||
# default:
|
||||
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG_ENABLED=y
|
||||
# default:
|
||||
CONFIG_ESP_CONSOLE_UART=y
|
||||
CONFIG_ESP_CONSOLE_UART_NUM=-1
|
||||
# default:
|
||||
CONFIG_ESP_CONSOLE_UART_NUM=0
|
||||
# default:
|
||||
CONFIG_ESP_CONSOLE_ROM_SERIAL_PORT_NUM=0
|
||||
# default:
|
||||
CONFIG_ESP_CONSOLE_UART_BAUDRATE=115200
|
||||
CONFIG_ESP_CONSOLE_ROM_SERIAL_PORT_NUM=3
|
||||
# end of ESP-STDIO
|
||||
|
||||
#
|
||||
@@ -3397,11 +3380,11 @@ CONFIG_SD_ENABLE_SDIO_SUPPORT=y
|
||||
# MMU Config
|
||||
#
|
||||
# default:
|
||||
CONFIG_MMU_PAGE_SIZE_32KB=y
|
||||
CONFIG_MMU_PAGE_SIZE_64KB=y
|
||||
# default:
|
||||
CONFIG_MMU_PAGE_MODE="32KB"
|
||||
CONFIG_MMU_PAGE_MODE="64KB"
|
||||
# default:
|
||||
CONFIG_MMU_PAGE_SIZE=0x8000
|
||||
CONFIG_MMU_PAGE_SIZE=0x10000
|
||||
# end of MMU Config
|
||||
|
||||
#
|
||||
@@ -3717,13 +3700,11 @@ CONFIG_ESP32_PHY_MAX_TX_POWER=20
|
||||
# CONFIG_REDUCE_PHY_TX_POWER is not set
|
||||
# CONFIG_ESP32_REDUCE_PHY_TX_POWER is not set
|
||||
CONFIG_ESP_SYSTEM_PM_POWER_DOWN_CPU=y
|
||||
CONFIG_CONSOLE_UART_DEFAULT=y
|
||||
# CONFIG_CONSOLE_UART_DEFAULT is not set
|
||||
# CONFIG_CONSOLE_UART_CUSTOM is not set
|
||||
# CONFIG_CONSOLE_UART_NONE is not set
|
||||
# CONFIG_ESP_CONSOLE_UART_NONE is not set
|
||||
CONFIG_CONSOLE_UART=y
|
||||
CONFIG_CONSOLE_UART_NUM=0
|
||||
CONFIG_CONSOLE_UART_BAUDRATE=115200
|
||||
CONFIG_CONSOLE_UART_NUM=-1
|
||||
CONFIG_ESP_SYSTEM_PMP_IDRAM_SPLIT=y
|
||||
CONFIG_ESP_SYSTEM_MEMPROT_FEATURE_VIA_TEE=y
|
||||
CONFIG_ESP_SYSTEM_MEMPROT_FEATURE=y
|
||||
|
||||
+4
-6
@@ -1,6 +1,4 @@
|
||||
# This file was automatically generated for projects
|
||||
# without default 'CMakeLists.txt' file.
|
||||
|
||||
FILE(GLOB_RECURSE app_sources ${CMAKE_SOURCE_DIR}/src/*.*)
|
||||
|
||||
idf_component_register(SRCS ${app_sources})
|
||||
idf_component_register(
|
||||
SRCS "main.c"
|
||||
REQUIRES esp_event esp_netif esp_timer heap maze32_config maze32_game maze32_maze maze32_network maze32_platform maze32_web
|
||||
)
|
||||
|
||||
+359
-1
@@ -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));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
#include "nvs.h"
|
||||
#include "nvs_flash.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
static int initialized;
|
||||
static int present;
|
||||
static uint32_t schema;
|
||||
static char ssid[33];
|
||||
static char password[64];
|
||||
|
||||
esp_err_t nvs_flash_init(void)
|
||||
{
|
||||
initialized = 1;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t nvs_flash_deinit(void)
|
||||
{
|
||||
initialized = 0;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t nvs_flash_erase(void)
|
||||
{
|
||||
present = 0;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t nvs_open(const char *namespace_name, int mode, nvs_handle_t *handle)
|
||||
{
|
||||
if (!initialized || strcmp(namespace_name, "maze32") != 0) return ESP_FAIL;
|
||||
if (mode == NVS_READONLY && !present) return ESP_ERR_NVS_NOT_FOUND;
|
||||
*handle = 1U;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void nvs_close(nvs_handle_t handle)
|
||||
{
|
||||
(void)handle;
|
||||
}
|
||||
|
||||
esp_err_t nvs_get_u32(nvs_handle_t handle, const char *key, uint32_t *value)
|
||||
{
|
||||
(void)handle;
|
||||
if (!present || strcmp(key, "cfg_ver") != 0) return ESP_ERR_NVS_NOT_FOUND;
|
||||
*value = schema;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t nvs_set_u32(nvs_handle_t handle, const char *key, uint32_t value)
|
||||
{
|
||||
(void)handle;
|
||||
if (strcmp(key, "cfg_ver") != 0) return ESP_FAIL;
|
||||
schema = value;
|
||||
present = 1;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t nvs_get_str(nvs_handle_t handle, const char *key, char *output, size_t *length)
|
||||
{
|
||||
(void)handle;
|
||||
const char *source = strcmp(key, "wifi_ssid") == 0 ? ssid :
|
||||
strcmp(key, "wifi_pass") == 0 ? password : NULL;
|
||||
if (!present || source == NULL) return ESP_ERR_NVS_NOT_FOUND;
|
||||
size_t required = strlen(source) + 1U;
|
||||
if (*length < required) return ESP_FAIL;
|
||||
memcpy(output, source, required);
|
||||
*length = required;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t nvs_set_str(nvs_handle_t handle, const char *key, const char *value)
|
||||
{
|
||||
(void)handle;
|
||||
char *destination = strcmp(key, "wifi_ssid") == 0 ? ssid :
|
||||
strcmp(key, "wifi_pass") == 0 ? password : NULL;
|
||||
size_t capacity = strcmp(key, "wifi_ssid") == 0 ? sizeof(ssid) : sizeof(password);
|
||||
if (destination == NULL || strlen(value) >= capacity) return ESP_FAIL;
|
||||
memcpy(destination, value, strlen(value) + 1U);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t nvs_erase_all(nvs_handle_t handle)
|
||||
{
|
||||
(void)handle;
|
||||
present = 0;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t nvs_commit(nvs_handle_t handle)
|
||||
{
|
||||
(void)handle;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void fake_nvs_set_schema(uint32_t version)
|
||||
{
|
||||
schema = version;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#ifndef TEST_ESP_ERR_H
|
||||
#define TEST_ESP_ERR_H
|
||||
|
||||
typedef int esp_err_t;
|
||||
|
||||
#define ESP_OK 0
|
||||
#define ESP_FAIL 0x101
|
||||
#define ESP_ERR_NO_MEM 0x102
|
||||
#define ESP_ERR_INVALID_ARG 0x103
|
||||
#define ESP_ERR_INVALID_STATE 0x104
|
||||
#define ESP_ERR_INVALID_VERSION 0x105
|
||||
#define ESP_ERR_NOT_FOUND 0x106
|
||||
#define ESP_ERR_NVS_NOT_FOUND 0x1102
|
||||
#define ESP_ERR_NVS_NO_FREE_PAGES 0x110d
|
||||
#define ESP_ERR_NVS_NEW_VERSION_FOUND 0x1110
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,25 @@
|
||||
#ifndef TEST_NVS_H
|
||||
#define TEST_NVS_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
typedef unsigned int nvs_handle_t;
|
||||
|
||||
#define NVS_READONLY 0
|
||||
#define NVS_READWRITE 1
|
||||
|
||||
esp_err_t nvs_open(const char *namespace_name, int mode, nvs_handle_t *handle);
|
||||
void nvs_close(nvs_handle_t handle);
|
||||
esp_err_t nvs_get_u32(nvs_handle_t handle, const char *key, uint32_t *value);
|
||||
esp_err_t nvs_set_u32(nvs_handle_t handle, const char *key, uint32_t value);
|
||||
esp_err_t nvs_get_str(nvs_handle_t handle, const char *key, char *output, size_t *length);
|
||||
esp_err_t nvs_set_str(nvs_handle_t handle, const char *key, const char *value);
|
||||
esp_err_t nvs_erase_all(nvs_handle_t handle);
|
||||
esp_err_t nvs_commit(nvs_handle_t handle);
|
||||
|
||||
void fake_nvs_set_schema(uint32_t version);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,10 @@
|
||||
#ifndef TEST_NVS_FLASH_H
|
||||
#define TEST_NVS_FLASH_H
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
esp_err_t nvs_flash_init(void);
|
||||
esp_err_t nvs_flash_erase(void);
|
||||
esp_err_t nvs_flash_deinit(void);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,45 @@
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "maze32_config_store.h"
|
||||
#include "nvs.h"
|
||||
#include "nvs_flash.h"
|
||||
|
||||
static maze32_wifi_credentials_t credentials(const char *ssid, const char *password)
|
||||
{
|
||||
maze32_wifi_credentials_t value = {0};
|
||||
memcpy(value.ssid, ssid, strlen(ssid) + 1U);
|
||||
memcpy(value.password, password, strlen(password) + 1U);
|
||||
return value;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
assert(maze32_config_store_init() == ESP_OK);
|
||||
maze32_wifi_credentials_t loaded = {0};
|
||||
assert(maze32_config_store_load(&loaded) == ESP_ERR_NOT_FOUND);
|
||||
|
||||
maze32_wifi_credentials_t saved = credentials("PersistentNetwork", "safe-passphrase");
|
||||
assert(maze32_config_store_save(&saved) == ESP_OK);
|
||||
assert(nvs_flash_deinit() == ESP_OK);
|
||||
assert(maze32_config_store_init() == ESP_OK);
|
||||
assert(maze32_config_store_load(&loaded) == ESP_OK);
|
||||
assert(strcmp(loaded.ssid, saved.ssid) == 0);
|
||||
assert(strcmp(loaded.password, saved.password) == 0);
|
||||
|
||||
maze32_wifi_credentials_t invalid = credentials("PersistentNetwork", "short");
|
||||
assert(maze32_config_store_save(&invalid) == ESP_ERR_INVALID_ARG);
|
||||
memset(&loaded, 0, sizeof(loaded));
|
||||
assert(maze32_config_store_load(&loaded) == ESP_OK);
|
||||
assert(strcmp(loaded.password, saved.password) == 0);
|
||||
|
||||
fake_nvs_set_schema(MAZE32_WIFI_CONFIG_SCHEMA_VERSION + 1U);
|
||||
assert(maze32_config_store_load(&loaded) == ESP_ERR_INVALID_VERSION);
|
||||
fake_nvs_set_schema(MAZE32_WIFI_CONFIG_SCHEMA_VERSION);
|
||||
|
||||
assert(maze32_config_store_clear() == ESP_OK);
|
||||
assert(maze32_config_store_load(&loaded) == ESP_ERR_NOT_FOUND);
|
||||
puts("configuration NVS tests: PASS");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#include "maze32_health.h"
|
||||
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
int main(void)
|
||||
{
|
||||
maze32_health_t health;
|
||||
maze32_health_init(&health, "test-version");
|
||||
|
||||
assert(strcmp(health.version, "test-version") == 0);
|
||||
assert(health.state == MAZE32_HEALTH_BOOTING);
|
||||
assert(strcmp(maze32_health_state_name(health.state), "booting") == 0);
|
||||
assert(maze32_health_is_healthy(&health) == 0);
|
||||
|
||||
maze32_health_set(&health, MAZE32_HEALTH_FILESYSTEM_READY, 0);
|
||||
assert(strcmp(maze32_health_state_name(health.state), "filesystem_ready") == 0);
|
||||
assert(maze32_health_is_healthy(&health) == 0);
|
||||
|
||||
maze32_health_set(&health, MAZE32_HEALTH_SERVING, 0);
|
||||
assert(strcmp(maze32_health_state_name(health.state), "serving") == 0);
|
||||
assert(maze32_health_is_healthy(&health) == 1);
|
||||
|
||||
maze32_health_set(&health, MAZE32_HEALTH_ERROR, 17);
|
||||
assert(strcmp(maze32_health_state_name(health.state), "error") == 0);
|
||||
assert(health.error_code == 17);
|
||||
assert(maze32_health_is_healthy(&health) == 0);
|
||||
assert(strcmp(maze32_health_state_name((maze32_health_state_t)99), "unknown") == 0);
|
||||
|
||||
printf("health state tests: PASS\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "maze32_provisioning_protocol.h"
|
||||
|
||||
static void accept(const char *json, const char *ssid, const char *password)
|
||||
{
|
||||
maze32_wifi_credentials_t credentials;
|
||||
assert(maze32_provisioning_parse_credentials(json, strlen(json), &credentials));
|
||||
assert(strcmp(credentials.ssid, ssid) == 0);
|
||||
assert(strcmp(credentials.password, password) == 0);
|
||||
}
|
||||
|
||||
static void reject(const char *json)
|
||||
{
|
||||
maze32_wifi_credentials_t credentials;
|
||||
assert(!maze32_provisioning_parse_credentials(json, strlen(json), &credentials));
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
accept("{\"ssid\":\"Maze WiFi\",\"password\":\"password8\"}",
|
||||
"Maze WiFi", "password8");
|
||||
accept(" { \"password\" : \"quote\\\"pass\" , \"ssid\" : \"Maze\" } ",
|
||||
"Maze", "quote\"pass");
|
||||
accept("{\"ssid\":\"Open\",\"password\":\"\"}", "Open", "");
|
||||
|
||||
reject("{}");
|
||||
reject("{\"ssid\":\"Maze\"}");
|
||||
reject("{\"ssid\":\"Maze\",\"password\":\"short\"}");
|
||||
reject("{\"ssid\":\"Maze\",\"password\":\"password8\",\"extra\":1}");
|
||||
reject("{\"ssid\":12,\"password\":\"password8\"}");
|
||||
reject("{\"ssid\":\"Maze\\nBad\",\"password\":\"password8\"}");
|
||||
reject("{\"ssid\":\"Maze\",\"ssid\":\"Again\"}");
|
||||
reject("not-json");
|
||||
|
||||
puts("provisioning protocol tests: PASS");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "maze32_wifi_config.h"
|
||||
|
||||
typedef struct {
|
||||
int guard_result;
|
||||
int guard_calls;
|
||||
int save_calls;
|
||||
int clear_calls;
|
||||
maze32_wifi_credentials_t saved;
|
||||
} fixture_t;
|
||||
|
||||
static int guard(void *context)
|
||||
{
|
||||
fixture_t *fixture = context;
|
||||
++fixture->guard_calls;
|
||||
return fixture->guard_result;
|
||||
}
|
||||
|
||||
static int save(const maze32_wifi_credentials_t *credentials, void *context)
|
||||
{
|
||||
fixture_t *fixture = context;
|
||||
++fixture->save_calls;
|
||||
fixture->saved = *credentials;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int clear(void *context)
|
||||
{
|
||||
fixture_t *fixture = context;
|
||||
++fixture->clear_calls;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static maze32_wifi_credentials_t valid_credentials(void)
|
||||
{
|
||||
maze32_wifi_credentials_t credentials = {0};
|
||||
memcpy(credentials.ssid, "Labyrinth", sizeof("Labyrinth"));
|
||||
memcpy(credentials.password, "eight888", sizeof("eight888"));
|
||||
return credentials;
|
||||
}
|
||||
|
||||
static void test_validation_boundaries(void)
|
||||
{
|
||||
maze32_wifi_credentials_t credentials = valid_credentials();
|
||||
assert(maze32_wifi_credentials_validate(&credentials) == MAZE32_WIFI_CONFIG_VALID);
|
||||
|
||||
credentials.password[0] = '\0';
|
||||
assert(maze32_wifi_credentials_validate(&credentials) == MAZE32_WIFI_CONFIG_VALID);
|
||||
|
||||
memcpy(credentials.password, "short", sizeof("short"));
|
||||
assert(maze32_wifi_credentials_validate(&credentials) ==
|
||||
MAZE32_WIFI_CONFIG_INVALID_PASSWORD);
|
||||
|
||||
credentials = valid_credentials();
|
||||
credentials.ssid[0] = '\0';
|
||||
assert(maze32_wifi_credentials_validate(&credentials) == MAZE32_WIFI_CONFIG_INVALID_SSID);
|
||||
|
||||
memset(credentials.ssid, 's', sizeof(credentials.ssid));
|
||||
assert(maze32_wifi_credentials_validate(&credentials) == MAZE32_WIFI_CONFIG_INVALID_SSID);
|
||||
|
||||
credentials = valid_credentials();
|
||||
credentials.ssid[2] = '\n';
|
||||
assert(maze32_wifi_credentials_validate(&credentials) == MAZE32_WIFI_CONFIG_INVALID_SSID);
|
||||
}
|
||||
|
||||
static void test_invalid_input_never_mutates_storage(void)
|
||||
{
|
||||
fixture_t fixture = {0};
|
||||
maze32_wifi_credentials_t credentials = valid_credentials();
|
||||
credentials.password[0] = 'x';
|
||||
credentials.password[1] = '\0';
|
||||
assert(maze32_wifi_config_apply(&credentials, guard, save, &fixture) == -1);
|
||||
assert(fixture.guard_calls == 0);
|
||||
assert(fixture.save_calls == 0);
|
||||
}
|
||||
|
||||
static void test_guard_precedes_every_mutation(void)
|
||||
{
|
||||
fixture_t fixture = {.guard_result = -1};
|
||||
maze32_wifi_credentials_t credentials = valid_credentials();
|
||||
assert(maze32_wifi_config_apply(&credentials, guard, save, &fixture) == -2);
|
||||
assert(fixture.guard_calls == 1);
|
||||
assert(fixture.save_calls == 0);
|
||||
assert(maze32_wifi_config_reset(guard, clear, &fixture) == -2);
|
||||
assert(fixture.guard_calls == 2);
|
||||
assert(fixture.clear_calls == 0);
|
||||
}
|
||||
|
||||
static void test_valid_apply_and_reset(void)
|
||||
{
|
||||
fixture_t fixture = {0};
|
||||
maze32_wifi_credentials_t credentials = valid_credentials();
|
||||
assert(maze32_wifi_config_apply(&credentials, guard, save, &fixture) == 0);
|
||||
assert(fixture.guard_calls == 1);
|
||||
assert(fixture.save_calls == 1);
|
||||
assert(strcmp(fixture.saved.ssid, credentials.ssid) == 0);
|
||||
assert(strcmp(fixture.saved.password, credentials.password) == 0);
|
||||
|
||||
assert(maze32_wifi_config_reset(guard, clear, &fixture) == 0);
|
||||
assert(fixture.guard_calls == 2);
|
||||
assert(fixture.clear_calls == 1);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
test_validation_boundaries();
|
||||
test_invalid_input_never_mutates_storage();
|
||||
test_guard_precedes_every_mutation();
|
||||
test_valid_apply_and_reset();
|
||||
puts("wifi configuration tests: PASS");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
0.6.0
|
||||
+130
@@ -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>
|
||||
@@ -0,0 +1,47 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>maze32 — настройка сети</title>
|
||||
<style>
|
||||
:root{color-scheme:dark;--bg:#09070f;--panel:#281445;--accent:#38e8d1;--ok:#68dc78;--warn:#ffd45c;--bad:#ff647c}
|
||||
*{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(circle at top,#211138,var(--bg) 58%);color:#f4efff;font:16px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace;padding:24px}
|
||||
main{width:min(620px,100%);margin:auto;background:var(--panel);border:2px solid #8058b7;box-shadow:8px 8px 0 #10071d;padding:clamp(18px,5vw,36px)}
|
||||
h1{margin:0;color:var(--accent);letter-spacing:.08em}p{margin:.55rem 0 1.3rem}.status{border-left:4px solid var(--warn);padding:.7rem 1rem;background:#160d27}
|
||||
label{display:block;margin:1rem 0 .35rem}input,select,button{width:100%;min-height:48px;border:2px solid #8058b7;background:#10091d;color:#fff;font:inherit;padding:.65rem}
|
||||
input:focus,select:focus,button:focus{outline:3px solid var(--accent);outline-offset:2px}button{margin-top:1rem;border-color:var(--accent);color:var(--accent);font-weight:700;cursor:pointer}button:disabled{opacity:.45;cursor:wait}.danger{border-color:var(--bad);color:var(--bad)}
|
||||
#message[data-kind="ok"]{color:var(--ok)}#message[data-kind="error"]{color:var(--bad)}small{color:#c9badd}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>MAZE32 // WI-FI</h1>
|
||||
<p>Поможем коробочке найти интернет. Сам лабиринт в интернет не убежит.</p>
|
||||
<div class="status" id="status">Проверяем сетевой режим…</div>
|
||||
<button id="scan" type="button">Сканировать сети</button>
|
||||
<form id="form">
|
||||
<label for="networks">Найденные сети</label>
|
||||
<select id="networks"><option value="">Выберите сеть или введите имя ниже</option></select>
|
||||
<label for="ssid">Имя сети (SSID)</label>
|
||||
<input id="ssid" name="ssid" maxlength="32" required autocomplete="off">
|
||||
<label for="password">Пароль</label>
|
||||
<input id="password" name="password" type="password" maxlength="63" autocomplete="new-password" aria-describedby="password-help">
|
||||
<small id="password-help">Оставьте пустым для открытой сети; иначе нужно 8–63 символа.</small>
|
||||
<button id="save" type="submit">Сохранить и подключиться</button>
|
||||
</form>
|
||||
<button id="reset" class="danger" type="button">Удалить настройки сети</button>
|
||||
<p id="message" role="status" aria-live="polite"></p>
|
||||
</main>
|
||||
<script>
|
||||
const $=id=>document.getElementById(id),message=(text,kind='')=>{$('message').textContent=text;$('message').dataset.kind=kind};
|
||||
async function json(url,options){const response=await fetch(url,options),body=await response.json().catch(()=>({}));if(!response.ok)throw new Error(body.error||'Устройство не ответило');return body}
|
||||
async function status(){try{const value=await json('/api/wifi/status');$('status').textContent=value.state==='provisioning'?`Точка настройки: ${value.accessPointSsid}`:value.state==='station_connected'?`Подключено. Адрес: ${value.ip}`:`Состояние: ${value.state}`}catch(error){$('status').textContent=error.message}}
|
||||
$('networks').addEventListener('change',event=>{if(event.target.value)$('ssid').value=event.target.value});
|
||||
$('scan').addEventListener('click',async()=>{const button=$('scan');button.disabled=true;message('Ищем Wi-Fi…');try{const value=await json('/api/wifi/scan'),select=$('networks');select.length=1;for(const network of value.networks){const option=document.createElement('option');option.value=network.ssid;option.textContent=`${network.ssid||'<скрытая>'} (${network.rssi} dBm${network.secure?', 🔒':''})`;select.append(option)}message(`Найдено сетей: ${value.networks.length}`,'ok')}catch(error){message(error.message,'error')}finally{button.disabled=false}});
|
||||
$('form').addEventListener('submit',async event=>{event.preventDefault();const button=$('save');button.disabled=true;message('Сохраняем настройки…');try{const value=await json('/api/wifi/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ssid:$('ssid').value,password:$('password').value})});$('password').value='';message(value.message,'ok');setTimeout(status,14000)}catch(error){message(error.message,'error')}finally{button.disabled=false}});
|
||||
$('reset').addEventListener('click',async()=>{const button=$('reset');button.disabled=true;try{const value=await json('/api/wifi/reset',{method:'POST'});$('password').value='';message(value.message,'ok');setTimeout(status,1000)}catch(error){message(error.message,'error')}finally{button.disabled=false}});
|
||||
status();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user