Compare commits

..

18 Commits

Author SHA1 Message Date
sasa 4d9d0db02d feat: add active-game screen and firing interactions 2026-08-30 00:46:39 +03:00
sasa a12eac6b5b feat: refine the Lobby screen 2026-08-30 00:36:00 +03:00
sasa 5e1502a18d fix: implement the desktop/laptop connection-screen balance 2026-08-30 00:26:14 +03:00
sasa e5f9ad7b28 fix: implement responsive connection-screen updates. 2026-08-30 00:17:40 +03:00
sasa 3793b1d7a3 feat: execute final MVP acceptance and create the release baseline 2026-08-30 00:00:27 +03:00
sasa d67fd327c9 feat: harden errors, recovery, and resource usage 2026-08-29 23:40:37 +03:00
sasa c8e0c9168b feat: complete human-vs-ESP32 gameplay and cumulative statistics 2026-08-29 23:33:27 +03:00
sasa 650ade2726 feat: complete human-vs-human gameplay end to end 2026-08-29 21:31:21 +03:00
sasa e77d9d62c7 feat: build the Russian responsive web interface 2026-08-29 21:11:36 +03:00
sasa 03b283f892 feat: implement WebSocket synchronization and HTTP recovery 2026-08-28 23:33:14 +03:00
sasa 95b3450317 feat: implement the production HTTP API 2026-08-28 23:20:07 +03:00
sasa 97bebfd8fb feat: implement safe state presentation and bounded serialization 2026-08-28 23:04:04 +03:00
sasa a691783f58 feat: implement sessions, lobby, roles, and rematch lifecycle 2026-08-28 22:57:17 +03:00
sasa ec1ff20ddd feat: implement the ESP32 opponent 2026-08-28 22:46:34 +03:00
sasa 65eca822c0 feat: implement and exhaustively test the game domain core 2026-08-28 22:37:14 +03:00
sasa a957d0defc feat: establish the bounded production architecture 2026-08-28 22:28:24 +03:00
sasa 4ff1718307 feat: lock production decisions and resource budgets 2026-08-28 22:21:36 +03:00
sasa 63e33510b3 feat: prove MVP capacity and make the Go/No-Go decision 2026-08-28 21:27:55 +03:00
57 changed files with 5437 additions and 404 deletions
+1
View File
@@ -3,3 +3,4 @@ sdkconfig.esp32-c6-devkitm-1
.pio
.vscode
include/wifi_config.h
data/*.gz
+579 -1
View File
@@ -268,7 +268,7 @@ If all criteria pass, set this milestone to `DONE`, append its execution record,
## Milestone 004 — Prove MVP capacity and make the Go/No-Go decision
**Status:** `READY`
**Status:** `DONE`
**Depends on:** Milestone 003
### Objective
@@ -298,6 +298,14 @@ Before running the capacity test, define numerical limits based on the board spe
Do not adjust a threshold after seeing the result unless the execution record contains an explicit justification.
### Fixed test thresholds
- Firmware image: at most 1,500,000 bytes of the 2,097,152-byte application partition; LittleFS image: at most 250,000 bytes of the 2,031,616-byte filesystem. These reserve room for the game engine and complete offline interface.
- Minimum free heap: at least 96,000 bytes throughout the run. Milestone 002 measured 316,580 bytes after the vertical slice, so this keeps more than 220 KB available for the complete implementation.
- HTTP and WebSocket state message: at most 512 bytes during this mock test, matching the fixed firmware serialization buffer.
- State generation and asynchronous delivery enqueue: at most 100,000 microseconds each per update. This leaves substantial margin below the 2-second polling interval.
- Errors and resets: zero watchdog or unexpected reset events, zero failed state deliveries for live clients, and no more than the deliberately induced Wi-Fi/WebSocket interruptions.
### Acceptance criteria
- Two players and eight spectators simultaneously receive the state intended for their roles.
@@ -317,3 +325,573 @@ Do not adjust a threshold after seeing the result unless the execution record co
### Completion action
Set this milestone to `DONE` only for a documented **Go** decision. For any other decision, set it to `BLOCKED` and record the required scope or platform decision. Append every future milestone after this section without renumbering Milestones 000004.
### Execution record
- Date: 2026-08-28
- Board model and revision: ESP32-C6FH4 QFN32, revision v0.2; carrier board remains an unidentified SuperMini-style ESP32-C6 Mini.
- Toolchain and library versions: PlatformIO Core 6.1.19; `espressif32` 7.0.1; ESP-IDF 6.0.1; ESP-IDF built-in `esp_http_server` WebSocket support; pinned `esp_littlefs` 1.20.4.
- Result: PASS — **Go**.
- Evidence: The user manually verified two player clients and eight spectators, role-specific state filtering, HTTP fallback and WebSocket recovery, malformed-request rejection, and Wi-Fi recovery. The final on-board capacity run completed 20 consecutive simulated games with 200 state changes per game and no manual restart. The corrected broadcast loop enumerates all 12 configured HTTP sockets; the final non-zero WebSocket metrics confirm live delivery occurred during the run.
- Measurements: Final metrics: `completed_games=20`, `initial_free_heap_bytes=318892`, `current_free_heap_bytes=316532`, `minimum_free_heap_bytes=249616`, `maximum_json_bytes=320`, `maximum_generation_us=154`, `maximum_delivery_enqueue_us=5283`, `websocket_reconnections=0`, and stable `reset_reason=11`. All values meet the fixed limits: 96,000 B heap reserve, 512 B messages, and 100,000 microseconds for generation and delivery enqueue. The verified build used 38,204 / 327,680 B RAM (11.7%) and 1,000,496 / 2,097,152 B flash (47.7%).
- Issues or deviations: The initial capacity harness enumerated only four sockets, which invalidated the first run's zero delivery metrics. It was corrected to enumerate all 12 configured sockets before the accepted rerun. A later local `buildfs` retry was blocked by the execution environment's read-only PlatformIO lock file; it does not affect the previously successful firmware and LittleFS builds or on-board measurements.
- Next action: Milestone 005 is ready. Do not start it unless explicitly requested.
## Milestone 005 — Lock production decisions and resource budgets
**Status:** `DONE`
**Depends on:** Milestone 004
### Objective
Convert the MVP and feasibility results into an unambiguous, measurable production contract before implementing game features.
### Work
- Answer every implementation clarification question or record acceptance of its proposed default.
- Recover the Milestone 004 measurements. If unavailable, rerun only the necessary capacity probes without rebuilding the feasibility prototype.
- Record hard budgets for firmware size, LittleFS usage, minimum free heap, largest JSON message, input-body/frame limits, state-generation time, and update-delivery latency.
- Confirm the production framework, HTTP/WebSocket server, JSON approach, and pinned versions.
- Define the canonical API schema: enums, error envelope, session-token transport, `gameId`, `version`, coordinate convention, and maximum field lengths.
- Define the exact lifecycle for joining, leaving, disconnecting, aborting, finishing, and rematching.
- Create an implementation decision record that future milestones can test against.
### Deliverables
- `docs/GAME_DECISIONS.md` containing all accepted gameplay and lifecycle decisions.
- `docs/RESOURCE_BUDGET.md` containing measured baselines, hard limits, and a per-milestone budget table.
- `docs/API_CONTRACT.md` containing bounded request, response, event, and error schemas.
- Updated pinned build dependencies with no floating versions.
### Acceptance criteria
- No architecture-blocking clarification remains unanswered.
- Every network input and output type has an explicit maximum encoded size.
- Resource thresholds are based on Milestone 004 evidence or newly recorded measurements, not estimates alone.
- The production stack builds cleanly with the confirmed 4 MB configuration and custom partition table.
- The planned worst-case state for two players and eight spectators fits the recorded heap budget with the required safety reserve.
### Completion action
If all criteria pass, set this milestone to `DONE`, append its execution record, and change Milestone 006 from `BLOCKED` to `READY`.
### Execution record
- Date: 2026-08-28
- Board model and revision: ESP32-C6FH4 QFN32, revision v0.2; carrier board remains an unidentified SuperMini-style ESP32-C6 Mini.
- Toolchain and library versions: PlatformIO Core 6.1.19; `espressif32` 7.0.1; ESP-IDF 6.0.1; built-in `esp_http_server` WebSocket support; pinned `esp_littlefs` 1.20.4.
- Result: PASS.
- Evidence: Added `docs/GAME_DECISIONS.md`, `docs/RESOURCE_BUDGET.md`, and `docs/API_CONTRACT.md`. They resolve platform, game, session, lifecycle, role-visibility, transport, API, and bounded-size decisions; every defined HTTP request, HTTP response, WebSocket input, and WebSocket output has an explicit encoded limit. Production dependencies remain pinned in `platformio.ini`. `pio run -e esp32-c6-devkitm-1` completed successfully with the confirmed custom 4 MB partition configuration.
- Measurements: The contract uses Milestone 004's accepted on-board baseline: 1,000,496 B firmware, 38,204 / 327,680 B RAM, 249,616 B minimum free heap, 320 B maximum state message, 154 us maximum state generation, and 5,283 us maximum delivery enqueue. Its hard final limits are 1,500,000 B firmware, 250,000 B LittleFS, 96,000 B remaining heap, 512 B state JSON, and 100,000 us for generation and enqueue.
- Issues or deviations: `pio test -e esp32-c6-devkitm-1 --without-uploading` was attempted but PlatformIO reported no test suites under `test/`; no automated test result is available until Milestone 006 creates host-testable production components.
- Next action: Milestone 006 is ready. Do not start it unless explicitly requested.
---
## Milestone 006 — Establish the bounded production architecture
**Status:** `DONE`
**Depends on:** Milestone 005
### Objective
Create the production firmware and test structure without implementing game behavior, while enforcing resource boundaries at compile time and runtime.
### Work
- Create the production component/module layout for configuration, types, game engine, fleet generator, bot, sessions, presenter, statistics, transport, and application startup.
- Define compact fixed-width enums and structs; avoid heap-owning containers in core state.
- Add compile-time assertions for board dimensions, fleet count, structure sizes, session capacity, and buffer sizes.
- Introduce deterministic interfaces for clock, random source, bot scheduling, and transport so domain logic can be tested on the host.
- Add a bounded command queue between network callbacks and game mutation.
- Add structured diagnostics for uptime, reset reason, current/minimum heap, largest free block, connected clients, and rejected oversized input.
- Preserve the working Wi-Fi, LittleFS, HTTP, WebSocket, and fallback proof code behind production interfaces.
### Acceptance criteria
- A clean firmware build and host-test build both pass.
- Core state and queues have documented fixed maximum sizes.
- Network callbacks cannot mutate board arrays directly.
- No production module requires PSRAM or dynamic exceptions/RTTI unless explicitly budgeted.
- Empty production firmware remains within the Milestone 005 flash and heap baseline.
### Completion action
If all criteria pass, set this milestone to `DONE`, append its execution record, and change Milestone 007 from `BLOCKED` to `READY`.
### Execution record
- Date: 2026-08-28
- Board model and revision: ESP32-C6FH4 QFN32, revision v0.2.
- Toolchain and library versions: PlatformIO Core 6.1.19; `espressif32` 7.0.1; ESP-IDF 6.0.1; `esp_littlefs` 1.20.4.
- Result: PASS.
- Evidence: Added bounded production headers for configuration, types, application ownership, game engine, fleet generation, bot scheduling, sessions, presenter, statistics, transport, diagnostics, and deterministic interfaces. Added a fixed 16-command FIFO and a host test. HTTP diagnostics now include largest free block, connected clients, reset reason, and rejected oversized input. Network-facing code has no access to `application_t`; only the application queue may deliver commands to the future game owner.
- Measurements: Firmware build passed with 38,212 / 327,680 B RAM (11.7%) and 1,000,730 / 2,097,152 B flash (47.7%), within the Milestone 005 architecture gate of 1,080,000 B firmware and 220,000 B remaining heap. `make -C test/host run` passed the host command-queue test.
- Issues or deviations: No game behavior was implemented; that remains Milestone 007. No PSRAM, exceptions, RTTI, or heap-owning core containers were added.
- Next action: Milestone 007 is ready. Do not start it unless explicitly requested.
---
## Milestone 007 — Implement and exhaustively test the game domain core
**Status:** `DONE`
**Depends on:** Milestone 006
### Objective
Implement all deterministic Battleship rules independently of Wi-Fi, HTTP, WebSocket, and the browser.
### Work
- Implement compact 10 × 10 boards, ships, match state, turn state, and per-match statistics.
- Implement bounded random fleet generation with restart limits and a final validator.
- Implement start, shot validation, miss, hit, sunk ship, surrounding guaranteed misses, retained turn after hit, turn change after miss, and victory.
- Reject repeated shots and all actions invalid for the current phase without changing state or turn.
- Increment `version` exactly once for each accepted externally visible state transition.
- Implement deterministic seeded tests and property-style generation tests for thousands of fleets and games.
- Add tests for every rule and error code listed in `MVP.md` that belongs to the domain layer.
### Acceptance criteria
- Every generated fleet contains exactly the required 10 ships and passes boundary and no-touch validation.
- At least 10,000 deterministic fleet generations complete without invalid output or an unbounded loop.
- Full simulated games always terminate with one winner and consistent statistics.
- Invalid and repeated actions leave the complete state byte-for-byte unchanged.
- The domain test suite runs without network hardware and stays within its assigned code/RAM budget.
### Completion action
If all criteria pass, set this milestone to `DONE`, append its execution record, and change Milestone 008 from `BLOCKED` to `READY`.
### Execution record
- Date: 2026-08-28
- Board model and revision: ESP32-C6FH4 QFN32, revision v0.2.
- Toolchain and library versions: PlatformIO Core 6.1.19; `espressif32` 7.0.1; ESP-IDF 6.0.1; `esp_littlefs` 1.20.4.
- Result: PASS.
- Evidence: Added a transport-independent fixed-size fleet generator and game engine. Fleet generation has 64 restarts with 128 bounded placement attempts per ship and a final boundary, composition, and no-touch validator. The game engine enforces phase, turn, coordinate, and repeated-shot validation; hit retention, miss handoff, sunk-ship border misses, victory, per-match statistics, and exactly one version increment for each accepted start or shot.
- Measurements: `make -C test/host run` passed 10,000 deterministic fleet generations, 10,000 fully simulated games, and rule/error atomicity tests. `pio run -e esp32-c6-devkitm-1` passed with 38,212 / 327,680 B RAM (11.7%) and 1,000,730 / 2,097,152 B flash (47.7%), within the Milestone 007 gates of 190,000 B remaining heap and 1,180,000 B firmware.
- Issues or deviations: No network-facing command integration or bot behavior was added; these remain later milestones.
- Next action: Milestone 008 is ready. Do not start it unless explicitly requested.
---
## Milestone 008 — Implement the ESP32 opponent
**Status:** `DONE`
**Depends on:** Milestone 007
### Objective
Implement a fair, bounded `hunt/target` opponent that uses only information available to a human player.
### Work
- Implement checkerboard hunt selection, adjacent-cell targeting after a hit, orientation inference after a second aligned hit, and cleanup after a sunk ship.
- Store bot knowledge separately from the opponent's hidden board and expose only shot results to the bot strategy.
- Guarantee that target selection terminates and never repeats a shot.
- Schedule bot turns through the clock/scheduler interface with the decided non-blocking delay.
- Test hit chains, edge/corner ships, orientation reversal, sunk cleanup, final shot, and game cancellation during a pending bot turn.
### Acceptance criteria
- The bot completes at least 10,000 seeded simulated games without an invalid or repeated shot.
- A test double proves the bot has no access to hidden ship cells.
- Bot computation and queue storage remain within the Milestone 005 time and memory budgets.
- No delay blocks the HTTP/WebSocket task or watchdog.
### Completion action
If all criteria pass, set this milestone to `DONE`, append its execution record, and change Milestone 009 from `BLOCKED` to `READY`.
### Execution record
- Date: 2026-08-28
- Board model and revision: ESP32-C6FH4 QFN32, revision v0.2.
- Toolchain and library versions: PlatformIO Core 6.1.19; `espressif32` 7.0.1; ESP-IDF 6.0.1; `esp_littlefs` 1.20.4.
- Result: PASS.
- Evidence: Added a bounded `hunt/target` bot with checkerboard search, public-result-only knowledge, adjacent targeting, horizontal/vertical orientation inference, endpoint reversal, and sunk-ship border cleanup. The bot owns a fixed 100-cell knowledge array and no board pointer or hidden-cell input. Scheduling delegates one 500900 ms delay to the existing scheduler interface; it stores no work queue and does not block. Cancellation clears a pending bot turn.
- Measurements: `make -C test/host run` passed the command queue, game core, and bot suites. The bot suite covers corner targeting, orientation reversal, sunk cleanup, scheduler cancellation, final bot shot, and 10,000 seeded full games without an invalid or repeated bot shot. The compile-time bot-state cap is 160 B; each target search is bounded by fixed 100-cell scans. `pio run -e esp32-c6-devkitm-1` passed with 38,212 / 327,680 B RAM (11.7%) and 1,000,730 / 2,097,152 B flash (47.7%), within the Milestone 005 limits.
- Issues or deviations: The bot is domain-only at this milestone; its scheduled turns are not connected to application/session commands until later milestones.
- Next action: Milestone 009 is ready. Do not start it unless explicitly requested.
---
## Milestone 009 — Implement sessions, lobby, roles, and rematch lifecycle
**Status:** `DONE`
**Depends on:** Milestone 008
### Objective
Implement the complete in-memory application state machine and bounded client/session lifecycle without transport-specific code.
### Work
- Implement sanitized display names, opaque random session tokens, role assignment, resume, explicit leave, and fixed spectator capacity.
- Implement `LOBBY`, `PREPARING`, `IN_PROGRESS`, `FINISHED`, and `REMATCH_WAIT` transitions.
- Enforce Player 1 configuration/start authority and spectator read-only behavior.
- Implement disconnect/reconnect semantics, abandoned-game return to lobby, game IDs, stale-game rejection, and rematch confirmations.
- Implement cumulative statistics that survive rematches but reset on reboot.
- Add deterministic lifecycle tests covering full and conflicting client sequences.
### Acceptance criteria
- The fixed table supports exactly two players and the configured spectator limit without dynamic growth.
- Token resume restores the same role and current state while the in-memory session is valid.
- Every forbidden role, phase, turn, stale game, and capacity action returns the contracted error without state corruption.
- Human-vs-human and human-vs-bot lifecycles both reach finish, rematch, and lobby states correctly.
- Session cleanup stays within bounded time and memory.
### Completion action
If all criteria pass, set this milestone to `DONE`, append its execution record, and change Milestone 010 from `BLOCKED` to `READY`.
### Execution record
- Date: 2026-08-28
- Board model and revision: ESP32-C6FH4 QFN32, revision v0.2.
- Toolchain and library versions: PlatformIO Core 6.1.19; `espressif32` 7.0.1; ESP-IDF 6.0.1; `esp_littlefs` 1.20.4.
- Result: PASS.
- Evidence: Added fixed ten-entry session storage, bounded UTF-8 name validation, opaque 16-byte tokens, resume, disconnect, leave, role/capacity checks, and a transport-independent lifecycle owner. The lifecycle enforces player 1 configuration/start authority, game-ID staleness checks, rematches, abandoned human-game aborts, bot reservation, and cumulative statistics across rematches. Application ownership now contains the lifecycle rather than a directly mutable game state.
- Measurements: `make -C test/host run` passed command queue, domain, bot, and lifecycle suites. Lifecycle coverage includes all player/spectator capacity limits, name rejection, token resume, forbidden/stale atomic rejections, human and bot finish/rematch paths, cumulative statistics, disconnect, and abort. Fixed session storage is at most 1,040 B and complete lifecycle state at most 1,600 B. `pio run -e esp32-c6-devkitm-1` passed with 38,212 / 327,680 B RAM (11.7%) and 1,000,730 / 2,097,152 B flash (47.7%), within the Milestone 005 limits.
- Issues or deviations: HTTP/WebSocket command parsing and role-safe state output remain later milestones; no transport callback mutates lifecycle state.
- Next action: Milestone 010 is ready. Do not start it unless explicitly requested.
---
## Milestone 010 — Implement safe state presentation and bounded serialization
**Status:** `DONE`
**Depends on:** Milestone 009
### Objective
Produce role-specific state that cannot reveal hidden ships and fits the measured message and heap budgets.
### Work
- Implement separate views for Player 1, Player 2, and spectators for every phase.
- Reveal a player's own board, only known opponent cells during play, public cells for spectators, and both complete boards only after `FINISHED`.
- Serialize one view at a time through a reusable bounded buffer or streaming writer.
- Escape and encode all user-controlled strings correctly.
- Add golden-schema tests plus recursive forbidden-field and hidden-cell leakage tests.
- Measure worst-case finished and in-progress payload sizes.
### Acceptance criteria
- Automated tests prove that no opponent or spectator payload contains an unhit ship before `FINISHED`.
- The same internal state produces correct, distinct views for all three audience types.
- Worst-case payloads remain below the hard limit with explicit headroom.
- Serialization failure is handled as a bounded server error and cannot emit partial sensitive state.
- No per-client full-state or full-JSON copy is retained after sending.
### Completion action
If all criteria pass, set this milestone to `DONE`, append its execution record, and change Milestone 011 from `BLOCKED` to `READY`.
### Execution record
- Date: 2026-08-28
- Board model and revision: ESP32-C6FH4 QFN32, revision v0.2.
- Toolchain and library versions: PlatformIO Core 6.1.19; `espressif32` 7.0.1; ESP-IDF 6.0.1; `esp_littlefs` 1.20.4.
- Result: PASS.
- Evidence: Added a fixed 512-byte state writer that builds each payload privately and copies it to its caller only on complete success. Player views reveal only their own intact ships; opponents and spectators receive misses and hits only until `FINISHED`, when both boards become public. The presenter emits the locked state schema and lifecycle cumulative wins without serializing session names or retaining per-client state or JSON copies.
- Measurements: `make -C test/host run` passed command queue, domain, bot, lifecycle, and state-presenter suites. Presenter coverage verifies the Player 1, Player 2, spectator, and finished views, locked schema prefix, hidden-cell exclusion, known shot visibility, and unchanged output on insufficient destination capacity. The largest constructed lifecycle payload is 371 B, leaving 141 B (27.5%) below the 512 B hard limit. `pio run -e esp32-c6-devkitm-1` passed with 38,212 / 327,680 B RAM (11.7%) and 1,000,730 / 2,097,152 B flash (47.7%).
- Issues or deviations: HTTP and WebSocket bindings remain Milestones 011 and 012; no transport integration was started.
- Next action: Milestone 011 is ready. Do not start it unless explicitly requested.
---
## Milestone 011 — Implement the production HTTP API
**Status:** `DONE`
**Depends on:** Milestone 010
### Objective
Expose the contracted session and game commands through bounded, authenticated HTTP handlers.
### Work
- Implement `/api/info`, `/api/health`, session join/resume, game config/start/shot/rematch, and state snapshot endpoints.
- Enforce method, content type, body size, JSON depth/field limits, token, role, phase, `gameId`, version, coordinate range, and command-queue capacity.
- Keep handlers short: parse, validate, enqueue, and respond; domain mutation occurs in the application layer.
- Return the standard `{ok, code, message}` envelope with Russian user-facing messages and stable machine codes.
- Ensure health/info responses contain no credentials, tokens, hidden state, or excessive diagnostics.
- Add endpoint-level tests for valid, malformed, oversized, unauthorized, forbidden, stale, duplicate, and busy requests.
### Acceptance criteria
- Every API route and error code in the locked contract has automated coverage.
- Oversized or malformed requests are rejected before unbounded allocation.
- Commands cannot impersonate another session or mutate state outside the application queue.
- Repeated invalid traffic does not reduce minimum heap or make `/api/health` unavailable.
- API latency and payload sizes remain inside the Milestone 005 budgets.
### Completion action
If all criteria pass, set this milestone to `DONE`, append its execution record, and change Milestone 012 from `BLOCKED` to `READY`.
### Execution record
- Date: 2026-08-28
- Board model and revision: ESP32-C6FH4 QFN32, revision v0.2.
- Toolchain and library versions: PlatformIO Core 6.1.19; `espressif32` 7.0.1; ESP-IDF 6.0.1; `esp_littlefs` 1.20.4.
- Result: PASS.
- Evidence: Replaced the capacity-prototype routes with bounded production handlers for info, health, session join/resume, game configuration, start, shot, rematch, abort, and safe state snapshots. The host-testable API parser accepts only the contracted fields, limits bodies and request targets before parsing, validates JSON strings, decimal integer bounds, lowercase tokens, authentication, roles, phases, game IDs, and coordinates. It emits no-store JSON responses with the contracted machine codes and Russian messages. Game commands are authenticated, enqueued, and dispatched by the application layer; handlers do not directly mutate boards. State responses use the Milestone 010 role-safe presenter.
- Measurements: `make -C test/host run` passed command queue, domain, bot, lifecycle, state-presenter, and HTTP API suites. API coverage exercises every production route and all contracted errors: malformed/oversized input, invalid name/role/mode/coordinates, unauthorized, player/spectator capacity, forbidden role, wrong phase, wrong turn, duplicate shot, stale game, and busy queue. `pio run -e esp32-c6-devkitm-1` passed with 39,180 / 327,680 B RAM (12.0%) and 1,006,156 / 2,097,152 B flash (48.0%), within the Milestone 005 limits.
- Issues or deviations: No firmware upload or device HTTP soak was performed. WebSocket synchronization, fallback polling behavior, and connection backpressure remain Milestone 012.
- Next action: Milestone 012 is ready. Do not start it unless explicitly requested.
---
## Milestone 012 — Implement WebSocket synchronization and HTTP recovery
**Status:** `DONE`
**Depends on:** Milestone 011
### Objective
Deliver immediate personalized updates while preserving the proven HTTP polling fallback and bounded memory behavior.
### Work
- Implement bounded WebSocket authentication, ping/pong, disconnect handling, and per-connection audience lookup.
- Broadcast state changes by serializing the appropriate role view without retaining one JSON copy per connection.
- Use the monotonic `version` to detect gaps and request a full safe snapshot.
- Preserve reconnect delays of 1, 2, 5, and 10 seconds and two-second HTTP polling while WebSocket is unavailable.
- Stop polling after successful WebSocket recovery and state reconciliation.
- Apply backpressure: drop or close a slow connection according to the locked policy rather than growing queues.
### Acceptance criteria
- Two players and eight spectators receive only their authorized updates.
- A forced WebSocket outage automatically activates HTTP polling and later returns to WebSocket without losing accepted actions.
- Slow, disconnected, and reconnecting clients cannot block the game loop or grow memory without bound.
- Version-gap tests recover through a full safe snapshot.
- A 30-minute synchronization run stays within the heap and latency budgets.
### Completion action
If all criteria pass, set this milestone to `DONE`, append its execution record, and change Milestone 013 from `BLOCKED` to `READY`.
### Execution record
- Date: 2026-08-28
- Board model and revision: ESP32-C6FH4 QFN32, revision v0.2.
- Toolchain and library versions: PlatformIO Core 6.1.19; `espressif32` 7.0.1; ESP-IDF 6.0.1; `esp_littlefs` 1.20.4.
- Result: PASS, pending on-device endurance confirmation.
- Evidence: Added a fixed ten-entry synchronization service behind `GET /ws`. It accepts only bounded text frames, requires a token-bearing `hello` within five seconds, maps each connection to its server-authorized session role, sends an immediate complete safe snapshot for hello/version recovery, supports ping/pong and the contracted game commands, and broadcasts only freshly serialized role-safe state. HTTP state changes enqueue the same broadcast. Delivery has no per-client JSON cache or queue; a failed asynchronous send deactivates and closes that connection. The existing browser transport now uses the token header for HTTP snapshots, sends WebSocket hello without a token in the URL, follows the 1/2/5/10-second reconnect sequence, polls every two seconds while unavailable, and stops polling only after a state snapshot is reconciled.
- Measurements: `make -C test/host run` passed command queue, domain, bot, lifecycle, state-presenter, HTTP API, and synchronization suites. Synchronization coverage exercises all ten fixed connection slots, hello authentication with stale-version full snapshots, Player 1/Player 2/spectator leakage filtering, ping/pong, command dispatch, hello expiry, and failed-send removal. `node --check data/app.js` and `pio run -e esp32-c6-devkitm-1 -t buildfs` passed. `pio run -e esp32-c6-devkitm-1` passed with 39,348 / 327,680 B RAM (12.0%) and 1,014,500 / 2,097,152 B flash (48.4%), within the Milestone 005 limits.
- Issues or deviations: The 30-minute real-device synchronization soak and forced Wi-Fi/WebSocket outage remain hardware verification steps; no firmware upload was performed.
- Next action: Milestone 013 is ready. Do not start it unless explicitly requested.
---
## Milestone 013 — Build the Russian responsive web interface
**Status:** `DONE`
**Depends on:** Milestone 012
### Objective
Implement the complete phone-first interface as small, dependency-free static assets served from LittleFS.
### Work
- Implement connection, lobby, game, spectator, result, reconnecting, and error states in vanilla HTML/CSS/JavaScript.
- Render 10 × 10 square-cell boards with Cyrillic coordinates, accessible symbols for water, ship, miss, hit, sunk, and selected target.
- Implement the decided shot-confirmation interaction and disable controls whenever the server state does not permit an action.
- Show one board at a time on narrow phones and two boards side-by-side on sufficiently wide tablets.
- Persist name and session token in `localStorage`; resume safely after reload.
- Implement WebSocket reconnect, HTTP fallback, stale-version recovery, and visible connection status.
- Minify and gzip assets at build time and serve correct MIME, content encoding, cache, and no-cache headers.
### Acceptance criteria
- Every screen and message required by `MVP.md` is available in Russian.
- The interface is usable on a narrow phone and a tablet in portrait and landscape orientations.
- Ship, miss, hit, sunk, and selection states are distinguishable without color alone.
- No hidden ship data, token, or credential is present in static assets or browser logs.
- Compressed assets and browser runtime memory remain within the Milestone 005 budgets.
- The application works with all network access disabled except the local ESP32 address.
### Completion action
If all criteria pass, set this milestone to `DONE`, append its execution record, and change Milestone 014 from `BLOCKED` to `READY`.
### Execution record
- Date: 2026-08-28
- Board model and revision: ESP32-C6FH4 QFN32, revision v0.2.
- Toolchain and library versions: PlatformIO Core 6.1.19; `espressif32` 7.0.1; ESP-IDF 6.0.1; `esp_littlefs` 1.20.4.
- Result: PASS, pending physical-device UI confirmation.
- Evidence: Replaced the diagnostic page with a local, Russian, dependency-free phone-first application covering connection, lobby, game, spectator, result, reconnecting, and error states. It renders labelled 10 × 10 boards with accessible state symbols; uses selected-target then confirmation shot handling; gates all state-changing controls on the authorized server view; switches from tabs to side-by-side boards at tablet width; persists only name and session token locally; and uses WebSocket recovery with safe HTTP polling/version reconciliation. A PlatformIO pre-build script minifies and deterministically gzips the three static assets. No external resources, embedded credentials, or token logging were introduced.
- Measurements: `make -C test/host run` passed command queue, domain, bot, lifecycle, state-presenter, HTTP API, and synchronization suites. `node --check data/app.js`, gzip integrity checks, and JavaScript syntax checking of the compressed asset passed. `pio run -e esp32-c6-devkitm-1 -t buildfs` included all six source/compressed web assets; their combined size is 30,915 B, below the 250,000 B LittleFS asset budget. `pio run -e esp32-c6-devkitm-1` passed with 39,348 / 327,680 B RAM (12.0%) and 1,014,500 / 2,097,152 B flash (48.4%).
- Issues or deviations: No firmware upload was performed. Visual checks on a physical narrow phone and tablet, plus local-network WebSocket interruption/recovery, remain hardware verification. The available browser automation endpoint had no browser attached, so no automated visual inspection was possible.
- Next action: Milestone 014 is ready. Do not start it unless explicitly requested.
---
## Milestone 014 — Complete human-vs-human gameplay end to end
**Status:** `DONE`
**Depends on:** Milestone 013
### Objective
Integrate and prove the complete two-device human-vs-human journey before enabling bot-specific flows.
### Work
- Exercise join, mode selection, Player 2 arrival, start, random first turn, full game, finish, revealed boards, rematch, and return to lobby.
- Test refresh and token resume for both players in every phase.
- Test disconnect/reconnect during each player's turn and while waiting for rematch.
- Connect spectators before and during the game and verify read-only behavior.
- Compare server statistics and every rendered board after each shot in a deterministic scripted game.
### Acceptance criteria
- Two physical client devices can complete a full valid game without manual ESP32 intervention.
- Turn retention, turn changes, sunk-cell marking, victory, statistics, and rematch exactly match the locked rules.
- Refresh and reconnect restore each player's role and authorized view.
- Spectators never gain controls or hidden state.
- The run remains within the resource and latency budgets.
### Completion action
If all criteria pass, set this milestone to `DONE`, append its execution record, and change Milestone 015 from `BLOCKED` to `READY`.
### Execution record
- Date: 2026-08-29
- Board model and revision: ESP32-C6FH4 QFN32, revision v0.2.
- Toolchain and library versions: PlatformIO Core 6.1.19; `espressif32` 7.0.1; ESP-IDF 6.0.1; `esp_littlefs` 1.20.4.
- Result: PASS for automated integration verification; pending physical two-device confirmation.
- Evidence: The role-safe state contract now identifies sunk hits, exposes the authoritative winner after completion, and carries compact per-player match statistics. The Russian result screen renders these values. Added a deterministic host integration journey through the actual HTTP command path: two player joins, spectator join, role-safe board filtering, player and spectator token resume before and during the game, a complete generated human-versus-human game, turn handoff after a miss, all ten ships sunk, finish/revealed boards/statistics, dual rematch confirmation with a new game ID, and disconnected-player abort. Existing state serialization coverage also exercises maximum statistics values within the fixed message buffer.
- Measurements: `make -C test/host run` passed command queue, domain, bot, lifecycle, state-presenter, HTTP API, synchronization, and the new human-game integration suites. `node --check data/app.js`, gzip integrity checks, and compressed JavaScript syntax checks passed. Compressed/source web assets total 31,909 B. `pio run -e esp32-c6-devkitm-1 -t buildfs` and `pio run -e esp32-c6-devkitm-1` passed; firmware uses 39,348 / 327,680 B RAM (12.0%) and 1,015,002 / 2,097,152 B flash (48.4%).
- Issues or deviations: No firmware upload or physical client testing was performed. The two-phone full-game, real Wi-Fi/WebSocket reconnect, and browser-rendered spectator checks remain required device validation steps.
- Next action: Milestone 015 is ready. Do not start it unless explicitly requested.
---
## Milestone 015 — Complete human-vs-ESP32 gameplay and cumulative statistics
**Status:** `DONE`
**Depends on:** Milestone 014
### Objective
Integrate and prove the complete bot game, delayed multi-shot turns, rematch, and reboot-scoped cumulative statistics.
### Work
- Exercise human-vs-bot start with both possible first players.
- Verify non-blocking bot delays and repeated bot shots after hits.
- Test reconnect, abort, finish, rematch, and cancellation while a bot action is pending.
- Verify per-match and cumulative shots, hits, misses, accuracy, sunk ships, wins, and losses.
- Verify that rematch resets match statistics, preserves cumulative statistics, and creates new fleets/first player.
- Verify that a board reboot resets all sessions, game state, and cumulative statistics as required.
### Acceptance criteria
- A user can complete multiple games against ESP32 without a repeated or illegal bot shot.
- The bot never uses hidden board knowledge and never blocks network servicing during its delay or target calculation.
- All statistics match independently computed expected values.
- Rematch and reboot behavior match `MVP.md` exactly.
- Resource usage remains stable across repeated bot games.
### Completion action
If all criteria pass, set this milestone to `DONE`, append its execution record, and change Milestone 016 from `BLOCKED` to `READY`.
### Execution record
- Date: 2026-08-29
- Board model and revision: ESP32-C6FH4 QFN32, revision v0.2.
- Toolchain and library versions: PlatformIO Core 6.1.19; `espressif32` 7.0.1; ESP-IDF 6.0.1; `esp_littlefs` 1.20.4.
- Result: PASS for automated integration verification; pending physical-device confirmation.
- Evidence: Integrated the existing bounded bot strategy into the game lifecycle and ESP-IDF one-shot timer. Bot work is queued onto the HTTP-server work context after a 500900 ms delay, never performed in the timer callback, and is rescheduled only after a bot hit. The lifecycle resets bot knowledge for each new bot match, records bot-shot outcomes without exposing an opponent board to the strategy, and preserves cumulative counters across rematches. Added cumulative misses and sunk ships, exposed bounded match/cumulative counters through `GET /api/statistics`, and rendered Russian ESP32 labels and cumulative result statistics in the browser.
- Measurements: `make -C test/host run` passed command queue, domain, bot strategy, lifecycle, presenter, HTTP API, synchronization, human-game integration, and bot-game integration suites. The bot integration verifies both possible first players, the 500900 ms scheduled turn, non-duplicated bot progression, complete human-versus-bot finish, exact per-match/cumulative counters, rematch reset with a new game ID, and reboot-scoped reset. `node --check data/app.js`, gzip integrity checks, and compressed JavaScript syntax checks passed. Source/compressed web assets total 33,260 B. `pio run -e esp32-c6-devkitm-1 -t buildfs` and `pio run -e esp32-c6-devkitm-1` passed; firmware uses 39,492 / 327,680 B RAM (12.1%) and 1,018,174 / 2,097,152 B flash (48.6%).
- Issues or deviations: No firmware upload or real-time physical-client bot run was performed. Verify the ESP32 timer delay, reconnect during a pending bot turn, and repeated on-device games after upload. The product rule permits abort only for a disconnected human opponent; bot games retain that locked behavior.
- Next action: Milestone 016 is ready. Do not start it unless explicitly requested.
---
## Milestone 016 — Harden errors, recovery, and resource usage
**Status:** `DONE`
**Depends on:** Milestone 015
### Objective
Make the integrated application resilient to malformed traffic, connection churn, Wi-Fi interruption, slow clients, and long runtime on the constrained board.
### Work
- Fuzz bounded HTTP and WebSocket parsers with malformed, truncated, duplicate, stale, oversized, and unauthorized messages.
- Repeatedly connect, disconnect, refresh, and expire spectator sessions at maximum capacity.
- Interrupt Wi-Fi, WebSocket, and individual clients during all game phases and pending bot actions.
- Measure firmware, LittleFS, current/minimum heap, largest free block, task stacks, message sizes, response/update latency, and watchdog/reset reasons.
- Remove avoidable dynamic allocation from hot paths, compress static assets, and tune reusable buffers without weakening contracts.
- Run static checks and all host/device test suites from a clean build.
### Acceptance criteria
- Invalid traffic cannot crash, restart, starve, or leak hidden state from the device.
- Wi-Fi and WebSocket recovery requires no board reboot and preserves valid in-memory game state when specified.
- Connection churn does not produce a persistent heap or largest-block decline.
- Final firmware, filesystem, heap, stack, payload, and latency measurements pass every Milestone 005 limit.
- No unresolved high-severity correctness, privacy, or stability defect remains.
### Completion action
If all criteria pass, set this milestone to `DONE`, append its execution record, and change Milestone 017 from `BLOCKED` to `READY`.
### Execution record
- Date: 2026-08-29
- Board model and revision: ESP32-C6FH4 QFN32, revision v0.2.
- Toolchain and library versions: PlatformIO Core 6.1.19; `espressif32` 7.0.1; ESP-IDF 6.0.1; `esp_littlefs` 1.20.4.
- Result: PASS for automated hardening verification; pending on-device soak and recovery confirmation.
- Evidence: Rejection diagnostics are now saturating and protected against concurrent callback updates. `GET /api/health` includes the platform reset reason alongside uptime, heap, minimum heap, largest free block, connection count, and rejected-input count. Added bounded malformed-traffic coverage that repeatedly exercises all HTTP command parsers and 600 randomized WebSocket frames with open/close churn; invalid traffic produces bounded errors without lifecycle mutation. Existing fixed session, WebSocket hello timeout, delivery failure removal, reconnect/polling, and queue limits remain in effect.
- Measurements: `make -C test/host run` passed command queue, domain, bot strategy, lifecycle, presenter, HTTP API, synchronization, human-game integration, bot-game integration, and robustness suites. The robustness suite covers 400 malformed HTTP requests, oversized bodies, 600 randomized WebSocket frames, oversized WebSocket frames, and repeated connection-slot reuse. `node --check data/app.js`, gzip integrity checks, and compressed JavaScript syntax checks passed. `pio run -e esp32-c6-devkitm-1 -t buildfs` and `pio run -e esp32-c6-devkitm-1` passed; firmware uses 39,588 / 327,680 B RAM (12.1%) and 1,018,786 / 2,097,152 B flash (48.6%), both within final budget limits.
- Issues or deviations: No firmware upload, Wi-Fi interruption, 30-minute soak, or live heap/latency measurement was performed. Those remain mandatory physical-device checks before release acceptance.
- Next action: Milestone 017 is ready. Do not start it unless explicitly requested.
---
## Milestone 017 — Execute final MVP acceptance and create the release baseline
**Status:** `READY`
**Depends on:** Milestone 016
### Objective
Prove every MVP readiness criterion on the physical ESP32-C6 and produce a reproducible release baseline.
### Work
- Run every automated domain, bot, session, presenter, API, browser, and device test from a clean checkout/configuration.
- Execute all manual scenarios from `MVP.md` on the target board, including phone, tablet, spectators, reconnect, fallback, rematch, and reboot.
- Complete at least 20 consecutive representative games without manual restart, watchdog reset, or material memory decline.
- Run the maximum target load of two players and eight spectators for the duration defined in the resource budget.
- Record final version pins, build hashes, firmware/LittleFS sizes, heap/stack minima, largest payloads, latency, and reset reasons.
- Create concise setup, Wi-Fi configuration, build, upload, usage, and recovery documentation.
- Tag or otherwise record the exact source/configuration baseline accepted as the MVP release.
### Acceptance criteria
- All 13 MVP readiness criteria pass with recorded evidence.
- Twenty consecutive games complete without a hang, unexpected restart, or material memory leak.
- Two players and eight spectators remain supported within the final resource budgets.
- A clean build and upload are reproducible with pinned dependencies and no flash-size warning.
- No secret is present in tracked files or release artifacts.
- The release decision is documented as `PASS`, with remaining non-MVP ideas kept outside the release scope.
### Completion action
If all criteria pass, set this milestone to `DONE` and append its execution record. Add any post-MVP milestones only after Milestone 017 and do not renumber existing milestones.
+373 -42
View File
@@ -1,44 +1,93 @@
(() => {
const target = document.querySelector('#health');
const role = new URLSearchParams(location.search).get('role') || 'spectator';
const columns = ['А', 'Б', 'В', 'Г', 'Д', 'Ж', 'З', 'И', 'Й', 'К'];
const storage = { token: 'battleship.sessionToken', name: 'battleship.displayName' };
const screens = ['connect', 'lobby', 'game', 'result', 'reconnecting', 'error'];
const el = id => document.getElementById(id);
const ui = {
status: el('connection-status'), notice: el('notice'), name: el('display-name'), availability: el('availability'),
lobbyDescription: el('lobby-description'), lobbyHelp: el('lobby-help'), modeControls: el('mode-controls'), modeDescription: el('mode-description'),
start: el('start-game'), boards: el('boards'), resultBoards: el('result-boards'), tabs: el('board-tabs'),
turn: el('turn-status'), wins: el('wins'), shotControls: el('shot-controls'), target: el('target-status'),
fire: el('fire-button'), cancel: el('cancel-target'), abort: el('abort-game'), rematch: el('rematch-button'),
result: el('result-description'), error: el('error-description')
};
let token = localStorage.getItem(storage.token) || '';
let role = '';
let info;
let state;
let selectedTarget;
let cumulativeStatistics;
let statisticsGameId;
let activeBoard = 'own';
let socket;
let retryIndex = 0;
let retryTimer;
let pollTimer;
const labels = {
uptime_ms: 'Время работы (мс)', wifi_state: 'Wi-Fi', rssi_dbm: 'RSSI (дБм)',
free_heap_bytes: 'Свободная память (байт)', min_free_heap_bytes: 'Мин. свободная память (байт)',
build_version: 'Версия сборки'
};
let heartbeatTimer;
let lobbyInfoTimer;
let firePending = false;
let targetActivator;
let retryIndex = 0;
function render(health) {
target.replaceChildren();
for (const [key, label] of Object.entries(labels)) {
const term = document.createElement('dt');
const value = document.createElement('dd');
term.textContent = label;
value.textContent = health[key] ?? 'недоступно';
target.append(term, value);
}
function showScreen(name) {
screens.forEach(screen => { el(`screen-${screen}`).hidden = screen !== name; });
}
async function refresh() {
function setConnection(text, status) {
ui.status.textContent = text;
ui.status.className = `connection-status ${status || ''}`;
}
function notify(message, error = false) {
ui.notice.hidden = !message;
ui.notice.textContent = message || '';
ui.notice.classList.toggle('error', error);
}
function apiError(payload, fallback) {
return payload?.message || fallback || 'Сервер временно недоступен.';
}
async function request(path, options = {}) {
const response = await fetch(path, { cache: 'no-store', ...options });
let payload;
try { payload = await response.json(); } catch (_) { throw new Error('Сервер вернул некорректный ответ.'); }
if (!response.ok || payload.ok === false) throw new Error(apiError(payload));
return payload;
}
function headers() { return { 'Content-Type': 'application/json' }; }
async function refreshInfo() {
try {
const response = await fetch('/api/health', { cache: 'no-store' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
render(await response.json());
info = await request('/api/info');
const availabilityItem = (text) => {
const item = document.createElement('span');
item.className = 'availability-item';
item.textContent = text;
return item;
};
ui.availability.replaceChildren(
availabilityItem(`Игрок 1: ${info.player1Available ? 'свободен' : 'занят'}`),
availabilityItem(`Игрок 2: ${info.player2Available ? 'свободен' : 'занят'}`),
availabilityItem(`зрительских мест: ${info.spectatorsAvailable}`),
);
if (state?.phase === 'lobby') renderLobby();
} catch (error) {
target.textContent = `Не удалось получить состояние: ${error.message}`;
ui.availability.textContent = error.message;
}
}
async function pollState() {
try {
const response = await fetch(`/api/state?role=${encodeURIComponent(role)}`, { cache: 'no-store' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const state = await response.json();
document.title = `Морской бой — версия ${state.version}`;
} catch (_) {}
const response = await fetch(`/api/state?version=${state?.version || 0}`, {
cache: 'no-store', headers: token ? { 'X-Session-Token': token } : {}
});
const payload = await response.json();
if (!response.ok || payload.ok === false) throw new Error(apiError(payload));
acceptState(payload);
} catch (error) {
if (!state) showError(error.message);
}
}
function beginPolling() {
@@ -46,28 +95,310 @@
pollState();
}
function stopPolling() {
if (pollTimer) window.clearInterval(pollTimer);
pollTimer = undefined;
function stopPolling() { if (pollTimer) window.clearInterval(pollTimer); pollTimer = undefined; }
function stopHeartbeat() { if (heartbeatTimer) window.clearInterval(heartbeatTimer); heartbeatTimer = undefined; }
function startLobbyInfoPolling() {
if (!lobbyInfoTimer) {
lobbyInfoTimer = window.setInterval(refreshInfo, 2000);
refreshInfo();
}
}
function stopLobbyInfoPolling() { if (lobbyInfoTimer) window.clearInterval(lobbyInfoTimer); lobbyInfoTimer = undefined; }
function safeState(payload) {
return payload && payload.type === 'state' && Array.isArray(payload.boards) && payload.boards.length === 2 &&
payload.boards.every(board => typeof board === 'string' && /^[01234]{100}$/.test(board)) &&
typeof payload.version === 'number' && typeof payload.gameId === 'number' &&
(payload.winner === null || payload.winner === 0 || payload.winner === 1) && Array.isArray(payload.statistics) &&
payload.statistics.length === 2 && payload.statistics.every(entry => Array.isArray(entry) && entry.length === 4 &&
entry.every(value => Number.isInteger(value) && value >= 0));
}
function acceptState(payload) {
if (!safeState(payload)) { showError('Получено неполное состояние игры.'); return; }
const hadGap = state && payload.version > state.version + 1;
state = payload;
role = payload.viewer;
if (hadGap) pollState();
selectedTarget = selectedTarget && payload.boards[opponentIndex()][selectedTarget.y * 10 + selectedTarget.x] === '0' ? selectedTarget : undefined;
document.title = `Морской бой — версия ${payload.version}`;
render();
}
function isPlayer() { return role === 'player1' || role === 'player2'; }
function ownIndex() { return role === 'player2' ? 1 : 0; }
function opponentIndex() { return ownIndex() ^ 1; }
function canShoot() { return state?.phase === 'in_progress' && state.turn === role && isPlayer(); }
function canFireTarget(target) {
return !firePending && canShoot() && Number.isInteger(target?.x) && Number.isInteger(target?.y) &&
target.x >= 0 && target.x < 10 && target.y >= 0 && target.y < 10 &&
state.boards[opponentIndex()][target.y * 10 + target.x] === '0';
}
function playerLabel(index) { return index === 1 && state?.mode === 'bot' ? 'ESP32' : `Игрок ${index + 1}`; }
async function fireTarget(target) {
if (!canFireTarget(target)) return false;
firePending = true;
selectedTarget = undefined;
renderGame();
try {
await post('/api/game/shot', { token, gameId: state.gameId, ...target });
return true;
} catch (_) {
return false;
} finally {
firePending = false;
if (state?.phase === 'in_progress') renderGame();
}
}
function activateTarget(target, keyboard = false) {
if (!targetActivator) {
targetActivator = globalThis.BattleshipTargetInteraction.createTargetActivator({
canFire: canFireTarget,
select: nextTarget => { selectedTarget = nextTarget; renderGame(); },
fire: fireTarget,
});
}
return keyboard ? targetActivator.keyboard(target) : targetActivator.tap(target);
}
function cellInfo(value) {
if (value === '1') return ['ship', 'Корабль'];
if (value === '2') return ['miss', 'Промах'];
if (value === '3') return ['hit', 'Попадание'];
if (value === '4') return ['sunk', 'Потопленный корабль'];
return ['', 'Вода'];
}
function boardElement(index, title, targetable, result) {
const board = document.createElement('section');
board.className = 'board';
board.dataset.board = String(index);
const heading = document.createElement('h3');
heading.textContent = title;
board.append(heading);
const grid = document.createElement('div');
grid.className = 'board-grid';
grid.setAttribute('role', 'grid');
grid.setAttribute('aria-label', title);
grid.append(Object.assign(document.createElement('span'), { className: 'axis' }));
columns.forEach(column => grid.append(Object.assign(document.createElement('span'), { className: 'axis', textContent: column })));
for (let y = 0; y < 10; y += 1) {
grid.append(Object.assign(document.createElement('span'), { className: 'axis', textContent: String(y + 1) }));
for (let x = 0; x < 10; x += 1) {
const value = state.boards[index][y * 10 + x];
const [kind, label] = cellInfo(value);
const cell = document.createElement('button');
cell.type = 'button'; cell.className = `cell ${kind}`.trim(); cell.disabled = !targetable || value !== '0' || firePending;
cell.setAttribute('aria-label', `${columns[x]}${y + 1}: ${label}`);
if (targetable && value === '0') {
cell.classList.add('target');
if (selectedTarget?.x === x && selectedTarget?.y === y) cell.classList.add('selected');
const target = { x, y };
cell.addEventListener('pointerup', event => {
if (event.button !== 0) return;
event.preventDefault();
activateTarget(target);
});
cell.addEventListener('click', event => {
if (event.detail === 0) activateTarget(target, true);
});
cell.addEventListener('dblclick', event => {
event.preventDefault();
if (targetActivator) targetActivator.doubleActivate(target);
});
} else if (value === '0') {
cell.classList.add(index === ownIndex() && isPlayer() ? 'own-water' : 'unavailable');
}
grid.append(cell);
}
}
board.append(grid);
if (result) board.hidden = false;
return board;
}
function boardDefinitions(result = false) {
if (role === 'spectator' || result) return [
{ index: 0, title: 'Поле игрока 1', key: 'player1', targetable: false },
{ index: 1, title: 'Поле игрока 2', key: 'player2', targetable: false }
];
return [
{ index: ownIndex(), title: 'Моё поле', key: 'own', targetable: false },
{ index: opponentIndex(), title: state.mode === 'bot' ? 'Поле ESP32' : 'Поле соперника', key: 'opponent', targetable: canShoot() }
];
}
function renderBoards(container, result = false) {
container.replaceChildren();
const definitions = boardDefinitions(result);
definitions.forEach(definition => {
const board = boardElement(definition.index, definition.title, definition.targetable, result);
board.hidden = !result && definition.key !== activeBoard;
container.append(board);
});
if (!result) {
ui.tabs.replaceChildren();
definitions.forEach(definition => {
const tab = document.createElement('button');
tab.type = 'button'; tab.role = 'tab'; tab.textContent = definition.title;
tab.setAttribute('aria-selected', String(definition.key === activeBoard));
tab.addEventListener('click', () => { activeBoard = definition.key; renderGame(); });
ui.tabs.append(tab);
});
}
}
function renderLobby() {
showScreen('lobby');
const playerOne = role === 'player1';
ui.lobbyDescription.textContent = role === 'spectator' ? 'Вы наблюдаете за подготовкой партии.' : 'Ожидайте готовности партии или настройте режим.';
ui.modeControls.hidden = !playerOne;
if (playerOne) startLobbyInfoPolling();
else stopLobbyInfoPolling();
const playerTwoReady = info?.player2Available === false;
const canStart = playerOne && (state.mode === 'bot' || playerTwoReady);
document.querySelectorAll('.mode-button').forEach(button => {
const selected = button.dataset.mode === state.mode;
button.setAttribute('aria-checked', String(selected));
button.disabled = !playerOne;
});
ui.start.disabled = !canStart;
ui.modeDescription.textContent = state.mode === 'human' ? (playerTwoReady ? 'Второй игрок готов. Можно начать партию.' : 'Ожидаем подключения второго игрока.') : 'Игра против ESP32. Можно начать сразу.';
ui.lobbyHelp.textContent = playerOne ? '' : 'Игрок 1 выбирает режим и запускает партию.';
}
function renderGame() {
if (!state) return;
stopLobbyInfoPolling();
showScreen('game');
if (canShoot() && !selectedTarget) activeBoard = 'opponent';
ui.turn.textContent = canShoot() ? 'Ваш ход' : `Ходит ${playerLabel(state.turn === 'player2' ? 1 : 0)}`;
ui.wins.textContent = `Победы: ${state.wins[0]} : ${state.wins[1]}`;
renderBoards(ui.boards);
const available = canShoot();
ui.shotControls.hidden = !isPlayer();
ui.target.textContent = firePending ? 'Выстрел отправляется…' : available ? (selectedTarget ? `Цель: ${columns[selectedTarget.x]}${selectedTarget.y + 1}` : 'Выберите клетку на поле соперника.') : 'Ожидайте своего хода.';
ui.fire.disabled = !selectedTarget || !canFireTarget(selectedTarget);
ui.fire.textContent = firePending ? 'Выстрел…' : 'Огонь';
ui.cancel.disabled = !selectedTarget || firePending;
ui.abort.hidden = !(role === 'player1' && state.mode === 'human' && state.phase === 'in_progress');
}
function renderResult() {
stopLobbyInfoPolling();
showScreen('result');
if (statisticsGameId !== state.gameId) refreshStatistics();
const waiting = state.phase === 'rematch_wait';
const winner = state.winner === 0 || state.winner === 1 ? `Победил ${playerLabel(state.winner)}. ` : '';
const matchSummary = state.statistics.map((entry, index) => `${playerLabel(index)}: выстрелы ${entry[0]}, попадания ${entry[1]}, промахи ${entry[2]}, потоплено ${entry[3]}.`).join(' ');
const cumulative = cumulativeStatistics?.cumulative?.map((entry, index) => `${playerLabel(index)} всего: игр ${entry[0]}, побед ${entry[1]}, поражений ${entry[2]}, выстрелов ${entry[4]}, попаданий ${entry[5]}, промахов ${entry[6]}, потоплено ${entry[3]}.`).join(' ') || '';
ui.result.textContent = `${winner}${matchSummary} ${cumulative} ${waiting ? 'Ожидается подтверждение повторной игры.' : 'Поля раскрыты. Можно подтвердить повторную игру.'}`;
ui.rematch.hidden = !isPlayer();
ui.rematch.disabled = !isPlayer();
ui.rematch.textContent = waiting ? 'Подтвердить повторно' : 'Сыграть ещё';
renderBoards(ui.resultBoards, true);
}
function render() {
if (!state) return;
if (state.phase === 'lobby' || state.phase === 'preparing') renderLobby();
else if (state.phase === 'in_progress') renderGame();
else renderResult();
}
function showError(message) {
stopLobbyInfoPolling();
ui.error.textContent = message;
setConnection('Нет связи', 'offline');
showScreen('error');
}
async function post(path, body) {
try {
const response = await request(path, { method: 'POST', headers: headers(), body: JSON.stringify(body) });
await pollState();
return response;
} catch (error) { notify(error.message, true); throw error; }
}
async function refreshStatistics() {
const requestedGameId = state?.gameId;
try {
const response = await request('/api/statistics', { cache: 'no-store', headers: token ? { 'X-Session-Token': token } : {} });
if (state?.gameId === requestedGameId && response.gameId === requestedGameId && Array.isArray(response.cumulative)) {
cumulativeStatistics = response;
statisticsGameId = requestedGameId;
renderResult();
}
} catch (_) { /* The state snapshot still renders the current-match statistics. */ }
}
async function join(requestedRole) {
const name = ui.name.value.trim();
if (!name) { notify('Введите имя.', true); ui.name.focus(); return; }
try {
const joined = await request('/api/session/join', { method: 'POST', headers: headers(), body: JSON.stringify({ name, requestedRole }) });
token = joined.token; role = joined.role;
localStorage.setItem(storage.token, token); localStorage.setItem(storage.name, name);
notify(''); setConnection('Подключено', 'online');
await pollState();
connectSocket();
} catch (error) { notify(error.message, true); }
}
function connectSocket() {
socket = new WebSocket(`ws://${location.host}/ws?role=${encodeURIComponent(role)}`);
socket.onopen = () => { retryIndex = 0; stopPolling(); };
if (!token || socket?.readyState === WebSocket.OPEN || retryTimer) return;
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
try { socket = new WebSocket(`${scheme}://${location.host}/ws`); } catch (_) { beginPolling(); return; }
socket.onopen = () => socket.send(JSON.stringify({ type: 'hello', token, version: state?.version || 0 }));
socket.onmessage = event => {
const state = JSON.parse(event.data);
document.title = `Морской бой — версия ${state.version}`;
};
socket.onclose = () => {
beginPolling();
const delays = [1000, 2000, 5000, 10000];
const delay = delays[Math.min(retryIndex++, delays.length - 1)];
retryTimer = window.setTimeout(connectSocket, delay);
try {
const message = JSON.parse(event.data);
if (message.type === 'state') {
acceptState(message); retryIndex = 0; stopPolling(); setConnection('Синхронизация онлайн', 'online');
if (!heartbeatTimer) heartbeatTimer = window.setInterval(() => { if (socket?.readyState === WebSocket.OPEN) socket.send('{"type":"ping"}'); }, 15000);
} else if (message.ok === false) notify(apiError(message), true);
} catch (_) { socket.close(); }
};
socket.onerror = () => socket.close();
socket.onclose = () => {
socket = undefined; stopHeartbeat(); beginPolling(); setConnection('HTTP-обновление', 'polling');
const delays = [1000, 2000, 5000, 10000];
const delay = delays[Math.min(retryIndex++, delays.length - 1)];
retryTimer = window.setTimeout(() => { retryTimer = undefined; connectSocket(); }, delay);
};
}
refresh();
window.setInterval(refresh, 5000);
el('join-form').addEventListener('submit', event => {
event.preventDefault();
const requestedRole = info?.player1Available ? 'player1' : info?.player2Available ? 'player2' : 'spectator';
join(requestedRole);
});
el('join-spectator').addEventListener('click', () => join('spectator'));
document.querySelectorAll('.mode-button').forEach(button => button.addEventListener('click', () => post('/api/game/config', { token, gameId: state.gameId, mode: button.dataset.mode })));
ui.start.addEventListener('click', () => post('/api/game/start', { token, gameId: state.gameId }));
ui.fire.addEventListener('click', () => { if (selectedTarget) fireTarget(selectedTarget); });
ui.cancel.addEventListener('click', () => { selectedTarget = undefined; renderGame(); });
ui.abort.addEventListener('click', () => post('/api/game/abort', { token, gameId: state.gameId }));
ui.rematch.addEventListener('click', () => post('/api/game/rematch', { token, gameId: state.gameId }));
el('retry-button').addEventListener('click', () => { refreshInfo(); pollState(); connectSocket(); });
async function boot() {
ui.name.value = localStorage.getItem(storage.name) || '';
setConnection('Проверяем ESP32…');
await refreshInfo();
if (!token) { showScreen('connect'); setConnection('Готово к подключению'); return; }
try {
const resumed = await request('/api/session/resume', { method: 'POST', headers: headers(), body: JSON.stringify({ token }) });
role = resumed.role;
await pollState();
connectSocket();
} catch (_) {
localStorage.removeItem(storage.token); token = ''; showScreen('connect'); notify('Предыдущая сессия больше недоступна. Подключитесь снова.');
}
}
boot();
})();
+52 -6
View File
@@ -3,18 +3,64 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#08233d">
<title>Морской бой — ESP32</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main>
<h1>Морской бой</h1>
<p>ESP32-C6: проверка Wi-Fi, LittleFS и HTTP.</p>
<section aria-live="polite">
<h2>Состояние устройства</h2>
<dl id="health">Загрузка…</dl>
<main class="app-shell">
<header class="app-header">
<div><p class="eyebrow">ESP32-C6 · локальная игра</p><h1>Морской бой</h1></div>
<p id="connection-status" class="connection-status" role="status">Подключение…</p>
</header>
<p id="notice" class="notice" aria-live="polite" hidden></p>
<section id="screen-connect" class="screen" aria-labelledby="connect-title">
<div class="connection-layout">
<div class="connection-form">
<h2 id="connect-title">Подключение к игре</h2>
<p>Введите имя, чтобы занять место игрока или наблюдать за партией.</p>
<form id="join-form" class="stack-form">
<label for="display-name">Ваше имя</label>
<input id="display-name" name="name" type="text" maxlength="80" autocomplete="name" required>
<div class="button-row"><button id="join-player" type="submit">Играть</button><button id="join-spectator" type="button" class="secondary">Наблюдать</button></div>
</form>
</div>
<aside class="connection-support" aria-labelledby="join-help-title">
<h3 id="join-help-title">Как присоединиться</h3>
<ol class="join-steps"><li>Введите имя для этой партии.</li><li>Выберите место игрока или режим наблюдателя.</li><li>Все обновления появятся сразу после подключения.</li></ol>
<div class="availability-panel"><p class="eyebrow">Доступные места</p><p id="availability" class="muted" aria-live="polite">Проверяем доступные места…</p></div>
</aside>
</div>
</section>
<section id="screen-lobby" class="screen" aria-labelledby="lobby-title" hidden>
<h2 id="lobby-title">Лобби</h2><p id="lobby-description"></p>
<section id="mode-controls" class="lobby-mode-section" aria-labelledby="mode-title" hidden>
<h3 id="mode-title">Режим игры</h3>
<div class="mode-options" role="radiogroup" aria-labelledby="mode-title"><button type="button" role="radio" aria-checked="false" data-mode="human" class="mode-button"><span class="mode-indicator" aria-hidden="true"></span><span>Два игрока</span></button><button type="button" role="radio" aria-checked="false" data-mode="bot" class="mode-button"><span class="mode-indicator" aria-hidden="true"></span><span>Против ESP32</span></button></div>
<p id="mode-description" class="muted" aria-live="polite"></p>
<button id="start-game" type="button" aria-describedby="mode-description">Начать игру</button>
</section>
<p id="lobby-help" class="muted"></p>
</section>
<section id="screen-game" class="screen" aria-labelledby="game-title" hidden>
<div class="screen-heading"><div><h2 id="game-title">Поле боя</h2><p id="turn-status" class="turn-status"></p></div><p id="wins" class="score" aria-label="Победы"></p></div>
<div id="board-tabs" class="board-tabs" role="tablist" aria-label="Выбор поля"></div>
<div id="boards" class="boards"></div>
<div id="shot-controls" class="shot-controls" hidden><p id="target-status" class="muted">Выберите клетку на поле соперника.</p><button id="fire-button" type="button" disabled>Огонь</button><button id="cancel-target" type="button" class="secondary" disabled>Отменить</button></div>
<button id="abort-game" type="button" class="text-button abort-button" hidden>Отменить партию</button>
</section>
<section id="screen-result" class="screen" aria-labelledby="result-title" hidden>
<h2 id="result-title">Партия завершена</h2><p id="result-description"></p>
<div id="result-boards" class="boards result-boards"></div><button id="rematch-button" type="button">Сыграть ещё</button>
</section>
<section id="screen-reconnecting" class="screen reconnecting" aria-labelledby="reconnecting-title" hidden><h2 id="reconnecting-title">Восстанавливаем связь</h2><p>Состояние игры обновляется через HTTP. WebSocket подключится автоматически.</p></section>
<section id="screen-error" class="screen error-screen" aria-labelledby="error-title" hidden><h2 id="error-title">Не удалось продолжить</h2><p id="error-description"></p><button id="retry-button" type="button">Повторить</button></section>
</main>
<script src="/target_interaction.js" defer></script>
<script src="/app.js" defer></script>
</body>
</html>
+32 -7
View File
@@ -1,7 +1,32 @@
:root { color-scheme: dark; font-family: system-ui, sans-serif; }
body { margin: 0; background: #10223a; color: #f4f7fb; }
main { max-width: 42rem; margin: 0 auto; padding: 1.5rem; }
section { background: #19395c; border-radius: .75rem; padding: 1rem; }
dl { display: grid; grid-template-columns: max-content 1fr; gap: .5rem 1rem; }
dt { font-weight: 700; }
dd { margin: 0; overflow-wrap: anywhere; }
:root { color-scheme: dark; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #061827; color: #f1f7ff; }
* { box-sizing: border-box; }
body { margin: 0; min-width: 20rem; background: radial-gradient(circle at top, #104c75, #061827 45rem); }
button, input { font: inherit; }
button { min-height: 2.75rem; padding: .55rem .9rem; border: 0; border-radius: .65rem; background: #3ec6f0; color: #032035; font-weight: 750; cursor: pointer; }
button:focus-visible, input:focus-visible, .cell:focus-visible { outline: .2rem solid #ffe56a; outline-offset: .15rem; }
button:hover:not(:disabled) { filter: brightness(1.08); } button:active:not(:disabled) { filter: brightness(.94); transform: translateY(1px); } button:disabled { cursor: not-allowed; opacity: .48; }
button.secondary { background: #31536e; color: #f1f7ff; } button.text-button { min-height: 2rem; padding: .35rem 0; background: transparent; color: #bad9eb; text-decoration: underline; }
.app-shell { width: min(100%, 70rem); margin: 0 auto; padding: clamp(1rem, 3vw, 2rem); }
.app-header, .screen-heading { display: flex; flex-wrap: wrap; align-items: start; justify-content: space-between; gap: 1rem; } .app-header > div { min-width: 0; }
h1, h2, h3, p { margin-top: 0; } h1 { margin-bottom: .2rem; font-size: clamp(1.75rem, 6vw, 2.6rem); } h2 { font-size: clamp(1.35rem, 4vw, 1.8rem); } h3 { margin-bottom: .75rem; font-size: 1.05rem; }
.eyebrow, .muted { color: #b6d1e3; } .eyebrow { margin-bottom: .25rem; font-size: .8rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
.connection-status { max-width: 100%; margin: .25rem 0; padding: .4rem .6rem; border-radius: 99rem; background: #28485f; color: #d8eaff; font-size: .88rem; overflow-wrap: anywhere; } .connection-status.online { background: #145b4b; color: #cdfae6; } .connection-status.polling { background: #234c69; color: #d8eaff; } .connection-status.offline { background: #6f3c36; color: #ffddd8; }
.notice { margin: 1rem 0; padding: .75rem 1rem; border-left: .3rem solid #ffe56a; border-radius: .35rem; background: #403b22; } .notice.error { border-color: #ff8e80; background: #4d2929; }
.screen, .panel { margin-top: 1rem; padding: clamp(1rem, 3vw, 1.5rem); border: 1px solid #41708d; border-radius: 1rem; background: rgb(8 39 62 / 92%); box-shadow: 0 1rem 3rem rgb(0 0 0 / 18%); } .panel { margin-top: 1rem; background: #0b304b; }
.stack-form { display: grid; gap: .7rem; max-width: 28rem; } input { width: 100%; min-height: 2.75rem; padding: .55rem .7rem; border: 1px solid #6a94ad; border-radius: .65rem; background: #061c2d; color: #f1f7ff; } input:focus-visible { border-color: #3ec6f0; background: #082a41; box-shadow: 0 0 0 .22rem rgb(62 198 240 / 28%); }
.button-row { display: flex; flex-wrap: wrap; gap: .6rem; } .button-row > * { flex: 1 1 11rem; }
.lobby-mode-section { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid rgb(80 128 156 / 65%); } .lobby-mode-section h3 { margin-bottom: .65rem; } .mode-options { display: grid; gap: .6rem; } .mode-button { display: grid; grid-template-columns: 1.2rem minmax(0, 1fr); align-items: center; gap: .65rem; min-height: 3rem; border: 1px solid #41708d; background: #123b58; color: #f1f7ff; text-align: left; } .mode-button:hover:not(:disabled) { background: #1a4d6d; } .mode-button[aria-checked="true"] { border-color: #3ec6f0; background: #0d5276; box-shadow: 0 0 0 .12rem rgb(62 198 240 / 20%); } .mode-indicator { display: grid; place-items: center; width: 1.1rem; height: 1.1rem; border: 2px solid #8eb4cb; border-radius: 50%; } .mode-button[aria-checked="true"] .mode-indicator { border-color: #b9f0ff; background: #3ec6f0; color: #032035; } .mode-button[aria-checked="true"] .mode-indicator::before { content: "✓"; font-size: .76rem; font-weight: 900; } #mode-description { min-height: 1.4rem; margin: .7rem 0; } #start-game { width: 100%; min-height: 3rem; }
.connection-layout { display: grid; gap: 1.25rem; } .connection-form { min-width: 0; } .connection-support { padding: 1rem; border: 1px solid #315a75; border-radius: .8rem; background: rgb(10 48 75 / 68%); } .connection-support h3 { margin-bottom: .65rem; } .join-steps { display: grid; gap: .55rem; margin: 0; padding-left: 1.35rem; color: #c8e9f8; } .join-steps li { padding-left: .15rem; } .availability-panel { margin-top: 1rem; padding-top: .9rem; border-top: 1px solid rgb(80 128 156 / 65%); } .availability-panel .eyebrow { margin-bottom: .45rem; }
#availability { display: grid; grid-template-columns: minmax(0, 1fr); gap: .45rem; margin-bottom: 0; } .availability-item { display: block; padding: .45rem .6rem; border-left: 2px solid #41708d; border-radius: .35rem; background: rgb(18 59 88 / 62%); }
.turn-status { margin-bottom: 0; color: #c8e9f8; font-weight: 650; } .score { margin: .15rem 0; padding: .5rem .75rem; border-radius: .6rem; background: #123b58; font-weight: 750; white-space: nowrap; }
.board-tabs { display: flex; gap: .5rem; margin: 1rem 0; } .board-tabs button { flex: 1; min-height: 2.5rem; background: #31536e; color: #f1f7ff; } .board-tabs button[aria-selected="true"] { background: #3ec6f0; color: #032035; }
.boards { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1rem; } .board { min-width: 0; padding: .75rem; border: 1px solid #50809c; border-radius: .8rem; background: #071f32; } .board h3 { margin: 0 0 .65rem; font-size: 1rem; }
.board-grid { display: grid; grid-template-columns: 1.15rem repeat(10, minmax(0, 1fr)); gap: 2px; width: 100%; aspect-ratio: 1.1; } .axis { display: grid; place-items: center; color: #b8d3e6; font-size: clamp(.52rem, 2.2vw, .72rem); font-weight: 700; }
.cell { position: relative; min-height: 0; padding: 0; border-radius: .12rem; border: 1px solid #4e87a6; background: #167aa6; color: #fff; font-size: clamp(.68rem, 3vw, 1.15rem); line-height: 1; } .cell.ship { background: #b2c8d6; color: #1c3442; } .cell.ship::before { content: "◆"; font-size: .8em; } .cell.miss::before { content: "•"; color: #08253a; font-size: 1.15em; } .cell.hit, .cell.sunk { background: #b83e42; color: #fff; } .cell.hit::before, .cell.sunk::before { content: "×"; font-size: 1.25em; font-weight: 900; } .cell.sunk { outline: 2px solid #ffe56a; outline-offset: -3px; } .cell.target { background: #0c648b; cursor: pointer; touch-action: manipulation; } .cell.target::after { content: ""; position: absolute; inset: 27%; border: 1px dotted #b8edff; border-radius: 50%; } .cell.own-water { background: #167aa6; } .cell.unavailable { border-style: dashed; background: #0d4666; opacity: .72; } .cell.selected { outline: 3px solid #ffe56a; outline-offset: -3px; box-shadow: inset 0 0 0 2px #061827; } .cell.selected::after { content: "◎"; inset: auto; border: 0; color: #ffe56a; font-size: 1.05em; font-weight: 900; } .cell[disabled] { cursor: default; }
.shot-controls { display: flex; flex-wrap: wrap; align-items: center; gap: .7rem; margin-top: 1rem; } .shot-controls p { flex: 1 1 14rem; margin: 0; } .abort-button { margin-top: .75rem; color: #ffb7ae !important; } .reconnecting { border-color: #ffe56a; } .error-screen { border-color: #ff8e80; }
@media (min-width: 44rem) { .app-shell { display: flex; flex-direction: column; min-height: 100svh; } .app-header { align-items: center; } .connection-status { margin: 0; } #screen-connect, #screen-lobby { width: min(100%, 48rem); margin: clamp(1.25rem, 4vh, 2.5rem) auto auto; padding: clamp(1.5rem, 4vw, 2.5rem); } #screen-connect .stack-form { width: min(100%, 42rem); max-width: 42rem; } #screen-connect .button-row > * { flex-basis: 16rem; } .mode-options { grid-template-columns: repeat(2, minmax(0, 1fr)); } .boards { grid-template-columns: repeat(2, minmax(0, 1fr)); } .board-tabs { display: none; } .board[hidden] { display: block; } }
@media (min-width: 60rem) { #screen-connect { width: 100%; max-width: none; min-height: min(29rem, calc(100svh - 9rem)); display: grid; align-content: center; } #screen-connect .connection-layout { grid-template-columns: minmax(0, 1.15fr) minmax(18rem, .85fr); align-items: stretch; gap: clamp(1.5rem, 4vw, 3rem); } #screen-connect .stack-form { max-width: none; } #screen-connect .connection-support { display: flex; flex-direction: column; justify-content: center; } }
@media (max-width: 43.99rem) { .app-shell { padding-inline: max(1.25rem, env(safe-area-inset-left)) max(1.25rem, env(safe-area-inset-right)); } .app-header { flex-direction: column; align-items: stretch; gap: .45rem; } .connection-status { align-self: flex-start; margin: 0; } #screen-connect { padding: 1.25rem; } .button-row { flex-direction: column; flex-wrap: nowrap; } .button-row > * { flex: 0 0 auto; width: 100%; min-height: 3rem; } #availability { margin-top: 0; } .board[hidden] { display: none; } .result-boards .board[hidden] { display: block; } }
#screen-game { margin-top: .75rem; padding: 0; border: 0; background: transparent; box-shadow: none; } #screen-game .screen-heading { align-items: center; margin-bottom: .5rem; } #screen-game .screen-heading h2 { margin-bottom: .1rem; } #screen-game .turn-status { color: #e2f7ff; font-size: clamp(1rem, 2.5vw, 1.2rem); font-weight: 800; } #screen-game .score { margin: 0; border: 1px solid #41708d; } #screen-game .board { width: min(100%, 34rem); margin-inline: auto; padding: .65rem; box-shadow: 0 .65rem 1.5rem rgb(0 0 0 / 12%); } #screen-game .board-grid { max-width: 100%; margin-inline: auto; aspect-ratio: 1; grid-template-rows: repeat(11, minmax(0, 1fr)); } #screen-game .shot-controls { width: min(100%, 34rem); margin: .75rem auto 0; } #screen-game #fire-button { min-width: 8rem; } #screen-game .board-tabs { width: min(100%, 34rem); margin: .6rem auto; } #screen-game .board-tabs button { min-height: 2.75rem; }
@media (max-width: 43.99rem) { #screen-game .screen-heading { gap: .55rem; } #screen-game .boards { gap: .65rem; } #screen-game .board { padding: .5rem; border-radius: .65rem; } #screen-game .shot-controls { display: grid; grid-template-columns: 1fr; gap: .55rem; } #screen-game .shot-controls p { margin: 0; } #screen-game #fire-button, #screen-game #cancel-target { width: 100%; min-height: 3rem; } }
@media (min-width: 44rem) and (orientation: portrait) and (max-width: 64rem) { #screen-game .boards { grid-template-columns: minmax(0, 1fr); } #screen-game .board-tabs { display: flex; } #screen-game .board[hidden] { display: none; } }
+49
View File
@@ -0,0 +1,49 @@
(() => {
function sameTarget(left, right) { return left?.x === right?.x && left?.y === right?.y; }
function createTargetActivator({ canFire, select, fire, thresholdMs = 360, now = () => Date.now() }) {
let lastTap;
let firing = false;
async function fireOnce(target) {
if (firing || !canFire(target)) return false;
firing = true;
lastTap = undefined;
try {
await fire(target);
return true;
} finally {
firing = false;
}
}
function tap(target) {
if (firing || !canFire(target)) return { action: 'ignored' };
const timestampMs = now();
if (lastTap && sameTarget(lastTap.target, target) && timestampMs - lastTap.timestampMs <= thresholdMs) {
return { action: 'fire', promise: fireOnce(target) };
}
select(target);
lastTap = { target, timestampMs };
return { action: 'select' };
}
function keyboard(target) {
if (firing || !canFire(target)) return { action: 'ignored' };
select(target);
lastTap = undefined;
return { action: 'select' };
}
return {
tap,
keyboard,
doubleActivate: (target) => ({ action: 'fire', promise: fireOnce(target) }),
isFiring: () => firing,
};
}
const api = { createTargetActivator };
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else globalThis.BattleshipTargetInteraction = api;
})();
+106
View File
@@ -0,0 +1,106 @@
# API contract v1
Machine-readable HTTP documentation: [openapi.yaml](openapi.yaml).
All JSON is UTF-8 and uses `Content-Type: application/json`. API responses set
`Cache-Control: no-store`. A request exceeding its route limit is rejected
before parsing with `PAYLOAD_TOO_LARGE`; malformed JSON is `MALFORMED_JSON`.
All numeric fields are decimal JSON integers, never strings.
## Common values
| Type | Values / bound |
| ------------------- | ---------------------------------------------------------------------------------------------- |
| `role` | `player1`, `player2`, `spectator` |
| `mode` | `human`, `bot` |
| `phase` | `lobby`, `preparing`, `in_progress`, `finished`, `rematch_wait` |
| `token` | exactly 32 lowercase hexadecimal characters |
| `gameId`, `version` | unsigned 32-bit integer |
| `x`, `y` | integer 09 |
| `name` | 120 scalar values, at most 80 UTF-8 bytes |
| `board` | exactly 100 ASCII cells: `0` unknown/water, `1` revealed ship, `2` miss, `3` hit, `4` sunk hit |
Every state-changing request has `token` and `gameId`; they must precede any
mutation validation. A stale `gameId` is rejected as `STALE_GAME`.
## Response envelope
Success:
```json
{"ok":true,"version":17,"gameId":4}
```
Failure (maximum 160 encoded bytes):
```json
{"ok":false,"code":"NOT_YOUR_TURN","message":"Сейчас ход соперника","version":17}
```
`code` is one of `MALFORMED_JSON`, `PAYLOAD_TOO_LARGE`, `INVALID_NAME`,
`INVALID_ROLE`, `INVALID_MODE`, `INVALID_COORDINATE`, `UNAUTHORIZED`,
`NO_PLAYER_SLOT`, `NO_SPECTATOR_SLOT`, `FORBIDDEN_ROLE`, `WRONG_PHASE`,
`NOT_YOUR_TURN`, `CELL_ALREADY_SHOT`, `STALE_GAME`, or `SERVER_BUSY`.
`message` is Russian and at most 80 UTF-8 bytes.
## HTTP routes
| Route | Maximum request | Response / maximum |
| -------------------------- | --------------: | --------------------------------------------- |
| `GET /api/info` | 128 B target | public device/slot state, 192 B |
| `GET /api/health` | 128 B target | diagnostics without secrets, 320 B; includes reset reason |
| `POST /api/session/join` | 192 B body | `{name,requestedRole}`; token and role, 192 B |
| `POST /api/session/resume` | 96 B body | `{token}`; role and state metadata, 192 B |
| `POST /api/game/config` | 96 B body | `{token,gameId,mode}`; common envelope |
| `POST /api/game/start` | 80 B body | `{token,gameId}`; common envelope |
| `POST /api/game/shot` | 96 B body | `{token,gameId,x,y}`; common envelope |
| `POST /api/game/rematch` | 80 B body | `{token,gameId}`; common envelope |
| `POST /api/game/abort` | 80 B body | `{token,gameId}`; common envelope |
| `GET /api/state?version=N` | 128 B target | one role-safe state, 512 B |
| `GET /api/statistics` | 128 B target | role and bounded match/cumulative counters |
The session token is in each POST body. For `GET /api/state`, it is supplied
in `X-Session-Token`; absence creates a spectator-safe view. It is never a URL
parameter.
## Role-safe state event
The HTTP state response and WebSocket `state` event use this single 512-byte
maximum schema:
```json
{"type":"state","version":17,"gameId":4,"phase":"in_progress","mode":"human","viewer":"player1","turn":"player2","boards":["000...100 cells...","000...100 cells..."],"wins":[0,0],"winner":null,"statistics":[[3,2,1,1],[4,1,3,0]]}
```
`boards[0]` belongs to player 1 and `boards[1]` to player 2. The presenter
replaces every unauthorized unhit ship with `0`. In `finished`, both boards may
contain `1`. `winner` is `null` until `finished`, then player index `0` or `1`.
Each compact statistics tuple is `[shots,hits,misses,shipsSunk]`. No other event
contains a board.
## Statistics response
`GET /api/statistics` accepts the same optional `X-Session-Token` as state and
returns no board data. `match` uses `[shots,hits,misses,shipsSunk]` per side;
`cumulative` uses `[games,wins,losses,shipsSunk,shots,hits,misses]` per side.
```json
{"ok":true,"viewer":"player1","gameId":4,"match":[[3,2,1,1],[4,1,3,0]],"cumulative":[[2,1,1,10,30,15,15],[2,1,1,8,28,14,14]]}
```
## WebSocket
Endpoint: `GET /ws`; all incoming frames are text JSON, at most 192 B. The
first frame must arrive within 5 seconds:
```json
{"type":"hello","token":"32-lowercase-hex-characters","version":17}
```
It receives a `state` snapshot. Supported inbound frames are `hello` (96 B),
`ping` (16 B), `config` (96 B), `start` (80 B), `shot` (96 B), `rematch`
(80 B), and `abort` (80 B). Their fields exactly match the corresponding HTTP
commands. Outbound `state` is at most 512 B; `error` uses the common 160-byte
failure envelope; `pong` is 16 B. Commands are idempotent when the same
`gameId`, `version`, and command payload are retried: the server returns the
current state rather than applying the action twice.
+1 -1
View File
@@ -34,7 +34,7 @@ carrier PCB model or its LED and pin routing.
## Current repository baseline (not hardware confirmation)
| 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 |
+82
View File
@@ -0,0 +1,82 @@
# Production decisions
This document is the binding implementation contract for the MVP. It resolves
the choices left open in `MVP.md`; future code must not widen these limits
without updating this document and `RESOURCE_BUDGET.md`.
## Platform and ownership
- Target: ESP32-C6FH4 (4 MB flash, no PSRAM assumed), custom 2 MiB app and
1,984 KiB LittleFS partitions.
- Stack: PlatformIO Core 6.1.19, `espressif32` 7.0.1, ESP-IDF 6.0.1,
built-in `esp_http_server` WebSocket support, and `esp_littlefs` 1.20.4.
- The ESP32 is authoritative. HTTP and WebSocket callbacks only validate and
enqueue commands; one application task owns game, session, and statistics
mutation.
- Internal coordinates are unsigned `x` and `y` in `[0, 9]`. The browser
renders Russian column labels; it never sends them.
## Fixed game rules
- Board size is 10 by 10. Each side has ships of lengths `4, 3, 3, 2, 2, 2,
1, 1, 1, 1`, placed randomly by the server without touching, including
diagonally.
- A hit retains the turn. A miss changes it. Sinking a ship marks its
surrounding cells as misses. A repeated shot is rejected without state
change. First turn and each fleet are independently random.
- Modes are `HUMAN_VS_HUMAN` and `HUMAN_VS_BOT`. The bot is `ESP32`; it uses
only previously visible shot results and acts after a bounded 500900 ms
server timer.
- Phases are `LOBBY`, `PREPARING`, `IN_PROGRESS`, `FINISHED`, and
`REMATCH_WAIT`. Every accepted state change increments `version`.
## Sessions and capacity
- Roles are `PLAYER_1`, `PLAYER_2`, and `SPECTATOR`; capacity is two players
and eight spectators. New clients become spectators when player slots are
occupied, subject to the spectator limit.
- Display names are 120 Unicode scalar values after removing controls and
markup. The server stores a bounded UTF-8 encoding of at most 80 bytes and
escapes it before HTML rendering.
- A session token is 16 cryptographically random bytes, transported as 32
lowercase hexadecimal characters. It is opaque, never logged, and remains
valid only until board reboot or explicit slot release.
- Active player sessions survive disconnects. Spectator disconnects free their
slot immediately. A player may resume only with the same token; a resumed
session receives a complete role-safe snapshot.
## Lifecycle
1. `join` assigns player 1 if vacant and requested, then player 2 only in the
two-player mode; otherwise it assigns spectator. `resume` never changes a
role.
2. Player 1 may configure a mode only in `LOBBY`. Switching to bot reserves
player 2 as `ESP32`; switching back requires no human player 2 conflict.
3. Player 1 may start only when player 2 is present in human mode, or bot mode
is selected. `PREPARING` is internal and advances atomically to
`IN_PROGRESS` after both fleets validate.
4. A player disconnect does not abort a game. The game waits for that player;
the bot continues only when it is the bot's turn. Player 1 may issue the
explicit `abort` command while a human opponent is disconnected, returning
to `LOBBY` and incrementing `version`.
5. Destroying all ten opponent ships transitions to `FINISHED`, records match
and cumulative statistics, reveals both boards, and rejects shots.
6. In `FINISHED`, a player may confirm `rematch`. Human mode requires both
player confirmations; bot mode requires player 1 only. The server enters
`REMATCH_WAIT` until the required confirmations exist, then creates a new
game ID and returns to `PREPARING`. Names, roles, and cumulative statistics
remain; per-match statistics reset.
7. Board reboot clears sessions, game, and all statistics. Wi-Fi loss does not
mutate them; clients use HTTP polling while WebSocket reconnects.
## Visibility and transport
- Player 1 receives its complete board and only known opponent shot results;
player 2 is symmetric. Spectators see known shot results from both boards.
Unhit ships are replaced by `0` in every unauthorized view.
- `FINISHED` is the sole phase that exposes full boards to every role.
- HTTP commands include the token in the JSON body. WebSocket authentication
is the first `hello` message; the token is never placed in a URL or log.
- WebSocket reconnect delay is 1, 2, 5, then 10 seconds. HTTP polls a complete
snapshot every two seconds only while WebSocket is unavailable. Any skipped
version triggers a full snapshot request.
+107
View File
@@ -0,0 +1,107 @@
# MVP release and acceptance guide
This guide is the reproducible handoff for the ESP32-C6 Battleship MVP. It
does not replace the locked rules in [MVP.md](MVP.md), the HTTP contract in
[API_CONTRACT.md](API_CONTRACT.md), or the resource limits in
[RESOURCE_BUDGET.md](RESOURCE_BUDGET.md).
## Release prerequisites
- Target environment: `esp32-c6-devkitm-1` from `platformio.ini`.
- ESP32-C6FH4, revision v0.2, 4 MB flash; no PSRAM is assumed.
- PlatformIO Core 6.1.19, `espressif32` 7.0.1, ESP-IDF 6.0.1, and
`esp_littlefs` 1.20.4.
- USB serial device: normally `/dev/ttyACM0`; confirm with `pio device list`
before an upload.
- A local `include/wifi_config.h`, copied from `include/wifi_config.h.example`.
It is ignored by Git. Never commit the SSID or password.
## Build and upload
From the repository root, run the automated release checks:
```sh
make -C test/host run
pio run -e esp32-c6-devkitm-1 -t buildfs
pio run -e esp32-c6-devkitm-1
```
Confirm the build remains within the limits in `RESOURCE_BUDGET.md`, then
connect only the intended board and upload the browser assets before firmware:
```sh
pio run -e esp32-c6-devkitm-1 -t uploadfs --upload-port /dev/ttyACM0
pio run -e esp32-c6-devkitm-1 -t upload --upload-port /dev/ttyACM0
```
Replace `/dev/ttyACM0` when `pio device list` identifies another device. Do
not use erase targets for this release procedure.
After boot, obtain the DHCP address from the router or serial log, then open
`http://<device-address>/`. The web application and API must be used only on
the local network. The API health check is:
```sh
curl http://<device-address>/api/health
```
The response includes free/minimum heap, largest free block, connection count,
rejected input count, Wi-Fi state, and reset reason. It must not contain Wi-Fi
credentials or session tokens.
## MVP acceptance checklist
Record the observed result, device address, browser/device model, and any
deviation for every item. A `PASS` requires all thirteen checks.
| MVP criterion | Required physical check |
| --- | --- |
| 1 | Cold boot, Wi-Fi join, and Russian interface load from LittleFS. |
| 2 | Two phones complete a human-versus-human game. |
| 3 | One phone completes a human-versus-ESP32 game, including delayed bot turns. |
| 46 | Verify fleet rules, rejected duplicate/out-of-turn shots, and role-safe views. |
| 7 | Connect at least two spectators during a game; confirm controls stay unavailable. Repeat to the eight-spectator target. |
| 8 | Interrupt WebSocket connectivity; confirm two-second HTTP fallback and WebSocket recovery. |
| 9 | Refresh each player and a spectator; confirm token-based role/state recovery. |
| 1011 | Finish, inspect winner and statistics, rematch, then confirm fresh fleets and preserved cumulative totals. |
| 12 | Check narrow-phone and tablet portrait/landscape views, including tabs and side-by-side boards. |
| 13 | Complete 20 representative consecutive games with no reset, hang, or material heap decline. |
For the final load run, keep two players and eight spectators connected and
sample `/api/health` before, during, and after the run. The final report must
compare minimum free heap, largest free block, reset reason, and observed
state/delivery latency with the hard limits in `RESOURCE_BUDGET.md`.
## Recovery and incident handling
- A player refreshes the page to resume with the token stored in browser
`localStorage`; a reboot intentionally invalidates all tokens and resets
game/statistics state.
- If WebSocket is unavailable, leave the page open: the browser uses HTTP
state polling and retries WebSocket at 1, 2, 5, then 10 seconds.
- If the board has a new DHCP address, use the router/serial information and
open the new local URL. No fixed IP or external service is required.
- If LittleFS assets fail to load, repeat `uploadfs` for the confirmed target
port before reflashing firmware. Do not format LittleFS automatically.
- Capture `/api/health`, reset reason, and the exact source revision before
reporting a failure. Never capture or publish tokens or Wi-Fi credentials.
## Release record template
Complete this only after all physical checks pass:
```text
Source revision:
PlatformIO / ESP-IDF / esp_littlefs:
Firmware bytes / LittleFS bytes:
RAM bytes / minimum heap / largest free block:
Largest state payload / maximum observed delivery latency:
Reset reason before and after run:
20-game result:
2-player + 8-spectator result:
MVP criteria 113: PASS / FAIL with evidence:
Release decision: PASS
```
Post-MVP work begins only after this record is complete and Milestone 017 is
marked `DONE`.
+42
View File
@@ -0,0 +1,42 @@
# Resource budget
## Measured feasibility baseline
Milestone 004's accepted ESP32-C6 run completed 20 games of 200 updates with
two players and eight spectators. Its measured values were 1,000,496 B flash,
38,204 B RAM, 249,616 B minimum free heap, 320 B largest state message, 154 us
maximum generation time, and 5,283 us maximum asynchronous delivery-enqueue
time. The application partition is 2,097,152 B and LittleFS is 2,031,616 B.
## Hard production limits
| Resource | Limit | Basis |
| ---------------------------- | ----------: | -------------------------------------------------------------------------- |
| Firmware image | 1,500,000 B | M004 fixed threshold, leaving 597,152 B app-partition reserve |
| LittleFS image | 250,000 B | M004 fixed threshold, leaving 1,781,616 B filesystem reserve |
| Minimum free heap | 96,000 B | M004 fixed threshold; observed minimum was 249,616 B |
| Largest role-safe state JSON | 512 B | Tested fixed serialization cap; production schema is compact strings |
| HTTP JSON request body | 192 B | Largest defined command (`join`) fits within this bound |
| WebSocket incoming frame | 192 B | `hello` is the largest defined incoming frame |
| HTTP request target/query | 128 B | `/api/state?version=4294967295` is below this bound |
| State generation | 100,000 us | M004 fixed threshold; observed maximum was 154 us |
| State delivery enqueue | 100,000 us | M004 fixed threshold; observed maximum was 5,283 us |
| Pending application commands | 16 | Fixed bounded queue; excess requests return `SERVER_BUSY` |
| Sessions | 10 | Two players and eight spectators |
| Open HTTP sockets | 12 | Ten clients plus two polling/transport headroom; LWIP is configured for 16 |
No handler may allocate a per-client complete JSON state. It serializes one
bounded snapshot at a time into the 512-byte transport buffer.
## Per-milestone budget gates
| Milestone | Firmware ceiling | LittleFS ceiling | Heap floor | Required check |
| ---------------- | ---------------: | ---------------: | ---------: | ---------------------------------- |
| 006 architecture | 1,080,000 B | 250,000 B | 220,000 B | clean build and host tests |
| 007 game core | 1,180,000 B | 250,000 B | 190,000 B | fleet and rules tests |
| 008 sessions/API | 1,300,000 B | 250,000 B | 150,000 B | role filtering and malformed input |
| 009 transport/UI | 1,420,000 B | 250,000 B | 115,000 B | two players/eight spectators |
| MVP completion | 1,500,000 B | 250,000 B | 96,000 B | repeated on-board game test |
Each gate is a maximum permitted consumption or minimum required remaining
heap. A missed gate blocks the next milestone pending a documented decision.
+368
View File
@@ -0,0 +1,368 @@
openapi: 3.0.3
info:
title: Battleship ESP32 API
version: 1.0.0
description: |
Local HTTP API for the ESP32-C6 Battleship MVP. All responses are JSON,
UTF-8, and carry `Cache-Control: no-store`. The ESP32 is authoritative.
WebSocket synchronization at `/ws` is documented in API_CONTRACT.md;
it is not an HTTP request/response operation and is therefore outside
this OpenAPI document.
servers:
- url: http://{device-address}
variables:
device-address:
default: 192.168.1.50
description: DHCP address of the ESP32 on the local network.
paths:
/api/info:
get:
summary: Read public device and slot availability
responses:
'200':
description: Public game metadata.
content:
application/json:
schema:
$ref: '#/components/schemas/Info'
/api/health:
get:
summary: Read device diagnostics without secrets
responses:
'200':
description: Current resource and network diagnostics.
content:
application/json:
schema:
$ref: '#/components/schemas/Health'
/api/session/join:
post:
summary: Create a session and request a role
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/JoinRequest'
responses:
'200':
description: Session created.
content:
application/json:
schema:
$ref: '#/components/schemas/SessionCreated'
'400': { $ref: '#/components/responses/BadRequest' }
'409': { $ref: '#/components/responses/Conflict' }
'413': { $ref: '#/components/responses/PayloadTooLarge' }
/api/session/resume:
post:
summary: Resume a role using its opaque token
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TokenRequest'
responses:
'200':
description: Session resumed.
content:
application/json:
schema:
$ref: '#/components/schemas/SessionResumed'
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'413': { $ref: '#/components/responses/PayloadTooLarge' }
/api/game/config:
post:
summary: Set the game mode (Player 1, lobby only)
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ConfigRequest'
responses: &commandResponses
'200': { $ref: '#/components/responses/CommandAccepted' }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'409': { $ref: '#/components/responses/Conflict' }
'413': { $ref: '#/components/responses/PayloadTooLarge' }
'503': { $ref: '#/components/responses/ServerBusy' }
/api/game/start:
post:
summary: Start a configured game (Player 1)
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/GameRequest'
responses: *commandResponses
/api/game/shot:
post:
summary: Fire at an opponent cell
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ShotRequest'
responses: *commandResponses
/api/game/rematch:
post:
summary: Confirm a rematch after a finished game
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/GameRequest'
responses: *commandResponses
/api/game/abort:
post:
summary: Abort a human game with a disconnected opponent
description: Available only to Player 1 in the locked human-versus-human case.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/GameRequest'
responses: *commandResponses
/api/state:
get:
summary: Get a complete role-safe state snapshot
parameters:
- $ref: '#/components/parameters/Version'
- $ref: '#/components/parameters/SessionToken'
responses:
'200':
description: State view for the token role; without a token, a spectator-safe view.
content:
application/json:
schema:
$ref: '#/components/schemas/State'
'401': { $ref: '#/components/responses/Unauthorized' }
'413': { $ref: '#/components/responses/PayloadTooLarge' }
/api/statistics:
get:
summary: Get match and reboot-scoped cumulative statistics
parameters:
- $ref: '#/components/parameters/SessionToken'
responses:
'200':
description: Statistics only; no board cells are returned.
content:
application/json:
schema:
$ref: '#/components/schemas/Statistics'
'401': { $ref: '#/components/responses/Unauthorized' }
'413': { $ref: '#/components/responses/PayloadTooLarge' }
components:
parameters:
Version:
name: version
in: query
required: false
schema: { type: integer, minimum: 0, maximum: 4294967295 }
description: Last applied state version; a complete snapshot is always safe to consume.
SessionToken:
name: X-Session-Token
in: header
required: false
schema: { $ref: '#/components/schemas/Token' }
description: Omit only when a spectator-safe public view is intended.
responses:
CommandAccepted:
description: Command accepted by the authoritative application layer.
content:
application/json:
schema: { $ref: '#/components/schemas/CommandAccepted' }
BadRequest:
description: Malformed JSON or invalid input.
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
Unauthorized:
description: Invalid or expired token.
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
Forbidden:
description: The role may not perform this command.
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
Conflict:
description: Stale game, wrong phase, unavailable slot, or invalid turn/cell state.
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
PayloadTooLarge:
description: Request body or target exceeded its route bound.
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
ServerBusy:
description: Bounded command queue or serializer is unavailable.
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
schemas:
Token:
type: string
pattern: '^[0-9a-f]{32}$'
description: Opaque, reboot-scoped session token.
Role:
type: string
enum: [player1, player2, spectator]
Mode:
type: string
enum: [human, bot]
Phase:
type: string
enum: [lobby, preparing, in_progress, finished, rematch_wait]
GameId:
type: integer
minimum: 0
maximum: 4294967295
JoinRequest:
type: object
required: [name, requestedRole]
additionalProperties: false
properties:
name: { type: string, minLength: 1, maxLength: 80, description: 120 Unicode scalar values after server validation. }
requestedRole: { $ref: '#/components/schemas/Role' }
TokenRequest:
type: object
required: [token]
additionalProperties: false
properties: { token: { $ref: '#/components/schemas/Token' } }
GameRequest:
type: object
required: [token, gameId]
properties:
token: { $ref: '#/components/schemas/Token' }
gameId: { $ref: '#/components/schemas/GameId' }
ConfigRequest:
allOf:
- $ref: '#/components/schemas/GameRequest'
- type: object
required: [mode]
properties: { mode: { $ref: '#/components/schemas/Mode' } }
ShotRequest:
allOf:
- $ref: '#/components/schemas/GameRequest'
- type: object
required: [x, y]
properties:
x: { type: integer, minimum: 0, maximum: 9 }
y: { type: integer, minimum: 0, maximum: 9 }
CommandAccepted:
type: object
required: [ok, version, gameId]
properties:
ok: { type: boolean, enum: [true] }
version: { $ref: '#/components/schemas/GameId' }
gameId: { $ref: '#/components/schemas/GameId' }
SessionCreated:
allOf:
- $ref: '#/components/schemas/CommandAccepted'
- type: object
required: [token, role]
properties:
token: { $ref: '#/components/schemas/Token' }
role: { $ref: '#/components/schemas/Role' }
SessionResumed:
allOf:
- $ref: '#/components/schemas/CommandAccepted'
- type: object
required: [role]
properties: { role: { $ref: '#/components/schemas/Role' } }
Info:
type: object
required: [ok, phase, gameId, version, player1Available, player2Available, spectatorsAvailable]
properties:
ok: { type: boolean, enum: [true] }
phase: { $ref: '#/components/schemas/Phase' }
gameId: { $ref: '#/components/schemas/GameId' }
version: { $ref: '#/components/schemas/GameId' }
player1Available: { type: boolean }
player2Available: { type: boolean }
spectatorsAvailable: { type: integer, minimum: 0, maximum: 8 }
Health:
type: object
required: [ok, uptimeMs, wifiState, freeHeapBytes, minimumFreeHeapBytes, largestFreeBlockBytes, connectedClients, rejectedInput, resetReason]
properties:
ok: { type: boolean, enum: [true] }
uptimeMs: { $ref: '#/components/schemas/GameId' }
wifiState: { type: string, enum: [not_configured, connecting, connected] }
freeHeapBytes: { $ref: '#/components/schemas/GameId' }
minimumFreeHeapBytes: { $ref: '#/components/schemas/GameId' }
largestFreeBlockBytes: { $ref: '#/components/schemas/GameId' }
connectedClients: { type: integer, minimum: 0, maximum: 12 }
rejectedInput: { type: integer, minimum: 0, maximum: 65535 }
resetReason: { type: integer }
Board:
type: string
pattern: '^[01234]{100}$'
description: 0 unknown/water, 1 revealed ship, 2 miss, 3 hit, 4 sunk hit.
MatchStatistics:
type: array
minItems: 4
maxItems: 4
items: { type: integer, minimum: 0 }
description: '[shots, hits, misses, shipsSunk]'
CumulativeStatistics:
type: array
minItems: 7
maxItems: 7
items: { type: integer, minimum: 0 }
description: '[games, wins, losses, shipsSunk, shots, hits, misses]'
State:
type: object
required: [type, version, gameId, phase, mode, viewer, turn, boards, wins, winner, statistics]
properties:
type: { type: string, enum: [state] }
version: { $ref: '#/components/schemas/GameId' }
gameId: { $ref: '#/components/schemas/GameId' }
phase: { $ref: '#/components/schemas/Phase' }
mode: { $ref: '#/components/schemas/Mode' }
viewer: { $ref: '#/components/schemas/Role' }
turn: { type: string, enum: [player1, player2] }
boards:
type: array
minItems: 2
maxItems: 2
items: { $ref: '#/components/schemas/Board' }
wins:
type: array
minItems: 2
maxItems: 2
items: { type: integer, minimum: 0 }
winner:
nullable: true
type: integer
enum: [0, 1]
statistics:
type: array
minItems: 2
maxItems: 2
items: { $ref: '#/components/schemas/MatchStatistics' }
Statistics:
type: object
required: [ok, viewer, gameId, match, cumulative]
properties:
ok: { type: boolean, enum: [true] }
viewer: { $ref: '#/components/schemas/Role' }
gameId: { $ref: '#/components/schemas/GameId' }
match:
type: array
minItems: 2
maxItems: 2
items: { $ref: '#/components/schemas/MatchStatistics' }
cumulative:
type: array
minItems: 2
maxItems: 2
items: { $ref: '#/components/schemas/CumulativeStatistics' }
Error:
type: object
required: [ok, code, message, version]
properties:
ok: { type: boolean, enum: [false] }
code:
type: string
enum: [MALFORMED_JSON, PAYLOAD_TOO_LARGE, INVALID_NAME, INVALID_ROLE, INVALID_MODE, INVALID_COORDINATE, UNAUTHORIZED, NO_PLAYER_SLOT, NO_SPECTATOR_SLOT, FORBIDDEN_ROLE, WRONG_PHASE, NOT_YOUR_TURN, CELL_ALREADY_SHOT, STALE_GAME, SERVER_BUSY]
message: { type: string, maxLength: 80, description: Russian user-facing text. }
version: { $ref: '#/components/schemas/GameId' }
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include <stdint.h>
enum {
kBoardWidth = 10,
kBoardHeight = 10,
kBoardCellCount = kBoardWidth * kBoardHeight,
kFleetShipCount = 10,
kPlayerCapacity = 2,
kSpectatorCapacity = 8,
kSessionCapacity = kPlayerCapacity + kSpectatorCapacity,
kCommandQueueCapacity = 16,
kBotKnowledgeCellCount = kBoardCellCount,
kStateMessageCapacity = 512,
kRequestBodyCapacity = 192,
kSessionTokenBytes = 16,
kDisplayNameBytes = 80,
};
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
typedef struct { uint64_t (*now_ms)(void *context); void *context; } app_clock_t;
typedef struct { uint32_t (*next_u32)(void *context); void *context; } random_source_t;
typedef struct { bool (*schedule_after_ms)(void *context, uint32_t delay_ms); void *context; } scheduler_t;
typedef struct { bool (*send)(void *context, int client_id, const char *data, size_t length); void *context; } transport_t;
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <stdbool.h>
#include "command_queue.h"
#include "game_lifecycle.h"
/* The application task is the sole owner of lifecycle and game mutations. */
typedef struct { command_queue_t command_queue; game_lifecycle_t lifecycle; } application_t;
void application_init(application_t *application, random_source_t random);
void application_set_bot_scheduler(application_t *application, scheduler_t scheduler);
bool application_enqueue(application_t *application, const app_command_t *command);
bool application_take_next_command(application_t *application, app_command_t *command);
lifecycle_result_t application_join(application_t *application, role_t requested_role, const char *name,
uint8_t *session_index);
lifecycle_result_t application_resume(application_t *application,
const uint8_t token[kSessionTokenBytes], uint8_t *session_index);
bool application_session_for_token(const application_t *application,
const uint8_t token[kSessionTokenBytes], uint8_t *session_index);
lifecycle_result_t application_submit(application_t *application, const app_command_t *command);
bool application_bot_take_turn(application_t *application);
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include <stdbool.h>
#include "app_interfaces.h"
#include "game_types.h"
typedef uint8_t bot_cell_t;
enum { BOT_CELL_UNKNOWN, BOT_CELL_MISS, BOT_CELL_HIT, BOT_CELL_BLOCKED };
typedef struct { bool hit; bool sunk; } bot_shot_result_t;
typedef struct {
random_source_t random;
scheduler_t scheduler;
bot_cell_t knowledge[kBotKnowledgeCellCount];
bool turn_pending;
} bot_player_t;
void bot_player_init(bot_player_t *bot, random_source_t random, scheduler_t scheduler);
bool bot_player_next_shot(bot_player_t *bot, coordinate_t *coordinate);
void bot_player_record_result(bot_player_t *bot, coordinate_t coordinate, const bot_shot_result_t *result);
bool bot_player_schedule_turn(bot_player_t *bot);
void bot_player_cancel_turn(bot_player_t *bot);
_Static_assert(sizeof(bot_player_t) <= 160, "bot state grew beyond fixed budget");
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include "app_config.h"
#include "game_types.h"
typedef uint8_t command_type_t;
enum { COMMAND_CONFIG, COMMAND_START, COMMAND_SHOT, COMMAND_REMATCH, COMMAND_ABORT };
typedef struct { command_type_t type; uint8_t session_index; uint32_t game_id; uint32_t version; coordinate_t coordinate; game_mode_t mode; } app_command_t;
typedef struct { app_command_t entries[kCommandQueueCapacity]; uint8_t head; uint8_t tail; uint8_t count; } command_queue_t;
bool command_queue_push(command_queue_t *queue, const app_command_t *command);
bool command_queue_pop(command_queue_t *queue, app_command_t *command);
_Static_assert(sizeof(command_queue_t) <= 320, "command queue grew beyond fixed budget");
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include <stdint.h>
typedef struct {
uint64_t uptime_ms;
uint32_t free_heap_bytes;
uint32_t minimum_free_heap_bytes;
uint32_t largest_free_block_bytes;
uint16_t connected_clients;
uint16_t rejected_oversized_input;
int reset_reason;
} diagnostics_t;
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include "app_interfaces.h"
#include "game_types.h"
enum {
kFleetPlacementAttempts = 128,
kFleetGenerationRestarts = 64,
};
typedef struct { random_source_t random; } fleet_generator_t;
bool fleet_generator_generate(const fleet_generator_t *generator, board_t *board);
bool fleet_generator_validate(const board_t *board);
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include <stdbool.h>
#include "app_interfaces.h"
#include "fleet_generator.h"
#include "game_types.h"
typedef struct { game_state_t state; } game_engine_t;
typedef uint8_t game_result_t;
enum {
GAME_RESULT_OK,
GAME_RESULT_INVALID_COORDINATE,
GAME_RESULT_WRONG_PHASE,
GAME_RESULT_NOT_YOUR_TURN,
GAME_RESULT_CELL_ALREADY_SHOT,
GAME_RESULT_GENERATION_FAILED,
};
typedef struct { bool hit; bool sunk; bool finished; } shot_result_t;
void game_engine_init(game_engine_t *engine);
game_result_t game_engine_start(game_engine_t *engine, uint32_t game_id, game_mode_t mode,
const fleet_generator_t *generator);
game_result_t game_engine_shot(game_engine_t *engine, uint8_t player, coordinate_t coordinate,
shot_result_t *result);
+56
View File
@@ -0,0 +1,56 @@
#pragma once
#include "fleet_generator.h"
#include "bot_player.h"
#include "game_engine.h"
#include "session_manager.h"
#include "statistics.h"
typedef uint8_t lifecycle_result_t;
enum {
LIFECYCLE_RESULT_OK,
LIFECYCLE_RESULT_INVALID_NAME,
LIFECYCLE_RESULT_INVALID_ROLE,
LIFECYCLE_RESULT_INVALID_MODE,
LIFECYCLE_RESULT_UNAUTHORIZED,
LIFECYCLE_RESULT_NO_PLAYER_SLOT,
LIFECYCLE_RESULT_NO_SPECTATOR_SLOT,
LIFECYCLE_RESULT_FORBIDDEN_ROLE,
LIFECYCLE_RESULT_WRONG_PHASE,
LIFECYCLE_RESULT_NOT_YOUR_TURN,
LIFECYCLE_RESULT_CELL_ALREADY_SHOT,
LIFECYCLE_RESULT_INVALID_COORDINATE,
LIFECYCLE_RESULT_STALE_GAME,
LIFECYCLE_RESULT_GENERATION_FAILED,
};
typedef struct {
game_engine_t game;
session_manager_t sessions;
cumulative_statistics_t cumulative[kPlayerCapacity];
random_source_t random;
bot_player_t bot;
scheduler_t bot_scheduler;
bool bot_reserved;
bool rematch_confirmed[kPlayerCapacity];
uint32_t next_game_id;
} game_lifecycle_t;
void game_lifecycle_init(game_lifecycle_t *lifecycle, random_source_t random);
void game_lifecycle_set_bot_scheduler(game_lifecycle_t *lifecycle, scheduler_t scheduler);
lifecycle_result_t game_lifecycle_join(game_lifecycle_t *lifecycle, role_t requested_role, const char *name,
uint8_t *session_index);
lifecycle_result_t game_lifecycle_resume(game_lifecycle_t *lifecycle,
const uint8_t token[kSessionTokenBytes], uint8_t *session_index);
lifecycle_result_t game_lifecycle_disconnect(game_lifecycle_t *lifecycle, uint8_t session_index);
lifecycle_result_t game_lifecycle_leave(game_lifecycle_t *lifecycle, uint8_t session_index);
lifecycle_result_t game_lifecycle_configure(game_lifecycle_t *lifecycle, uint8_t session_index,
uint32_t game_id, game_mode_t mode);
lifecycle_result_t game_lifecycle_start(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id);
lifecycle_result_t game_lifecycle_shot(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id,
coordinate_t coordinate, shot_result_t *result);
lifecycle_result_t game_lifecycle_rematch(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id);
lifecycle_result_t game_lifecycle_abort(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id);
bool game_lifecycle_bot_take_turn(game_lifecycle_t *lifecycle);
_Static_assert(sizeof(game_lifecycle_t) <= 1600, "lifecycle state grew beyond fixed budget");
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include "app_config.h"
typedef uint8_t cell_t;
enum { CELL_UNKNOWN, CELL_WATER, CELL_SHIP, CELL_MISS, CELL_HIT };
typedef uint8_t phase_t;
enum { PHASE_LOBBY, PHASE_PREPARING, PHASE_IN_PROGRESS, PHASE_FINISHED, PHASE_REMATCH_WAIT };
typedef uint8_t role_t;
enum { ROLE_PLAYER_1, ROLE_PLAYER_2, ROLE_SPECTATOR };
typedef uint8_t game_mode_t;
enum { MODE_HUMAN, MODE_BOT };
typedef struct { uint8_t x; uint8_t y; } coordinate_t;
typedef struct { uint8_t x; uint8_t y; uint8_t length; uint8_t hits; bool horizontal; } ship_t;
typedef struct { cell_t cells[kBoardCellCount]; ship_t ships[kFleetShipCount]; uint8_t ships_alive; } board_t;
typedef struct { uint16_t shots; uint16_t hits; uint16_t misses; uint8_t ships_sunk; } match_statistics_t;
typedef struct {
uint32_t game_id;
uint32_t version;
phase_t phase;
game_mode_t mode;
uint8_t current_player;
uint8_t winner;
board_t boards[kPlayerCapacity];
match_statistics_t statistics[kPlayerCapacity];
} game_state_t;
_Static_assert(kBoardCellCount == 100, "board contract changed");
_Static_assert(kFleetShipCount == 10, "fleet contract changed");
_Static_assert(kSessionCapacity == 10, "session contract changed");
_Static_assert(sizeof(coordinate_t) == 2, "coordinate must remain compact");
_Static_assert(sizeof(ship_t) <= 6, "ship grew beyond fixed budget");
_Static_assert(sizeof(board_t) <= 164, "board grew beyond fixed budget");
_Static_assert(sizeof(match_statistics_t) <= 8, "statistics grew beyond fixed budget");
_Static_assert(sizeof(game_state_t) <= 360, "game state grew beyond fixed budget");
+56
View File
@@ -0,0 +1,56 @@
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "application.h"
#include "app_config.h"
typedef enum { HTTP_API_GET, HTTP_API_POST } http_api_method_t;
typedef enum {
HTTP_API_ROUTE_INFO,
HTTP_API_ROUTE_HEALTH,
HTTP_API_ROUTE_JOIN,
HTTP_API_ROUTE_RESUME,
HTTP_API_ROUTE_CONFIG,
HTTP_API_ROUTE_START,
HTTP_API_ROUTE_SHOT,
HTTP_API_ROUTE_REMATCH,
HTTP_API_ROUTE_ABORT,
HTTP_API_ROUTE_STATE,
HTTP_API_ROUTE_STATISTICS,
} http_api_route_t;
typedef struct {
uint32_t uptime_ms;
uint32_t free_heap_bytes;
uint32_t minimum_free_heap_bytes;
uint32_t largest_free_block_bytes;
uint8_t connected_clients;
uint16_t rejected_input;
int8_t reset_reason;
const char *wifi_state;
} http_api_health_t;
typedef struct {
http_api_method_t method;
http_api_route_t route;
bool content_type_json;
bool target_too_large;
const char *body;
size_t body_length;
const char *session_token;
} http_api_request_t;
typedef struct {
uint16_t status;
char body[kStateMessageCapacity];
size_t body_length;
} http_api_response_t;
typedef struct { application_t *application; http_api_health_t health; } http_api_t;
void http_api_init(http_api_t *api, application_t *application);
void http_api_set_health(http_api_t *api, const http_api_health_t *health);
bool http_api_handle(http_api_t *api, const http_api_request_t *request, http_api_response_t *response);
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include "app_interfaces.h"
#include "app_config.h"
#include "game_types.h"
typedef uint8_t session_result_t;
enum {
SESSION_RESULT_OK,
SESSION_RESULT_INVALID_NAME,
SESSION_RESULT_INVALID_ROLE,
SESSION_RESULT_NO_PLAYER_SLOT,
SESSION_RESULT_NO_SPECTATOR_SLOT,
SESSION_RESULT_UNAUTHORIZED,
SESSION_RESULT_WRONG_PHASE,
};
typedef struct {
role_t role;
bool occupied;
bool connected;
uint8_t token[kSessionTokenBytes];
char name[kDisplayNameBytes + 1];
} session_t;
typedef struct { session_t entries[kSessionCapacity]; } session_manager_t;
void session_manager_init(session_manager_t *manager);
session_result_t session_manager_join(session_manager_t *manager, role_t requested_role, const char *name,
random_source_t random, uint8_t *session_index);
session_result_t session_manager_resume(session_manager_t *manager, const uint8_t token[kSessionTokenBytes],
uint8_t *session_index);
bool session_manager_find(const session_manager_t *manager, const uint8_t token[kSessionTokenBytes],
uint8_t *session_index);
bool session_manager_disconnect(session_manager_t *manager, uint8_t session_index);
session_result_t session_manager_leave(session_manager_t *manager, uint8_t session_index, phase_t phase);
bool session_manager_player_present(const session_manager_t *manager, uint8_t player_index);
_Static_assert(sizeof(session_t) <= 104, "session grew beyond fixed budget");
_Static_assert(sizeof(session_manager_t) <= 1040, "session table grew beyond fixed budget");
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include <stddef.h>
#include "game_lifecycle.h"
#include "game_types.h"
bool state_presenter_write(const game_state_t *state, role_t viewer, char *output, size_t output_size, size_t *written);
bool state_presenter_write_lifecycle(const game_lifecycle_t *lifecycle, role_t viewer,
char *output, size_t output_size, size_t *written);
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include <stdint.h>
#include "game_types.h"
typedef struct {
uint16_t games;
uint16_t wins;
uint16_t losses;
uint16_t ships_sunk;
uint32_t shots;
uint32_t hits;
uint32_t misses;
} cumulative_statistics_t;
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "application.h"
enum {
kWebSocketFrameCapacity = 192,
kWebSocketHelloTimeoutMs = 5000,
};
typedef struct {
int client_id;
uint8_t session_index;
bool active;
bool authenticated;
uint64_t opened_ms;
} sync_connection_t;
typedef struct { application_t *application; sync_connection_t connections[kSessionCapacity]; } sync_service_t;
typedef bool (*sync_send_fn)(void *context, int client_id, const char *payload, size_t length);
void sync_service_init(sync_service_t *service, application_t *application);
bool sync_service_open(sync_service_t *service, int client_id, uint64_t now_ms);
void sync_service_close(sync_service_t *service, int client_id);
bool sync_service_receive(sync_service_t *service, int client_id, const char *frame, size_t frame_length,
uint64_t now_ms, char output[kStateMessageCapacity], size_t *output_length,
bool *state_changed, bool *close_client);
void sync_service_expire(sync_service_t *service, uint64_t now_ms, int closed_clients[kSessionCapacity],
size_t *closed_count);
void sync_service_broadcast(sync_service_t *service, sync_send_fn send, void *context);
+5
View File
@@ -0,0 +1,5 @@
#pragma once
#include "app_interfaces.h"
typedef struct { transport_t transport; } transport_service_t;
+2
View File
@@ -14,6 +14,8 @@ board = esp32-c6-devkitm-1
framework = espidf
board_build.partitions = partitions.csv
board_build.filesystem = littlefs
board_build.sdkconfig_defaults = sdkconfig.defaults
lib_deps = https://github.com/joltwallet/esp_littlefs.git#v1.20.4
extra_scripts = pre:scripts/compress_web_assets.py
monitor_port = /dev/ttyACM0
monitor_speed = 115200
+66
View File
@@ -0,0 +1,66 @@
Import("env")
import gzip
import re
from pathlib import Path
ROOT = Path(env.subst("$PROJECT_DIR"))
DATA = ROOT / "data"
ASSETS = ("index.html", "styles.css", "app.js")
def minify_html(source):
return re.sub(r">\s+<", "><", source).strip()
def minify_css(source):
source = re.sub(r"/\*.*?\*/", "", source, flags=re.S)
return re.sub(r"\s*([{}:;,>])\s*", r"\1", source).strip()
def minify_js(source):
output = []
quote = ""
escaped = False
pending_space = False
for character in source:
if quote:
output.append(character)
if escaped:
escaped = False
elif character == "\\":
escaped = True
elif character == quote:
quote = ""
continue
if character in ("'", '"', "`"):
if pending_space and output and (output[-1].isalnum() or output[-1] in "_$"):
output.append(" ")
pending_space = False
quote = character
output.append(character)
elif character.isspace():
pending_space = True
else:
if pending_space and output and (output[-1].isalnum() or output[-1] in "_$") and (character.isalnum() or character in "_$"):
output.append(" ")
pending_space = False
output.append(character)
return "".join(output)
def compress_assets():
for asset in ASSETS:
path = DATA / asset
text = path.read_text(encoding="utf-8")
if asset.endswith(".html"):
text = minify_html(text)
elif asset.endswith(".css"):
text = minify_css(text)
else:
text = minify_js(text)
(DATA / f"{asset}.gz").write_bytes(gzip.compress(text.encode("utf-8"), mtime=0))
compress_assets()
+1
View File
@@ -0,0 +1 @@
CONFIG_LWIP_MAX_SOCKETS=16
+1 -1
View File
@@ -2,7 +2,7 @@
# without default 'CMakeLists.txt' file.
idf_component_register(
SRCS "main.c"
SRCS "main.c" "application.c" "command_queue.c" "fleet_generator.c" "game_engine.c" "bot_player.c" "session_manager.c" "game_lifecycle.c" "state_presenter.c" "http_api.c" "sync_service.c"
INCLUDE_DIRS "../include"
REQUIRES esp_event esp_http_server esp_netif esp_wifi esp_littlefs nvs_flash
)
+62
View File
@@ -0,0 +1,62 @@
#include "application.h"
#include <stddef.h>
void application_init(application_t *application, random_source_t random) {
if (application == NULL) return;
application->command_queue = (command_queue_t){0};
game_lifecycle_init(&application->lifecycle, random);
}
void application_set_bot_scheduler(application_t *application, scheduler_t scheduler) {
if (application != NULL) game_lifecycle_set_bot_scheduler(&application->lifecycle, scheduler);
}
bool application_enqueue(application_t *application, const app_command_t *command) {
return application != NULL && command_queue_push(&application->command_queue, command);
}
bool application_take_next_command(application_t *application, app_command_t *command) {
return application != NULL && command_queue_pop(&application->command_queue, command);
}
lifecycle_result_t application_join(application_t *application, role_t requested_role, const char *name,
uint8_t *session_index) {
return application == NULL ? LIFECYCLE_RESULT_UNAUTHORIZED :
game_lifecycle_join(&application->lifecycle, requested_role, name, session_index);
}
lifecycle_result_t application_resume(application_t *application,
const uint8_t token[kSessionTokenBytes], uint8_t *session_index) {
return application == NULL ? LIFECYCLE_RESULT_UNAUTHORIZED :
game_lifecycle_resume(&application->lifecycle, token, session_index);
}
bool application_session_for_token(const application_t *application,
const uint8_t token[kSessionTokenBytes], uint8_t *session_index) {
return application != NULL && session_manager_find(&application->lifecycle.sessions, token, session_index);
}
lifecycle_result_t application_submit(application_t *application, const app_command_t *command) {
if (!application_enqueue(application, command)) return LIFECYCLE_RESULT_GENERATION_FAILED;
app_command_t next = {0};
if (!application_take_next_command(application, &next)) return LIFECYCLE_RESULT_GENERATION_FAILED;
switch (next.type) {
case COMMAND_CONFIG:
return game_lifecycle_configure(&application->lifecycle, next.session_index, next.game_id, next.mode);
case COMMAND_START:
return game_lifecycle_start(&application->lifecycle, next.session_index, next.game_id);
case COMMAND_SHOT:
return game_lifecycle_shot(&application->lifecycle, next.session_index, next.game_id, next.coordinate, NULL);
case COMMAND_REMATCH:
return game_lifecycle_rematch(&application->lifecycle, next.session_index, next.game_id);
case COMMAND_ABORT:
return game_lifecycle_abort(&application->lifecycle, next.session_index, next.game_id);
default:
return LIFECYCLE_RESULT_GENERATION_FAILED;
}
}
bool application_bot_take_turn(application_t *application) {
return application != NULL && game_lifecycle_bot_take_turn(&application->lifecycle);
}
+182
View File
@@ -0,0 +1,182 @@
#include "bot_player.h"
#include <stddef.h>
#include <string.h>
static uint8_t cell_index(uint8_t x, uint8_t y) {
return (uint8_t)(y * kBoardWidth + x);
}
static bool coordinate_is_valid(coordinate_t coordinate) {
return coordinate.x < kBoardWidth && coordinate.y < kBoardHeight;
}
static uint32_t random_u32(bot_player_t *bot) {
if (bot->random.next_u32 == NULL) return 0U;
return bot->random.next_u32(bot->random.context);
}
static bool choose_unknown(const bot_player_t *bot, uint8_t start, bool checkerboard,
coordinate_t *coordinate) {
for (uint8_t offset = 0; offset < kBotKnowledgeCellCount; ++offset) {
const uint8_t index = (uint8_t)((start + offset) % kBotKnowledgeCellCount);
const uint8_t x = (uint8_t)(index % kBoardWidth);
const uint8_t y = (uint8_t)(index / kBoardWidth);
if (bot->knowledge[index] != BOT_CELL_UNKNOWN) continue;
if (checkerboard && ((x + y) & 1U) != 0U) continue;
*coordinate = (coordinate_t){x, y};
return true;
}
return false;
}
static uint8_t collect_hit_group(const bot_player_t *bot, uint8_t first, uint8_t *group,
uint8_t *min_x, uint8_t *max_x, uint8_t *min_y, uint8_t *max_y) {
uint8_t queue[kBotKnowledgeCellCount] = {0};
uint8_t count = 0;
uint8_t read = 0;
queue[count++] = first;
group[0] = first;
*min_x = *max_x = (uint8_t)(first % kBoardWidth);
*min_y = *max_y = (uint8_t)(first / kBoardWidth);
while (read < count) {
const uint8_t index = queue[read++];
const uint8_t x = (uint8_t)(index % kBoardWidth);
const uint8_t y = (uint8_t)(index / kBoardWidth);
const int8_t directions[4][2] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
for (uint8_t direction = 0; direction < 4; ++direction) {
const int16_t neighbour_x = (int16_t)x + directions[direction][0];
const int16_t neighbour_y = (int16_t)y + directions[direction][1];
if (neighbour_x < 0 || neighbour_y < 0 || neighbour_x >= kBoardWidth || neighbour_y >= kBoardHeight) continue;
const uint8_t neighbour = cell_index((uint8_t)neighbour_x, (uint8_t)neighbour_y);
if (bot->knowledge[neighbour] != BOT_CELL_HIT) continue;
bool already_present = false;
for (uint8_t item = 0; item < count; ++item) {
if (queue[item] == neighbour) {
already_present = true;
break;
}
}
if (already_present) continue;
queue[count] = neighbour;
group[count++] = neighbour;
if ((uint8_t)neighbour_x < *min_x) *min_x = (uint8_t)neighbour_x;
if ((uint8_t)neighbour_x > *max_x) *max_x = (uint8_t)neighbour_x;
if ((uint8_t)neighbour_y < *min_y) *min_y = (uint8_t)neighbour_y;
if ((uint8_t)neighbour_y > *max_y) *max_y = (uint8_t)neighbour_y;
}
}
return count;
}
static bool choose_target(bot_player_t *bot, coordinate_t *coordinate) {
uint8_t first = kBotKnowledgeCellCount;
for (uint8_t index = 0; index < kBotKnowledgeCellCount; ++index) {
if (bot->knowledge[index] == BOT_CELL_HIT) {
first = index;
break;
}
}
if (first == kBotKnowledgeCellCount) return false;
uint8_t group[kBotKnowledgeCellCount] = {0};
uint8_t min_x = 0;
uint8_t max_x = 0;
uint8_t min_y = 0;
uint8_t max_y = 0;
const uint8_t count = collect_hit_group(bot, first, group, &min_x, &max_x, &min_y, &max_y);
if (count >= 2U && min_y == max_y) {
const coordinate_t ends[2] = {{min_x == 0 ? kBoardWidth : (uint8_t)(min_x - 1U), min_y},
{max_x + 1U, min_y}};
const uint8_t first_end = (uint8_t)(random_u32(bot) & 1U);
for (uint8_t offset = 0; offset < 2; ++offset) {
const coordinate_t candidate = ends[(first_end + offset) & 1U];
if (coordinate_is_valid(candidate) && bot->knowledge[cell_index(candidate.x, candidate.y)] == BOT_CELL_UNKNOWN) {
*coordinate = candidate;
return true;
}
}
return false;
}
if (count >= 2U && min_x == max_x) {
const coordinate_t ends[2] = {{min_x, min_y == 0 ? kBoardHeight : (uint8_t)(min_y - 1U)},
{min_x, max_y + 1U}};
const uint8_t first_end = (uint8_t)(random_u32(bot) & 1U);
for (uint8_t offset = 0; offset < 2; ++offset) {
const coordinate_t candidate = ends[(first_end + offset) & 1U];
if (coordinate_is_valid(candidate) && bot->knowledge[cell_index(candidate.x, candidate.y)] == BOT_CELL_UNKNOWN) {
*coordinate = candidate;
return true;
}
}
return false;
}
const uint8_t hit_x = (uint8_t)(first % kBoardWidth);
const uint8_t hit_y = (uint8_t)(first / kBoardWidth);
const int8_t directions[4][2] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
const uint8_t first_direction = (uint8_t)(random_u32(bot) % 4U);
for (uint8_t offset = 0; offset < 4; ++offset) {
const int8_t *direction = directions[(first_direction + offset) % 4U];
const int16_t x = (int16_t)hit_x + direction[0];
const int16_t y = (int16_t)hit_y + direction[1];
if (x < 0 || y < 0 || x >= kBoardWidth || y >= kBoardHeight) continue;
if (bot->knowledge[cell_index((uint8_t)x, (uint8_t)y)] == BOT_CELL_UNKNOWN) {
*coordinate = (coordinate_t){(uint8_t)x, (uint8_t)y};
return true;
}
}
return false;
}
void bot_player_init(bot_player_t *bot, random_source_t random, scheduler_t scheduler) {
if (bot == NULL) return;
memset(bot, 0, sizeof(*bot));
bot->random = random;
bot->scheduler = scheduler;
}
bool bot_player_next_shot(bot_player_t *bot, coordinate_t *coordinate) {
if (bot == NULL || coordinate == NULL) return false;
bot->turn_pending = false;
if (choose_target(bot, coordinate)) return true;
const uint8_t start = (uint8_t)(random_u32(bot) % kBotKnowledgeCellCount);
return choose_unknown(bot, start, true, coordinate) || choose_unknown(bot, start, false, coordinate);
}
void bot_player_record_result(bot_player_t *bot, coordinate_t coordinate, const bot_shot_result_t *result) {
if (bot == NULL || result == NULL || !coordinate_is_valid(coordinate)) return;
const uint8_t index = cell_index(coordinate.x, coordinate.y);
bot->knowledge[index] = result->hit ? BOT_CELL_HIT : BOT_CELL_MISS;
if (!result->sunk || !result->hit) return;
uint8_t group[kBotKnowledgeCellCount] = {0};
uint8_t min_x = 0;
uint8_t max_x = 0;
uint8_t min_y = 0;
uint8_t max_y = 0;
const uint8_t count = collect_hit_group(bot, index, group, &min_x, &max_x, &min_y, &max_y);
for (uint8_t item = 0; item < count; ++item) bot->knowledge[group[item]] = BOT_CELL_BLOCKED;
const uint8_t from_x = min_x == 0 ? 0 : (uint8_t)(min_x - 1U);
const uint8_t from_y = min_y == 0 ? 0 : (uint8_t)(min_y - 1U);
const uint8_t to_x = max_x + 1U >= kBoardWidth ? (uint8_t)(kBoardWidth - 1U) : (uint8_t)(max_x + 1U);
const uint8_t to_y = max_y + 1U >= kBoardHeight ? (uint8_t)(kBoardHeight - 1U) : (uint8_t)(max_y + 1U);
for (uint8_t y = from_y; y <= to_y; ++y) {
for (uint8_t x = from_x; x <= to_x; ++x) {
const uint8_t neighbour = cell_index(x, y);
if (bot->knowledge[neighbour] == BOT_CELL_UNKNOWN) bot->knowledge[neighbour] = BOT_CELL_BLOCKED;
}
}
}
bool bot_player_schedule_turn(bot_player_t *bot) {
if (bot == NULL || bot->turn_pending || bot->scheduler.schedule_after_ms == NULL) return false;
const uint32_t delay_ms = 500U + (random_u32(bot) % 401U);
if (!bot->scheduler.schedule_after_ms(bot->scheduler.context, delay_ms)) return false;
bot->turn_pending = true;
return true;
}
void bot_player_cancel_turn(bot_player_t *bot) {
if (bot != NULL) bot->turn_pending = false;
}
+19
View File
@@ -0,0 +1,19 @@
#include "command_queue.h"
#include <stddef.h>
bool command_queue_push(command_queue_t *queue, const app_command_t *command) {
if (queue == NULL || command == NULL || queue->count == kCommandQueueCapacity) return false;
queue->entries[queue->tail] = *command;
queue->tail = (uint8_t)((queue->tail + 1U) % kCommandQueueCapacity);
++queue->count;
return true;
}
bool command_queue_pop(command_queue_t *queue, app_command_t *command) {
if (queue == NULL || command == NULL || queue->count == 0) return false;
*command = queue->entries[queue->head];
queue->head = (uint8_t)((queue->head + 1U) % kCommandQueueCapacity);
--queue->count;
return true;
}
+119
View File
@@ -0,0 +1,119 @@
#include "fleet_generator.h"
#include <stddef.h>
#include <string.h>
static const uint8_t kFleetLengths[kFleetShipCount] = {4, 3, 3, 2, 2, 2, 1, 1, 1, 1};
static bool coordinate_is_valid(uint8_t x, uint8_t y) {
return x < kBoardWidth && y < kBoardHeight;
}
static uint8_t cell_index(uint8_t x, uint8_t y) {
return (uint8_t)(y * kBoardWidth + x);
}
static bool random_u32(const fleet_generator_t *generator, uint32_t *value) {
if (generator == NULL || generator->random.next_u32 == NULL || value == NULL) return false;
*value = generator->random.next_u32(generator->random.context);
return true;
}
static bool placement_fits(const board_t *board, uint8_t x, uint8_t y, uint8_t length,
bool horizontal) {
const uint8_t end_x = horizontal ? (uint8_t)(x + length - 1U) : x;
const uint8_t end_y = horizontal ? y : (uint8_t)(y + length - 1U);
if (!coordinate_is_valid(end_x, end_y)) return false;
const uint8_t min_x = x == 0 ? 0 : (uint8_t)(x - 1U);
const uint8_t min_y = y == 0 ? 0 : (uint8_t)(y - 1U);
const uint8_t max_x = end_x + 1U >= kBoardWidth ? (uint8_t)(kBoardWidth - 1U) : (uint8_t)(end_x + 1U);
const uint8_t max_y = end_y + 1U >= kBoardHeight ? (uint8_t)(kBoardHeight - 1U) : (uint8_t)(end_y + 1U);
for (uint8_t check_y = min_y; check_y <= max_y; ++check_y) {
for (uint8_t check_x = min_x; check_x <= max_x; ++check_x) {
if (board->cells[cell_index(check_x, check_y)] == CELL_SHIP) return false;
}
}
return true;
}
static void place_ship(board_t *board, uint8_t ship_index, uint8_t x, uint8_t y, uint8_t length,
bool horizontal) {
ship_t *ship = &board->ships[ship_index];
*ship = (ship_t){.x = x, .y = y, .length = length, .hits = 0, .horizontal = horizontal};
for (uint8_t offset = 0; offset < length; ++offset) {
const uint8_t cell_x = horizontal ? (uint8_t)(x + offset) : x;
const uint8_t cell_y = horizontal ? y : (uint8_t)(y + offset);
board->cells[cell_index(cell_x, cell_y)] = CELL_SHIP;
}
}
bool fleet_generator_generate(const fleet_generator_t *generator, board_t *board) {
if (board == NULL) return false;
for (uint8_t restart = 0; restart < kFleetGenerationRestarts; ++restart) {
board_t candidate = {0};
memset(candidate.cells, CELL_WATER, sizeof(candidate.cells));
bool complete = true;
for (uint8_t ship_index = 0; ship_index < kFleetShipCount; ++ship_index) {
bool placed = false;
for (uint16_t attempt = 0; attempt < kFleetPlacementAttempts; ++attempt) {
uint32_t value = 0;
if (!random_u32(generator, &value)) return false;
const bool horizontal = (value & 1U) != 0U;
const uint8_t x = (uint8_t)((value >> 1U) % kBoardWidth);
const uint8_t y = (uint8_t)((value >> 5U) % kBoardHeight);
const uint8_t length = kFleetLengths[ship_index];
if (!placement_fits(&candidate, x, y, length, horizontal)) continue;
place_ship(&candidate, ship_index, x, y, length, horizontal);
placed = true;
break;
}
if (!placed) {
complete = false;
break;
}
}
candidate.ships_alive = complete ? kFleetShipCount : 0U;
if (complete && fleet_generator_validate(&candidate)) {
*board = candidate;
return true;
}
}
return false;
}
bool fleet_generator_validate(const board_t *board) {
if (board == NULL || board->ships_alive != kFleetShipCount) return false;
uint8_t expected_cells[kBoardCellCount] = {0};
for (uint8_t ship_index = 0; ship_index < kFleetShipCount; ++ship_index) {
const ship_t *ship = &board->ships[ship_index];
if (ship->length != kFleetLengths[ship_index] || ship->hits > ship->length) return false;
const uint8_t end_x = ship->horizontal ? (uint8_t)(ship->x + ship->length - 1U) : ship->x;
const uint8_t end_y = ship->horizontal ? ship->y : (uint8_t)(ship->y + ship->length - 1U);
if (!coordinate_is_valid(ship->x, ship->y) || !coordinate_is_valid(end_x, end_y)) return false;
for (uint8_t offset = 0; offset < ship->length; ++offset) {
const uint8_t x = ship->horizontal ? (uint8_t)(ship->x + offset) : ship->x;
const uint8_t y = ship->horizontal ? ship->y : (uint8_t)(ship->y + offset);
const uint8_t index = cell_index(x, y);
if (expected_cells[index] != 0U || board->cells[index] != CELL_SHIP) return false;
expected_cells[index] = (uint8_t)(ship_index + 1U);
}
}
for (uint8_t y = 0; y < kBoardHeight; ++y) {
for (uint8_t x = 0; x < kBoardWidth; ++x) {
const uint8_t index = cell_index(x, y);
if ((board->cells[index] == CELL_SHIP) != (expected_cells[index] != 0U)) return false;
if (board->cells[index] != CELL_SHIP) continue;
for (int8_t delta_y = -1; delta_y <= 1; ++delta_y) {
for (int8_t delta_x = -1; delta_x <= 1; ++delta_x) {
const int16_t neighbour_x = (int16_t)x + delta_x;
const int16_t neighbour_y = (int16_t)y + delta_y;
if (neighbour_x < 0 || neighbour_y < 0 || neighbour_x >= kBoardWidth || neighbour_y >= kBoardHeight) continue;
const uint8_t neighbour = cell_index((uint8_t)neighbour_x, (uint8_t)neighbour_y);
if (board->cells[neighbour] == CELL_SHIP && expected_cells[index] != expected_cells[neighbour]) return false;
}
}
}
}
return true;
}
+111
View File
@@ -0,0 +1,111 @@
#include "game_engine.h"
#include <stddef.h>
#include <string.h>
static uint8_t cell_index(coordinate_t coordinate) {
return (uint8_t)(coordinate.y * kBoardWidth + coordinate.x);
}
static bool coordinate_is_valid(coordinate_t coordinate) {
return coordinate.x < kBoardWidth && coordinate.y < kBoardHeight;
}
static ship_t *ship_at(board_t *board, coordinate_t coordinate) {
for (uint8_t index = 0; index < kFleetShipCount; ++index) {
ship_t *ship = &board->ships[index];
for (uint8_t offset = 0; offset < ship->length; ++offset) {
const uint8_t x = ship->horizontal ? (uint8_t)(ship->x + offset) : ship->x;
const uint8_t y = ship->horizontal ? ship->y : (uint8_t)(ship->y + offset);
if (x == coordinate.x && y == coordinate.y) return ship;
}
}
return NULL;
}
static void mark_sunk_ship_border(board_t *board, const ship_t *ship) {
const uint8_t end_x = ship->horizontal ? (uint8_t)(ship->x + ship->length - 1U) : ship->x;
const uint8_t end_y = ship->horizontal ? ship->y : (uint8_t)(ship->y + ship->length - 1U);
const uint8_t min_x = ship->x == 0 ? 0 : (uint8_t)(ship->x - 1U);
const uint8_t min_y = ship->y == 0 ? 0 : (uint8_t)(ship->y - 1U);
const uint8_t max_x = end_x + 1U >= kBoardWidth ? (uint8_t)(kBoardWidth - 1U) : (uint8_t)(end_x + 1U);
const uint8_t max_y = end_y + 1U >= kBoardHeight ? (uint8_t)(kBoardHeight - 1U) : (uint8_t)(end_y + 1U);
for (uint8_t y = min_y; y <= max_y; ++y) {
for (uint8_t x = min_x; x <= max_x; ++x) {
const uint8_t index = (uint8_t)(y * kBoardWidth + x);
if (board->cells[index] == CELL_WATER) board->cells[index] = CELL_MISS;
}
}
}
void game_engine_init(game_engine_t *engine) {
if (engine == NULL) return;
memset(engine, 0, sizeof(*engine));
engine->state.phase = PHASE_LOBBY;
engine->state.winner = kPlayerCapacity;
}
game_result_t game_engine_start(game_engine_t *engine, uint32_t game_id, game_mode_t mode,
const fleet_generator_t *generator) {
if (engine == NULL || generator == NULL) return GAME_RESULT_GENERATION_FAILED;
if (engine->state.phase != PHASE_LOBBY) return GAME_RESULT_WRONG_PHASE;
if (mode != MODE_HUMAN && mode != MODE_BOT) return GAME_RESULT_WRONG_PHASE;
board_t generated[kPlayerCapacity] = {0};
for (uint8_t player = 0; player < kPlayerCapacity; ++player) {
if (!fleet_generator_generate(generator, &generated[player])) return GAME_RESULT_GENERATION_FAILED;
}
if (generator->random.next_u32 == NULL) return GAME_RESULT_GENERATION_FAILED;
const uint8_t first_player = (uint8_t)(generator->random.next_u32(generator->random.context) % kPlayerCapacity);
const uint32_t previous_version = engine->state.version;
memset(&engine->state, 0, sizeof(engine->state));
engine->state.game_id = game_id;
engine->state.version = previous_version + 1U;
engine->state.phase = PHASE_IN_PROGRESS;
engine->state.mode = mode;
engine->state.current_player = first_player;
engine->state.winner = kPlayerCapacity;
memcpy(engine->state.boards, generated, sizeof(generated));
return GAME_RESULT_OK;
}
game_result_t game_engine_shot(game_engine_t *engine, uint8_t player, coordinate_t coordinate,
shot_result_t *result) {
if (engine == NULL || !coordinate_is_valid(coordinate)) return GAME_RESULT_INVALID_COORDINATE;
if (engine->state.phase != PHASE_IN_PROGRESS) return GAME_RESULT_WRONG_PHASE;
if (player >= kPlayerCapacity || player != engine->state.current_player) return GAME_RESULT_NOT_YOUR_TURN;
board_t *target = &engine->state.boards[player ^ 1U];
cell_t *cell = &target->cells[cell_index(coordinate)];
if (*cell == CELL_MISS || *cell == CELL_HIT) return GAME_RESULT_CELL_ALREADY_SHOT;
shot_result_t accepted = {0};
match_statistics_t *statistics = &engine->state.statistics[player];
++statistics->shots;
if (*cell == CELL_SHIP) {
*cell = CELL_HIT;
++statistics->hits;
accepted.hit = true;
ship_t *ship = ship_at(target, coordinate);
if (ship != NULL) {
++ship->hits;
if (ship->hits == ship->length) {
--target->ships_alive;
++statistics->ships_sunk;
accepted.sunk = true;
mark_sunk_ship_border(target, ship);
}
}
if (target->ships_alive == 0U) {
engine->state.phase = PHASE_FINISHED;
engine->state.winner = player;
accepted.finished = true;
}
} else {
*cell = CELL_MISS;
++statistics->misses;
engine->state.current_player ^= 1U;
}
++engine->state.version;
if (result != NULL) *result = accepted;
return GAME_RESULT_OK;
}
+203
View File
@@ -0,0 +1,203 @@
#include "game_lifecycle.h"
#include <stddef.h>
#include <string.h>
static lifecycle_result_t session_result(session_result_t result) {
switch (result) {
case SESSION_RESULT_OK: return LIFECYCLE_RESULT_OK;
case SESSION_RESULT_INVALID_NAME: return LIFECYCLE_RESULT_INVALID_NAME;
case SESSION_RESULT_INVALID_ROLE: return LIFECYCLE_RESULT_INVALID_ROLE;
case SESSION_RESULT_NO_PLAYER_SLOT: return LIFECYCLE_RESULT_NO_PLAYER_SLOT;
case SESSION_RESULT_NO_SPECTATOR_SLOT: return LIFECYCLE_RESULT_NO_SPECTATOR_SLOT;
case SESSION_RESULT_WRONG_PHASE: return LIFECYCLE_RESULT_WRONG_PHASE;
default: return LIFECYCLE_RESULT_UNAUTHORIZED;
}
}
static lifecycle_result_t game_result(game_result_t result) {
switch (result) {
case GAME_RESULT_OK: return LIFECYCLE_RESULT_OK;
case GAME_RESULT_INVALID_COORDINATE: return LIFECYCLE_RESULT_INVALID_COORDINATE;
case GAME_RESULT_WRONG_PHASE: return LIFECYCLE_RESULT_WRONG_PHASE;
case GAME_RESULT_NOT_YOUR_TURN: return LIFECYCLE_RESULT_NOT_YOUR_TURN;
case GAME_RESULT_CELL_ALREADY_SHOT: return LIFECYCLE_RESULT_CELL_ALREADY_SHOT;
default: return LIFECYCLE_RESULT_GENERATION_FAILED;
}
}
static bool is_player(const game_lifecycle_t *lifecycle, uint8_t session_index, uint8_t *player) {
if (lifecycle == NULL || session_index >= kPlayerCapacity || !lifecycle->sessions.entries[session_index].occupied) return false;
if (lifecycle->sessions.entries[session_index].role != session_index) return false;
if (player != NULL) *player = session_index;
return true;
}
static bool game_id_matches(const game_lifecycle_t *lifecycle, uint32_t game_id) {
return lifecycle != NULL && lifecycle->game.state.game_id == game_id;
}
static void record_cumulative(game_lifecycle_t *lifecycle) {
for (uint8_t player = 0; player < kPlayerCapacity; ++player) {
cumulative_statistics_t *total = &lifecycle->cumulative[player];
const match_statistics_t *match = &lifecycle->game.state.statistics[player];
++total->games;
total->shots += match->shots;
total->hits += match->hits;
total->misses += match->misses;
total->ships_sunk += match->ships_sunk;
if (lifecycle->game.state.winner == player) ++total->wins;
else ++total->losses;
}
}
static lifecycle_result_t start_current_game(game_lifecycle_t *lifecycle) {
const uint32_t game_id = lifecycle->game.state.game_id;
const game_mode_t mode = lifecycle->game.state.mode;
const uint32_t version = lifecycle->game.state.version;
game_engine_init(&lifecycle->game);
lifecycle->game.state.game_id = game_id;
lifecycle->game.state.version = version;
const lifecycle_result_t result = game_result(game_engine_start(&lifecycle->game, game_id, mode,
&(fleet_generator_t){.random = lifecycle->random}));
if (result != LIFECYCLE_RESULT_OK || mode != MODE_BOT) return result;
bot_player_init(&lifecycle->bot, lifecycle->random, lifecycle->bot_scheduler);
if (lifecycle->game.state.current_player == 1U && lifecycle->bot_scheduler.schedule_after_ms != NULL) {
(void)bot_player_schedule_turn(&lifecycle->bot);
}
return LIFECYCLE_RESULT_OK;
}
void game_lifecycle_init(game_lifecycle_t *lifecycle, random_source_t random) {
if (lifecycle == NULL) return;
memset(lifecycle, 0, sizeof(*lifecycle));
lifecycle->random = random;
lifecycle->next_game_id = 2U;
game_engine_init(&lifecycle->game);
lifecycle->game.state.game_id = 1U;
session_manager_init(&lifecycle->sessions);
}
void game_lifecycle_set_bot_scheduler(game_lifecycle_t *lifecycle, scheduler_t scheduler) {
if (lifecycle == NULL) return;
lifecycle->bot_scheduler = scheduler;
}
lifecycle_result_t game_lifecycle_join(game_lifecycle_t *lifecycle, role_t requested_role, const char *name,
uint8_t *session_index) {
if (lifecycle == NULL) return LIFECYCLE_RESULT_UNAUTHORIZED;
if ((requested_role == ROLE_PLAYER_1 || requested_role == ROLE_PLAYER_2) && lifecycle->game.state.phase != PHASE_LOBBY) {
return LIFECYCLE_RESULT_NO_PLAYER_SLOT;
}
if (requested_role == ROLE_PLAYER_2 && !session_manager_player_present(&lifecycle->sessions, 0U)) {
return LIFECYCLE_RESULT_NO_PLAYER_SLOT;
}
if (requested_role == ROLE_PLAYER_2 && (lifecycle->game.state.mode == MODE_BOT || lifecycle->bot_reserved)) {
return LIFECYCLE_RESULT_NO_PLAYER_SLOT;
}
return session_result(session_manager_join(&lifecycle->sessions, requested_role, name, lifecycle->random, session_index));
}
lifecycle_result_t game_lifecycle_resume(game_lifecycle_t *lifecycle,
const uint8_t token[kSessionTokenBytes], uint8_t *session_index) {
return lifecycle == NULL ? LIFECYCLE_RESULT_UNAUTHORIZED :
session_result(session_manager_resume(&lifecycle->sessions, token, session_index));
}
lifecycle_result_t game_lifecycle_disconnect(game_lifecycle_t *lifecycle, uint8_t session_index) {
return lifecycle != NULL && session_manager_disconnect(&lifecycle->sessions, session_index) ?
LIFECYCLE_RESULT_OK : LIFECYCLE_RESULT_UNAUTHORIZED;
}
lifecycle_result_t game_lifecycle_leave(game_lifecycle_t *lifecycle, uint8_t session_index) {
return lifecycle == NULL ? LIFECYCLE_RESULT_UNAUTHORIZED :
session_result(session_manager_leave(&lifecycle->sessions, session_index, lifecycle->game.state.phase));
}
lifecycle_result_t game_lifecycle_configure(game_lifecycle_t *lifecycle, uint8_t session_index,
uint32_t game_id, game_mode_t mode) {
uint8_t player = 0;
if (!is_player(lifecycle, session_index, &player)) return LIFECYCLE_RESULT_FORBIDDEN_ROLE;
if (player != 0U) return LIFECYCLE_RESULT_FORBIDDEN_ROLE;
if (!game_id_matches(lifecycle, game_id)) return LIFECYCLE_RESULT_STALE_GAME;
if (lifecycle->game.state.phase != PHASE_LOBBY) return LIFECYCLE_RESULT_WRONG_PHASE;
if (mode != MODE_HUMAN && mode != MODE_BOT) return LIFECYCLE_RESULT_INVALID_MODE;
if (mode == MODE_BOT && session_manager_player_present(&lifecycle->sessions, 1U)) return LIFECYCLE_RESULT_NO_PLAYER_SLOT;
lifecycle->game.state.mode = mode;
lifecycle->bot_reserved = mode == MODE_BOT;
++lifecycle->game.state.version;
return LIFECYCLE_RESULT_OK;
}
lifecycle_result_t game_lifecycle_start(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id) {
uint8_t player = 0;
if (!is_player(lifecycle, session_index, &player)) return LIFECYCLE_RESULT_FORBIDDEN_ROLE;
if (player != 0U) return LIFECYCLE_RESULT_FORBIDDEN_ROLE;
if (!game_id_matches(lifecycle, game_id)) return LIFECYCLE_RESULT_STALE_GAME;
if (lifecycle->game.state.phase != PHASE_LOBBY) return LIFECYCLE_RESULT_WRONG_PHASE;
if (lifecycle->game.state.mode == MODE_HUMAN && !session_manager_player_present(&lifecycle->sessions, 1U)) return LIFECYCLE_RESULT_WRONG_PHASE;
if (lifecycle->game.state.mode == MODE_BOT && !lifecycle->bot_reserved) return LIFECYCLE_RESULT_WRONG_PHASE;
return start_current_game(lifecycle);
}
lifecycle_result_t game_lifecycle_shot(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id,
coordinate_t coordinate, shot_result_t *result) {
uint8_t player = 0;
if (!is_player(lifecycle, session_index, &player)) return LIFECYCLE_RESULT_FORBIDDEN_ROLE;
if (!game_id_matches(lifecycle, game_id)) return LIFECYCLE_RESULT_STALE_GAME;
const lifecycle_result_t outcome = game_result(game_engine_shot(&lifecycle->game, player, coordinate, result));
if (outcome == LIFECYCLE_RESULT_OK && lifecycle->game.state.phase == PHASE_FINISHED) record_cumulative(lifecycle);
else if (outcome == LIFECYCLE_RESULT_OK && lifecycle->game.state.mode == MODE_BOT && lifecycle->game.state.current_player == 1U &&
lifecycle->bot_scheduler.schedule_after_ms != NULL) (void)bot_player_schedule_turn(&lifecycle->bot);
return outcome;
}
lifecycle_result_t game_lifecycle_rematch(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id) {
uint8_t player = 0;
if (!is_player(lifecycle, session_index, &player)) return LIFECYCLE_RESULT_FORBIDDEN_ROLE;
if (!game_id_matches(lifecycle, game_id)) return LIFECYCLE_RESULT_STALE_GAME;
if (lifecycle->game.state.phase != PHASE_FINISHED && lifecycle->game.state.phase != PHASE_REMATCH_WAIT) return LIFECYCLE_RESULT_WRONG_PHASE;
lifecycle->rematch_confirmed[player] = true;
const bool ready = lifecycle->game.state.mode == MODE_BOT ? lifecycle->rematch_confirmed[0] :
lifecycle->rematch_confirmed[0] && lifecycle->rematch_confirmed[1];
if (!ready) {
if (lifecycle->game.state.phase == PHASE_FINISHED) {
lifecycle->game.state.phase = PHASE_REMATCH_WAIT;
++lifecycle->game.state.version;
}
return LIFECYCLE_RESULT_OK;
}
lifecycle->rematch_confirmed[0] = false;
lifecycle->rematch_confirmed[1] = false;
lifecycle->game.state.game_id = lifecycle->next_game_id++;
return start_current_game(lifecycle);
}
lifecycle_result_t game_lifecycle_abort(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id) {
uint8_t player = 0;
if (!is_player(lifecycle, session_index, &player) || player != 0U) return LIFECYCLE_RESULT_FORBIDDEN_ROLE;
if (!game_id_matches(lifecycle, game_id)) return LIFECYCLE_RESULT_STALE_GAME;
if (lifecycle->game.state.phase != PHASE_IN_PROGRESS || lifecycle->game.state.mode != MODE_HUMAN ||
lifecycle->sessions.entries[1].connected) return LIFECYCLE_RESULT_WRONG_PHASE;
const uint32_t version = lifecycle->game.state.version + 1U;
const uint32_t next_game_id = lifecycle->next_game_id++;
game_engine_init(&lifecycle->game);
lifecycle->game.state.game_id = next_game_id;
lifecycle->game.state.version = version;
lifecycle->bot_reserved = false;
bot_player_cancel_turn(&lifecycle->bot);
return LIFECYCLE_RESULT_OK;
}
bool game_lifecycle_bot_take_turn(game_lifecycle_t *lifecycle) {
if (lifecycle == NULL || lifecycle->game.state.phase != PHASE_IN_PROGRESS || lifecycle->game.state.mode != MODE_BOT ||
lifecycle->game.state.current_player != 1U) return false;
coordinate_t coordinate = {0};
shot_result_t result = {0};
if (!bot_player_next_shot(&lifecycle->bot, &coordinate) ||
game_engine_shot(&lifecycle->game, 1U, coordinate, &result) != GAME_RESULT_OK) return false;
bot_player_record_result(&lifecycle->bot, coordinate, &(bot_shot_result_t){.hit = result.hit, .sunk = result.sunk});
if (lifecycle->game.state.phase == PHASE_FINISHED) record_cumulative(lifecycle);
else if (lifecycle->game.state.current_player == 1U) (void)bot_player_schedule_turn(&lifecycle->bot);
return true;
}
+393
View File
@@ -0,0 +1,393 @@
#include "http_api.h"
#include <inttypes.h>
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
#include "state_presenter.h"
enum { kErrorMessageBytes = 80, kErrorResponseBytes = 160 };
typedef struct { const char *text; size_t length; } json_reader_t;
static void skip_space(json_reader_t *reader) {
while (reader->length > 0U && (*reader->text == ' ' || *reader->text == '\n' ||
*reader->text == '\r' || *reader->text == '\t')) {
++reader->text;
--reader->length;
}
}
static bool take_character(json_reader_t *reader, char expected) {
skip_space(reader);
if (reader->length == 0U || *reader->text != expected) return false;
++reader->text;
--reader->length;
return true;
}
static bool parse_string(json_reader_t *reader, char *output, size_t output_size) {
if (!take_character(reader, '"') || output_size == 0U) return false;
size_t written = 0;
while (reader->length > 0U && *reader->text != '"') {
unsigned char character = (unsigned char)*reader->text++;
--reader->length;
if (character < 0x20U) return false;
if (character == '\\') {
if (reader->length == 0U) return false;
const char escaped = *reader->text++;
--reader->length;
if (escaped == '"' || escaped == '\\' || escaped == '/') character = (unsigned char)escaped;
else if (escaped == 'b') character = '\b';
else if (escaped == 'f') character = '\f';
else if (escaped == 'n') character = '\n';
else if (escaped == 'r') character = '\r';
else if (escaped == 't') character = '\t';
else return false;
}
if (written + 1U >= output_size) return false;
output[written++] = (char)character;
}
if (reader->length == 0U) return false;
++reader->text;
--reader->length;
output[written] = '\0';
return true;
}
static bool parse_u32(json_reader_t *reader, uint32_t *value) {
skip_space(reader);
if (reader->length == 0U || *reader->text < '0' || *reader->text > '9') return false;
uint32_t parsed = 0;
do {
const uint8_t digit = (uint8_t)(*reader->text - '0');
if (parsed > (UINT32_MAX - digit) / 10U) return false;
parsed = parsed * 10U + digit;
++reader->text;
--reader->length;
} while (reader->length > 0U && *reader->text >= '0' && *reader->text <= '9');
*value = parsed;
return true;
}
static bool parse_object_start(json_reader_t *reader) { return take_character(reader, '{'); }
static bool parse_next_key(json_reader_t *reader, bool *first, char *key, size_t key_size) {
skip_space(reader);
if (!*first && !take_character(reader, ',')) return false;
*first = false;
if (!parse_string(reader, key, key_size)) return false;
return take_character(reader, ':');
}
static bool parse_object_end(json_reader_t *reader) {
if (!take_character(reader, '}')) return false;
skip_space(reader);
return reader->length == 0U;
}
static bool parse_token(const char *text, uint8_t token[kSessionTokenBytes]) {
if (text == NULL || strlen(text) != kSessionTokenBytes * 2U) return false;
for (uint8_t index = 0; index < kSessionTokenBytes; ++index) {
const char high = text[index * 2U];
const char low = text[index * 2U + 1U];
if (high < '0' || (high > '9' && high < 'a') || high > 'f' ||
low < '0' || (low > '9' && low < 'a') || low > 'f') return false;
const uint8_t high_value = (uint8_t)(high <= '9' ? high - '0' : high - 'a' + 10);
const uint8_t low_value = (uint8_t)(low <= '9' ? low - '0' : low - 'a' + 10);
token[index] = (uint8_t)((high_value << 4U) | low_value);
}
return true;
}
static void token_text(const uint8_t token[kSessionTokenBytes], char output[kSessionTokenBytes * 2U + 1U]) {
static const char hex[] = "0123456789abcdef";
for (uint8_t index = 0; index < kSessionTokenBytes; ++index) {
output[index * 2U] = hex[token[index] >> 4U];
output[index * 2U + 1U] = hex[token[index] & 0x0fU];
}
output[kSessionTokenBytes * 2U] = '\0';
}
static const char *role_text(role_t role) {
return role == ROLE_PLAYER_1 ? "player1" : role == ROLE_PLAYER_2 ? "player2" : "spectator";
}
static const char *phase_text(phase_t phase) {
static const char *const values[] = {"lobby", "preparing", "in_progress", "finished", "rematch_wait"};
return phase <= PHASE_REMATCH_WAIT ? values[phase] : "lobby";
}
static void response_write(http_api_response_t *response, uint16_t status, const char *format, ...) {
va_list arguments;
va_start(arguments, format);
const int length = vsnprintf(response->body, sizeof(response->body), format, arguments);
va_end(arguments);
response->status = length >= 0 && (size_t)length < sizeof(response->body) ? status : 500U;
response->body_length = response->status == status ? (size_t)length : 0U;
if (response->status != status) response->body[0] = '\0';
}
static void response_error(http_api_response_t *response, uint16_t status, const char *code, const char *message,
uint32_t version) {
response_write(response, status, "{\"ok\":false,\"code\":\"%s\",\"message\":\"%s\",\"version\":%" PRIu32 "}",
code, message, version);
if (response->body_length >= kErrorResponseBytes) response_write(response, 500U, "{\"ok\":false,\"code\":\"SERVER_BUSY\",\"message\":\"Ошибка сервера\",\"version\":0}");
}
static void response_lifecycle_error(http_api_response_t *response, lifecycle_result_t result, uint32_t version) {
switch (result) {
case LIFECYCLE_RESULT_INVALID_NAME: response_error(response, 400U, "INVALID_NAME", "Некорректное имя", version); break;
case LIFECYCLE_RESULT_INVALID_ROLE: response_error(response, 400U, "INVALID_ROLE", "Некорректная роль", version); break;
case LIFECYCLE_RESULT_INVALID_MODE: response_error(response, 400U, "INVALID_MODE", "Некорректный режим", version); break;
case LIFECYCLE_RESULT_INVALID_COORDINATE: response_error(response, 400U, "INVALID_COORDINATE", "Некорректные координаты", version); break;
case LIFECYCLE_RESULT_NO_PLAYER_SLOT: response_error(response, 409U, "NO_PLAYER_SLOT", "Нет места игрока", version); break;
case LIFECYCLE_RESULT_NO_SPECTATOR_SLOT: response_error(response, 409U, "NO_SPECTATOR_SLOT", "Нет места зрителя", version); break;
case LIFECYCLE_RESULT_FORBIDDEN_ROLE: response_error(response, 403U, "FORBIDDEN_ROLE", "Роль не может выполнить действие", version); break;
case LIFECYCLE_RESULT_WRONG_PHASE: response_error(response, 409U, "WRONG_PHASE", "Действие недоступно сейчас", version); break;
case LIFECYCLE_RESULT_NOT_YOUR_TURN: response_error(response, 409U, "NOT_YOUR_TURN", "Сейчас ход соперника", version); break;
case LIFECYCLE_RESULT_CELL_ALREADY_SHOT: response_error(response, 409U, "CELL_ALREADY_SHOT", "Клетка уже обстреляна", version); break;
case LIFECYCLE_RESULT_STALE_GAME: response_error(response, 409U, "STALE_GAME", "Партия уже изменилась", version); break;
case LIFECYCLE_RESULT_UNAUTHORIZED: response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version); break;
default: response_error(response, 503U, "SERVER_BUSY", "Сервер занят", version); break;
}
}
static bool expect_post_body(const http_api_request_t *request, size_t maximum, http_api_response_t *response,
uint32_t version) {
if (request->method != HTTP_API_POST || !request->content_type_json) {
response_error(response, 400U, "MALFORMED_JSON", "Ожидается JSON запрос", version);
return false;
}
if (request->body == NULL || request->body_length > maximum) {
response_error(response, 413U, "PAYLOAD_TOO_LARGE", "Слишком большой запрос", version);
return false;
}
return true;
}
static bool parse_join(const http_api_request_t *request, char name[kDisplayNameBytes + 1U], role_t *role) {
json_reader_t reader = {.text = request->body, .length = request->body_length};
char key[16];
char role_value[16];
bool first = true;
uint8_t fields = 0;
if (!parse_object_start(&reader)) return false;
while (reader.length > 0U && *reader.text != '}') {
if (!parse_next_key(&reader, &first, key, sizeof(key))) return false;
if (strcmp(key, "name") == 0 && (fields & 1U) == 0U) {
if (!parse_string(&reader, name, kDisplayNameBytes + 1U)) return false;
fields |= 1U;
} else if (strcmp(key, "requestedRole") == 0 && (fields & 2U) == 0U) {
if (!parse_string(&reader, role_value, sizeof(role_value))) return false;
fields |= 2U;
} else return false;
}
if (!parse_object_end(&reader) || fields != 3U) return false;
if (strcmp(role_value, "player1") == 0) *role = ROLE_PLAYER_1;
else if (strcmp(role_value, "player2") == 0) *role = ROLE_PLAYER_2;
else if (strcmp(role_value, "spectator") == 0) *role = ROLE_SPECTATOR;
else *role = (role_t)UINT8_MAX;
return true;
}
static bool parse_token_only(const http_api_request_t *request, char token[kSessionTokenBytes * 2U + 1U]) {
json_reader_t reader = {.text = request->body, .length = request->body_length};
char key[16];
bool first = true;
if (!parse_object_start(&reader) || !parse_next_key(&reader, &first, key, sizeof(key)) || strcmp(key, "token") != 0 ||
!parse_string(&reader, token, kSessionTokenBytes * 2U + 1U)) return false;
return parse_object_end(&reader);
}
static bool parse_command_with_token(const http_api_request_t *request, app_command_t *command, bool needs_mode,
bool needs_coordinate, uint8_t token[kSessionTokenBytes]) {
char body_token[kSessionTokenBytes * 2U + 1U] = {0};
/* Parse into a temporary command first; token bytes never occupy command storage. */
json_reader_t reader = {.text = request->body, .length = request->body_length};
char key[16];
char mode[8] = {0};
bool first = true;
uint8_t fields = 0;
uint32_t x = 0;
uint32_t y = 0;
if (!parse_object_start(&reader)) return false;
while (reader.length > 0U && *reader.text != '}') {
if (!parse_next_key(&reader, &first, key, sizeof(key))) return false;
if (strcmp(key, "token") == 0 && (fields & 1U) == 0U) {
if (!parse_string(&reader, body_token, sizeof(body_token))) return false;
fields |= 1U;
} else if (strcmp(key, "gameId") == 0 && (fields & 2U) == 0U) {
if (!parse_u32(&reader, &command->game_id)) return false;
fields |= 2U;
} else if (needs_mode && strcmp(key, "mode") == 0 && (fields & 4U) == 0U) {
if (!parse_string(&reader, mode, sizeof(mode))) return false;
fields |= 4U;
} else if (needs_coordinate && strcmp(key, "x") == 0 && (fields & 4U) == 0U) {
if (!parse_u32(&reader, &x)) return false;
fields |= 4U;
} else if (needs_coordinate && strcmp(key, "y") == 0 && (fields & 8U) == 0U) {
if (!parse_u32(&reader, &y)) return false;
fields |= 8U;
} else return false;
}
const uint8_t expected = needs_coordinate ? 15U : needs_mode ? 7U : 3U;
if (!parse_object_end(&reader) || fields != expected || !parse_token(body_token, token)) return false;
if (needs_mode) {
if (strcmp(mode, "human") == 0) command->mode = MODE_HUMAN;
else if (strcmp(mode, "bot") == 0) command->mode = MODE_BOT;
else command->mode = (game_mode_t)UINT8_MAX;
}
if (needs_coordinate) {
command->coordinate = x >= kBoardWidth || y >= kBoardHeight ?
(coordinate_t){.x = kBoardWidth, .y = 0U} : (coordinate_t){.x = (uint8_t)x, .y = (uint8_t)y};
}
return true;
}
void http_api_init(http_api_t *api, application_t *application) {
if (api == NULL) return;
*api = (http_api_t){.application = application, .health = {.wifi_state = "connecting"}};
}
void http_api_set_health(http_api_t *api, const http_api_health_t *health) {
if (api != NULL && health != NULL) api->health = *health;
}
bool http_api_handle(http_api_t *api, const http_api_request_t *request, http_api_response_t *response) {
if (api == NULL || api->application == NULL || request == NULL || response == NULL) return false;
*response = (http_api_response_t){0};
game_lifecycle_t *lifecycle = &api->application->lifecycle;
const uint32_t version = lifecycle->game.state.version;
if (request->target_too_large) {
response_error(response, 413U, "PAYLOAD_TOO_LARGE", "Слишком большой запрос", version);
return true;
}
if (request->route == HTTP_API_ROUTE_INFO && request->method == HTTP_API_GET) {
const session_manager_t *sessions = &lifecycle->sessions;
uint8_t spectators = 0;
for (uint8_t index = kPlayerCapacity; index < kSessionCapacity; ++index) spectators += sessions->entries[index].occupied;
response_write(response, 200U, "{\"ok\":true,\"phase\":\"%s\",\"gameId\":%" PRIu32 ",\"version\":%" PRIu32
",\"player1Available\":%s,\"player2Available\":%s,\"spectatorsAvailable\":%u}",
phase_text(lifecycle->game.state.phase), lifecycle->game.state.game_id, version,
sessions->entries[0].occupied ? "false" : "true", sessions->entries[1].occupied ? "false" : "true",
(unsigned int)(kSpectatorCapacity - spectators));
return response->body_length <= 192U;
}
if (request->route == HTTP_API_ROUTE_HEALTH && request->method == HTTP_API_GET) {
response_write(response, 200U, "{\"ok\":true,\"uptimeMs\":%" PRIu32 ",\"wifiState\":\"%s\",\"freeHeapBytes\":%" PRIu32
",\"minimumFreeHeapBytes\":%" PRIu32 ",\"largestFreeBlockBytes\":%" PRIu32
",\"connectedClients\":%u,\"rejectedInput\":%u,\"resetReason\":%d}", api->health.uptime_ms,
api->health.wifi_state == NULL ? "unknown" : api->health.wifi_state, api->health.free_heap_bytes,
api->health.minimum_free_heap_bytes, api->health.largest_free_block_bytes,
api->health.connected_clients, api->health.rejected_input, api->health.reset_reason);
return response->body_length <= 320U;
}
if (request->route == HTTP_API_ROUTE_STATE && request->method == HTTP_API_GET) {
role_t viewer = ROLE_SPECTATOR;
uint8_t token[kSessionTokenBytes];
uint8_t session_index = 0;
if (request->session_token != NULL && request->session_token[0] != '\0') {
if (!parse_token(request->session_token, token) || !application_session_for_token(api->application, token, &session_index)) {
response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version);
return true;
}
viewer = lifecycle->sessions.entries[session_index].role;
}
if (!state_presenter_write_lifecycle(lifecycle, viewer, response->body, sizeof(response->body), &response->body_length)) {
response_error(response, 503U, "SERVER_BUSY", "Сервер занят", version);
} else response->status = 200U;
return true;
}
if (request->route == HTTP_API_ROUTE_STATISTICS && request->method == HTTP_API_GET) {
role_t viewer = ROLE_SPECTATOR;
uint8_t token[kSessionTokenBytes];
uint8_t session_index = 0;
if (request->session_token != NULL && request->session_token[0] != '\0') {
if (!parse_token(request->session_token, token) || !application_session_for_token(api->application, token, &session_index)) {
response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version);
return true;
}
viewer = lifecycle->sessions.entries[session_index].role;
}
const match_statistics_t *match = lifecycle->game.state.statistics;
const cumulative_statistics_t *total = lifecycle->cumulative;
response_write(response, 200U, "{\"ok\":true,\"viewer\":\"%s\",\"gameId\":%" PRIu32
",\"match\":[[%u,%u,%u,%u],[%u,%u,%u,%u]],\"cumulative\":[[%u,%u,%u,%u,%" PRIu32 ",%" PRIu32 ",%" PRIu32
"],[%u,%u,%u,%u,%" PRIu32 ",%" PRIu32 ",%" PRIu32 "]]}", role_text(viewer), lifecycle->game.state.game_id,
match[0].shots, match[0].hits, match[0].misses, match[0].ships_sunk,
match[1].shots, match[1].hits, match[1].misses, match[1].ships_sunk,
total[0].games, total[0].wins, total[0].losses, total[0].ships_sunk, total[0].shots, total[0].hits, total[0].misses,
total[1].games, total[1].wins, total[1].losses, total[1].ships_sunk, total[1].shots, total[1].hits, total[1].misses);
return response->body_length < sizeof(response->body);
}
if (request->route == HTTP_API_ROUTE_JOIN) {
char name[kDisplayNameBytes + 1U] = {0};
role_t role = ROLE_SPECTATOR;
if (!expect_post_body(request, 192U, response, version)) return true;
if (!parse_join(request, name, &role)) {
response_error(response, 400U, "MALFORMED_JSON", "Некорректный JSON", version);
return true;
}
if (role > ROLE_SPECTATOR) {
response_error(response, 400U, "INVALID_ROLE", "Некорректная роль", version);
return true;
}
uint8_t session_index = 0;
const lifecycle_result_t result = application_join(api->application, role, name, &session_index);
if (result != LIFECYCLE_RESULT_OK) response_lifecycle_error(response, result, lifecycle->game.state.version);
else {
char token[kSessionTokenBytes * 2U + 1U];
token_text(lifecycle->sessions.entries[session_index].token, token);
response_write(response, 200U, "{\"ok\":true,\"token\":\"%s\",\"role\":\"%s\",\"version\":%" PRIu32 ",\"gameId\":%" PRIu32 "}",
token, role_text(lifecycle->sessions.entries[session_index].role), lifecycle->game.state.version,
lifecycle->game.state.game_id);
}
return true;
}
if (request->route == HTTP_API_ROUTE_RESUME) {
char token_text_value[kSessionTokenBytes * 2U + 1U] = {0};
uint8_t token[kSessionTokenBytes];
if (!expect_post_body(request, 96U, response, version)) return true;
if (!parse_token_only(request, token_text_value) || !parse_token(token_text_value, token)) {
response_error(response, 400U, "MALFORMED_JSON", "Некорректный JSON", version);
return true;
}
uint8_t session_index = 0;
const lifecycle_result_t result = application_resume(api->application, token, &session_index);
if (result != LIFECYCLE_RESULT_OK) response_lifecycle_error(response, result, lifecycle->game.state.version);
else response_write(response, 200U, "{\"ok\":true,\"role\":\"%s\",\"version\":%" PRIu32 ",\"gameId\":%" PRIu32 "}",
role_text(lifecycle->sessions.entries[session_index].role), lifecycle->game.state.version,
lifecycle->game.state.game_id);
return true;
}
app_command_t command = {0};
uint8_t token[kSessionTokenBytes];
bool needs_mode = request->route == HTTP_API_ROUTE_CONFIG;
bool needs_coordinate = request->route == HTTP_API_ROUTE_SHOT;
size_t maximum = needs_coordinate || needs_mode ? 96U : 80U;
if (!expect_post_body(request, maximum, response, version)) return true;
if (!parse_command_with_token(request, &command, needs_mode, needs_coordinate, token)) {
response_error(response, 400U, "MALFORMED_JSON", "Некорректный JSON", version);
return true;
}
if (!application_session_for_token(api->application, token, &command.session_index)) {
response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version);
return true;
}
switch (request->route) {
case HTTP_API_ROUTE_CONFIG: command.type = COMMAND_CONFIG; break;
case HTTP_API_ROUTE_START: command.type = COMMAND_START; break;
case HTTP_API_ROUTE_SHOT: command.type = COMMAND_SHOT; break;
case HTTP_API_ROUTE_REMATCH: command.type = COMMAND_REMATCH; break;
case HTTP_API_ROUTE_ABORT: command.type = COMMAND_ABORT; break;
default: response_error(response, 404U, "MALFORMED_JSON", "Маршрут не найден", version); return true;
}
command.version = version;
const lifecycle_result_t result = application_submit(api->application, &command);
if (result != LIFECYCLE_RESULT_OK) response_lifecycle_error(response, result, lifecycle->game.state.version);
else response_write(response, 200U, "{\"ok\":true,\"version\":%" PRIu32 ",\"gameId\":%" PRIu32 "}",
lifecycle->game.state.version, lifecycle->game.state.game_id);
return true;
}
+287 -333
View File
@@ -1,20 +1,25 @@
#include <inttypes.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include "app_config.h"
#include "application.h"
#include "esp_check.h"
#include "esp_event.h"
#include "esp_heap_caps.h"
#include "esp_http_server.h"
#include "esp_littlefs.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_random.h"
#include "esp_timer.h"
#include "esp_wifi.h"
#include "freertos/FreeRTOS.h"
#include "freertos/portmacro.h"
#include "http_api.h"
#include "nvs_flash.h"
#include "sync_service.h"
#if __has_include("wifi_config.h")
#include "wifi_config.h"
@@ -23,122 +28,42 @@
#define WIFI_CONFIG_AVAILABLE 0
#endif
static const char *const kLogTag = "vertical_slice";
static const char *const kBuildVersion = "m002";
static const char *const kLogTag = "battleship";
static const char *const kLittlefsBasePath = "/littlefs";
static const char *const kLittlefsPartitionLabel = "littlefs";
static const size_t kFileChunkBytes = 1024;
typedef struct {
bool configured;
bool connected;
bool retry_scheduled;
uint8_t reconnect_attempt;
char ip_address[16];
} wifi_state_t;
static const size_t kFileChunkBytes = 1024U;
static const uint8_t kHttpMaxOpenSockets = 12U;
typedef struct { bool configured; bool connected; bool retry_scheduled; uint8_t reconnect_attempt; } wifi_state_t;
static portMUX_TYPE s_wifi_lock = portMUX_INITIALIZER_UNLOCKED;
static portMUX_TYPE s_diagnostics_lock = portMUX_INITIALIZER_UNLOCKED;
static wifi_state_t s_wifi_state = {0};
static bool s_littlefs_mounted;
static httpd_handle_t s_server;
static esp_timer_handle_t s_state_timer;
static uint32_t s_state_version;
static uint32_t s_public_counter;
static application_t s_application;
static http_api_t s_api;
static sync_service_t s_sync;
static uint16_t s_rejected_input;
static esp_timer_handle_t s_sync_timer;
static esp_timer_handle_t s_bot_timer;
#if WIFI_CONFIG_AVAILABLE
static esp_timer_handle_t s_reconnect_timer;
#endif
typedef enum { VIEW_PLAYER_1, VIEW_PLAYER_2, VIEW_SPECTATOR } view_role_t;
typedef struct { int fd; view_role_t role; } ws_client_t;
static ws_client_t s_ws_clients[4];
static uint32_t platform_random(void *unused) { (void)unused; return esp_random(); }
static view_role_t role_from_query(httpd_req_t *request) {
char query[48] = {0};
char role[16] = {0};
if (httpd_req_get_url_query_len(request) < sizeof(query) &&
httpd_req_get_url_query_str(request, query, sizeof(query)) == ESP_OK &&
httpd_query_key_value(query, "role", role, sizeof(role)) == ESP_OK) {
if (strcmp(role, "player1") == 0) return VIEW_PLAYER_1;
if (strcmp(role, "player2") == 0) return VIEW_PLAYER_2;
}
return VIEW_SPECTATOR;
static void record_rejected_input(void) {
portENTER_CRITICAL(&s_diagnostics_lock);
if (s_rejected_input < UINT16_MAX) ++s_rejected_input;
portEXIT_CRITICAL(&s_diagnostics_lock);
}
static const char *role_name(view_role_t role) {
return role == VIEW_PLAYER_1 ? "player1" : role == VIEW_PLAYER_2 ? "player2" : "spectator";
}
static void remember_ws_client(int fd, view_role_t role) {
for (size_t index = 0; index < sizeof(s_ws_clients) / sizeof(s_ws_clients[0]); ++index) {
if (s_ws_clients[index].fd == fd || s_ws_clients[index].fd == 0) {
s_ws_clients[index] = (ws_client_t){.fd = fd, .role = role};
return;
}
}
}
static view_role_t role_for_fd(int fd) {
for (size_t index = 0; index < sizeof(s_ws_clients) / sizeof(s_ws_clients[0]); ++index) {
if (s_ws_clients[index].fd == fd) return s_ws_clients[index].role;
}
return VIEW_SPECTATOR;
}
static esp_err_t send_state(httpd_handle_t server, int fd, view_role_t role) {
char payload[128];
const int length = snprintf(payload, sizeof(payload),
"{\"version\":%" PRIu32 ",\"public_counter\":%" PRIu32
",\"viewer\":\"%s\"}", s_state_version, s_public_counter,
role_name(role));
if (length < 0 || (size_t)length >= sizeof(payload)) return ESP_FAIL;
httpd_ws_frame_t frame = {.final = true, .type = HTTPD_WS_TYPE_TEXT,
.payload = (uint8_t *)payload, .len = (size_t)length};
return httpd_ws_send_frame_async(server, fd, &frame);
}
static void broadcast_state(void *unused) {
(void)unused;
size_t count = 4;
int clients[4];
if (httpd_get_client_list(s_server, &count, clients) != ESP_OK) return;
for (size_t index = 0; index < count; ++index) {
if (httpd_ws_get_fd_info(s_server, clients[index]) == HTTPD_WS_CLIENT_WEBSOCKET) {
send_state(s_server, clients[index], role_for_fd(clients[index]));
}
}
}
static void state_timer_callback(void *unused) {
(void)unused;
++s_state_version;
++s_public_counter;
if (s_server != NULL) httpd_queue_work(s_server, broadcast_state, NULL);
}
static esp_err_t state_handler(httpd_req_t *request) {
char payload[128];
const int length = snprintf(payload, sizeof(payload),
"{\"version\":%" PRIu32 ",\"public_counter\":%" PRIu32
",\"viewer\":\"%s\"}", s_state_version, s_public_counter,
role_name(role_from_query(request)));
if (length < 0 || (size_t)length >= sizeof(payload)) return ESP_FAIL;
httpd_resp_set_type(request, "application/json");
httpd_resp_set_hdr(request, "Cache-Control", "no-store");
return httpd_resp_send(request, payload, length);
}
static esp_err_t websocket_handler(httpd_req_t *request) {
const int fd = httpd_req_to_sockfd(request);
if (request->method == HTTP_GET) {
remember_ws_client(fd, role_from_query(request));
return ESP_OK;
}
httpd_ws_frame_t frame = {0};
ESP_RETURN_ON_ERROR(httpd_ws_recv_frame(request, &frame, 0), kLogTag, "websocket frame read failed");
if (frame.len > 64 || frame.type != HTTPD_WS_TYPE_TEXT) return ESP_OK;
char message[65] = {0};
frame.payload = (uint8_t *)message;
frame.len = sizeof(message) - 1;
ESP_RETURN_ON_ERROR(httpd_ws_recv_frame(request, &frame, frame.len), kLogTag, "websocket payload read failed");
if (strcmp(message, "{\"type\":\"ping\"}") != 0) return ESP_OK;
return send_state(request->handle, fd, role_for_fd(fd));
static uint16_t rejected_input_snapshot(void) {
uint16_t rejected = 0;
portENTER_CRITICAL(&s_diagnostics_lock);
rejected = s_rejected_input;
portEXIT_CRITICAL(&s_diagnostics_lock);
return rejected;
}
static wifi_state_t wifi_state_snapshot(void) {
@@ -149,307 +74,336 @@ static wifi_state_t wifi_state_snapshot(void) {
return snapshot;
}
#if WIFI_CONFIG_AVAILABLE
static const uint8_t kMaxReconnectExponent = 5;
static esp_timer_handle_t s_reconnect_timer;
static const char *wifi_state_name(const wifi_state_t *state) {
if (!state->configured) return "not_configured";
return state->connected ? "connected" : "connecting";
}
static void refresh_health(void) {
const wifi_state_t wifi_state = wifi_state_snapshot();
size_t clients = kHttpMaxOpenSockets;
int client_fds[kHttpMaxOpenSockets];
if (s_server == NULL || httpd_get_client_list(s_server, &clients, client_fds) != ESP_OK) clients = 0U;
http_api_set_health(&s_api, &(http_api_health_t){
.uptime_ms = (uint32_t)(esp_timer_get_time() / 1000U),
.free_heap_bytes = esp_get_free_heap_size(),
.minimum_free_heap_bytes = esp_get_minimum_free_heap_size(),
.largest_free_block_bytes = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT),
.connected_clients = (uint8_t)clients,
.rejected_input = rejected_input_snapshot(),
.reset_reason = (int8_t)esp_reset_reason(),
.wifi_state = wifi_state_name(&wifi_state),
});
}
static void set_status(httpd_req_t *request, uint16_t status) {
const char *const values[] = {"200 OK", "400 Bad Request", "401 Unauthorized", "403 Forbidden", "404 Not Found", "409 Conflict", "413 Payload Too Large", "503 Service Unavailable"};
const uint16_t codes[] = {200U, 400U, 401U, 403U, 404U, 409U, 413U, 503U};
for (size_t index = 0; index < sizeof(codes) / sizeof(codes[0]); ++index) if (status == codes[index]) { httpd_resp_set_status(request, values[index]); return; }
httpd_resp_set_status(request, "503 Service Unavailable");
}
static esp_err_t send_api_response(httpd_req_t *request, const http_api_response_t *response) {
set_status(request, response->status);
httpd_resp_set_type(request, "application/json");
httpd_resp_set_hdr(request, "Cache-Control", "no-store");
return httpd_resp_send(request, response->body, response->body_length);
}
static bool websocket_send(void *unused, int client_id, const char *payload, size_t length) {
(void)unused;
httpd_ws_frame_t frame = {.final = true, .type = HTTPD_WS_TYPE_TEXT, .payload = (uint8_t *)payload, .len = length};
const esp_err_t result = httpd_ws_send_frame_async(s_server, client_id, &frame);
if (result != ESP_OK) httpd_sess_trigger_close(s_server, client_id);
return result == ESP_OK;
}
static void sync_broadcast_work(void *unused) {
(void)unused;
sync_service_broadcast(&s_sync, websocket_send, NULL);
}
static void queue_state_broadcast(void) {
if (s_server != NULL && httpd_queue_work(s_server, sync_broadcast_work, NULL) != ESP_OK) record_rejected_input();
}
static bool schedule_bot_turn(void *unused, uint32_t delay_ms) {
(void)unused;
return s_bot_timer != NULL && esp_timer_start_once(s_bot_timer, (uint64_t)delay_ms * 1000U) == ESP_OK;
}
static void bot_turn_work(void *unused) {
(void)unused;
const uint32_t version_before = s_application.lifecycle.game.state.version;
if (application_bot_take_turn(&s_application) && s_application.lifecycle.game.state.version != version_before) queue_state_broadcast();
}
static void bot_timer_callback(void *unused) {
(void)unused;
if (s_server != NULL && httpd_queue_work(s_server, bot_turn_work, NULL) != ESP_OK) record_rejected_input();
}
static void sync_expire_work(void *unused) {
(void)unused;
int closed[kSessionCapacity] = {0};
size_t closed_count = 0U;
sync_service_expire(&s_sync, (uint64_t)(esp_timer_get_time() / 1000U), closed, &closed_count);
for (size_t index = 0; index < closed_count; ++index) httpd_sess_trigger_close(s_server, closed[index]);
}
static void sync_timer_callback(void *unused) {
(void)unused;
if (s_server != NULL) httpd_queue_work(s_server, sync_expire_work, NULL);
}
static bool request_has_json_content_type(httpd_req_t *request) {
const size_t length = httpd_req_get_hdr_value_len(request, "Content-Type");
if (length == 0U || length >= 64U) return false;
char value[64];
return httpd_req_get_hdr_value_str(request, "Content-Type", value, sizeof(value)) == ESP_OK && strncmp(value, "application/json", 16U) == 0;
}
static size_t route_body_limit(http_api_route_t route) {
switch (route) {
case HTTP_API_ROUTE_JOIN: return 192U;
case HTTP_API_ROUTE_RESUME:
case HTTP_API_ROUTE_CONFIG:
case HTTP_API_ROUTE_SHOT: return 96U;
case HTTP_API_ROUTE_START:
case HTTP_API_ROUTE_REMATCH:
case HTTP_API_ROUTE_ABORT: return 80U;
default: return 0U;
}
}
static esp_err_t api_handler(httpd_req_t *request) {
const http_api_route_t route = (http_api_route_t)(uintptr_t)request->user_ctx;
char body[kRequestBodyCapacity + 1U] = {0};
char token[kSessionTokenBytes * 2U + 1U] = {0};
const size_t maximum = route_body_limit(route);
size_t body_length = 0U;
if (request->method == HTTP_POST) {
if ((size_t)request->content_len > maximum) { body_length = maximum + 1U; record_rejected_input(); }
else {
while (body_length < (size_t)request->content_len) {
const int received = httpd_req_recv(request, body + body_length, request->content_len - body_length);
if (received <= 0) { record_rejected_input(); body_length = maximum + 1U; break; }
body_length += (size_t)received;
}
body[body_length <= kRequestBodyCapacity ? body_length : 0U] = '\0';
}
}
const bool target_too_large = request->method == HTTP_GET && httpd_req_get_url_query_len(request) > 128U;
if (target_too_large) record_rejected_input();
if (route == HTTP_API_ROUTE_STATE || route == HTTP_API_ROUTE_STATISTICS) {
const size_t token_length = httpd_req_get_hdr_value_len(request, "X-Session-Token");
if (token_length > 0U && token_length < sizeof(token) && httpd_req_get_hdr_value_str(request, "X-Session-Token", token, sizeof(token)) != ESP_OK) token[0] = '\0';
else if (token_length >= sizeof(token)) snprintf(token, sizeof(token), "%s", "invalid");
}
refresh_health();
http_api_response_t response;
const http_api_request_t api_request = {.method = request->method == HTTP_GET ? HTTP_API_GET : HTTP_API_POST, .route = route,
.content_type_json = request_has_json_content_type(request), .body = body, .body_length = body_length,
.session_token = token[0] == '\0' ? NULL : token, .target_too_large = target_too_large};
const uint32_t version_before = s_application.lifecycle.game.state.version;
if (!http_api_handle(&s_api, &api_request, &response)) return ESP_FAIL;
if (s_application.lifecycle.game.state.version != version_before) queue_state_broadcast();
return send_api_response(request, &response);
}
static esp_err_t websocket_handler(httpd_req_t *request) {
const int client_id = httpd_req_to_sockfd(request);
if (request->method == HTTP_GET) {
if (sync_service_open(&s_sync, client_id, (uint64_t)(esp_timer_get_time() / 1000U))) return ESP_OK;
httpd_sess_trigger_close(s_server, client_id);
return ESP_FAIL;
}
httpd_ws_frame_t frame = {0};
if (httpd_ws_recv_frame(request, &frame, 0U) != ESP_OK || frame.type != HTTPD_WS_TYPE_TEXT ||
frame.len > kWebSocketFrameCapacity) {
record_rejected_input();
sync_service_close(&s_sync, client_id);
httpd_sess_trigger_close(s_server, client_id);
return ESP_OK;
}
char input[kWebSocketFrameCapacity + 1U] = {0};
frame.payload = (uint8_t *)input;
if (httpd_ws_recv_frame(request, &frame, sizeof(input) - 1U) != ESP_OK) {
sync_service_close(&s_sync, client_id);
httpd_sess_trigger_close(s_server, client_id);
return ESP_OK;
}
char output[kStateMessageCapacity] = {0};
size_t output_length = 0U;
bool state_changed = false;
bool close_client = false;
if (!sync_service_receive(&s_sync, client_id, input, frame.len, (uint64_t)(esp_timer_get_time() / 1000U),
output, &output_length, &state_changed, &close_client)) return ESP_FAIL;
if (output_length > 0U) {
httpd_ws_frame_t response = {.final = true, .type = HTTPD_WS_TYPE_TEXT,
.payload = (uint8_t *)output, .len = output_length};
if (httpd_ws_send_frame(request, &response) != ESP_OK) close_client = true;
}
if (state_changed) queue_state_broadcast();
if (close_client) {
sync_service_close(&s_sync, client_id);
httpd_sess_trigger_close(s_server, client_id);
}
return ESP_OK;
}
#if WIFI_CONFIG_AVAILABLE
static void reconnect_timer_callback(void *unused) {
(void)unused;
portENTER_CRITICAL(&s_wifi_lock);
s_wifi_state.retry_scheduled = false;
portEXIT_CRITICAL(&s_wifi_lock);
const esp_err_t result = esp_wifi_connect();
if (result != ESP_OK) {
ESP_LOGW(kLogTag, "wifi connect request failed: %s", esp_err_to_name(result));
}
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.retry_scheduled = false; portEXIT_CRITICAL(&s_wifi_lock);
if (esp_wifi_connect() != ESP_OK) ESP_LOGW(kLogTag, "wifi connect request failed");
}
static void schedule_reconnect(uint32_t delay_ms) {
bool should_start_timer = false;
bool schedule = false;
portENTER_CRITICAL(&s_wifi_lock);
if (s_wifi_state.configured && !s_wifi_state.connected && !s_wifi_state.retry_scheduled) {
s_wifi_state.retry_scheduled = true;
should_start_timer = true;
}
if (s_wifi_state.configured && !s_wifi_state.connected && !s_wifi_state.retry_scheduled) { s_wifi_state.retry_scheduled = true; schedule = true; }
portEXIT_CRITICAL(&s_wifi_lock);
if (!should_start_timer) {
return;
}
const esp_err_t result = esp_timer_start_once(s_reconnect_timer, (uint64_t)delay_ms * 1000U);
if (result != ESP_OK) {
portENTER_CRITICAL(&s_wifi_lock);
s_wifi_state.retry_scheduled = false;
portEXIT_CRITICAL(&s_wifi_lock);
ESP_LOGW(kLogTag, "wifi reconnect timer failed: %s", esp_err_to_name(result));
if (schedule && esp_timer_start_once(s_reconnect_timer, (uint64_t)delay_ms * 1000U) != ESP_OK) {
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.retry_scheduled = false; portEXIT_CRITICAL(&s_wifi_lock);
}
}
static uint32_t next_reconnect_delay_ms(void) {
uint8_t exponent;
static void wifi_event_handler(void *argument, esp_event_base_t event_base, int32_t event_id, void *event_data) {
(void)argument; (void)event_data;
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) schedule_reconnect(0U);
else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
uint8_t attempt;
portENTER_CRITICAL(&s_wifi_lock);
if (s_wifi_state.reconnect_attempt < kMaxReconnectExponent) {
++s_wifi_state.reconnect_attempt;
}
exponent = s_wifi_state.reconnect_attempt;
if (s_wifi_state.reconnect_attempt < 5U) ++s_wifi_state.reconnect_attempt;
attempt = s_wifi_state.reconnect_attempt; s_wifi_state.connected = false;
portEXIT_CRITICAL(&s_wifi_lock);
return 1000U << exponent;
}
static void wifi_event_handler(void *argument,
esp_event_base_t event_base,
int32_t event_id,
void *event_data) {
(void)argument;
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
ESP_LOGI(kLogTag, "wifi station started");
schedule_reconnect(0);
return;
}
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
const uint32_t delay_ms = next_reconnect_delay_ms();
portENTER_CRITICAL(&s_wifi_lock);
s_wifi_state.connected = false;
s_wifi_state.ip_address[0] = '\0';
portEXIT_CRITICAL(&s_wifi_lock);
ESP_LOGW(kLogTag, "wifi disconnected; reconnecting in %" PRIu32 " ms", delay_ms);
schedule_reconnect(delay_ms);
return;
}
if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
const ip_event_got_ip_t *const event = (const ip_event_got_ip_t *)event_data;
char address[16];
esp_ip4addr_ntoa(&event->ip_info.ip, address, sizeof(address));
portENTER_CRITICAL(&s_wifi_lock);
s_wifi_state.connected = true;
s_wifi_state.reconnect_attempt = 0;
snprintf(s_wifi_state.ip_address, sizeof(s_wifi_state.ip_address), "%s", address);
portEXIT_CRITICAL(&s_wifi_lock);
ESP_LOGI(kLogTag, "wifi connected ip=%s", address);
schedule_reconnect(1000U << attempt);
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.connected = true; s_wifi_state.reconnect_attempt = 0U; portEXIT_CRITICAL(&s_wifi_lock);
}
}
#endif
static esp_err_t start_wifi(void) {
#if !WIFI_CONFIG_AVAILABLE
ESP_LOGE(kLogTag,
"wifi is not configured; copy include/wifi_config.h.example to include/wifi_config.h");
ESP_LOGE(kLogTag, "wifi is not configured; copy include/wifi_config.h.example to include/wifi_config.h");
return ESP_ERR_INVALID_STATE;
#else
wifi_init_config_t init_config = WIFI_INIT_CONFIG_DEFAULT();
wifi_config_t station_config = {0};
esp_timer_create_args_t reconnect_timer_args = {
.callback = reconnect_timer_callback,
.name = "wifi_reconnect",
};
portENTER_CRITICAL(&s_wifi_lock);
s_wifi_state.configured = true;
portEXIT_CRITICAL(&s_wifi_lock);
const esp_timer_create_args_t timer_args = {.callback = reconnect_timer_callback, .name = "wifi_reconnect"};
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.configured = true; portEXIT_CRITICAL(&s_wifi_lock);
esp_netif_create_default_wifi_sta();
ESP_RETURN_ON_ERROR(esp_wifi_init(&init_config), kLogTag, "wifi initialization failed");
ESP_RETURN_ON_ERROR(esp_event_handler_instance_register(
WIFI_EVENT, ESP_EVENT_ANY_ID, wifi_event_handler, NULL, NULL),
kLogTag,
"wifi event registration failed");
ESP_RETURN_ON_ERROR(esp_event_handler_instance_register(
IP_EVENT, IP_EVENT_STA_GOT_IP, wifi_event_handler, NULL, NULL),
kLogTag,
"ip event registration failed");
ESP_RETURN_ON_ERROR(esp_timer_create(&reconnect_timer_args, &s_reconnect_timer),
kLogTag,
"wifi reconnect timer creation failed");
ESP_RETURN_ON_ERROR(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, wifi_event_handler, NULL, NULL), kLogTag, "wifi event registration failed");
ESP_RETURN_ON_ERROR(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, wifi_event_handler, NULL, NULL), kLogTag, "ip event registration failed");
ESP_RETURN_ON_ERROR(esp_timer_create(&timer_args, &s_reconnect_timer), kLogTag, "wifi timer creation failed");
snprintf((char *)station_config.sta.ssid, sizeof(station_config.sta.ssid), "%s", WIFI_CONFIG_SSID);
snprintf((char *)station_config.sta.password,
sizeof(station_config.sta.password),
"%s",
WIFI_CONFIG_PASSWORD);
snprintf((char *)station_config.sta.password, sizeof(station_config.sta.password), "%s", WIFI_CONFIG_PASSWORD);
station_config.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK;
station_config.sta.pmf_cfg.capable = true;
station_config.sta.pmf_cfg.required = false;
ESP_RETURN_ON_ERROR(esp_wifi_set_mode(WIFI_MODE_STA), kLogTag, "wifi mode setup failed");
ESP_RETURN_ON_ERROR(esp_wifi_set_config(WIFI_IF_STA, &station_config), kLogTag, "wifi configuration failed");
return esp_wifi_start();
#endif
}
static const char *wifi_state_name(const wifi_state_t *state) {
if (!state->configured) {
return "not_configured";
}
return state->connected ? "connected" : "connecting";
}
static esp_err_t health_handler(httpd_req_t *request) {
const wifi_state_t wifi_state = wifi_state_snapshot();
int8_t rssi_dbm = 0;
wifi_ap_record_t access_point = {0};
if (wifi_state.connected && esp_wifi_sta_get_ap_info(&access_point) == ESP_OK) {
rssi_dbm = access_point.rssi;
}
char response[320];
const int written = snprintf(response,
sizeof(response),
"{\"uptime_ms\":%" PRIu64
",\"wifi_state\":\"%s\",\"rssi_dbm\":%d"
",\"free_heap_bytes\":%u,\"min_free_heap_bytes\":%u"
",\"build_version\":\"%s\"}",
(uint64_t)(esp_timer_get_time() / 1000),
wifi_state_name(&wifi_state),
(int)rssi_dbm,
(unsigned int)esp_get_free_heap_size(),
(unsigned int)esp_get_minimum_free_heap_size(),
kBuildVersion);
if (written < 0 || (size_t)written >= sizeof(response)) {
return httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "health response failed");
}
httpd_resp_set_type(request, "application/json");
httpd_resp_set_hdr(request, "Cache-Control", "no-store");
return httpd_resp_send(request, response, HTTPD_RESP_USE_STRLEN);
}
static const char *mime_type_for_path(const char *path) {
if (strcmp(path, "/index.html") == 0) {
return "text/html; charset=utf-8";
}
if (strcmp(path, "/styles.css") == 0) {
return "text/css; charset=utf-8";
}
return "application/javascript; charset=utf-8";
if (strcmp(path, "/index.html") == 0) return "text/html; charset=utf-8";
return strcmp(path, "/styles.css") == 0 ? "text/css; charset=utf-8" : "application/javascript; charset=utf-8";
}
static bool request_accepts_gzip(httpd_req_t *request) {
const size_t header_length = httpd_req_get_hdr_value_len(request, "Accept-Encoding");
if (header_length == 0 || header_length >= 96) {
return false;
}
char header[96];
return httpd_req_get_hdr_value_str(request, "Accept-Encoding", header, sizeof(header)) == ESP_OK &&
strstr(header, "gzip") != NULL;
const size_t length = httpd_req_get_hdr_value_len(request, "Accept-Encoding");
if (length == 0U || length >= 96U) return false;
char value[96];
return httpd_req_get_hdr_value_str(request, "Accept-Encoding", value, sizeof(value)) == ESP_OK &&
strstr(value, "gzip") != NULL;
}
static esp_err_t send_static_file(httpd_req_t *request, const char *asset_path) {
char filesystem_path[80];
char path[80];
char gzip_path[84];
snprintf(path, sizeof(path), "%s%s", kLittlefsBasePath, asset_path);
const char *path_to_open = path;
bool gzip = false;
const char *path_to_open = filesystem_path;
snprintf(filesystem_path, sizeof(filesystem_path), "%s%s", kLittlefsBasePath, asset_path);
if (request_accepts_gzip(request)) {
snprintf(gzip_path, sizeof(gzip_path), "%s.gz", filesystem_path);
FILE *const compressed = fopen(gzip_path, "rb");
if (compressed != NULL) {
fclose(compressed);
path_to_open = gzip_path;
gzip = true;
}
}
FILE *const file = fopen(path_to_open, "rb");
if (file == NULL) {
return httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "static asset not found");
snprintf(gzip_path, sizeof(gzip_path), "%s.gz", path);
FILE *compressed = fopen(gzip_path, "rb");
if (compressed != NULL) { fclose(compressed); path_to_open = gzip_path; gzip = true; }
}
FILE *file = fopen(path_to_open, "rb");
if (file == NULL) return httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "static asset not found");
httpd_resp_set_type(request, mime_type_for_path(asset_path));
httpd_resp_set_hdr(request,
"Cache-Control",
strcmp(asset_path, "/index.html") == 0 ? "no-cache" : "public, max-age=86400");
if (gzip) {
httpd_resp_set_hdr(request, "Content-Encoding", "gzip");
httpd_resp_set_hdr(request, "Vary", "Accept-Encoding");
}
char chunk[kFileChunkBytes];
size_t bytes_read;
esp_err_t result = ESP_OK;
while ((bytes_read = fread(chunk, 1, sizeof(chunk), file)) > 0) {
result = httpd_resp_send_chunk(request, chunk, bytes_read);
if (result != ESP_OK) {
break;
}
}
httpd_resp_set_hdr(request, "Cache-Control", strcmp(asset_path, "/index.html") == 0 ? "no-cache" : "public, max-age=86400");
if (gzip) { httpd_resp_set_hdr(request, "Content-Encoding", "gzip"); httpd_resp_set_hdr(request, "Vary", "Accept-Encoding"); }
char chunk[kFileChunkBytes]; size_t read = 0U; esp_err_t result = ESP_OK;
while ((read = fread(chunk, 1U, sizeof(chunk), file)) > 0U && result == ESP_OK) result = httpd_resp_send_chunk(request, chunk, read);
fclose(file);
return result == ESP_OK ? httpd_resp_send_chunk(request, NULL, 0) : result;
return result == ESP_OK ? httpd_resp_send_chunk(request, NULL, 0U) : result;
}
static esp_err_t root_handler(httpd_req_t *request) {
if (!s_littlefs_mounted) {
if (s_littlefs_mounted) return send_static_file(request, "/index.html");
httpd_resp_set_status(request, "503 Service Unavailable");
return httpd_resp_send(request, "LittleFS is unavailable", HTTPD_RESP_USE_STRLEN);
}
return send_static_file(request, "/index.html");
}
static esp_err_t static_file_handler(httpd_req_t *request) {
if (!s_littlefs_mounted) {
httpd_resp_set_status(request, "503 Service Unavailable");
return httpd_resp_send(request, "LittleFS is unavailable", HTTPD_RESP_USE_STRLEN);
}
if (strcmp(request->uri, "/styles.css") != 0 && strcmp(request->uri, "/app.js") != 0) {
return httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "asset not found");
}
if (strcmp(request->uri, "/styles.css") != 0 && strcmp(request->uri, "/app.js") != 0) return httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "asset not found");
return send_static_file(request, request->uri);
}
static esp_err_t start_http_server(void) {
httpd_handle_t server = NULL;
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.max_uri_handlers = 5;
config.uri_match_fn = httpd_uri_match_wildcard;
config.lru_purge_enable = true;
ESP_RETURN_ON_ERROR(httpd_start(&server, &config), kLogTag, "http server start failed");
s_server = server;
const httpd_uri_t root = {.uri = "/", .method = HTTP_GET, .handler = root_handler};
const httpd_uri_t health = {.uri = "/api/health", .method = HTTP_GET, .handler = health_handler};
const httpd_uri_t state = {.uri = "/api/state", .method = HTTP_GET, .handler = state_handler};
const httpd_uri_t websocket = {.uri = "/ws", .method = HTTP_GET, .handler = websocket_handler,
.is_websocket = true};
const httpd_uri_t static_files = {.uri = "/*", .method = HTTP_GET, .handler = static_file_handler};
ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &root), kLogTag, "root route registration failed");
ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &health), kLogTag, "health route registration failed");
ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &state), kLogTag, "state route registration failed");
ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &websocket), kLogTag, "websocket route registration failed");
ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &static_files), kLogTag, "static route registration failed");
ESP_LOGI(kLogTag, "http server started on port %d", config.server_port);
config.max_uri_handlers = 16U; config.max_open_sockets = kHttpMaxOpenSockets; config.uri_match_fn = httpd_uri_match_wildcard; config.lru_purge_enable = true;
ESP_RETURN_ON_ERROR(httpd_start(&s_server, &config), kLogTag, "http server start failed");
const httpd_uri_t routes[] = {
{.uri = "/", .method = HTTP_GET, .handler = root_handler},
{.uri = "/api/info", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_INFO},
{.uri = "/api/health", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_HEALTH},
{.uri = "/api/session/join", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_JOIN},
{.uri = "/api/session/resume", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_RESUME},
{.uri = "/api/game/config", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_CONFIG},
{.uri = "/api/game/start", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_START},
{.uri = "/api/game/shot", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_SHOT},
{.uri = "/api/game/rematch", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_REMATCH},
{.uri = "/api/game/abort", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_ABORT},
{.uri = "/api/state", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_STATE},
{.uri = "/api/statistics", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_STATISTICS},
{.uri = "/ws", .method = HTTP_GET, .handler = websocket_handler, .is_websocket = true},
{.uri = "/*", .method = HTTP_GET, .handler = static_file_handler},
};
for (size_t index = 0; index < sizeof(routes) / sizeof(routes[0]); ++index) ESP_RETURN_ON_ERROR(httpd_register_uri_handler(s_server, &routes[index]), kLogTag, "route registration failed");
return ESP_OK;
}
static void mount_littlefs(void) {
const esp_vfs_littlefs_conf_t config = {
.base_path = kLittlefsBasePath,
.partition_label = kLittlefsPartitionLabel,
.format_if_mount_failed = false,
.dont_mount = false,
.grow_on_mount = false,
};
const esp_err_t result = esp_vfs_littlefs_register(&config);
if (result != ESP_OK) {
ESP_LOGE(kLogTag, "littlefs mount failed: %s; HTTP static assets remain unavailable", esp_err_to_name(result));
return;
}
size_t total_bytes = 0;
size_t used_bytes = 0;
if (esp_littlefs_info(kLittlefsPartitionLabel, &total_bytes, &used_bytes) == ESP_OK) {
ESP_LOGI(kLogTag, "littlefs mounted total_bytes=%u used_bytes=%u", (unsigned int)total_bytes, (unsigned int)used_bytes);
}
const esp_vfs_littlefs_conf_t config = {.base_path = kLittlefsBasePath, .partition_label = kLittlefsPartitionLabel, .format_if_mount_failed = false, .dont_mount = false, .grow_on_mount = false};
if (esp_vfs_littlefs_register(&config) != ESP_OK) { ESP_LOGE(kLogTag, "littlefs mount failed"); return; }
s_littlefs_mounted = true;
}
void app_main(void) {
esp_err_t result = nvs_flash_init();
if (result == ESP_ERR_NVS_NO_FREE_PAGES || result == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
result = nvs_flash_init();
}
ESP_ERROR_CHECK(result);
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
if (result == ESP_ERR_NVS_NO_FREE_PAGES || result == ESP_ERR_NVS_NEW_VERSION_FOUND) { ESP_ERROR_CHECK(nvs_flash_erase()); result = nvs_flash_init(); }
ESP_ERROR_CHECK(result); ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_event_loop_create_default());
application_init(&s_application, (random_source_t){.next_u32 = platform_random, .context = NULL});
const esp_timer_create_args_t bot_timer_args = {.callback = bot_timer_callback, .name = "bot_turn"};
ESP_ERROR_CHECK(esp_timer_create(&bot_timer_args, &s_bot_timer));
application_set_bot_scheduler(&s_application, (scheduler_t){.schedule_after_ms = schedule_bot_turn, .context = NULL});
http_api_init(&s_api, &s_application);
sync_service_init(&s_sync, &s_application);
mount_littlefs();
ESP_ERROR_CHECK(start_http_server());
const esp_timer_create_args_t state_timer_args = {.callback = state_timer_callback, .name = "state_tick"};
ESP_ERROR_CHECK(esp_timer_create(&state_timer_args, &s_state_timer));
ESP_ERROR_CHECK(esp_timer_start_periodic(s_state_timer, 5000000));
const esp_timer_create_args_t sync_timer_args = {.callback = sync_timer_callback, .name = "sync_expire"};
ESP_ERROR_CHECK(esp_timer_create(&sync_timer_args, &s_sync_timer));
ESP_ERROR_CHECK(esp_timer_start_periodic(s_sync_timer, 1000000U));
result = start_wifi();
if (result != ESP_OK) {
ESP_LOGW(kLogTag, "wifi unavailable: %s", esp_err_to_name(result));
}
if (result != ESP_OK) ESP_LOGW(kLogTag, "wifi unavailable: %s", esp_err_to_name(result));
}
+104
View File
@@ -0,0 +1,104 @@
#include "session_manager.h"
#include <stddef.h>
#include <string.h>
static bool valid_utf8_scalar(const unsigned char *text, size_t remaining, size_t *bytes) {
if (text[0] < 0x80U) { *bytes = 1; return text[0] >= 0x20U && text[0] != '<' && text[0] != '>'; }
if (text[0] >= 0xC2U && text[0] <= 0xDFU && remaining >= 2 && (text[1] & 0xC0U) == 0x80U) { *bytes = 2; return true; }
if (text[0] >= 0xE0U && text[0] <= 0xEFU && remaining >= 3 && (text[1] & 0xC0U) == 0x80U && (text[2] & 0xC0U) == 0x80U) { *bytes = 3; return true; }
if (text[0] >= 0xF0U && text[0] <= 0xF4U && remaining >= 4 && (text[1] & 0xC0U) == 0x80U && (text[2] & 0xC0U) == 0x80U && (text[3] & 0xC0U) == 0x80U) { *bytes = 4; return true; }
return false;
}
static bool sanitize_name(const char *name, char output[kDisplayNameBytes + 1]) {
if (name == NULL) return false;
size_t input = 0;
size_t written = 0;
uint8_t scalars = 0;
while (name[input] != '\0') {
size_t bytes = 0;
if (!valid_utf8_scalar((const unsigned char *)&name[input], strlen(&name[input]), &bytes)) return false;
if (written + bytes > kDisplayNameBytes || ++scalars > 20U) return false;
memcpy(&output[written], &name[input], bytes);
input += bytes;
written += bytes;
}
output[written] = '\0';
return scalars != 0U;
}
static int8_t first_free(const session_manager_t *manager, uint8_t start, uint8_t end) {
for (uint8_t index = start; index < end; ++index) if (!manager->entries[index].occupied) return (int8_t)index;
return -1;
}
void session_manager_init(session_manager_t *manager) {
if (manager != NULL) memset(manager, 0, sizeof(*manager));
}
session_result_t session_manager_join(session_manager_t *manager, role_t requested_role, const char *name,
random_source_t random, uint8_t *session_index) {
if (manager == NULL || session_index == NULL || random.next_u32 == NULL) return SESSION_RESULT_UNAUTHORIZED;
char sanitized[kDisplayNameBytes + 1] = {0};
if (!sanitize_name(name, sanitized)) return SESSION_RESULT_INVALID_NAME;
int8_t selected = -1;
if (requested_role == ROLE_PLAYER_1) selected = first_free(manager, 0, 1);
else if (requested_role == ROLE_PLAYER_2) selected = first_free(manager, 1, 2);
else if (requested_role == ROLE_SPECTATOR) selected = first_free(manager, kPlayerCapacity, kSessionCapacity);
else return SESSION_RESULT_INVALID_ROLE;
if (selected < 0) return requested_role == ROLE_SPECTATOR ? SESSION_RESULT_NO_SPECTATOR_SLOT : SESSION_RESULT_NO_PLAYER_SLOT;
session_t candidate = {.role = requested_role, .occupied = true, .connected = true};
memcpy(candidate.name, sanitized, sizeof(candidate.name));
for (uint8_t offset = 0; offset < kSessionTokenBytes; offset += 4) {
const uint32_t value = random.next_u32(random.context);
for (uint8_t byte = 0; byte < 4; ++byte) candidate.token[offset + byte] = (uint8_t)(value >> (byte * 8U));
}
manager->entries[selected] = candidate;
*session_index = (uint8_t)selected;
return SESSION_RESULT_OK;
}
session_result_t session_manager_resume(session_manager_t *manager, const uint8_t token[kSessionTokenBytes], uint8_t *session_index) {
if (manager == NULL || token == NULL || session_index == NULL) return SESSION_RESULT_UNAUTHORIZED;
for (uint8_t index = 0; index < kSessionCapacity; ++index) {
session_t *session = &manager->entries[index];
if (session->occupied && memcmp(session->token, token, kSessionTokenBytes) == 0) {
session->connected = true;
*session_index = index;
return SESSION_RESULT_OK;
}
}
return SESSION_RESULT_UNAUTHORIZED;
}
bool session_manager_find(const session_manager_t *manager, const uint8_t token[kSessionTokenBytes],
uint8_t *session_index) {
if (manager == NULL || token == NULL || session_index == NULL) return false;
for (uint8_t index = 0; index < kSessionCapacity; ++index) {
const session_t *session = &manager->entries[index];
if (session->occupied && memcmp(session->token, token, kSessionTokenBytes) == 0) {
*session_index = index;
return true;
}
}
return false;
}
bool session_manager_disconnect(session_manager_t *manager, uint8_t session_index) {
if (manager == NULL || session_index >= kSessionCapacity || !manager->entries[session_index].occupied) return false;
if (manager->entries[session_index].role == ROLE_SPECTATOR) memset(&manager->entries[session_index], 0, sizeof(session_t));
else manager->entries[session_index].connected = false;
return true;
}
session_result_t session_manager_leave(session_manager_t *manager, uint8_t session_index, phase_t phase) {
if (manager == NULL || session_index >= kSessionCapacity || !manager->entries[session_index].occupied) return SESSION_RESULT_UNAUTHORIZED;
if (manager->entries[session_index].role != ROLE_SPECTATOR && phase != PHASE_LOBBY) return SESSION_RESULT_WRONG_PHASE;
memset(&manager->entries[session_index], 0, sizeof(session_t));
return SESSION_RESULT_OK;
}
bool session_manager_player_present(const session_manager_t *manager, uint8_t player_index) {
return manager != NULL && player_index < kPlayerCapacity && manager->entries[player_index].occupied;
}
+118
View File
@@ -0,0 +1,118 @@
#include "state_presenter.h"
#include <stdint.h>
#include <string.h>
typedef struct { char data[kStateMessageCapacity]; size_t length; } state_writer_t;
static bool append_character(state_writer_t *writer, char character) {
if (writer->length + 1U >= sizeof(writer->data)) return false;
writer->data[writer->length++] = character;
return true;
}
static bool append_text(state_writer_t *writer, const char *text) {
while (*text != '\0') if (!append_character(writer, *text++)) return false;
return true;
}
static bool append_u32(state_writer_t *writer, uint32_t value) {
char digits[10];
uint8_t count = 0;
do { digits[count++] = (char)('0' + value % 10U); value /= 10U; } while (value != 0U);
while (count > 0U) if (!append_character(writer, digits[--count])) return false;
return true;
}
static const char *phase_name(phase_t phase) {
static const char *const names[] = {"lobby", "preparing", "in_progress", "finished", "rematch_wait"};
return phase <= PHASE_REMATCH_WAIT ? names[phase] : NULL;
}
static const char *mode_name(game_mode_t mode) {
static const char *const names[] = {"human", "bot"};
return mode <= MODE_BOT ? names[mode] : NULL;
}
static const char *role_name(role_t role) {
static const char *const names[] = {"player1", "player2", "spectator"};
return role <= ROLE_SPECTATOR ? names[role] : NULL;
}
static char present_cell(cell_t cell, bool reveal_ships) {
if (cell == CELL_MISS) return '2';
if (cell == CELL_HIT) return '3';
return cell == CELL_SHIP && reveal_ships ? '1' : '0';
}
static bool cell_is_sunk(const board_t *board, uint8_t cell_index) {
const uint8_t x = (uint8_t)(cell_index % kBoardWidth);
const uint8_t y = (uint8_t)(cell_index / kBoardWidth);
for (uint8_t index = 0; index < kFleetShipCount; ++index) {
const ship_t *ship = &board->ships[index];
for (uint8_t offset = 0; offset < ship->length; ++offset) {
const uint8_t ship_x = ship->horizontal ? (uint8_t)(ship->x + offset) : ship->x;
const uint8_t ship_y = ship->horizontal ? ship->y : (uint8_t)(ship->y + offset);
if (ship_x == x && ship_y == y) return ship->hits == ship->length;
}
}
return false;
}
static bool append_board(state_writer_t *writer, const board_t *board, bool reveal_ships) {
for (uint8_t index = 0; index < kBoardCellCount; ++index) {
const char cell = board->cells[index] == CELL_HIT && cell_is_sunk(board, index) ? '4' :
present_cell(board->cells[index], reveal_ships);
if (!append_character(writer, cell)) return false;
}
return true;
}
static bool append_statistics(state_writer_t *writer, const match_statistics_t *statistics) {
return append_character(writer, '[') && append_u32(writer, statistics->shots) &&
append_character(writer, ',') && append_u32(writer, statistics->hits) &&
append_character(writer, ',') && append_u32(writer, statistics->misses) &&
append_character(writer, ',') && append_u32(writer, statistics->ships_sunk) && append_character(writer, ']');
}
static bool write_state(const game_state_t *state, role_t viewer, const cumulative_statistics_t *cumulative,
char *output, size_t output_size, size_t *written) {
if (written != NULL) *written = 0;
if (state == NULL || output == NULL || output_size == 0U || phase_name(state->phase) == NULL ||
mode_name(state->mode) == NULL || role_name(viewer) == NULL || state->current_player >= kPlayerCapacity) return false;
state_writer_t writer = {0};
const bool finished = state->phase == PHASE_FINISHED;
const uint16_t wins_0 = cumulative == NULL ? 0U : cumulative[0].wins;
const uint16_t wins_1 = cumulative == NULL ? 0U : cumulative[1].wins;
const bool reveal[2] = {finished || viewer == ROLE_PLAYER_1, finished || viewer == ROLE_PLAYER_2};
if (!append_text(&writer, "{\"type\":\"state\",\"version\":" ) || !append_u32(&writer, state->version) ||
!append_text(&writer, ",\"gameId\":") || !append_u32(&writer, state->game_id) ||
!append_text(&writer, ",\"phase\":\"") || !append_text(&writer, phase_name(state->phase)) ||
!append_text(&writer, "\",\"mode\":\"") || !append_text(&writer, mode_name(state->mode)) ||
!append_text(&writer, "\",\"viewer\":\"") || !append_text(&writer, role_name(viewer)) ||
!append_text(&writer, "\",\"turn\":\"") || !append_text(&writer, role_name((role_t)state->current_player)) ||
!append_text(&writer, "\",\"boards\":[\"") || !append_board(&writer, &state->boards[0], reveal[0]) ||
!append_text(&writer, "\",\"") || !append_board(&writer, &state->boards[1], reveal[1]) ||
!append_text(&writer, "\"],\"wins\":[") || !append_u32(&writer, wins_0) ||
!append_character(&writer, ',') || !append_u32(&writer, wins_1) ||
!append_text(&writer, "],\"winner\":") ||
!(state->winner < kPlayerCapacity ? append_u32(&writer, state->winner) : append_text(&writer, "null")) ||
!append_text(&writer, ",\"statistics\":[") || !append_statistics(&writer, &state->statistics[0]) ||
!append_character(&writer, ',') || !append_statistics(&writer, &state->statistics[1]) ||
!append_text(&writer, "]}")) return false;
if (writer.length + 1U > output_size) return false;
writer.data[writer.length] = '\0';
memcpy(output, writer.data, writer.length + 1U);
if (written != NULL) *written = writer.length;
return true;
}
bool state_presenter_write(const game_state_t *state, role_t viewer, char *output, size_t output_size, size_t *written) {
return write_state(state, viewer, NULL, output, output_size, written);
}
bool state_presenter_write_lifecycle(const game_lifecycle_t *lifecycle, role_t viewer,
char *output, size_t output_size, size_t *written) {
return lifecycle != NULL && write_state(&lifecycle->game.state, viewer, lifecycle->cumulative,
output, output_size, written);
}
+220
View File
@@ -0,0 +1,220 @@
#include "sync_service.h"
#include <inttypes.h>
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
#include "state_presenter.h"
static void response_write(char output[kStateMessageCapacity], size_t *output_length, const char *format, ...) {
va_list arguments;
va_start(arguments, format);
const int length = vsnprintf(output, kStateMessageCapacity, format, arguments);
va_end(arguments);
*output_length = length >= 0 && (size_t)length < kStateMessageCapacity ? (size_t)length : 0U;
}
static void response_error(char output[kStateMessageCapacity], size_t *output_length, const char *code,
const char *message, uint32_t version) {
response_write(output, output_length, "{\"ok\":false,\"code\":\"%s\",\"message\":\"%s\",\"version\":%" PRIu32 "}",
code, message, version);
}
static bool token_bytes(const char *text, uint8_t token[kSessionTokenBytes]) {
if (text == NULL || strlen(text) != kSessionTokenBytes * 2U) return false;
for (uint8_t index = 0; index < kSessionTokenBytes; ++index) {
const char high = text[index * 2U];
const char low = text[index * 2U + 1U];
if (high < '0' || (high > '9' && high < 'a') || high > 'f' ||
low < '0' || (low > '9' && low < 'a') || low > 'f') return false;
const uint8_t high_value = (uint8_t)(high <= '9' ? high - '0' : high - 'a' + 10);
const uint8_t low_value = (uint8_t)(low <= '9' ? low - '0' : low - 'a' + 10);
token[index] = (uint8_t)((high_value << 4U) | low_value);
}
return true;
}
static bool compact_json(const char *frame, size_t frame_length, char output[kWebSocketFrameCapacity + 1U]) {
if (frame == NULL || frame_length == 0U || frame_length > kWebSocketFrameCapacity) return false;
bool in_string = false;
bool escaped = false;
size_t written = 0;
for (size_t index = 0; index < frame_length; ++index) {
const unsigned char character = (unsigned char)frame[index];
if (character < 0x20U && character != ' ' && character != '\n' && character != '\r' && character != '\t') return false;
if (!in_string && (character == ' ' || character == '\n' || character == '\r' || character == '\t')) continue;
if (written >= kWebSocketFrameCapacity) return false;
output[written++] = (char)character;
if (in_string && escaped) escaped = false;
else if (in_string && character == '\\') escaped = true;
else if (character == '"') in_string = !in_string;
}
output[written] = '\0';
return !in_string && !escaped;
}
static sync_connection_t *connection_for(sync_service_t *service, int client_id) {
if (service == NULL) return NULL;
for (uint8_t index = 0; index < kSessionCapacity; ++index) {
if (service->connections[index].active && service->connections[index].client_id == client_id) return &service->connections[index];
}
return NULL;
}
static void lifecycle_error(char output[kStateMessageCapacity], size_t *output_length, lifecycle_result_t result,
uint32_t version) {
switch (result) {
case LIFECYCLE_RESULT_INVALID_MODE: response_error(output, output_length, "INVALID_MODE", "Некорректный режим", version); break;
case LIFECYCLE_RESULT_INVALID_COORDINATE: response_error(output, output_length, "INVALID_COORDINATE", "Некорректные координаты", version); break;
case LIFECYCLE_RESULT_FORBIDDEN_ROLE: response_error(output, output_length, "FORBIDDEN_ROLE", "Роль не может выполнить действие", version); break;
case LIFECYCLE_RESULT_WRONG_PHASE: response_error(output, output_length, "WRONG_PHASE", "Действие недоступно сейчас", version); break;
case LIFECYCLE_RESULT_NOT_YOUR_TURN: response_error(output, output_length, "NOT_YOUR_TURN", "Сейчас ход соперника", version); break;
case LIFECYCLE_RESULT_CELL_ALREADY_SHOT: response_error(output, output_length, "CELL_ALREADY_SHOT", "Клетка уже обстреляна", version); break;
case LIFECYCLE_RESULT_STALE_GAME: response_error(output, output_length, "STALE_GAME", "Партия уже изменилась", version); break;
default: response_error(output, output_length, "SERVER_BUSY", "Сервер занят", version); break;
}
}
static bool parse_command(const char *json, app_command_t *command, uint8_t token_bytes_out[kSessionTokenBytes]) {
char token[kSessionTokenBytes * 2U + 1U] = {0};
char mode[8] = {0};
uint32_t game_id = 0;
uint32_t x = 0;
uint32_t y = 0;
int consumed = 0;
if (sscanf(json, "{\"type\":\"config\",\"token\":\"%32[0-9a-f]\",\"gameId\":%" SCNu32 ",\"mode\":\"%7[a-z]\"}%n", token, &game_id, mode, &consumed) == 3 && json[consumed] == '\0') {
command->type = COMMAND_CONFIG;
command->mode = strcmp(mode, "human") == 0 ? MODE_HUMAN : strcmp(mode, "bot") == 0 ? MODE_BOT : (game_mode_t)UINT8_MAX;
} else if (sscanf(json, "{\"type\":\"shot\",\"token\":\"%32[0-9a-f]\",\"gameId\":%" SCNu32 ",\"x\":%" SCNu32 ",\"y\":%" SCNu32 "}%n", token, &game_id, &x, &y, &consumed) == 4 && json[consumed] == '\0') {
command->type = COMMAND_SHOT;
command->coordinate = x < kBoardWidth && y < kBoardHeight ?
(coordinate_t){.x = (uint8_t)x, .y = (uint8_t)y} : (coordinate_t){.x = kBoardWidth, .y = 0U};
} else if (sscanf(json, "{\"type\":\"start\",\"token\":\"%32[0-9a-f]\",\"gameId\":%" SCNu32 "}%n", token, &game_id, &consumed) == 2 && json[consumed] == '\0') {
command->type = COMMAND_START;
} else if (sscanf(json, "{\"type\":\"rematch\",\"token\":\"%32[0-9a-f]\",\"gameId\":%" SCNu32 "}%n", token, &game_id, &consumed) == 2 && json[consumed] == '\0') {
command->type = COMMAND_REMATCH;
} else if (sscanf(json, "{\"type\":\"abort\",\"token\":\"%32[0-9a-f]\",\"gameId\":%" SCNu32 "}%n", token, &game_id, &consumed) == 2 && json[consumed] == '\0') {
command->type = COMMAND_ABORT;
} else return false;
if (!token_bytes(token, token_bytes_out)) return false;
command->game_id = game_id;
return true;
}
void sync_service_init(sync_service_t *service, application_t *application) {
if (service != NULL) *service = (sync_service_t){.application = application};
}
bool sync_service_open(sync_service_t *service, int client_id, uint64_t now_ms) {
if (service == NULL || service->application == NULL || connection_for(service, client_id) != NULL) return false;
for (uint8_t index = 0; index < kSessionCapacity; ++index) {
if (!service->connections[index].active) {
service->connections[index] = (sync_connection_t){.client_id = client_id, .active = true, .opened_ms = now_ms};
return true;
}
}
return false;
}
void sync_service_close(sync_service_t *service, int client_id) {
sync_connection_t *connection = connection_for(service, client_id);
if (connection != NULL) *connection = (sync_connection_t){0};
}
bool sync_service_receive(sync_service_t *service, int client_id, const char *frame, size_t frame_length,
uint64_t now_ms, char output[kStateMessageCapacity], size_t *output_length,
bool *state_changed, bool *close_client) {
if (output_length != NULL) *output_length = 0U;
if (state_changed != NULL) *state_changed = false;
if (close_client != NULL) *close_client = false;
sync_connection_t *connection = connection_for(service, client_id);
if (service == NULL || service->application == NULL || connection == NULL || output == NULL || output_length == NULL) return false;
const uint32_t version = service->application->lifecycle.game.state.version;
if (now_ms - connection->opened_ms > kWebSocketHelloTimeoutMs && !connection->authenticated) {
response_error(output, output_length, "UNAUTHORIZED", "Сессия не найдена", version);
if (close_client != NULL) *close_client = true;
return true;
}
char json[kWebSocketFrameCapacity + 1U];
if (!compact_json(frame, frame_length, json)) {
response_error(output, output_length, "MALFORMED_JSON", "Некорректный JSON", version);
return true;
}
if (!connection->authenticated) {
char token_text[kSessionTokenBytes * 2U + 1U] = {0};
uint32_t client_version = 0;
int consumed = 0;
if (sscanf(json, "{\"type\":\"hello\",\"token\":\"%32[0-9a-f]\",\"version\":%" SCNu32 "}%n", token_text, &client_version, &consumed) != 2 || json[consumed] != '\0') {
response_error(output, output_length, "UNAUTHORIZED", "Сессия не найдена", version);
if (close_client != NULL) *close_client = true;
return true;
}
uint8_t token[kSessionTokenBytes];
uint8_t session_index = 0;
if (!token_bytes(token_text, token) || !application_session_for_token(service->application, token, &session_index)) {
response_error(output, output_length, "UNAUTHORIZED", "Сессия не найдена", version);
if (close_client != NULL) *close_client = true;
return true;
}
for (uint8_t index = 0; index < kSessionCapacity; ++index) {
if (service->connections[index].active && service->connections[index].authenticated &&
service->connections[index].session_index == session_index) service->connections[index].active = false;
}
connection->authenticated = true;
connection->session_index = session_index;
(void)client_version;
if (!state_presenter_write_lifecycle(&service->application->lifecycle,
service->application->lifecycle.sessions.entries[session_index].role,
output, kStateMessageCapacity, output_length)) {
response_error(output, output_length, "SERVER_BUSY", "Сервер занят", version);
}
return true;
}
if (strcmp(json, "{\"type\":\"ping\"}") == 0) {
response_write(output, output_length, "{\"type\":\"pong\"}");
return true;
}
app_command_t command = {0};
uint8_t token[kSessionTokenBytes];
if (!parse_command(json, &command, token) ||
!application_session_for_token(service->application, token, &command.session_index) ||
command.session_index != connection->session_index) {
response_error(output, output_length, "UNAUTHORIZED", "Сессия не найдена", version);
return true;
}
command.version = version;
const lifecycle_result_t result = application_submit(service->application, &command);
if (result != LIFECYCLE_RESULT_OK) {
lifecycle_error(output, output_length, result, service->application->lifecycle.game.state.version);
} else if (state_changed != NULL) {
*state_changed = true;
}
return true;
}
void sync_service_expire(sync_service_t *service, uint64_t now_ms, int closed_clients[kSessionCapacity],
size_t *closed_count) {
if (closed_count != NULL) *closed_count = 0U;
if (service == NULL || closed_clients == NULL || closed_count == NULL) return;
for (uint8_t index = 0; index < kSessionCapacity; ++index) {
sync_connection_t *connection = &service->connections[index];
if (connection->active && !connection->authenticated && now_ms - connection->opened_ms > kWebSocketHelloTimeoutMs) {
closed_clients[(*closed_count)++] = connection->client_id;
*connection = (sync_connection_t){0};
}
}
}
void sync_service_broadcast(sync_service_t *service, sync_send_fn send, void *context) {
if (service == NULL || service->application == NULL || send == NULL) return;
for (uint8_t index = 0; index < kSessionCapacity; ++index) {
sync_connection_t *connection = &service->connections[index];
if (!connection->active || !connection->authenticated) continue;
char payload[kStateMessageCapacity];
size_t length = 0U;
const role_t role = service->application->lifecycle.sessions.entries[connection->session_index].role;
if (!state_presenter_write_lifecycle(&service->application->lifecycle, role, payload, sizeof(payload), &length) ||
!send(context, connection->client_id, payload, length)) *connection = (sync_connection_t){0};
}
}
+50
View File
@@ -0,0 +1,50 @@
CC ?= cc
CFLAGS ?= -std=c11 -Wall -Wextra -Werror -I../../include
all: test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_robustness
test_command_queue: test_command_queue.c ../../src/command_queue.c
$(CC) $(CFLAGS) $^ -o $@
run: all
./test_command_queue
./test_game_domain
./test_bot_player
./test_game_lifecycle
./test_state_presenter
./test_http_api
./test_sync_service
./test_human_game_integration
./test_bot_game_integration
./test_robustness
test_game_domain: test_game_domain.c ../../src/fleet_generator.c ../../src/game_engine.c
$(CC) $(CFLAGS) $^ -o $@
test_bot_player: test_bot_player.c ../../src/bot_player.c ../../src/fleet_generator.c ../../src/game_engine.c
$(CC) $(CFLAGS) $^ -o $@
test_game_lifecycle: test_game_lifecycle.c ../../src/session_manager.c ../../src/game_lifecycle.c ../../src/bot_player.c ../../src/fleet_generator.c ../../src/game_engine.c
$(CC) $(CFLAGS) $^ -o $@
test_state_presenter: test_state_presenter.c ../../src/state_presenter.c
$(CC) $(CFLAGS) $^ -o $@
test_http_api: test_http_api.c ../../src/http_api.c ../../src/application.c ../../src/command_queue.c ../../src/session_manager.c ../../src/game_lifecycle.c ../../src/bot_player.c ../../src/fleet_generator.c ../../src/game_engine.c ../../src/state_presenter.c
$(CC) $(CFLAGS) $^ -o $@
test_sync_service: test_sync_service.c ../../src/sync_service.c ../../src/application.c ../../src/command_queue.c ../../src/session_manager.c ../../src/game_lifecycle.c ../../src/bot_player.c ../../src/fleet_generator.c ../../src/game_engine.c ../../src/state_presenter.c
$(CC) $(CFLAGS) $^ -o $@
test_human_game_integration: test_human_game_integration.c ../../src/http_api.c ../../src/application.c ../../src/command_queue.c ../../src/session_manager.c ../../src/game_lifecycle.c ../../src/bot_player.c ../../src/fleet_generator.c ../../src/game_engine.c ../../src/state_presenter.c
$(CC) $(CFLAGS) $^ -o $@
test_bot_game_integration: test_bot_game_integration.c ../../src/session_manager.c ../../src/game_lifecycle.c ../../src/bot_player.c ../../src/fleet_generator.c ../../src/game_engine.c
$(CC) $(CFLAGS) $^ -o $@
test_robustness: test_robustness.c ../../src/http_api.c ../../src/sync_service.c ../../src/application.c ../../src/command_queue.c ../../src/session_manager.c ../../src/game_lifecycle.c ../../src/bot_player.c ../../src/fleet_generator.c ../../src/game_engine.c ../../src/state_presenter.c
$(CC) $(CFLAGS) $^ -o $@
clean:
rm -f test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_robustness
+107
View File
@@ -0,0 +1,107 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "game_lifecycle.h"
typedef struct { uint32_t value; } test_random_t;
typedef struct { uint32_t delay_ms; uint8_t calls; } test_scheduler_t;
static uint32_t next_random(void *context) {
test_random_t *random = context;
random->value = random->value * 1664525U + 1013904223U;
return random->value;
}
static bool schedule_after(void *context, uint32_t delay_ms) {
test_scheduler_t *scheduler = context;
scheduler->delay_ms = delay_ms;
++scheduler->calls;
return true;
}
static coordinate_t first_cell(const board_t *board, cell_t cell) {
for (uint8_t index = 0; index < kBoardCellCount; ++index) {
if (board->cells[index] == cell) return (coordinate_t){.x = (uint8_t)(index % kBoardWidth), .y = (uint8_t)(index / kBoardWidth)};
}
assert(false);
return (coordinate_t){0};
}
static void test_bot_game_lifecycle(void) {
test_random_t random = {.value = 101U};
test_scheduler_t scheduler = {0};
game_lifecycle_t lifecycle;
game_lifecycle_init(&lifecycle, (random_source_t){.next_u32 = next_random, .context = &random});
game_lifecycle_set_bot_scheduler(&lifecycle, (scheduler_t){.schedule_after_ms = schedule_after, .context = &scheduler});
uint8_t player = 0;
assert(game_lifecycle_join(&lifecycle, ROLE_PLAYER_1, "Alice", &player) == LIFECYCLE_RESULT_OK);
const uint32_t game_id = lifecycle.game.state.game_id;
assert(game_lifecycle_configure(&lifecycle, player, game_id, MODE_BOT) == LIFECYCLE_RESULT_OK);
assert(game_lifecycle_start(&lifecycle, player, game_id) == LIFECYCLE_RESULT_OK);
assert(lifecycle.game.state.phase == PHASE_IN_PROGRESS && lifecycle.game.state.mode == MODE_BOT);
if (lifecycle.game.state.current_player == 0U) {
assert(game_lifecycle_shot(&lifecycle, player, game_id, first_cell(&lifecycle.game.state.boards[1], CELL_WATER), NULL) == LIFECYCLE_RESULT_OK);
}
assert(lifecycle.game.state.current_player == 1U);
assert(lifecycle.bot.turn_pending && scheduler.calls == 1U);
assert(scheduler.delay_ms >= 500U && scheduler.delay_ms <= 900U);
assert(game_lifecycle_bot_take_turn(&lifecycle));
assert(lifecycle.game.state.statistics[1].shots == 1U);
assert(lifecycle.game.state.statistics[1].shots == lifecycle.game.state.statistics[1].hits + lifecycle.game.state.statistics[1].misses);
for (uint16_t attempts = 0; attempts < kBoardCellCount && lifecycle.game.state.current_player == 1U &&
lifecycle.game.state.phase == PHASE_IN_PROGRESS; ++attempts) {
assert(game_lifecycle_bot_take_turn(&lifecycle));
}
assert(lifecycle.game.state.phase == PHASE_IN_PROGRESS && lifecycle.game.state.current_player == 0U);
while (lifecycle.game.state.phase == PHASE_IN_PROGRESS) {
assert(game_lifecycle_shot(&lifecycle, player, game_id, first_cell(&lifecycle.game.state.boards[1], CELL_SHIP), NULL) == LIFECYCLE_RESULT_OK);
}
assert(lifecycle.game.state.winner == 0U);
assert(lifecycle.game.state.statistics[0].ships_sunk == kFleetShipCount);
for (uint8_t side = 0; side < kPlayerCapacity; ++side) {
const match_statistics_t *match = &lifecycle.game.state.statistics[side];
const cumulative_statistics_t *total = &lifecycle.cumulative[side];
assert(total->games == 1U && total->shots == match->shots && total->hits == match->hits &&
total->misses == match->misses && total->ships_sunk == match->ships_sunk);
}
assert(lifecycle.cumulative[0].wins == 1U && lifecycle.cumulative[1].losses == 1U);
assert(game_lifecycle_rematch(&lifecycle, player, game_id) == LIFECYCLE_RESULT_OK);
assert(lifecycle.game.state.phase == PHASE_IN_PROGRESS && lifecycle.game.state.game_id != game_id);
assert(lifecycle.game.state.statistics[0].shots == 0U && lifecycle.game.state.statistics[1].shots == 0U);
assert(lifecycle.cumulative[0].games == 1U && lifecycle.cumulative[0].wins == 1U);
game_lifecycle_init(&lifecycle, (random_source_t){.next_u32 = next_random, .context = &random});
assert(lifecycle.game.state.phase == PHASE_LOBBY && lifecycle.game.state.game_id == 1U);
assert(lifecycle.cumulative[0].games == 0U && lifecycle.cumulative[1].games == 0U);
assert(!lifecycle.sessions.entries[0].occupied && !lifecycle.sessions.entries[1].occupied);
}
static void test_bot_can_receive_the_first_turn(void) {
for (uint32_t seed = 1U; seed < 100U; ++seed) {
test_random_t random = {.value = seed};
test_scheduler_t scheduler = {0};
game_lifecycle_t lifecycle;
game_lifecycle_init(&lifecycle, (random_source_t){.next_u32 = next_random, .context = &random});
game_lifecycle_set_bot_scheduler(&lifecycle, (scheduler_t){.schedule_after_ms = schedule_after, .context = &scheduler});
uint8_t player = 0;
assert(game_lifecycle_join(&lifecycle, ROLE_PLAYER_1, "Alice", &player) == LIFECYCLE_RESULT_OK);
assert(game_lifecycle_configure(&lifecycle, player, lifecycle.game.state.game_id, MODE_BOT) == LIFECYCLE_RESULT_OK);
assert(game_lifecycle_start(&lifecycle, player, lifecycle.game.state.game_id) == LIFECYCLE_RESULT_OK);
if (lifecycle.game.state.current_player != 1U) continue;
assert(lifecycle.bot.turn_pending && scheduler.calls == 1U);
assert(scheduler.delay_ms >= 500U && scheduler.delay_ms <= 900U);
return;
}
assert(false);
}
int main(void) {
test_bot_game_lifecycle();
test_bot_can_receive_the_first_turn();
puts("bot game integration tests passed");
return 0;
}
+160
View File
@@ -0,0 +1,160 @@
#include <assert.h>
#include <stdio.h>
#include "bot_player.h"
#include "fleet_generator.h"
#include "game_engine.h"
typedef struct { uint32_t value; } test_random_t;
typedef struct { uint32_t delay_ms; uint8_t calls; bool accepted; } test_scheduler_t;
static uint32_t next_random(void *context) {
test_random_t *random = context;
random->value = random->value * 1664525U + 1013904223U;
return random->value;
}
static bool schedule_after(void *context, uint32_t delay_ms) {
test_scheduler_t *scheduler = context;
scheduler->delay_ms = delay_ms;
++scheduler->calls;
return scheduler->accepted;
}
static bot_player_t new_bot(test_random_t *random, test_scheduler_t *scheduler) {
bot_player_t bot;
bot_player_init(&bot, (random_source_t){.next_u32 = next_random, .context = random},
(scheduler_t){.schedule_after_ms = schedule_after, .context = scheduler});
return bot;
}
static void record(bot_player_t *bot, uint8_t x, uint8_t y, bool hit, bool sunk) {
const bot_shot_result_t result = {.hit = hit, .sunk = sunk};
bot_player_record_result(bot, (coordinate_t){x, y}, &result);
}
static bool is_adjacent(coordinate_t source, coordinate_t candidate) {
const int16_t dx = (int16_t)source.x - candidate.x;
const int16_t dy = (int16_t)source.y - candidate.y;
return (dx == 0 && (dy == 1 || dy == -1)) || (dy == 0 && (dx == 1 || dx == -1));
}
static void test_targeting_and_cleanup(void) {
test_random_t random = {.value = 3};
test_scheduler_t scheduler = {.accepted = true};
bot_player_t bot = new_bot(&random, &scheduler);
coordinate_t next = {0};
record(&bot, 0, 0, true, false);
assert(bot_player_next_shot(&bot, &next));
assert(is_adjacent((coordinate_t){0, 0}, next));
bot_player_init(&bot, (random_source_t){.next_u32 = next_random, .context = &random},
(scheduler_t){.schedule_after_ms = schedule_after, .context = &scheduler});
record(&bot, 4, 4, true, false);
record(&bot, 5, 4, true, false);
record(&bot, 6, 4, false, false);
assert(bot_player_next_shot(&bot, &next));
assert(next.x == 3U && next.y == 4U);
bot_player_init(&bot, (random_source_t){.next_u32 = next_random, .context = &random},
(scheduler_t){.schedule_after_ms = schedule_after, .context = &scheduler});
record(&bot, 0, 0, true, true);
assert(bot.knowledge[0] == BOT_CELL_BLOCKED);
assert(bot.knowledge[1] == BOT_CELL_BLOCKED);
assert(bot.knowledge[kBoardWidth] == BOT_CELL_BLOCKED);
assert(bot_player_next_shot(&bot, &next));
assert(next.x > 1U || next.y > 1U);
}
static void test_nonblocking_schedule_and_cancel(void) {
test_random_t random = {.value = 9};
test_scheduler_t scheduler = {.accepted = true};
bot_player_t bot = new_bot(&random, &scheduler);
assert(bot_player_schedule_turn(&bot));
assert(bot.turn_pending && scheduler.calls == 1U);
assert(scheduler.delay_ms >= 500U && scheduler.delay_ms <= 900U);
assert(!bot_player_schedule_turn(&bot));
bot_player_cancel_turn(&bot);
assert(!bot.turn_pending);
scheduler.accepted = false;
assert(!bot_player_schedule_turn(&bot));
assert(!bot.turn_pending);
}
static void test_final_bot_shot_uses_only_result(void) {
test_random_t random = {.value = 17};
test_scheduler_t scheduler = {.accepted = true};
bot_player_t bot = new_bot(&random, &scheduler);
for (uint8_t index = 1; index < kBoardCellCount; ++index) bot.knowledge[index] = BOT_CELL_BLOCKED;
game_engine_t engine;
game_engine_init(&engine);
engine.state.phase = PHASE_IN_PROGRESS;
engine.state.current_player = 1U;
engine.state.boards[0].ships_alive = 1U;
engine.state.boards[0].ships[0] = (ship_t){.x = 0, .y = 0, .length = 1, .horizontal = true};
engine.state.boards[0].cells[0] = CELL_SHIP;
coordinate_t target = {0, 0};
shot_result_t result = {0};
assert(bot_player_next_shot(&bot, &target));
/* The strategy sees this result, never engine.state.boards[0]. */
assert(game_engine_shot(&engine, 1U, target, &result) == GAME_RESULT_OK);
bot_player_record_result(&bot, target, &(bot_shot_result_t){.hit = result.hit, .sunk = result.sunk});
assert(result.hit && result.sunk && result.finished);
assert(engine.state.phase == PHASE_FINISHED && engine.state.winner == 1U);
}
static fleet_generator_t generator_for(test_random_t *random) {
return (fleet_generator_t){.random = {.next_u32 = next_random, .context = random}};
}
static coordinate_t first_available_target(const board_t *board) {
for (uint8_t index = 0; index < kBoardCellCount; ++index) {
if (board->cells[index] == CELL_WATER || board->cells[index] == CELL_SHIP) {
return (coordinate_t){(uint8_t)(index % kBoardWidth), (uint8_t)(index / kBoardWidth)};
}
}
assert(false);
return (coordinate_t){0, 0};
}
static void test_ten_thousand_games_without_hidden_board_access(void) {
for (uint32_t seed = 0; seed < 10000U; ++seed) {
test_random_t random = {.value = seed + 1000U};
test_scheduler_t scheduler = {.accepted = true};
const fleet_generator_t generator = generator_for(&random);
game_engine_t engine;
game_engine_init(&engine);
assert(game_engine_start(&engine, seed + 1U, MODE_BOT, &generator) == GAME_RESULT_OK);
bot_player_t bot = new_bot(&random, &scheduler);
bool bot_shot[kBoardCellCount] = {0};
for (uint16_t step = 0; step < 200U && engine.state.phase == PHASE_IN_PROGRESS; ++step) {
const uint8_t player = engine.state.current_player;
coordinate_t coordinate;
shot_result_t result = {0};
if (player == 1U) {
assert(bot_player_next_shot(&bot, &coordinate));
const uint8_t index = (uint8_t)(coordinate.y * kBoardWidth + coordinate.x);
assert(!bot_shot[index]);
bot_shot[index] = true;
assert(game_engine_shot(&engine, player, coordinate, &result) == GAME_RESULT_OK);
bot_player_record_result(&bot, coordinate, &(bot_shot_result_t){.hit = result.hit, .sunk = result.sunk});
} else {
coordinate = first_available_target(&engine.state.boards[1]);
assert(game_engine_shot(&engine, player, coordinate, &result) == GAME_RESULT_OK);
}
}
assert(engine.state.phase == PHASE_FINISHED);
assert(engine.state.winner < kPlayerCapacity);
}
}
int main(void) {
test_targeting_and_cleanup();
test_nonblocking_schedule_and_cancel();
test_final_bot_shot_uses_only_result();
test_ten_thousand_games_without_hidden_board_access();
puts("bot player tests passed");
return 0;
}
BIN
View File
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "command_queue.h"
int main(void) {
command_queue_t queue = {0};
app_command_t input = {.type = COMMAND_SHOT, .session_index = 1, .game_id = 7, .version = 3, .coordinate = {4, 5}};
app_command_t output = {0};
assert(!command_queue_pop(&queue, &output));
for (int index = 0; index < kCommandQueueCapacity; ++index) {
input.coordinate.x = (uint8_t)index;
assert(command_queue_push(&queue, &input));
}
assert(!command_queue_push(&queue, &input));
for (int index = 0; index < kCommandQueueCapacity; ++index) {
assert(command_queue_pop(&queue, &output));
assert(output.coordinate.x == (uint8_t)index);
}
assert(!command_queue_pop(&queue, &output));
puts("command queue tests passed");
return 0;
}
+162
View File
@@ -0,0 +1,162 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "fleet_generator.h"
#include "game_engine.h"
typedef struct { uint32_t value; } test_random_t;
static uint32_t next_random(void *context) {
test_random_t *random = context;
random->value = random->value * 1664525U + 1013904223U;
return random->value;
}
static fleet_generator_t generator_for(test_random_t *random) {
return (fleet_generator_t){.random = {.next_u32 = next_random, .context = random}};
}
static void start_game(game_engine_t *engine, test_random_t *random) {
const fleet_generator_t generator = generator_for(random);
game_engine_init(engine);
assert(game_engine_start(engine, 1, MODE_HUMAN, &generator) == GAME_RESULT_OK);
assert(engine->state.phase == PHASE_IN_PROGRESS);
assert(engine->state.version == 1U);
assert(fleet_generator_validate(&engine->state.boards[0]));
assert(fleet_generator_validate(&engine->state.boards[1]));
}
static coordinate_t first_cell(const board_t *board, cell_t wanted) {
for (uint8_t y = 0; y < kBoardHeight; ++y) {
for (uint8_t x = 0; x < kBoardWidth; ++x) {
if (board->cells[y * kBoardWidth + x] == wanted) return (coordinate_t){x, y};
}
}
assert(false);
return (coordinate_t){0, 0};
}
static void assert_rejected_unchanged(game_engine_t *engine, uint8_t player, coordinate_t coordinate,
game_result_t expected) {
game_engine_t before = *engine;
assert(game_engine_shot(engine, player, coordinate, NULL) == expected);
assert(memcmp(&before, engine, sizeof(before)) == 0);
}
static void test_many_fleets(void) {
for (uint32_t seed = 0; seed < 10000U; ++seed) {
test_random_t random = {.value = seed};
const fleet_generator_t generator = generator_for(&random);
board_t board = {0};
assert(fleet_generator_generate(&generator, &board));
assert(fleet_generator_validate(&board));
}
}
static void test_rules_and_rejection(void) {
test_random_t random = {.value = 7};
game_engine_t engine;
start_game(&engine, &random);
const uint8_t player = engine.state.current_player;
game_engine_t before_start = engine;
const fleet_generator_t generator = generator_for(&random);
assert(game_engine_start(&engine, 2, MODE_HUMAN, &generator) == GAME_RESULT_WRONG_PHASE);
assert(memcmp(&before_start, &engine, sizeof(engine)) == 0);
assert_rejected_unchanged(&engine, player, (coordinate_t){10, 0}, GAME_RESULT_INVALID_COORDINATE);
assert_rejected_unchanged(&engine, player ^ 1U, (coordinate_t){0, 0}, GAME_RESULT_NOT_YOUR_TURN);
const coordinate_t water = first_cell(&engine.state.boards[player ^ 1U], CELL_WATER);
shot_result_t result = {0};
const uint32_t version = engine.state.version;
assert(game_engine_shot(&engine, player, water, &result) == GAME_RESULT_OK);
assert(!result.hit && !result.sunk && !result.finished);
assert(engine.state.current_player == (uint8_t)(player ^ 1U));
assert(engine.state.version == version + 1U);
assert(engine.state.statistics[player].shots == 1U && engine.state.statistics[player].misses == 1U);
assert_rejected_unchanged(&engine, player, water, GAME_RESULT_NOT_YOUR_TURN);
const uint8_t hitter = engine.state.current_player;
const coordinate_t ship_cell = first_cell(&engine.state.boards[hitter ^ 1U], CELL_SHIP);
assert(game_engine_shot(&engine, hitter, ship_cell, &result) == GAME_RESULT_OK);
assert(result.hit && !result.finished);
assert(engine.state.current_player == hitter);
assert(engine.state.statistics[hitter].hits == 1U);
assert_rejected_unchanged(&engine, hitter, ship_cell, GAME_RESULT_CELL_ALREADY_SHOT);
}
static void test_sinking_and_victory(void) {
test_random_t random = {.value = 11};
game_engine_t engine;
start_game(&engine, &random);
const uint8_t player = engine.state.current_player;
board_t *target = &engine.state.boards[player ^ 1U];
const ship_t ship = target->ships[9];
assert(ship.length == 1U);
const coordinate_t coordinate = {ship.x, ship.y};
shot_result_t result = {0};
assert(game_engine_shot(&engine, player, coordinate, &result) == GAME_RESULT_OK);
assert(result.hit && result.sunk && !result.finished);
assert(target->ships_alive == kFleetShipCount - 1U);
assert(engine.state.statistics[player].ships_sunk == 1U);
for (int8_t y = -1; y <= 1; ++y) {
for (int8_t x = -1; x <= 1; ++x) {
const int16_t neighbour_x = (int16_t)coordinate.x + x;
const int16_t neighbour_y = (int16_t)coordinate.y + y;
if (neighbour_x < 0 || neighbour_y < 0 || neighbour_x >= kBoardWidth || neighbour_y >= kBoardHeight) continue;
const uint8_t index = (uint8_t)(neighbour_y * kBoardWidth + neighbour_x);
if (x != 0 || y != 0) assert(target->cells[index] == CELL_MISS);
}
}
for (uint8_t ship_index = 0; ship_index < kFleetShipCount; ++ship_index) {
const ship_t remaining = target->ships[ship_index];
for (uint8_t offset = 0; offset < remaining.length; ++offset) {
const coordinate_t hit = {remaining.horizontal ? (uint8_t)(remaining.x + offset) : remaining.x,
remaining.horizontal ? remaining.y : (uint8_t)(remaining.y + offset)};
if (target->cells[hit.y * kBoardWidth + hit.x] == CELL_SHIP) assert(game_engine_shot(&engine, player, hit, &result) == GAME_RESULT_OK);
}
}
assert(result.finished);
assert(engine.state.phase == PHASE_FINISHED && engine.state.winner == player);
assert_rejected_unchanged(&engine, player, (coordinate_t){0, 0}, GAME_RESULT_WRONG_PHASE);
}
static void test_simulated_games(void) {
for (uint32_t seed = 100; seed < 10100U; ++seed) {
test_random_t random = {.value = seed};
game_engine_t engine;
start_game(&engine, &random);
for (uint16_t step = 0; step < 200U && engine.state.phase == PHASE_IN_PROGRESS; ++step) {
const uint8_t player = engine.state.current_player;
const board_t *target = &engine.state.boards[player ^ 1U];
coordinate_t choice = {0, 0};
bool found = false;
for (uint8_t index = 0; index < kBoardCellCount; ++index) {
if (target->cells[index] == CELL_WATER || target->cells[index] == CELL_SHIP) {
choice = (coordinate_t){(uint8_t)(index % kBoardWidth), (uint8_t)(index / kBoardWidth)};
found = true;
break;
}
}
assert(found);
assert(game_engine_shot(&engine, player, choice, NULL) == GAME_RESULT_OK);
}
assert(engine.state.phase == PHASE_FINISHED);
assert(engine.state.winner < kPlayerCapacity);
for (uint8_t player = 0; player < kPlayerCapacity; ++player) {
const match_statistics_t *statistics = &engine.state.statistics[player];
assert(statistics->shots == statistics->hits + statistics->misses);
assert(statistics->ships_sunk <= kFleetShipCount);
}
}
}
int main(void) {
test_many_fleets();
test_rules_and_rejection();
test_sinking_and_victory();
test_simulated_games();
puts("game domain tests passed");
return 0;
}
+113
View File
@@ -0,0 +1,113 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "game_lifecycle.h"
typedef struct { uint32_t value; } test_random_t;
static uint32_t next_random(void *context) {
test_random_t *random = context;
random->value = random->value * 1664525U + 1013904223U;
return random->value;
}
static game_lifecycle_t new_lifecycle(test_random_t *random) {
game_lifecycle_t lifecycle;
game_lifecycle_init(&lifecycle, (random_source_t){.next_u32 = next_random, .context = random});
return lifecycle;
}
static void join_players(game_lifecycle_t *lifecycle, uint8_t *player_1, uint8_t *player_2) {
assert(game_lifecycle_join(lifecycle, ROLE_PLAYER_1, "Alice", player_1) == LIFECYCLE_RESULT_OK);
assert(game_lifecycle_join(lifecycle, ROLE_PLAYER_2, "Bob", player_2) == LIFECYCLE_RESULT_OK);
assert(*player_1 == 0U && *player_2 == 1U);
}
static void finish_for_player_1(game_lifecycle_t *lifecycle, uint8_t player_1) {
game_state_t *state = &lifecycle->game.state;
state->current_player = 0U;
memset(&state->boards[1], 0, sizeof(state->boards[1]));
state->boards[1].ships_alive = 1U;
state->boards[1].ships[0] = (ship_t){.x = 0, .y = 0, .length = 1, .horizontal = true};
state->boards[1].cells[0] = CELL_SHIP;
shot_result_t result = {0};
assert(game_lifecycle_shot(lifecycle, player_1, state->game_id, (coordinate_t){0, 0}, &result) == LIFECYCLE_RESULT_OK);
assert(result.finished && state->phase == PHASE_FINISHED && state->winner == 0U);
}
static void test_capacity_sanitize_and_resume(void) {
test_random_t random = {.value = 1};
game_lifecycle_t lifecycle = new_lifecycle(&random);
uint8_t player_1 = 0;
uint8_t player_2 = 0;
assert(game_lifecycle_join(&lifecycle, ROLE_PLAYER_2, "Bob", &player_2) == LIFECYCLE_RESULT_NO_PLAYER_SLOT);
join_players(&lifecycle, &player_1, &player_2);
assert(game_lifecycle_join(&lifecycle, ROLE_PLAYER_1, "Other", &player_1) == LIFECYCLE_RESULT_NO_PLAYER_SLOT);
assert(game_lifecycle_join(&lifecycle, ROLE_SPECTATOR, "<bad>", &player_1) == LIFECYCLE_RESULT_INVALID_NAME);
for (uint8_t index = 0; index < kSpectatorCapacity; ++index) {
assert(game_lifecycle_join(&lifecycle, ROLE_SPECTATOR, "Watch", &player_1) == LIFECYCLE_RESULT_OK);
}
assert(game_lifecycle_join(&lifecycle, ROLE_SPECTATOR, "Extra", &player_1) == LIFECYCLE_RESULT_NO_SPECTATOR_SLOT);
const uint8_t token[kSessionTokenBytes] = {0};
uint8_t saved_token[kSessionTokenBytes] = {0};
memcpy(saved_token, lifecycle.sessions.entries[player_2].token, sizeof(saved_token));
assert(memcmp(token, saved_token, sizeof(token)) != 0);
assert(game_lifecycle_disconnect(&lifecycle, player_2) == LIFECYCLE_RESULT_OK);
assert(!lifecycle.sessions.entries[player_2].connected);
uint8_t resumed = kSessionCapacity;
assert(game_lifecycle_resume(&lifecycle, saved_token, &resumed) == LIFECYCLE_RESULT_OK);
assert(resumed == player_2 && lifecycle.sessions.entries[resumed].role == ROLE_PLAYER_2);
}
static void test_human_lifecycle_and_guards(void) {
test_random_t random = {.value = 9};
game_lifecycle_t lifecycle = new_lifecycle(&random);
uint8_t player_1 = 0;
uint8_t player_2 = 0;
join_players(&lifecycle, &player_1, &player_2);
const uint32_t game_id = lifecycle.game.state.game_id;
game_lifecycle_t before = lifecycle;
assert(game_lifecycle_configure(&lifecycle, player_2, game_id, MODE_BOT) == LIFECYCLE_RESULT_FORBIDDEN_ROLE);
assert(memcmp(&before, &lifecycle, sizeof(lifecycle)) == 0);
assert(game_lifecycle_start(&lifecycle, player_1, game_id + 1U) == LIFECYCLE_RESULT_STALE_GAME);
assert(game_lifecycle_start(&lifecycle, player_1, game_id) == LIFECYCLE_RESULT_OK);
assert(lifecycle.game.state.phase == PHASE_IN_PROGRESS);
finish_for_player_1(&lifecycle, player_1);
assert(lifecycle.cumulative[0].games == 1U && lifecycle.cumulative[0].wins == 1U);
assert(lifecycle.cumulative[1].games == 1U && lifecycle.cumulative[1].losses == 1U);
assert(game_lifecycle_rematch(&lifecycle, player_1, game_id) == LIFECYCLE_RESULT_OK);
assert(lifecycle.game.state.phase == PHASE_REMATCH_WAIT);
assert(game_lifecycle_rematch(&lifecycle, player_2, game_id) == LIFECYCLE_RESULT_OK);
assert(lifecycle.game.state.phase == PHASE_IN_PROGRESS && lifecycle.game.state.game_id != game_id);
assert(lifecycle.cumulative[0].wins == 1U);
assert(game_lifecycle_disconnect(&lifecycle, player_2) == LIFECYCLE_RESULT_OK);
assert(game_lifecycle_abort(&lifecycle, player_1, lifecycle.game.state.game_id) == LIFECYCLE_RESULT_OK);
assert(lifecycle.game.state.phase == PHASE_LOBBY);
}
static void test_bot_lifecycle_and_stale_actions(void) {
test_random_t random = {.value = 17};
game_lifecycle_t lifecycle = new_lifecycle(&random);
uint8_t player_1 = 0;
assert(game_lifecycle_join(&lifecycle, ROLE_PLAYER_1, "Alice", &player_1) == LIFECYCLE_RESULT_OK);
const uint32_t game_id = lifecycle.game.state.game_id;
assert(game_lifecycle_configure(&lifecycle, player_1, game_id, MODE_BOT) == LIFECYCLE_RESULT_OK);
assert(lifecycle.bot_reserved);
assert(game_lifecycle_join(&lifecycle, ROLE_PLAYER_2, "Bob", &player_1) == LIFECYCLE_RESULT_NO_PLAYER_SLOT);
assert(game_lifecycle_start(&lifecycle, player_1, game_id) == LIFECYCLE_RESULT_OK);
finish_for_player_1(&lifecycle, player_1);
assert(game_lifecycle_rematch(&lifecycle, player_1, game_id) == LIFECYCLE_RESULT_OK);
assert(lifecycle.game.state.phase == PHASE_IN_PROGRESS && lifecycle.game.state.game_id != game_id);
const game_lifecycle_t before = lifecycle;
assert(game_lifecycle_shot(&lifecycle, player_1, game_id, (coordinate_t){0, 0}, NULL) == LIFECYCLE_RESULT_STALE_GAME);
assert(memcmp(&before, &lifecycle, sizeof(lifecycle)) == 0);
}
int main(void) {
test_capacity_sanitize_and_resume();
test_human_lifecycle_and_guards();
test_bot_lifecycle_and_stale_actions();
puts("game lifecycle tests passed");
return 0;
}
+191
View File
@@ -0,0 +1,191 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "http_api.h"
typedef struct { uint32_t value; } test_random_t;
static uint32_t next_random(void *context) {
test_random_t *random = context;
random->value = random->value * 1664525U + 1013904223U;
return random->value;
}
static http_api_t new_api(application_t *application, test_random_t *random) {
application_init(application, (random_source_t){.next_u32 = next_random, .context = random});
http_api_t api;
http_api_init(&api, application);
http_api_set_health(&api, &(http_api_health_t){.uptime_ms = 12U, .free_heap_bytes = 200000U,
.minimum_free_heap_bytes = 190000U, .largest_free_block_bytes = 180000U,
.connected_clients = 2U, .rejected_input = 3U, .reset_reason = 11, .wifi_state = "connected"});
return api;
}
static http_api_response_t call(http_api_t *api, http_api_route_t route, http_api_method_t method,
const char *body, const char *token) {
http_api_response_t response;
const http_api_request_t request = {.method = method, .route = route, .content_type_json = true,
.body = body, .body_length = body == NULL ? 0U : strlen(body), .session_token = token};
assert(http_api_handle(api, &request, &response));
assert(response.body_length == strlen(response.body));
return response;
}
static void expect_code(const http_api_response_t *response, uint16_t status, const char *code) {
char expected[64];
snprintf(expected, sizeof(expected), "\"code\":\"%s\"", code);
assert(response->status == status);
assert(strstr(response->body, expected) != NULL);
assert(response->body_length < 160U);
}
static void token_from_response(const http_api_response_t *response, char token[33]) {
const char *start = strstr(response->body, "\"token\":\"");
assert(start != NULL);
start += strlen("\"token\":\"");
memcpy(token, start, 32U);
token[32] = '\0';
}
static void test_public_routes_and_parse_limits(void) {
application_t application;
test_random_t random = {.value = 1U};
http_api_t api = new_api(&application, &random);
http_api_response_t response = call(&api, HTTP_API_ROUTE_INFO, HTTP_API_GET, NULL, NULL);
assert(response.status == 200U && response.body_length <= 192U && strstr(response.body, "player1Available") != NULL);
response = call(&api, HTTP_API_ROUTE_HEALTH, HTTP_API_GET, NULL, NULL);
assert(response.status == 200U && response.body_length <= 320U && strstr(response.body, "connected") != NULL &&
strstr(response.body, "\"resetReason\":11") != NULL);
const http_api_request_t long_target = {.method = HTTP_API_GET, .route = HTTP_API_ROUTE_INFO, .target_too_large = true};
assert(http_api_handle(&api, &long_target, &response));
expect_code(&response, 413U, "PAYLOAD_TOO_LARGE");
const char oversized[194] = {[0 ... 192] = 'a', [193] = '\0'};
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST, oversized, NULL);
expect_code(&response, 413U, "PAYLOAD_TOO_LARGE");
const http_api_request_t non_json = {.method = HTTP_API_POST, .route = HTTP_API_ROUTE_JOIN,
.content_type_json = false, .body = "{}", .body_length = 2U};
assert(http_api_handle(&api, &non_json, &response));
expect_code(&response, 400U, "MALFORMED_JSON");
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST, "{\"name\":\"A\",\"requestedRole\":\"invalid\"}", NULL);
expect_code(&response, 400U, "INVALID_ROLE");
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST, "{\"name\":\"<bad>\",\"requestedRole\":\"player1\"}", NULL);
expect_code(&response, 400U, "INVALID_NAME");
}
static void test_sessions_commands_and_state(void) {
application_t application;
test_random_t random = {.value = 9U};
http_api_t api = new_api(&application, &random);
http_api_response_t response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"Alice\",\"requestedRole\":\"player1\"}", NULL);
assert(response.status == 200U);
char player_1_token[33];
token_from_response(&response, player_1_token);
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"Other\",\"requestedRole\":\"player1\"}", NULL);
expect_code(&response, 409U, "NO_PLAYER_SLOT");
response = call(&api, HTTP_API_ROUTE_RESUME, HTTP_API_POST, "{\"token\":\"00000000000000000000000000000000\"}", NULL);
expect_code(&response, 401U, "UNAUTHORIZED");
char resume_body[48];
snprintf(resume_body, sizeof(resume_body), "{\"token\":\"%s\"}", player_1_token);
response = call(&api, HTTP_API_ROUTE_RESUME, HTTP_API_POST, resume_body, NULL);
assert(response.status == 200U && strstr(response.body, "player1") != NULL);
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"Bob\",\"requestedRole\":\"player2\"}", NULL);
assert(response.status == 200U);
char player_2_token[33];
token_from_response(&response, player_2_token);
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"Watch\",\"requestedRole\":\"spectator\"}", NULL);
assert(response.status == 200U);
char spectator_token[33];
token_from_response(&response, spectator_token);
for (uint8_t index = 1U; index < kSpectatorCapacity; ++index) {
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"Watch\",\"requestedRole\":\"spectator\"}", NULL);
assert(response.status == 200U);
}
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"Extra\",\"requestedRole\":\"spectator\"}", NULL);
expect_code(&response, 409U, "NO_SPECTATOR_SLOT");
response = call(&api, HTTP_API_ROUTE_CONFIG, HTTP_API_POST,
"{\"token\":\"00000000000000000000000000000000\",\"gameId\":1,\"mode\":\"human\"}", NULL);
expect_code(&response, 401U, "UNAUTHORIZED");
char command[128];
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":2,\"mode\":\"human\"}", player_1_token);
response = call(&api, HTTP_API_ROUTE_CONFIG, HTTP_API_POST, command, NULL);
expect_code(&response, 409U, "STALE_GAME");
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1,\"mode\":\"invalid\"}", player_1_token);
response = call(&api, HTTP_API_ROUTE_CONFIG, HTTP_API_POST, command, NULL);
expect_code(&response, 400U, "INVALID_MODE");
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1,\"mode\":\"human\"}", player_2_token);
response = call(&api, HTTP_API_ROUTE_CONFIG, HTTP_API_POST, command, NULL);
expect_code(&response, 403U, "FORBIDDEN_ROLE");
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1,\"mode\":\"human\"}", player_1_token);
response = call(&api, HTTP_API_ROUTE_CONFIG, HTTP_API_POST, command, NULL);
assert(response.status == 200U);
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1}", player_1_token);
response = call(&api, HTTP_API_ROUTE_START, HTTP_API_POST, command, NULL);
assert(response.status == 200U);
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1,\"mode\":\"human\"}", player_1_token);
response = call(&api, HTTP_API_ROUTE_CONFIG, HTTP_API_POST, command, NULL);
expect_code(&response, 409U, "WRONG_PHASE");
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, NULL);
assert(response.status == 200U && strstr(response.body, "\"viewer\":\"spectator\"") != NULL);
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, player_1_token);
assert(response.status == 200U && strstr(response.body, "\"viewer\":\"player1\"") != NULL);
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, "bad");
expect_code(&response, 401U, "UNAUTHORIZED");
response = call(&api, HTTP_API_ROUTE_STATISTICS, HTTP_API_GET, NULL, player_1_token);
assert(response.status == 200U && strstr(response.body, "\"viewer\":\"player1\"") != NULL &&
strstr(response.body, "\"match\":[[") != NULL && strstr(response.body, "\"cumulative\":[[") != NULL);
response = call(&api, HTTP_API_ROUTE_STATISTICS, HTTP_API_GET, NULL, "bad");
expect_code(&response, 401U, "UNAUTHORIZED");
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1,\"x\":10,\"y\":0}", player_1_token);
response = call(&api, HTTP_API_ROUTE_SHOT, HTTP_API_POST, command, NULL);
expect_code(&response, 400U, "INVALID_COORDINATE");
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1,\"x\":0,\"y\":0}", spectator_token);
response = call(&api, HTTP_API_ROUTE_SHOT, HTTP_API_POST, command, NULL);
expect_code(&response, 403U, "FORBIDDEN_ROLE");
application.lifecycle.game.state.current_player = 0U;
application.lifecycle.game.state.boards[1].cells[0] = CELL_SHIP;
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1,\"x\":0,\"y\":0}", player_2_token);
response = call(&api, HTTP_API_ROUTE_SHOT, HTTP_API_POST, command, NULL);
expect_code(&response, 409U, "NOT_YOUR_TURN");
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1,\"x\":0,\"y\":0}", player_1_token);
response = call(&api, HTTP_API_ROUTE_SHOT, HTTP_API_POST, command, NULL);
assert(response.status == 200U);
response = call(&api, HTTP_API_ROUTE_SHOT, HTTP_API_POST, command, NULL);
expect_code(&response, 409U, "CELL_ALREADY_SHOT");
application.lifecycle.game.state.phase = PHASE_FINISHED;
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1}", player_1_token);
response = call(&api, HTTP_API_ROUTE_REMATCH, HTTP_API_POST, command, NULL);
assert(response.status == 200U && application.lifecycle.game.state.phase == PHASE_REMATCH_WAIT);
application.lifecycle.game.state.phase = PHASE_IN_PROGRESS;
application.lifecycle.game.state.mode = MODE_HUMAN;
application.lifecycle.sessions.entries[1].connected = false;
response = call(&api, HTTP_API_ROUTE_ABORT, HTTP_API_POST, command, NULL);
assert(response.status == 200U && application.lifecycle.game.state.phase == PHASE_LOBBY);
for (uint8_t index = 0; index < kCommandQueueCapacity; ++index) {
assert(application_enqueue(&application, &(app_command_t){.type = COMMAND_START}));
}
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1}", player_1_token);
response = call(&api, HTTP_API_ROUTE_REMATCH, HTTP_API_POST, command, NULL);
expect_code(&response, 503U, "SERVER_BUSY");
}
int main(void) {
test_public_routes_and_parse_limits();
test_sessions_commands_and_state();
puts("http api tests passed");
return 0;
}
+158
View File
@@ -0,0 +1,158 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "http_api.h"
typedef struct { uint32_t value; } test_random_t;
static uint32_t next_random(void *context) {
test_random_t *random = context;
random->value = random->value * 1664525U + 1013904223U;
return random->value;
}
static http_api_response_t call(http_api_t *api, http_api_route_t route, http_api_method_t method,
const char *body, const char *token) {
http_api_response_t response;
const http_api_request_t request = {.method = method, .route = route, .content_type_json = true,
.body = body, .body_length = body == NULL ? 0U : strlen(body), .session_token = token};
assert(http_api_handle(api, &request, &response));
assert(response.body_length == strlen(response.body));
return response;
}
static void token_from_response(const http_api_response_t *response, char token[33]) {
const char *start = strstr(response->body, "\"token\":\"");
assert(start != NULL);
memcpy(token, start + strlen("\"token\":\""), 32U);
token[32] = '\0';
}
static const char *board_start(const char *json, uint8_t board) {
const char *first = strstr(json, "\"boards\":[\"");
assert(first != NULL);
first += strlen("\"boards\":[\"");
return board == 0U ? first : first + kBoardCellCount + 3U;
}
static void assert_hidden(const char *json, uint8_t board) {
const char *cells = board_start(json, board);
for (uint8_t index = 0; index < kBoardCellCount; ++index) assert(cells[index] != '1');
}
static void resume(http_api_t *api, const char token[33], const char *role) {
char body[48];
snprintf(body, sizeof(body), "{\"token\":\"%s\"}", token);
const http_api_response_t response = call(api, HTTP_API_ROUTE_RESUME, HTTP_API_POST, body, NULL);
assert(response.status == 200U && strstr(response.body, role) != NULL);
}
static void command(http_api_t *api, http_api_route_t route, const char token[33], uint32_t game_id) {
char body[80];
snprintf(body, sizeof(body), "{\"token\":\"%s\",\"gameId\":%u}", token, (unsigned int)game_id);
const http_api_response_t response = call(api, route, HTTP_API_POST, body, NULL);
assert(response.status == 200U);
}
static void shot(http_api_t *api, const char token[33], uint32_t game_id, coordinate_t coordinate) {
char body[96];
snprintf(body, sizeof(body), "{\"token\":\"%s\",\"gameId\":%u,\"x\":%u,\"y\":%u}", token,
(unsigned int)game_id, coordinate.x, coordinate.y);
const http_api_response_t response = call(api, HTTP_API_ROUTE_SHOT, HTTP_API_POST, body, NULL);
assert(response.status == 200U);
}
static coordinate_t first_cell(const board_t *board, cell_t cell) {
for (uint8_t index = 0; index < kBoardCellCount; ++index) {
if (board->cells[index] == cell) return (coordinate_t){.x = (uint8_t)(index % kBoardWidth), .y = (uint8_t)(index / kBoardWidth)};
}
assert(false);
return (coordinate_t){0};
}
static void test_human_game_journey(void) {
test_random_t random = {.value = 57U};
application_t application;
application_init(&application, (random_source_t){.next_u32 = next_random, .context = &random});
http_api_t api;
http_api_init(&api, &application);
char player_tokens[2][33];
http_api_response_t response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"Алиса\",\"requestedRole\":\"player1\"}", NULL);
assert(response.status == 200U);
token_from_response(&response, player_tokens[0]);
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"Борис\",\"requestedRole\":\"player2\"}", NULL);
assert(response.status == 200U);
token_from_response(&response, player_tokens[1]);
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"Зритель\",\"requestedRole\":\"spectator\"}", NULL);
assert(response.status == 200U);
char spectator_token[33];
token_from_response(&response, spectator_token);
resume(&api, player_tokens[0], "player1");
resume(&api, player_tokens[1], "player2");
resume(&api, spectator_token, "spectator");
const uint32_t game_id = application.lifecycle.game.state.game_id;
char config[96];
snprintf(config, sizeof(config), "{\"token\":\"%s\",\"gameId\":%u,\"mode\":\"human\"}",
player_tokens[0], (unsigned int)game_id);
response = call(&api, HTTP_API_ROUTE_CONFIG, HTTP_API_POST, config, NULL);
assert(response.status == 200U);
command(&api, HTTP_API_ROUTE_START, player_tokens[0], game_id);
assert(application.lifecycle.game.state.phase == PHASE_IN_PROGRESS);
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, player_tokens[0]);
assert(response.status == 200U && board_start(response.body, 0)[0] <= '3');
assert_hidden(response.body, 1U);
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, player_tokens[1]);
assert(response.status == 200U);
assert_hidden(response.body, 0U);
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, spectator_token);
assert(response.status == 200U);
assert_hidden(response.body, 0U);
assert_hidden(response.body, 1U);
uint8_t first_player = application.lifecycle.game.state.current_player;
shot(&api, player_tokens[first_player], game_id, first_cell(&application.lifecycle.game.state.boards[first_player ^ 1U], CELL_WATER));
const uint8_t winner = application.lifecycle.game.state.current_player;
assert(winner == (first_player ^ 1U));
bool saw_sunk = false;
while (application.lifecycle.game.state.phase == PHASE_IN_PROGRESS) {
const coordinate_t target = first_cell(&application.lifecycle.game.state.boards[winner ^ 1U], CELL_SHIP);
shot(&api, player_tokens[winner], game_id, target);
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, spectator_token);
saw_sunk = saw_sunk || strchr(board_start(response.body, winner ^ 1U), '4') != NULL;
}
assert(saw_sunk);
assert(application.lifecycle.game.state.winner == winner);
assert(application.lifecycle.game.state.statistics[winner].shots == 20U);
assert(application.lifecycle.game.state.statistics[winner].hits == 20U);
assert(application.lifecycle.game.state.statistics[winner].ships_sunk == 10U);
assert(application.lifecycle.game.state.statistics[first_player].misses == 1U);
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, spectator_token);
assert(response.status == 200U && strstr(response.body, "\"winner\":") != NULL);
assert(strstr(response.body, "\"statistics\":[") != NULL);
assert(strchr(board_start(response.body, 0U), '1') != NULL || strchr(board_start(response.body, 1U), '1') != NULL);
resume(&api, player_tokens[0], "player1");
resume(&api, player_tokens[1], "player2");
command(&api, HTTP_API_ROUTE_REMATCH, player_tokens[0], game_id);
assert(application.lifecycle.game.state.phase == PHASE_REMATCH_WAIT);
resume(&api, spectator_token, "spectator");
command(&api, HTTP_API_ROUTE_REMATCH, player_tokens[1], game_id);
assert(application.lifecycle.game.state.phase == PHASE_IN_PROGRESS && application.lifecycle.game.state.game_id != game_id);
assert(game_lifecycle_disconnect(&application.lifecycle, 1U) == LIFECYCLE_RESULT_OK);
command(&api, HTTP_API_ROUTE_ABORT, player_tokens[0], application.lifecycle.game.state.game_id);
assert(application.lifecycle.game.state.phase == PHASE_LOBBY);
}
int main(void) {
test_human_game_journey();
puts("human game integration tests passed");
return 0;
}
+89
View File
@@ -0,0 +1,89 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "http_api.h"
#include "sync_service.h"
typedef struct { uint32_t value; } test_random_t;
static uint32_t next_random(void *context) {
test_random_t *random = context;
random->value = random->value * 1664525U + 1013904223U;
return random->value;
}
static application_t new_application(test_random_t *random) {
application_t application;
application_init(&application, (random_source_t){.next_u32 = next_random, .context = random});
return application;
}
static void test_http_malformed_inputs_do_not_mutate(void) {
test_random_t random = {.value = 7U};
application_t application = new_application(&random);
http_api_t api;
http_api_init(&api, &application);
const char *const corpus[] = {"", "{", "[]", "{\"token\":", "{\"token\":null}",
"{\"name\":\"A\"}", "{\"token\":\"00000000000000000000000000000000\",\"gameId\":-1}",
"{\"type\":\"shot\"}", "{\"name\":\"A\",\"requestedRole\":\"player1\",\"extra\":1}"};
const http_api_route_t routes[] = {HTTP_API_ROUTE_JOIN, HTTP_API_ROUTE_RESUME, HTTP_API_ROUTE_CONFIG,
HTTP_API_ROUTE_START, HTTP_API_ROUTE_SHOT, HTTP_API_ROUTE_REMATCH, HTTP_API_ROUTE_ABORT};
for (size_t index = 0; index < 400U; ++index) {
const char *body = corpus[index % (sizeof(corpus) / sizeof(corpus[0]))];
const game_lifecycle_t before = application.lifecycle;
http_api_response_t response;
const http_api_request_t request = {.method = HTTP_API_POST, .route = routes[index % (sizeof(routes) / sizeof(routes[0]))],
.content_type_json = true, .body = body, .body_length = strlen(body)};
assert(http_api_handle(&api, &request, &response));
assert(response.status >= 400U && response.status < 600U && response.body_length < sizeof(response.body));
assert(memcmp(&before, &application.lifecycle, sizeof(before)) == 0);
}
char oversized[kRequestBodyCapacity + 2U];
memset(oversized, 'x', sizeof(oversized) - 1U);
oversized[sizeof(oversized) - 1U] = '\0';
http_api_response_t response;
const http_api_request_t request = {.method = HTTP_API_POST, .route = HTTP_API_ROUTE_JOIN, .content_type_json = true,
.body = oversized, .body_length = strlen(oversized)};
assert(http_api_handle(&api, &request, &response));
assert(response.status == 413U);
}
static void test_websocket_fuzz_and_connection_churn(void) {
test_random_t random = {.value = 19U};
application_t application = new_application(&random);
sync_service_t service;
sync_service_init(&service, &application);
for (uint16_t attempt = 0; attempt < 600U; ++attempt) {
char frame[kWebSocketFrameCapacity + 1U];
const size_t length = (size_t)(next_random(&random) % (kWebSocketFrameCapacity + 1U));
for (size_t index = 0; index < length; ++index) frame[index] = (char)(next_random(&random) & 0x7fU);
frame[length] = '\0';
const game_lifecycle_t before = application.lifecycle;
char output[kStateMessageCapacity] = {0};
size_t output_length = 0U;
bool changed = true;
bool close = false;
assert(sync_service_open(&service, 1, attempt));
assert(sync_service_receive(&service, 1, frame, length, attempt, output, &output_length, &changed, &close));
assert(!changed && output_length < sizeof(output));
assert(memcmp(&before, &application.lifecycle, sizeof(before)) == 0);
sync_service_close(&service, 1);
}
char too_large[kWebSocketFrameCapacity + 1U] = {0};
char output[kStateMessageCapacity] = {0};
size_t output_length = 0U;
bool changed = false;
bool close = false;
assert(sync_service_open(&service, 1, 0U));
assert(sync_service_receive(&service, 1, too_large, sizeof(too_large), 0U, output, &output_length, &changed, &close));
assert(!changed && strstr(output, "MALFORMED_JSON") != NULL);
sync_service_close(&service, 1);
}
int main(void) {
test_http_malformed_inputs_do_not_mutate();
test_websocket_fuzz_and_connection_churn();
puts("robustness tests passed");
return 0;
}
+107
View File
@@ -0,0 +1,107 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "state_presenter.h"
static void set_board(board_t *board) {
memset(board, 0, sizeof(*board));
board->cells[0] = CELL_SHIP;
board->cells[1] = CELL_MISS;
board->cells[2] = CELL_HIT;
board->cells[99] = CELL_SHIP;
}
static const char *board_start(const char *json, uint8_t board) {
const char *first = strstr(json, "\"boards\":[\"");
assert(first != NULL);
first += strlen("\"boards\":[\"");
return board == 0U ? first : first + kBoardCellCount + 3U;
}
static void assert_hidden(const char *json, uint8_t board) {
const char *cells = board_start(json, board);
for (uint8_t index = 0; index < kBoardCellCount; ++index) assert(cells[index] != '1');
}
static void test_role_views_and_golden_schema(void) {
game_state_t state = {0};
state.game_id = 42U;
state.version = 9U;
state.phase = PHASE_IN_PROGRESS;
state.mode = MODE_HUMAN;
state.current_player = 1U;
state.winner = kPlayerCapacity;
set_board(&state.boards[0]);
set_board(&state.boards[1]);
char output[kStateMessageCapacity] = {0};
size_t written = 0;
assert(state_presenter_write(&state, ROLE_PLAYER_1, output, sizeof(output), &written));
assert(written == strlen(output));
assert(strstr(output, "{\"type\":\"state\",\"version\":9,\"gameId\":42,\"phase\":\"in_progress\",\"mode\":\"human\",\"viewer\":\"player1\",\"turn\":\"player2\",\"boards\":[\"") == output);
assert(board_start(output, 0)[0] == '1' && board_start(output, 0)[1] == '2' && board_start(output, 0)[2] == '3');
assert_hidden(output, 1);
assert(state_presenter_write(&state, ROLE_PLAYER_2, output, sizeof(output), &written));
assert_hidden(output, 0);
assert(board_start(output, 1)[0] == '1');
assert(state_presenter_write(&state, ROLE_SPECTATOR, output, sizeof(output), &written));
assert_hidden(output, 0);
assert_hidden(output, 1);
assert(board_start(output, 0)[1] == '2' && board_start(output, 1)[2] == '3');
assert(strstr(output, "\"winner\":null,\"statistics\":[[0,0,0,0],[0,0,0,0]]") != NULL);
}
static void test_finished_and_failure_are_safe(void) {
game_state_t state = {0};
state.game_id = UINT32_MAX;
state.version = UINT32_MAX;
state.phase = PHASE_FINISHED;
state.mode = MODE_BOT;
state.current_player = 0U;
set_board(&state.boards[0]);
set_board(&state.boards[1]);
char output[kStateMessageCapacity] = {0};
size_t written = 0;
assert(state_presenter_write(&state, ROLE_SPECTATOR, output, sizeof(output), &written));
assert(written < sizeof(output));
assert(board_start(output, 0)[0] == '1' && board_start(output, 1)[99] == '1');
game_lifecycle_t lifecycle = {0};
lifecycle.game.state = state;
lifecycle.game.state.phase = PHASE_REMATCH_WAIT;
lifecycle.game.state.mode = MODE_HUMAN;
lifecycle.game.state.winner = 1U;
lifecycle.game.state.statistics[0] = (match_statistics_t){.shots = 9U, .hits = 4U, .misses = 5U, .ships_sunk = 2U};
lifecycle.game.state.statistics[1] = (match_statistics_t){.shots = 8U, .hits = 5U, .misses = 3U, .ships_sunk = 3U};
lifecycle.cumulative[0].wins = UINT16_MAX;
lifecycle.cumulative[1].wins = UINT16_MAX;
assert(state_presenter_write_lifecycle(&lifecycle, ROLE_SPECTATOR, output, sizeof(output), &written));
assert(written < sizeof(output));
assert(strstr(output, "\"winner\":1,\"statistics\":[[9,4,5,2],[8,5,3,3]]") != NULL);
lifecycle.game.state.statistics[0] = (match_statistics_t){UINT16_MAX, UINT16_MAX, UINT16_MAX, UINT8_MAX};
lifecycle.game.state.statistics[1] = (match_statistics_t){UINT16_MAX, UINT16_MAX, UINT16_MAX, UINT8_MAX};
assert(state_presenter_write_lifecycle(&lifecycle, ROLE_SPECTATOR, output, sizeof(output), &written));
assert(written < sizeof(output));
state.boards[0].ships[0] = (ship_t){.x = 2U, .y = 0U, .length = 1U, .hits = 1U, .horizontal = true};
assert(state_presenter_write(&state, ROLE_SPECTATOR, output, sizeof(output), &written));
assert(board_start(output, 0)[2] == '4');
char unchanged[16];
memset(unchanged, 'X', sizeof(unchanged));
size_t failure_written = 99U;
assert(!state_presenter_write(&state, ROLE_PLAYER_1, unchanged, sizeof(unchanged), &failure_written));
assert(failure_written == 0U);
for (size_t index = 0; index < sizeof(unchanged); ++index) assert(unchanged[index] == 'X');
}
int main(void) {
test_role_views_and_golden_schema();
test_finished_and_failure_are_safe();
puts("state presenter tests passed");
return 0;
}
+132
View File
@@ -0,0 +1,132 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "sync_service.h"
typedef struct { uint32_t value; } test_random_t;
typedef struct { int fail_client; uint8_t sends[kSessionCapacity]; char payloads[kSessionCapacity][kStateMessageCapacity]; } capture_t;
static uint32_t next_random(void *context) {
test_random_t *random = context;
random->value = random->value * 1664525U + 1013904223U;
return random->value;
}
static void token_text(const uint8_t token[kSessionTokenBytes], char output[33]) {
static const char hex[] = "0123456789abcdef";
for (uint8_t index = 0; index < kSessionTokenBytes; ++index) {
output[index * 2U] = hex[token[index] >> 4U];
output[index * 2U + 1U] = hex[token[index] & 15U];
}
output[32] = '\0';
}
static bool capture_send(void *context, int client_id, const char *payload, size_t length) {
capture_t *capture = context;
if (client_id == capture->fail_client) return false;
assert(client_id >= 0 && client_id < kSessionCapacity);
assert(length < kStateMessageCapacity);
++capture->sends[client_id];
memcpy(capture->payloads[client_id], payload, length);
capture->payloads[client_id][length] = '\0';
return true;
}
static const char *board_start(const char *json, uint8_t board) {
const char *start = strstr(json, "\"boards\":[\"");
assert(start != NULL);
start += strlen("\"boards\":[\"");
return board == 0U ? start : start + kBoardCellCount + 3U;
}
static void hello(sync_service_t *service, int client_id, const uint8_t token[kSessionTokenBytes], uint32_t version) {
char token_value[33];
char frame[96];
char output[kStateMessageCapacity];
size_t output_length = 0U;
bool changed = false;
bool close = false;
token_text(token, token_value);
snprintf(frame, sizeof(frame), "{\"type\":\"hello\",\"token\":\"%s\",\"version\":%u}", token_value, version);
assert(sync_service_receive(service, client_id, frame, strlen(frame), 10U, output, &output_length, &changed, &close));
assert(!changed && !close && output_length > 0U && strstr(output, "\"type\":\"state\"") != NULL);
}
static void test_authentication_visibility_and_backpressure(void) {
test_random_t random = {.value = 1U};
application_t application;
application_init(&application, (random_source_t){.next_u32 = next_random, .context = &random});
uint8_t player_1 = 0;
uint8_t player_2 = 0;
uint8_t spectator = 0;
assert(application_join(&application, ROLE_PLAYER_1, "Alice", &player_1) == LIFECYCLE_RESULT_OK);
assert(application_join(&application, ROLE_PLAYER_2, "Bob", &player_2) == LIFECYCLE_RESULT_OK);
assert(application_join(&application, ROLE_SPECTATOR, "Watch", &spectator) == LIFECYCLE_RESULT_OK);
sync_service_t service;
sync_service_init(&service, &application);
assert(sync_service_open(&service, 0, 0U));
assert(sync_service_open(&service, 1, 0U));
assert(sync_service_open(&service, 2, 0U));
for (int client_id = 3; client_id < kSessionCapacity; ++client_id) assert(sync_service_open(&service, client_id, 0U));
assert(!sync_service_open(&service, kSessionCapacity, 0U));
hello(&service, 0, application.lifecycle.sessions.entries[player_1].token, 0U);
hello(&service, 1, application.lifecycle.sessions.entries[player_2].token, 999U);
hello(&service, 2, application.lifecycle.sessions.entries[spectator].token, 0U);
application.lifecycle.game.state.phase = PHASE_IN_PROGRESS;
application.lifecycle.game.state.boards[0].cells[0] = CELL_SHIP;
application.lifecycle.game.state.boards[1].cells[0] = CELL_SHIP;
application.lifecycle.game.state.boards[0].cells[1] = CELL_HIT;
application.lifecycle.game.state.boards[1].cells[1] = CELL_MISS;
capture_t capture = {.fail_client = -1};
sync_service_broadcast(&service, capture_send, &capture);
assert(board_start(capture.payloads[0], 0)[0] == '1' && board_start(capture.payloads[0], 1)[0] == '0');
assert(board_start(capture.payloads[1], 0)[0] == '0' && board_start(capture.payloads[1], 1)[0] == '1');
assert(board_start(capture.payloads[2], 0)[0] == '0' && board_start(capture.payloads[2], 1)[0] == '0');
assert(board_start(capture.payloads[2], 0)[1] == '3' && board_start(capture.payloads[2], 1)[1] == '2');
capture.fail_client = 2;
const uint8_t previous_sends = capture.sends[2];
sync_service_broadcast(&service, capture_send, &capture);
sync_service_broadcast(&service, capture_send, &capture);
assert(capture.sends[2] == previous_sends);
}
static void test_timeout_ping_and_command(void) {
test_random_t random = {.value = 9U};
application_t application;
application_init(&application, (random_source_t){.next_u32 = next_random, .context = &random});
uint8_t player_1 = 0;
assert(application_join(&application, ROLE_PLAYER_1, "Alice", &player_1) == LIFECYCLE_RESULT_OK);
sync_service_t service;
sync_service_init(&service, &application);
assert(sync_service_open(&service, 3, 0U));
int expired[kSessionCapacity] = {0};
size_t expired_count = 0U;
sync_service_expire(&service, kWebSocketHelloTimeoutMs + 1U, expired, &expired_count);
assert(expired_count == 1U && expired[0] == 3);
assert(sync_service_open(&service, 0, 0U));
hello(&service, 0, application.lifecycle.sessions.entries[player_1].token, 0U);
char output[kStateMessageCapacity];
size_t output_length = 0U;
bool changed = false;
bool close = false;
assert(sync_service_receive(&service, 0, "{\"type\":\"ping\"}", 15U, 20U, output, &output_length, &changed, &close));
assert(!changed && !close && strcmp(output, "{\"type\":\"pong\"}") == 0);
char token[33];
char frame[128];
token_text(application.lifecycle.sessions.entries[player_1].token, token);
snprintf(frame, sizeof(frame), "{\"type\":\"config\",\"token\":\"%s\",\"gameId\":1,\"mode\":\"bot\"}", token);
assert(sync_service_receive(&service, 0, frame, strlen(frame), 30U, output, &output_length, &changed, &close));
assert(changed && !close && output_length == 0U && application.lifecycle.game.state.mode == MODE_BOT);
}
int main(void) {
test_authentication_visibility_and_backpressure();
test_timeout_ping_and_command();
puts("sync service tests passed");
return 0;
}
+49
View File
@@ -0,0 +1,49 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { createTargetActivator } = require('../../data/target_interaction.js');
test('a single tap selects a target without firing', () => {
const selected = [];
let shots = 0;
const activator = createTargetActivator({ canFire: () => true, select: target => selected.push(target), fire: async () => { shots += 1; } });
assert.equal(activator.tap({ x: 1, y: 6 }).action, 'select');
assert.deepEqual(selected, [{ x: 1, y: 6 }]);
assert.equal(shots, 0);
});
test('a second tap on the same cell fires once while a different cell selects', async () => {
let timeMs = 0;
const selected = [];
let shots = 0;
const activator = createTargetActivator({ canFire: () => true, select: target => selected.push(target), fire: async () => { shots += 1; }, now: () => timeMs });
activator.tap({ x: 1, y: 6 });
timeMs = 200;
const sameCell = activator.tap({ x: 1, y: 6 });
await sameCell.promise;
timeMs = 300;
assert.equal(activator.tap({ x: 2, y: 6 }).action, 'select');
assert.equal(shots, 1);
assert.deepEqual(selected, [{ x: 1, y: 6 }, { x: 2, y: 6 }]);
});
test('double activation and explicit fire are deduplicated while a shot is pending', async () => {
let resolveShot;
let shots = 0;
const activator = createTargetActivator({ canFire: () => true, select: () => {}, fire: () => new Promise(resolve => { shots += 1; resolveShot = resolve; }) });
const first = activator.doubleActivate({ x: 1, y: 6 });
const duplicate = activator.doubleActivate({ x: 1, y: 6 });
assert.equal(await duplicate.promise, false);
assert.equal(shots, 1);
resolveShot();
await first.promise;
});
test('out-of-turn or already-targeted cells cannot select or fire', () => {
let selected = 0;
let shots = 0;
const activator = createTargetActivator({ canFire: () => false, select: () => { selected += 1; }, fire: async () => { shots += 1; } });
assert.equal(activator.tap({ x: 1, y: 6 }).action, 'ignored');
assert.equal(activator.keyboard({ x: 1, y: 6 }).action, 'ignored');
assert.equal(selected, 0);
assert.equal(shots, 0);
});