Compare commits

...

7 Commits

32 changed files with 1851 additions and 62 deletions
+857
View File
@@ -1336,3 +1336,860 @@ After implementation, report:
- Measurements: `test/web/test_ship_sprite.js` verifies all four distinct symbols, sprite raw size below 15 KiB, and shared cross styling; it and the existing interaction tests passed 6/6. `make -C test/host run`, `node --check data/app.js`, `git diff --check`, `pio run -e esp32-c6-devkitm-1 -t buildfs`, and `pio run -e esp32-c6-devkitm-1` passed. Sprite size is 1,032 B raw / 419 B gzip; related CSS is 11,721 B raw / 3,130 B gzip; JavaScript is 23,305 B raw / 6,299 B gzip; the generated LittleFS image is 2,031,616 B. Firmware uses 39,588 / 327,680 B RAM (12.1%) and 1,019,306 / 2,097,152 B flash (48.6%). - Measurements: `test/web/test_ship_sprite.js` verifies all four distinct symbols, sprite raw size below 15 KiB, and shared cross styling; it and the existing interaction tests passed 6/6. `make -C test/host run`, `node --check data/app.js`, `git diff --check`, `pio run -e esp32-c6-devkitm-1 -t buildfs`, and `pio run -e esp32-c6-devkitm-1` passed. Sprite size is 1,032 B raw / 419 B gzip; related CSS is 11,721 B raw / 3,130 B gzip; JavaScript is 23,305 B raw / 6,299 B gzip; the generated LittleFS image is 2,031,616 B. Firmware uses 39,588 / 327,680 B RAM (12.1%) and 1,019,306 / 2,097,152 B flash (48.6%).
- Issues or deviations: No browser backend is attached in this environment, so live mobile/tablet screenshots and visual cross-readability checks remain physical-browser verification steps. No firmware or filesystem upload was performed. - Issues or deviations: No browser backend is attached in this environment, so live mobile/tablet screenshots and visual cross-readability checks remain physical-browser verification steps. No firmware or filesystem upload was performed.
- Next action: Milestone 020 is not started. - Next action: Milestone 020 is not started.
# Milestone 020 — Amendment: Recognizable Early-20th-Century Warship Silhouettes
**Status:** DONE
**Depends on:** Milestone 019
Redesign the fleet SVG artwork. The current silhouettes look like generic boats and do not clearly represent early-20th-century warships.
Create four independently illustrated side-profile silhouettes inspired by naval vessels from approximately 19051918.
Do not copy a specific historical ship exactly. Use historically recognizable class characteristics while keeping the artwork simple enough for a compact game interface.
## General art direction
All vessels must:
- use a consistent horizontal side view;
- have the bow facing right;
- share a consistent waterline and baseline;
- look like steel military vessels, not civilian boats or modern yachts;
- use early-20th-century funnels, masts, bridges, and gun arrangements;
- avoid modern missile launchers, radar domes, aircraft decks, submarines, and contemporary angular stealth profiles;
- remain recognizable at mobile size;
- use clean filled shapes rather than thin technical line drawings;
- preserve their aspect ratios;
- have independently drawn geometry rather than stretched copies of one vessel.
Use restrained detail. At small sizes, emphasize the features that distinguish each class rather than adding tiny historically accurate details that disappear when rendered.
## Class 1: Cutter or torpedo boat
The one-cell ship should appear small, fast, and lightly armed.
Required identifying features:
- short, narrow, low hull;
- noticeably raised bow;
- compact wheelhouse;
- one small funnel;
- one short mast or signal pole;
- one small gun near the bow;
- minimal rear superstructure.
It must not look like a fishing boat, yacht, tugboat, or miniature battleship.
## Class 2: Destroyer
The two-cell ship should have the characteristic long, narrow profile of an early destroyer.
Required identifying features:
- long, low hull;
- raised forecastle;
- small bridge near the forward section;
- two or three narrow funnels;
- a light mast;
- small guns positioned toward the bow and stern;
- a relatively open deck;
- narrow stern.
The multiple funnels and long, light hull should make it immediately distinguishable from the cutter and cruiser.
## Class 3: Cruiser
The three-cell ship should look heavier and taller than the destroyer without reaching battleship proportions.
Required identifying features:
- deeper and longer hull;
- more substantial bow and stern;
- larger central superstructure;
- two prominent funnels;
- one or two visible masts;
- several medium gun positions;
- visibly greater freeboard than the destroyer;
- balanced profile with significant structure across the middle of the ship.
It must not look like a destroyer stretched horizontally.
## Class 4: Battleship or dreadnought
The four-cell ship should be the heaviest and most visually dominant vessel.
Required identifying features:
- long, deep, massive hull;
- high freeboard;
- heavy main-gun turrets near the bow and stern;
- clearly visible large gun barrels;
- substantial bridge and central superstructure;
- two large funnels;
- prominent mast or tripod-style mast;
- broad visual mass through the center;
- a heavier bow and stern profile than every other class.
The battleship must remain identifiable even when shown without its label.
## Visual hierarchy and proportional scale
Use proportional width based on fleet class:
- cutter: approximately 1 visual unit;
- destroyer: approximately 2 units;
- cruiser: approximately 3 units;
- battleship: approximately 4 units.
Height should also communicate class:
- cutter: lowest and lightest;
- destroyer: long but relatively low;
- cruiser: visibly taller and heavier;
- battleship: tallest and most massive.
Do not force every ship into an identical-height bounding box if that destroys the class differences. Instead, place them inside consistently aligned fleet-item containers with a shared waterline.
Recommended SVG construction:
- separate `<symbol>` and `viewBox` for every class;
- hull as the primary filled shape;
- superstructure as one or two simplified filled shapes;
- funnels, masts, and guns with a minimum visual thickness that survives mobile rendering;
- optional subtle deck or waterline cutout;
- no filters, raster textures, embedded images, or unnecessary path complexity.
## Small-size legibility
Test every silhouette at its actual mobile display size, not only at an enlarged development preview.
At the final size:
- funnels must not merge into one indistinct block;
- gun barrels must remain visible;
- masts must not disappear;
- the gap between major structures must remain readable;
- the bow direction must remain obvious;
- adjacent ships must not visually merge;
- silhouettes must not appear blurry or unevenly scaled.
Slightly exaggerate turrets, funnels, and superstructures when necessary for recognition.
## Fleet-list layout
Improve the current fleet presentation:
- align class names in a consistent label column;
- align all ship silhouettes on a shared waterline;
- keep predictable spacing between repeated ships;
- give the artwork more horizontal room;
- avoid making silhouettes so small that their class-specific features disappear;
- keep each class on its own row;
- preserve the order:
1. “Линкор”;
2. “Крейсер”;
3. “Эсминец”;
4. “Катер”.
On narrow mobile screens, reduce spacing before reducing ship artwork below its recognizable minimum size.
## Sunk appearance
Keep the requested red-cross treatment.
For each sunk ship:
- retain the recognizable silhouette underneath;
- reduce the ship opacity moderately;
- overlay an individual red X across that ship only;
- use two clean diagonal strokes;
- use rounded stroke caps;
- ensure the X follows the complete width and height of the particular vessel;
- prevent the X from crossing adjacent ships or the class label.
Use a strong naval-warning red with sufficient contrast against both the white silhouette and dark background.
The red X must not be the only indication of state. Also apply a muted silhouette treatment and an accessible “потоплен” label.
## Review requirement
Before integrating the artwork, render a visual reference sheet containing:
- all four classes at enlarged size;
- all four classes at their actual mobile size;
- alive and sunk versions;
- the complete classic fleet in its final mobile layout.
Review the reference sheet for recognizability before replacing the production assets.
A reviewer should be able to identify the battleship, cruiser, destroyer, and cutter from their silhouettes without reading the labels.
## Acceptance criteria
- Every class uses unique SVG geometry.
- The ships visually evoke the 19051918 naval period.
- The battleship has recognizable heavy turrets and a massive hull.
- The cruiser has a substantial superstructure and medium armament.
- The destroyer has a long, light hull and multiple narrow funnels.
- The cutter is visibly small and lightly armed.
- All silhouettes remain recognizable at the actual mobile size.
- Sunk ships use an individual red X without obscuring adjacent ships.
- The complete fleet fits on mobile without horizontal page overflow.
- The SVG sprite remains local, compact, and compatible with the existing LittleFS asset pipeline.
- The implementation does not expose opponent ship positions or orientations.
At completion, provide the reference sheet, screenshots of the mobile and tablet fleet lists, the SVG sprite size before and after gzip, and the resulting LittleFS size delta.
### Execution record
- Date: 2026-08-30
- 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 live-browser visual capture.
- Evidence: Replaced the four fleet symbols with independently drawn, filled side profiles inspired by 19051918 naval vessels: a low cutter with one funnel and light bow gun, a long destroyer with three narrow funnels, a taller cruiser with two funnels and medium armament, and a broad battleship with a deep hull, turret/gun groups, heavy superstructure, two funnels, and mast. The renderer now assigns every external SVG `<use>` instance its matching class-specific `viewBox` and `preserveAspectRatio`, correcting the former common default viewport that made the silhouettes appear the same. Rows retain the required order and a shared waterline, with class-proportional widths. The red X stays at 96% opacity above only the 48%-opacity sunk silhouette. The review sheet is `docs/ship-silhouette-reference.html` and references the production sprite for enlarged, sunk, and complete compact mobile-fleet variants.
- Measurements: `node --test test/web/test_target_interaction.js test/web/test_ship_sprite.js` passed 7/7, including the class-specific viewport assertion; `make -C test/host run`, `node --check data/app.js`, `git diff --check`, `pio run -e esp32-c6-devkitm-1 -t buildfs`, and `pio run -e esp32-c6-devkitm-1` passed. The sprite changed from 1,032 B to 975 B raw and from 419 B to 496 B gzip; it remains below the 15 KiB / 5 KiB targets. The generated LittleFS image remains the fixed 2,031,616-B partition image (0-B image-size delta). Firmware uses 39,588 / 327,680 B RAM (12.1%) and 1,019,248 / 2,097,152 B flash (48.6%). Static assets now use `no-cache`, so an updated filesystem upload is revalidated instead of retaining the prior sprite/app bundle.
- Issues or deviations: This environment has neither a Chromium executable nor an attached browser backend, so screenshots and live mobile/tablet inspection could not be captured. No firmware or filesystem upload was performed.
- Next action: No subsequent milestone was started.
---
## Milestone 021 — Define and implement safe leave, profile reset, and full game reset semantics
**Status:** `DONE`
**Depends on:** Milestone 020
### Objective
Add bounded, authoritative recovery operations that let a user leave, clear only their local profile, or reset the complete in-memory game when the party becomes unusable.
This milestone establishes the behavior and server contract before adding the interface controls. The operations must be safe in every phase from the lobby through the result and rematch screens.
### Reset scopes
Implement three deliberately different operations.
1. **Leave the game**
- Invalidate and release only the requesting session.
- Clear the session token in that browser and return it to the registration screen.
- Preserve the locally remembered display name and selected hero so the same user can rejoin quickly.
- A spectator leaving must not change the match.
- If an active player leaves during a human-vs-human match, abort the current match safely, return the remaining valid player to the lobby, release the departed seat, and allow a replacement player to join.
- Leaving a bot match must abort that match and release the human player's seat.
2. **Reset my profile**
- Perform the same server-side session release as leaving.
- Clear the requesting browser's session token, display name, selected hero, and other user-specific browser preferences.
- Do not erase another user's local data or unrelated device-wide game state.
- Return the browser to the initial registration screen with an empty profile.
3. **Reset the entire game**
- Be available only to a currently authenticated player, never to a spectator or anonymous client.
- Atomically clear both player sessions, spectator sessions, names, selected mode, boards, turn, winner, rematch approvals, bot state, match statistics, cumulative in-memory statistics, queued commands, and active WebSocket ownership.
- Create a fresh lobby/game generation so stale commands from the previous party cannot mutate the new state.
- Notify every connected client that the game was reset, invalidate every browser session, and return all clients to the initial registration screen.
- Work from `LOBBY`, `PREPARING`, `IN_PROGRESS`, `FINISHED`, and `REMATCH_WAIT`.
The full game reset is an application recovery operation. It must **not** erase Wi-Fi credentials, LittleFS assets, firmware, flash partitions, board configuration, or other device settings.
### Server and lifecycle work
- Define stable routes or commands for session leave and full game reset using the existing validated application/command-queue path.
- Require a valid bounded session token for every state-changing recovery request.
- Require the current `gameId` or reset generation where applicable so delayed requests are rejected as stale.
- Make repeated leave and reset requests idempotent: retries must not corrupt state, double-increment counters, or release a newly created session.
- Add an explicit machine-readable reset reason such as `session_left`, `profile_reset`, or `game_reset` instead of forcing the browser to infer recovery from a generic authorization error.
- Broadcast a minimal public reset notification before invalidating or closing affected WebSocket connections. Do not include former tokens, hidden boards, or private session data.
- Ensure HTTP polling with an invalidated token receives the same stable recovery reason.
- Clear or invalidate queued commands from the previous generation before a new player can act.
- Do not hold the application mutex while closing sockets or sending network responses; take a bounded reset snapshot and complete transport cleanup outside the critical section.
- Keep all request bodies, responses, queues, and reset notifications within the existing resource budgets.
### Authorization and abuse resistance
- Anonymous clients and spectators must receive a stable forbidden response for full reset.
- Either authenticated player may use full reset as an emergency recovery action on the trusted home network.
- Confirmation is a browser responsibility added in Milestone 022; the server must still validate role, token, phase/generation, body bounds, and command freshness independently.
- A stale page from a previous party must not be able to reset or leave a newly created party using an old token or `gameId`.
- Reset endpoints must not expose whether a guessed token belongs to a particular named player.
### Tests
Add host tests covering at least:
1. Spectator leave in every phase without match mutation.
2. Player leave from the lobby and release of the correct seat.
3. Player leave during human-vs-human play and safe return of the remaining player to the lobby.
4. Player leave during bot play and safe match abort.
5. Full reset from every supported phase.
6. Clearing of sessions, names, mode, boards, bot state, rematch state, match statistics, and cumulative statistics.
7. Reset-generation change and rejection of stale queued commands.
8. Idempotent retry of leave and full reset.
9. Rejection of anonymous, spectator, malformed, oversized, and stale reset requests.
10. Role-safe reset notification and HTTP recovery response.
11. Recovery while WebSocket clients and HTTP pollers are connected.
12. No mutation when the command queue is full or the reset request is rejected.
### Acceptance criteria
- The three operations have distinct, documented scopes and cannot be confused by the client.
- Leave and profile reset affect only the requesting browser/session, subject to the documented active-player match-abort rule.
- Full reset returns the complete application to a clean pre-registration state without rebooting the ESP32.
- Every connected client learns about a full reset and discards its invalid session.
- Stale tokens, game IDs, and queued commands cannot affect the fresh party.
- Spectators and anonymous clients cannot reset the complete game.
- No Wi-Fi, filesystem, firmware, or hardware configuration is erased.
- All host suites pass within the established memory and payload budgets.
### Completion action
When all criteria pass, set Milestone 021 to `DONE`, append its execution record, and change Milestone 022 from `BLOCKED` to `READY`. Do not start Milestone 022 in the same task unless explicitly requested.
### Execution record
- Date: 2026-08-30
- 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 `POST /api/session/leave`, `POST /api/session/profile-reset`, and player-authorized `POST /api/game/reset` commands. Leave/profile reset release only the authenticated session; an active player departure safely creates a new lobby game identity while preserving other valid sessions. Full reset cancels bot work, clears all sessions, boards, mode, bot state, rematch approvals, match and cumulative statistics, and command queue, then creates a fresh game identity and recovery generation. Invalidated HTTP state polling receives stable `SESSION_INVALIDATED` recovery metadata. Authenticated WebSocket owners receive a minimal `{type:"reset"}` notification without board or token data and are invalidated before any role-safe state can be serialized.
- Measurements: Added lifecycle coverage for spectator/player leave and full reset, HTTP coverage for idempotent leave/reset and invalidated polling, and WebSocket coverage for minimal reset notification/invalidation. `make -C test/host run` passed all ten suites. `node --check data/app.js` and the 12 browser/unit tests passed. `pio run -e esp32-c6-devkitm-1 -t buildfs`, `pio run -e esp32-c6-devkitm-1`, and `git diff --check` passed. Firmware uses 39,604 / 327,680 B RAM (12.1%) and 1,020,806 / 2,097,152 B flash (48.7%).
- Issues or deviations: No firmware upload or physical multi-client recovery exercise was performed. Milestone 022 owns the browser controls and confirmations; none were added here.
- Next action: Milestone 022 is READY. Do not start it unless explicitly requested.
---
## Milestone 022 — Add the always-available recovery menu and child-safe confirmations
**Status:** `DONE`
**Depends on:** Milestone 021
### Objective
Add one clearly recognizable recovery button that is available from the lobby through the complete game lifecycle and opens actions for leaving, resetting the current profile, or resetting the entire game.
The design must prevent accidental destructive taps while remaining understandable to young children who may not read confidently.
### Button availability and placement
- Show the recovery/menu button on `LOBBY`, `PREPARING`, `IN_PROGRESS`, `FINISHED`, and `REMATCH_WAIT` screens.
- Keep it available on the HTTP-recovery/reconnecting screen so a user can at least clear their local session when the network path is unhealthy.
- Do not show it on the initial registration screen, because there is no active session to leave.
- Place it consistently in the header or another fixed safe area where it does not cover the board, score, field tabs, Fire control, or browser safe-area insets.
- Use a local SVG symbol such as a door, lifebuoy, or reset arrow plus a concise text/accessibility label. Do not rely on color or text alone.
- Keep a minimum touch target of 44 × 44 CSS pixels.
### Recovery menu
Opening the button must present three visibly distinct choices:
1. **Выйти из игры** — leave while keeping the remembered name/hero.
2. **Сбросить мой профиль** — leave and clear this browser's name, hero, token, and user preferences.
3. **Сбросить всю игру** — clear the complete in-memory party for every connected user.
- Show the full-game option only to authenticated players.
- Never show or enable the full-game option for spectators or anonymous/recovery-only clients.
- Use a modal or bottom sheet with a clear close control, focus trapping, Escape support, and restoration of focus to the menu button.
- The default focused action must be Cancel, not a destructive action.
- Prevent background board interaction while the menu or confirmation is open.
### Confirmation behavior
- Every leave or reset action requires a separate confirmation step.
- The confirmation must state the scope visually and in text:
- one-person icon for local leave/profile reset;
- all-players icon for full game reset.
- Use two clearly separated choices: Cancel and the requested action.
- Full game reset requires a stronger final gesture: press and hold the destructive button for approximately two seconds while a visible progress ring/bar fills.
- Releasing early cancels the hold without sending a request.
- Keyboard and assistive-technology users must have an equivalent explicit confirmation path.
- Do not require typing a phrase; the recovery flow must remain usable by children who cannot read or type confidently.
- Disable the action after submission and show bounded progress so double taps cannot enqueue duplicate requests.
- If the request fails, keep the user in a recoverable state and offer Retry or local-only profile clearing where safe.
### Browser behavior
- On successful leave, remove only the session token and render the registration screen with the remembered name/hero.
- On successful profile reset, remove all Battleship user keys from `localStorage` and render an empty registration screen.
- On a full reset notification, every client must remove all session/profile keys, stop WebSocket retries and polling for the invalid session, clear transient UI/game state, and render the empty registration screen.
- Clear selected targets, pending Fire state, animation timers, combo counters, notices, cumulative-statistics cache, active-board selection, and retry timers during local or global reset.
- Do not briefly render hidden boards or stale names while transitioning to registration.
- If the browser is offline or the server is unreachable, allow local profile clearing only after confirmation and explain that the server seat may remain occupied until its normal timeout or a later successful leave.
### Child-friendly visual requirements
- Distinguish the three choices using shape, icon, spacing, and label, not red color alone.
- Keep the most destructive action visually separated from the two local actions.
- Use calm wording; do not make failure or reset feel like punishment.
- Avoid flashing the destructive confirmation red repeatedly.
- Respect `prefers-reduced-motion` for the hold progress and transition effects.
- Provide complete Russian accessible labels and live status announcements.
### Browser tests
Add tests covering at least:
1. Recovery button visibility in every supported phase and its absence on registration.
2. Role-specific visibility of the full-game option.
3. Cancel from every confirmation without API submission or local-data loss.
4. Successful leave while preserving remembered name/hero.
5. Successful profile reset clearing every Battleship browser key.
6. Two-second hold requirement for full reset and cancellation on early release.
7. Request deduplication during repeated taps, pointer events, and keyboard activation.
8. Reset notification handling through WebSocket and HTTP fallback.
9. Clearing timers, selected targets, combo state, cached statistics, and stale UI after reset.
10. Offline local-profile recovery and its warning.
11. Focus trapping, Escape behavior, focus restoration, and accessible announcements.
12. Mobile, tablet portrait, tablet landscape, and laptop layout without board overlap.
### Acceptance criteria
- A user can reach the recovery menu from the lobby through the result/rematch flow without scrolling past the game board.
- No leave or reset occurs from a single accidental tap on the menu button.
- Full reset requires an authenticated player and the stronger confirmation gesture.
- Local leave, local profile reset, and full game reset produce their documented distinct outcomes.
- All affected clients return to the correct registration or lobby state without stale data.
- The flow works with touch, mouse, keyboard, assistive technology, WebSocket, and HTTP fallback.
- The controls remain legible and unobtrusive at the target phone and tablet sizes.
- JavaScript, CSS, SVG, gzip, and LittleFS budgets remain acceptable.
### Completion action
When all criteria pass, set Milestone 022 to `DONE`, append its execution record, and change Milestone 023 from `BLOCKED` to `READY`. Do not start Milestone 023 in the same task unless explicitly requested.
### Execution record
- Date: 2026-08-30
- 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 layout confirmation.
- Evidence: Added a 44-pixel lifebuoy-labelled recovery control outside the gameplay surfaces. It is hidden at registration and visible in lobby, game, result, reconnecting, and error states with an active session. Its modal has focus containment, Escape/close restoration, role-gated full reset, separate icon/text scopes, and cancellation as the initial confirmation focus. Leave and profile reset require a second explicit confirmation; full reset requires a two-second pointer hold with visible progress, while keyboard activation supplies the accessible equivalent. Successful leave preserves the name; profile/full reset clears all `battleship.*` browser data, selection, cached state/statistics, effects, timers, polling, and socket retry state before showing registration. Offline profile reset is explicitly local-only and warns about the retained server seat.
- Measurements: `node --test test/web/test_target_interaction.js test/web/test_ship_sprite.js test/web/test_recovery_ui.js` passed 15/15, including recovery visibility, role gate, scope clearing, hold/cancel, keyboard, and Escape assertions. `make -C test/host run` passed all ten host suites. `node --check data/app.js`, `git diff --check`, `pio run -e esp32-c6-devkitm-1 -t buildfs`, and `pio run -e esp32-c6-devkitm-1` passed. Browser assets are 43,011 B / 10,622 B gzip JavaScript, 23,570 B / 5,589 B gzip CSS, and 4,466 B / 1,633 B gzip SVG. The LittleFS image is the fixed 2,031,616-B partition image. Firmware remains 39,604 / 327,680 B RAM (12.1%) and 1,020,806 / 2,097,152 B flash (48.7%).
- Issues or deviations: No attached browser backend is available for live phone/tablet screenshots, and no firmware/filesystem upload or physical recovery exercise was performed.
- Next action: Milestone 023 is READY. Do not start it unless explicitly requested.
---
## Milestone 023 — Verify multi-client reset recovery and document the emergency workflow
**Status:** `DONE`
**Depends on:** Milestone 022
### Objective
Prove that leave, profile reset, and full game reset recover real phones/tablets and the ESP32 consistently under normal play, reconnect, HTTP fallback, and partially broken client conditions.
### Integration scenarios
Run and record at least the following scenarios with two player clients and one spectator where applicable:
1. Player leaves from the lobby; the correct seat becomes available and the other user remains valid.
2. Spectator leaves during play; the match and both player sessions remain unchanged.
3. Player leaves during human-vs-human play; the match aborts safely and the remaining player returns to the lobby.
4. Human leaves a bot match; the bot stops and the game returns to a clean lobby.
5. User resets only their profile and can register again under a new name/hero.
6. Player performs full reset from the lobby.
7. Player performs full reset during active play while another client has a selected target.
8. Player performs full reset from the result and rematch screens.
9. Full reset while one client is on WebSocket and another is using HTTP polling.
10. Full reset while one browser is temporarily disconnected and reconnects with an invalidated token.
11. Repeated confirmation taps and retried HTTP requests do not execute more than one reset.
12. A stale pre-reset tab cannot alter the newly registered party.
13. ESP32 remains responsive after at least 50 mixed leave/profile-reset/full-reset cycles.
### Verification and measurements
- Run all host lifecycle, API, synchronization, integration, robustness, and browser tests.
- Build firmware and LittleFS from a clean state.
- Record firmware flash/RAM use, LittleFS usage, reset payload sizes, minimum free heap, largest free block, and connection count before and after the reset-cycle run.
- Confirm that free heap has no persistent downward trend across the repeated cycle test.
- Confirm there is no watchdog reset, socket leak, stuck seat, stale name, stale board, duplicate command, or orphaned bot action.
- Verify the recovery menu at target mobile and tablet sizes, including safe areas and landscape orientation.
- Verify that the full-reset hold interaction cannot accidentally fire a board cell underneath it.
- Confirm no reset operation erases Wi-Fi credentials or requires a device reboot.
### Documentation
Document the user-facing emergency workflow in concise Russian:
- how to leave while keeping a profile;
- how to clear only the current browser profile;
- how a player resets the entire party;
- what happens to the other connected users;
- what to do when the browser is offline;
- explicit assurance that Wi-Fi and firmware settings are not erased.
Also document the API routes/commands, authorization, stable reset reason codes, idempotency behavior, and reset-generation rules for future maintainers.
### Acceptance criteria
- Every integration scenario passes on the target ESP32 with objective logs or screenshots.
- All clients converge on the correct post-reset state through both WebSocket and HTTP polling.
- No stale session or queued command survives a full reset.
- Fifty mixed reset cycles complete without a hang, reboot, material memory decline, or unavailable session slot.
- Recovery instructions are understandable without developer tools or a serial console.
- The final build remains within the established firmware, heap, payload, and LittleFS budgets.
### Completion action
When all criteria pass, set Milestone 023 to `DONE` and append its execution record. Add later post-MVP work only after Milestone 023 without renumbering existing milestones.
### Execution record
- Date: 2026-08-31
- 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 `docs/RECOVERY_GUIDE.md`, a concise Russian emergency workflow covering leave, local-only profile clearing while offline, player-only full reset, effects on other users, and explicit non-erasure of Wi-Fi/firmware settings. Added a deterministic fifty-cycle mixed leave/bot-abort/full-reset lifecycle regression. All ten host suites passed, including the existing API, synchronization, human integration, and robustness coverage. All 17 web tests and `node --check data/app.js` passed. Firmware and LittleFS builds passed; firmware uses 39,604 / 327,680 B RAM (12.1%) and 1,020,920 / 2,097,152 B flash (48.7%); the fixed LittleFS partition image is 2,031,616 B.
- Physical verification: The user confirmed every required manual target-device scenario passed, including the two-player-plus-spectator flows, WebSocket and HTTP fallback convergence, temporarily disconnected browser recovery, recovery-menu layouts and hold safety, stale-tab rejection, and fifty mixed recovery cycles without a hang, reboot, memory decline, stuck seat, socket leak, stale state, duplicate command, or orphaned bot action. Wi-Fi credentials and firmware settings remained unchanged.
- Next action: Milestone 024 remains blocked and was not started.
---
## Milestone 024 — Build a bounded browser Web Audio engine and sound controls
**Status:** `BLOCKED`
**Depends on:** Milestone 023
### Objective
Add a compact, framework-free Web Audio engine that synthesizes all game sounds in the phone or tablet browser without storing prerecorded audio files on the ESP32.
The engine must be explicitly activated by the user, remain optional, consume bounded browser resources, and fail silently when Web Audio is unavailable. This milestone builds only the reusable audio foundation and settings; the large game-reaction library is added in Milestone 025.
### Architecture
- Implement the engine as a small maintainable browser module rather than mixing oscillator code throughout `app.js`.
- Use the Web Audio API only after a user gesture has enabled sound.
- Generate sound from bounded combinations of:
- oscillators with sine, triangle, square, and sawtooth waveforms;
- one reusable in-memory noise buffer;
- gain envelopes;
- pitch ramps;
- low-pass, high-pass, and band-pass filters;
- short delays only when they materially improve a cue;
- a master gain and dynamics-compressor/limiter stage.
- Do not add MP3, AAC, OGG, WAV, sampled meme, CDN, or internet assets.
- Do not synthesize audio on the ESP32. The ESP32 only serves the browser code; the client device produces the sound.
- Do not require a server protocol change solely for sound. Derive cues from the same authoritative, role-safe state changes already used for visual feedback.
### Audio-context lifecycle
- Create or resume `AudioContext` only after an explicit sound-toggle or other clearly associated user action.
- Handle browsers that begin with a suspended context.
- Reuse one context instead of creating a new context for every effect.
- Suspend or stop scheduling sound when the page is hidden for a sustained period, the session is reset, the user leaves, or sound is muted.
- Resume safely after the page becomes active and the browser permits it.
- Disconnect and release every temporary node after its envelope completes.
- Cancel scheduled voices and reset the engine during profile reset, full game reset, navigation back to registration, or browser teardown.
- Degrade to a silent no-op implementation when Web Audio is unsupported or initialization fails.
### Resource limits
- Define a hard maximum simultaneous voice count, initially no more than eight voices.
- Reuse a single bounded noise buffer instead of allocating noise per playback.
- Limit ordinary cues to approximately 100900 ms and major victory/sinking cues to a maximum of approximately 2.5 seconds.
- Prevent unbounded timers, event listeners, audio nodes, buffers, and promise chains.
- Drop or replace low-priority UI cues when the voice limit is reached; never delay an important hit, sinking, combo, or reset cue behind a long queue.
- Prevent duplicate state snapshots and HTTP/WebSocket retransmissions from replaying the same sound.
- Record the added raw/gzip JavaScript size and resulting LittleFS usage.
### Child-safe sound controls
- Add a clear `Sound on / Sound off` control using a local SVG speaker icon and a complete Russian accessible label.
- Make the sound control available from the lobby through game, result, rematch, and recovery screens.
- Preserve the choice in a bounded `battleship.*` browser preference.
- Default to sound off until the user explicitly enables it for the first time.
- Provide three simple volume levels: quiet, normal, and loud, with normal as the recommended maximum for children.
- Cap master output so overlapping voices do not clip or become startling.
- Avoid sustained very high frequencies, sudden full-scale gain jumps, and long low-frequency rumbles.
- Keep essential game information visible; sound must enhance feedback, never become the only indication of turn, hit, miss, sinking, victory, error, or reset.
- Provide an immediate mute action that stops currently scheduled nonessential audio.
- Keep sound preferences independent from `prefers-reduced-motion`; also provide a separate reduced-intensity sound option for users who are sensitive to strong effects.
### Originality and tone
- Create original synthesized cues only.
- Do not copy recognizable sounds from YouTube, Roblox, games, films, social-media memes, or commercial sound libraries.
- The interaction style may use the short, energetic rhythm familiar to modern children's games, but the melodies, timing, and synthesis presets must be original to this project.
- Avoid frightening alarms, realistic gunfire, screams, mocking failure sounds, or punishment-like audio.
### Tests
Add browser/unit tests with a fake or instrumented audio context covering at least:
1. No context creation before explicit enablement.
2. Context creation/resume after a valid user gesture.
3. Persistent mute, volume, and reduced-intensity preferences.
4. Master gain caps for every volume level.
5. Bounded voice allocation and priority replacement.
6. Noise-buffer reuse.
7. Node disconnection and timer cleanup after playback.
8. Duplicate event/version suppression.
9. Cleanup on leave, profile reset, full reset, and registration transition.
10. Page hide/resume behavior.
11. Graceful no-op fallback when Web Audio is unavailable or throws.
12. No effect on existing game state, transport, role, or recovery behavior.
### Acceptance criteria
- Sound can be explicitly enabled, muted immediately, and adjusted without reloading the game.
- The engine plays a deterministic test cue through a bounded reusable graph.
- No audio context or sound begins before user activation.
- The implementation introduces no prerecorded audio asset and no internet dependency.
- Simultaneous voices, node lifetimes, timers, and buffers remain within documented limits.
- The browser remains fully playable with sound disabled or unsupported.
- All existing host and browser suites continue to pass.
- Firmware, gzip, and LittleFS budgets remain acceptable.
### Completion action
When all criteria pass, set Milestone 024 to `DONE`, append its execution record, and change Milestone 025 from `BLOCKED` to `READY`. Do not start Milestone 025 in the same task unless explicitly requested.
---
## Milestone 025 — Create a large randomized library of playful game sounds and audio combos
**Status:** `BLOCKED`
**Depends on:** Milestone 024
### Objective
Create a broad, original, randomized set of short synthesized reactions that keeps children engaged without becoming repetitive, chaotic, exhausting, or distracting from gameplay.
Audio should reinforce the existing randomized visual/meme reactions. Each event family needs multiple clearly related but nonidentical variants, and the same variant must not play twice consecutively for the same event.
### Event families and minimum variety
Implement at least the following synthesized cue families:
1. **Target selection** — at least 4 quiet variants:
- sonar tick;
- short radar ping;
- soft bubble click;
- tiny aiming chirp.
2. **Shot launch** — at least 8 energetic variants:
- compact cannon pop;
- rising charge followed by a burst;
- fast whoosh;
- comic `pew`-style pitch drop;
- double pop;
- low naval thump;
- short sparkling launch;
- rapid arcade-like sweep.
3. **Miss** — at least 10 light and funny variants:
- several splash shapes;
- bubbles rising;
- soft `bloop`;
- water drop;
- descending whistle into water;
- wave wash;
- tiny fish-like chirp;
- comic empty echo.
4. **Hit** — at least 10 satisfying variants:
- filtered impact;
- short explosion;
- metallic thud;
- impact plus bright confirmation ping;
- two-stage `boom-ding`;
- bass pop;
- crunchy noise burst;
- short critical-hit sparkle;
- rising confirmation tone;
- compact layered impact.
5. **Ship sunk** — at least 8 multi-stage variants:
- heavy impact followed by bubbles;
- descending hull tone and splash;
- short explosion sequence;
- victory sparkle over a low splash;
- collapsing pitch sweep;
- three-hit dramatic cadence;
- deep thump with bright finish;
- comic large `bloop` followed by stars.
6. **Incoming miss / dodge** — at least 6 encouraging variants that sound different from the local player's miss.
7. **Incoming hit** — at least 6 clear but nonfrightening variants using lower, softer timbres than the player's successful hit.
8. **Turn ready** — at least 6 brief attention cues that invite action without sounding like an alarm.
9. **Waiting / opponent turn** — at least 4 optional low-priority ambient ticks, disabled in reduced-intensity mode and rate-limited so they never loop continuously.
10. **Game start** — at least 6 short original launch stingers.
11. **Victory** — at least 8 original fanfare variants with different rhythms and instrument-like oscillator combinations.
12. **Defeat / rematch invitation** — at least 5 friendly, encouraging cues that do not mock the child.
13. **Recovery actions** — separate calm cues for menu open, cancel, leave, profile reset, and full game reset. Destructive actions must not sound rewarding or resemble a gameplay explosion.
### Randomization rules
- Use bounded variation of preset selection, pitch, envelope duration, filter cutoff, pan when supported, and particle-like voice timing.
- Keep pitch and duration variation within ranges that preserve the meaning of each event.
- Never allow two consecutive uses of an event family to select the same complete preset.
- Avoid a short repeating pattern such as alternating between only two variants when more are available.
- Seed selection from browser randomness; do not use game-board randomness or reveal any hidden server state.
- Do not let random choices affect authoritative game logic, timing, network messages, or visual state.
- Store only the last few selected preset identifiers needed for anti-repetition; do not build an unbounded history.
### Combo and streak audio
- Track only the local presentation streak already derived from authoritative hit/sunk events.
- Add escalating original combo layers:
- combo ×2: an extra bright confirmation note;
- mega-combo ×3: a three-note rising motif;
- ultra-combo ×4 and above: a bounded fanfare layer with stronger particles/visual synchronization.
- Increase excitement through rhythm, harmony, and layering rather than unlimited volume.
- Reset the audio combo exactly when the visual combo resets.
- Ensure a sinking or victory cue remains recognizable when combined with a streak layer.
- Cap combo layering inside the global voice and gain limits.
### Synchronization with visual reactions
- Select visual text/animation and audio from compatible event families without requiring an exact one-to-one preset pairing.
- Begin launch sound immediately after an accepted local Fire action.
- Play hit, miss, sinking, dodge, damage, turn, and result sounds only after the authoritative state transition is accepted.
- Spectators receive public hit/miss/sinking cues without player-private orientation or target information.
- Do not replay sounds when the same state version arrives through both WebSocket and HTTP polling.
- When several changes arrive in one snapshot, apply explicit priority: victory, sinking, hit/damage, miss/dodge, turn, then low-priority UI sound.
- Rate-limit bursts caused by reconnect snapshots so a returning client receives one summary cue instead of a backlog of historical sounds.
### Attention without overload
- Keep ordinary gameplay cues short enough that the next action is never delayed.
- Alternate timbre, rhythm, and spatial impression, not only pitch.
- Reserve the richest sounds for sinking, high combos, and victory so rewards retain meaning.
- Keep selection and menu sounds much quieter than gameplay results.
- Do not add continuous background music in this milestone.
- Do not play repeated waiting sounds more often than a documented safe interval.
- When many events occur rapidly, prioritize the newest important event and suppress obsolete low-priority audio.
- Reduced-intensity mode must remove waiting ticks, simplify combos, reduce bass/noise layers, and shorten victory/sinking cues.
### Tests
Add deterministic tests using injected randomness and a fake audio clock covering at least:
1. Minimum preset count for every event family.
2. No immediate preset repetition.
3. Bounded anti-repeat history.
4. Pitch, duration, filter, pan, gain, and voice-count limits across many randomized selections.
5. Correct priority when one state snapshot contains multiple changes.
6. No duplicate sound for repeated versions or WebSocket/HTTP duplicates.
7. Correct local-player, opponent, and spectator sound perspective.
8. Shot sound only for an accepted local action.
9. Combo ×2, mega-combo ×3, ultra-combo ×4+, and reset behavior.
10. Reduced-intensity substitutions and suppression.
11. Reconnect summary cue without historical sound storms.
12. Recovery cues that cannot be confused with shot, hit, or victory.
13. Thousands of randomized cue selections without an exception, leaked voice, or limit violation.
### Acceptance criteria
- Every required event family meets or exceeds its minimum variant count.
- Repeated shots and outcomes feel varied in preset, rhythm, timbre, and motion synchronization.
- Important events remain immediately distinguishable without reading text.
- Combos become progressively more exciting without exceeding gain or voice limits.
- Failure and defeat sounds remain playful and encouraging.
- No copyrighted or recognizable third-party sound is included or imitated.
- Audio never reveals hidden board information or changes gameplay timing.
- Sound-disabled and reduced-intensity modes remain complete and usable.
- Automated anti-repetition, range, priority, and stress tests pass.
### Completion action
When all criteria pass, set Milestone 025 to `DONE`, append its execution record, and change Milestone 026 from `BLOCKED` to `READY`. Do not start Milestone 026 in the same task unless explicitly requested.
---
## Milestone 026 — Validate Web Audio engagement, compatibility, and long-run stability on real devices
**Status:** `BLOCKED`
**Depends on:** Milestone 025
### Objective
Verify that the synthesized sound system is entertaining, understandable, compatible with the target phones/tablets, and stable during long games without harming network responsiveness or browser performance.
### Device matrix
Test at minimum:
- one current Android phone in a Chromium-based browser;
- one Android tablet or second Android device;
- one iPhone or iPad using Safari when available;
- one laptop browser for keyboard and accessibility checks;
- sound on, muted, quiet, normal, loud, and reduced-intensity modes;
- portrait and landscape orientation;
- WebSocket operation and HTTP polling fallback.
### Functional scenarios
Verify on real devices:
1. First-time sound enablement from the lobby after an explicit tap.
2. Persistent sound preference after reload and normal session resume.
3. Immediate mute during a currently playing cue.
4. Target, shot, miss, hit, sinking, incoming result, turn, combo, victory, defeat, and recovery sounds.
5. At least 30 consecutive shots without an immediate same-family preset repeat.
6. Combo ×2, mega-combo ×3, and ultra-combo ×4+ synchronization with the visual badges.
7. Page background/foreground transition without broken or queued audio.
8. Screen lock/unlock and browser-tab switching when supported.
9. WebSocket loss followed by HTTP fallback and WebSocket recovery without duplicate sounds.
10. Leave, profile reset, and full game reset while a sound is active.
11. Spectator audio without hidden-information leakage.
12. Graceful silent operation when Web Audio is blocked or unavailable.
### Child-engagement review
Run a short supervised usability review with age-appropriate participants or adult proxies acting from the child's perspective.
Record whether:
- shot, miss, hit, sinking, and victory can be distinguished without reading the message;
- repeated play still feels varied after a complete match;
- combo sounds feel more rewarding than ordinary hits;
- any cue is frightening, painfully sharp, too bass-heavy, mocking, or excessively loud;
- waiting sounds become annoying;
- mute and volume controls can be found quickly;
- the sounds help attention without causing the child to tap randomly or miss the actual turn cue.
Do not record children, collect identifying data, or conduct unsupervised testing as part of this project. Record only anonymous design observations supplied by a responsible adult.
### Performance and stability
- Complete at least 20 representative games with sound enabled.
- Run at least one 60-minute browser session containing repeated shots, combos, resets, reconnects, and tab visibility changes.
- Record browser console errors, approximate active voice count, peak scheduled-node count, audio-context state transitions, and any delayed or dropped important cue.
- Confirm no persistent growth in active nodes, timers, event listeners, buffers, or browser memory attributable to audio.
- Confirm ESP32 free heap, HTTP latency, WebSocket delivery, and connected-client capacity remain within the established budgets.
- Measure final raw/gzip sizes for the audio engine and preset library and the resulting LittleFS usage.
- Confirm the firmware image does not materially grow unless a documented server change was required.
### Accessibility and safety review
- Verify every event remains understandable visually with sound muted.
- Verify screen-reader labels for sound controls and state changes do not duplicate or fight with audio cues.
- Verify reduced-intensity mode meaningfully reduces layers and loudness.
- Verify the master limiter prevents clipping during the largest combo/victory stack.
- Verify no cue exceeds the documented duration, gain, voice-count, and repetition limits.
- Verify no sound starts unexpectedly on registration, reload, or background resume.
### Acceptance criteria
- Required cues play reliably on the tested phone and tablet browsers after explicit activation.
- A complete match remains varied and understandable without copied audio assets.
- No immediate same-event preset repetition is observed in the recorded sequence.
- Users can mute audio immediately and retain their preference.
- Reduced-intensity mode is noticeably calmer while preserving event meaning.
- Twenty games and the 60-minute session complete without audio-node leaks, browser errors, gameplay stalls, ESP32 instability, or transport regressions.
- Audio remains synchronized with authoritative state through WebSocket, HTTP fallback, reconnect, and reset flows.
- The final browser assets and LittleFS image remain inside the established budgets.
### Completion action
When all criteria pass, set Milestone 026 to `DONE` and append its execution record. Add later milestones only after Milestone 026 without renumbering existing milestones.
+294 -20
View File
@@ -1,6 +1,7 @@
(() => { (() => {
const columns = ['А', 'Б', 'В', 'Г', 'Д', 'Ж', 'З', 'И', 'Й', 'К']; const columns = ['А', 'Б', 'В', 'Г', 'Д', 'Ж', 'З', 'И', 'Й', 'К'];
const storage = { token: 'battleship.sessionToken', name: 'battleship.displayName' }; const storage = { token: 'battleship.sessionToken', name: 'battleship.displayName' };
const boardKeys = Object.freeze({ own: 'own', opponent: 'opponent', player1: 'player1', player2: 'player2' });
const screens = ['connect', 'lobby', 'game', 'result', 'reconnecting', 'error']; const screens = ['connect', 'lobby', 'game', 'result', 'reconnecting', 'error'];
const el = id => document.getElementById(id); const el = id => document.getElementById(id);
const ui = { const ui = {
@@ -9,7 +10,10 @@
start: el('start-game'), boards: el('boards'), resultBoards: el('result-boards'), tabs: el('board-tabs'), 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'), 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'), fire: el('fire-button'), cancel: el('cancel-target'), abort: el('abort-game'), rematch: el('rematch-button'),
result: el('result-description'), error: el('error-description') result: el('result-description'), error: el('error-description'), lobbyIcon: el('lobby-icon'), resultIcon: el('result-icon'),
cue: el('action-cue'), cueIcon: el('action-cue-icon'), cueText: el('action-cue-text'), effects: el('game-effects'),
effectIcon: el('effect-icon'), effectText: el('effect-text'), effectBurst: el('effect-burst'), effectBadge: el('effect-badge'),
recoveryButton: el('recovery-menu-button'), recoveryDialog: el('recovery-dialog'), recoveryClose: el('recovery-close'), recoveryChoices: el('recovery-choices'), recoveryGlobal: el('recovery-global-choice'), recoveryConfirmation: el('recovery-confirmation'), recoveryCancel: el('recovery-cancel'), recoveryConfirm: el('recovery-confirm'), recoveryConfirmTitle: el('recovery-confirm-title'), recoveryConfirmText: el('recovery-confirm-text'), recoveryConfirmLabel: el('recovery-confirm-label'), recoveryHoldNote: el('recovery-hold-note'), recoveryScopeIcon: el('recovery-scope-icon'), recoveryLive: el('recovery-live')
}; };
let token = localStorage.getItem(storage.token) || ''; let token = localStorage.getItem(storage.token) || '';
let role = ''; let role = '';
@@ -27,9 +31,233 @@
let firePending = false; let firePending = false;
let targetActivator; let targetActivator;
let retryIndex = 0; let retryIndex = 0;
let effectTimer;
let hitStreak = 0;
let recoveryAction;
let recoveryPending = false;
let recoveryHoldTimer;
let recoveryOpener;
const reaction = (kind, icon, text, motion = 'pop', burst = 'none', vibration = [45]) => ({ kind, icon, text, motion, burst, vibration });
const feedback = {
start: [
reaction('start', 'ship-4-battleship', 'Бой начинается!', 'swoop', 'bubbles', [80]),
reaction('start', 'icon-rocket', 'Полный вперёд!', 'zoom', 'sparks', [50, 30, 90]),
reaction('start', 'ship-3-cruiser', 'Поднять якоря!', 'bounce', 'bubbles', [90]),
reaction('start', 'icon-target', 'К бою готовы!', 'spin', 'stars', [60, 30, 60]),
],
turn: [
reaction('turn', 'icon-target', 'Твой ход!', 'pop', 'sparks', [80, 40, 80]),
reaction('turn', 'icon-blast', 'Капитан, выбирай!', 'bounce', 'stars', [70, 30, 70]),
reaction('turn', 'icon-target', 'Пора пулять!', 'zoom', 'sparks', [90]),
reaction('turn', 'ship-1-cutter', 'Твоя очередь!', 'swoop', 'bubbles', [60, 30, 80]),
reaction('turn', 'icon-target', 'Где прячется корабль?', 'spin', 'stars', [60]),
reaction('turn', 'icon-target', 'Твой мув!', 'zoom', 'sparks', [70]),
],
hit: [
reaction('hit', 'icon-blast', 'Попадание!', 'pop', 'sparks', [110]),
reaction('hit', 'icon-target', 'Точно в цель!', 'zoom', 'stars', [80, 25, 120]),
reaction('hit', 'icon-blast', 'Нннааа!', 'spin', 'sparks', [130]),
reaction('hit', 'ship-1-cutter', 'Есть контакт!', 'swoop', 'stars', [70, 30, 100]),
reaction('hit', 'icon-blast', 'Мочный выстрел!', 'bounce', 'stars', [90, 30, 120]),
reaction('hit', 'icon-target', 'Прямо в яички!', 'zoom', 'sparks', [120]),
reaction('hit', 'icon-blast', 'КРИТ!', 'zoom', 'sparks', [90, 20, 130]),
reaction('hit', 'icon-trophy', 'ИМБА!', 'spin', 'stars', [80, 30, 120]),
reaction('hit', 'icon-target', 'Лютый пострил!', 'bounce', 'stars', [100]),
reaction('hit', 'icon-blast', 'Опасное попадание!', 'swoop', 'sparks', [100]),
],
sunk: [
reaction('hit', 'icon-trophy', 'Этот уже не похилится!', 'spin', 'stars', [90, 40, 140]),
reaction('hit', 'icon-blast', 'Минус один!', 'zoom', 'sparks', [100, 40, 150]),
reaction('hit', 'icon-trophy', 'Вот это капитан!', 'bounce', 'stars', [80, 30, 80, 30, 140]),
reaction('hit', 'icon-wave', 'Лодка буль-буль!', 'swoop', 'bubbles', [120, 50, 150]),
reaction('hit', 'ship-4-battleship', 'Победа будет наша!', 'pop', 'stars', [90, 40, 130]),
reaction('hit', 'icon-trophy', 'Ты Босс!', 'zoom', 'stars', [100, 40, 160]),
reaction('hit', 'icon-blast', 'Ультра-урон!', 'spin', 'sparks', [110, 40, 150]),
reaction('hit', 'icon-trophy', 'Это было Жопично!', 'bounce', 'stars', [100, 40, 150]),
],
miss: [
reaction('miss', 'icon-wave', 'Мимо!', 'swoop', 'bubbles', [45]),
reaction('miss', 'icon-wave', 'Буль-буль!', 'bounce', 'bubbles', [35]),
reaction('miss', 'icon-target', 'Чуть-чуть не попал!', 'pop', 'bubbles', [40]),
reaction('miss', 'icon-wave', 'Рыбки увернулись!', 'spin', 'bubbles', [30, 20, 30]),
reaction('miss', 'icon-wave', 'Волна поймала снаряд!', 'zoom', 'bubbles', [45]),
reaction('miss', 'icon-target', 'Не угадал!', 'swoop', 'none', [35]),
reaction('miss', 'icon-wave', 'А там ничего нет!', 'spin', 'bubbles', [40]),
reaction('miss', 'icon-target', 'Почти хайлайт!', 'zoom', 'none', [35]),
reaction('miss', 'icon-wave', 'Вода: 1. Снаряд: 0.', 'bounce', 'bubbles', [35]),
],
dodged: [
reaction('miss', 'icon-wave', 'Не попали!', 'swoop', 'bubbles', [40]),
reaction('miss', 'ship-1-cutter', 'В этот раз повезло!', 'bounce', 'stars', [35, 20, 35]),
reaction('miss', 'icon-wave', 'Дуракам везет!', 'pop', 'bubbles', [45]),
reaction('miss', 'ship-2-destroyer', 'Косоглазые!', 'zoom', 'bubbles', [35]),
],
damage: [
reaction('damage', 'icon-blast', 'В нас попали!', 'zoom', 'sparks', [160]),
reaction('damage', 'ship-2-destroyer', 'Нас вычислили!', 'swoop', 'sparks', [140, 40, 100]),
reaction('damage', 'icon-blast', 'Спасайся кто может!', 'spin', 'sparks', [170]),
reaction('damage', 'ship-1-cutter', 'A-a-a ранен!', 'bounce', 'none', [150]),
],
'sunk-damage': [
reaction('damage', 'icon-wave', 'Наш корабль потоплен!', 'swoop', 'bubbles', [180, 60, 180]),
reaction('damage', 'ship-4-battleship', 'Еще не все пропало!', 'bounce', 'sparks', [150, 50, 130]),
reaction('damage', 'icon-repeat', 'Не сдаёмся!', 'zoom', 'stars', [130, 40, 160]),
reaction('damage', 'icon-blast', 'Мы отомстим!', 'spin', 'sparks', [160]),
],
victory: [
reaction('victory', 'icon-trophy', 'Победа!', 'pop', 'stars', [80, 40, 80, 40, 180]),
reaction('victory', 'icon-trophy', 'БОСС моря!', 'spin', 'stars', [70, 30, 70, 30, 170]),
reaction('victory', 'ship-4-battleship', 'Мы их всех утопили!', 'swoop', 'bubbles', [90, 40, 160]),
reaction('victory', 'icon-trophy', 'Вот это победа!', 'bounce', 'stars', [80, 30, 90, 30, 180]),
reaction('victory', 'icon-rocket', 'Капитан — суперзвезда!', 'zoom', 'sparks', [100, 40, 180]),
reaction('victory', 'icon-trophy', 'ЛЕГЕНДА!', 'spin', 'stars', [90, 30, 90, 30, 190]),
reaction('victory', 'icon-trophy', 'Вот это скилл!', 'bounce', 'stars', [80, 30, 180]),
reaction('victory', 'icon-rocket', 'GG! Красиво!', 'zoom', 'sparks', [100, 40, 180]),
],
defeat: [
reaction('damage', 'icon-repeat', 'Поиграем еще раз?', 'spin', 'stars', [120]),
reaction('damage', 'ship-3-cruiser', 'В следующий раз порву!', 'swoop', 'bubbles', [100, 40, 120]),
reaction('damage', 'icon-repeat', 'В плен не сдаемся!', 'bounce', 'stars', [110]),
reaction('damage', 'icon-target', 'Попробуем ещё раз!', 'zoom', 'sparks', [100]),
],
finish: [
reaction('victory', 'icon-trophy', 'Бой окончен!', 'pop', 'stars', [80]),
reaction('victory', 'ship-4-battleship', 'Вот это битва!', 'swoop', 'bubbles', [80]),
reaction('victory', 'icon-trophy', 'Морской бой завершён!', 'spin', 'stars', [80]),
],
'watch-hit': [
reaction('hit', 'icon-blast', 'Уууууу!', 'pop', 'sparks', [80]),
reaction('hit', 'icon-target', 'Попал!', 'zoom', 'stars', [80]),
reaction('hit', 'icon-blast', 'Нннааа!', 'spin', 'sparks', [80]),
],
'watch-sunk': [
reaction('hit', 'icon-trophy', 'Корабль потоплен!', 'bounce', 'stars', [100]),
reaction('hit', 'icon-wave', 'Ушёл под воду!', 'swoop', 'bubbles', [100]),
reaction('hit', 'icon-blast', 'Вот это выстрел!', 'zoom', 'sparks', [100]),
],
'watch-miss': [
reaction('miss', 'icon-wave', 'Мимо!', 'swoop', 'bubbles', [40]),
reaction('miss', 'icon-wave', 'Буль!', 'bounce', 'bubbles', [40]),
reaction('miss', 'icon-target', 'Почти попал!', 'pop', 'none', [40]),
],
};
const pickReaction = globalThis.BattleshipTargetInteraction.createReactionPicker(feedback);
function setSpriteIcon(container, symbol) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'ui-icon'); svg.setAttribute('aria-hidden', 'true');
const use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
use.setAttribute('href', `/ship-sprite.svg#${symbol}`); svg.append(use); container.replaceChildren(svg);
}
function fillBurst(burst) {
ui.effectBurst.replaceChildren(); ui.effectBurst.className = `effect-burst burst-${burst}`;
if (burst === 'none') return;
const symbols = burst === 'bubbles' ? ['○', 'o', '·'] : burst === 'sparks' ? ['✦', '+', '*'] : ['★', '✦', '*'];
const count = burst === 'bubbles' ? 14 : burst === 'stars' ? 22 : 18;
for (let index = 0; index < count; index += 1) {
const particle = document.createElement('span');
const angle = (Math.PI * 2 * index) / count;
const distance = 35 + (index % 4) * 12;
particle.textContent = symbols[index % symbols.length];
particle.style.setProperty('--x', `${Math.cos(angle) * distance}vw`);
particle.style.setProperty('--y', `${Math.sin(angle) * distance}vh`);
particle.style.setProperty('--spin', `${(index % 2 ? 1 : -1) * (140 + index * 13)}deg`);
particle.style.setProperty('--rotate', `${index * 23}deg`);
particle.style.setProperty('--delay', `${(index % 5) * 35}ms`);
ui.effectBurst.append(particle);
}
}
function showFeedback(event) {
const content = pickReaction(event?.type);
if (!content) return;
let { motion, burst } = content;
const { kind, icon, text, vibration } = content;
if (event.type === 'hit' || event.type === 'sunk') hitStreak += 1;
else if (event.type === 'miss' || event.type === 'start' || event.type === 'victory' || event.type === 'defeat') hitStreak = 0;
const combo = hitStreak >= 2;
if (combo) { motion = hitStreak >= 3 ? 'combo' : motion; burst = 'stars'; }
window.clearTimeout(effectTimer);
ui.effects.className = `game-effects effect-${kind} motion-${motion}${combo ? ' combo-power' : ''}`;
setSpriteIcon(ui.effectIcon, icon); ui.effectText.textContent = text;
ui.effectBadge.hidden = !combo;
ui.effectBadge.textContent = combo ? (hitStreak >= 4 ? `УЛЬТРА-КОМБО ×${hitStreak}` : hitStreak === 3 ? 'МЕГА-КОМБО ×3' : 'КОМБО ×2') : '';
fillBurst(burst); ui.effects.hidden = false;
document.body.classList.remove('fx-hit', 'fx-damage');
void document.body.offsetWidth;
document.body.classList.add(kind === 'damage' ? 'fx-damage' : 'fx-hit');
if (navigator.vibrate) navigator.vibrate(vibration);
effectTimer = window.setTimeout(() => {
ui.effects.hidden = true; document.body.classList.remove('fx-hit', 'fx-damage');
}, kind === 'victory' ? 2200 : combo ? 1800 : 1350);
}
function showScreen(name) { function showScreen(name) {
screens.forEach(screen => { el(`screen-${screen}`).hidden = screen !== name; }); screens.forEach(screen => { el(`screen-${screen}`).hidden = screen !== name; });
ui.recoveryButton.hidden = name === 'connect' || !token;
}
function clearRecoveryTimers() {
window.clearTimeout(retryTimer); retryTimer = undefined;
window.clearInterval(pollTimer); pollTimer = undefined;
window.clearInterval(heartbeatTimer); heartbeatTimer = undefined;
window.clearInterval(lobbyInfoTimer); lobbyInfoTimer = undefined;
window.clearTimeout(effectTimer); effectTimer = undefined;
window.clearTimeout(recoveryHoldTimer); recoveryHoldTimer = undefined;
}
function clearLocalProfile(profile) {
clearRecoveryTimers();
if (socket) { socket.onclose = null; socket.close(); socket = undefined; }
selectedTarget = undefined; firePending = false; cumulativeStatistics = undefined; statisticsGameId = undefined;
activeBoard = 'own'; hitStreak = 0; state = undefined; role = ''; info = undefined; targetActivator = undefined;
document.body.classList.remove('fx-hit', 'fx-damage'); ui.effects.hidden = true; notify('');
Object.keys(localStorage).filter(key => key.startsWith('battleship.')).forEach(key => localStorage.removeItem(key));
if (!profile) { const rememberedName = ui.name.value.trim(); if (rememberedName) localStorage.setItem(storage.name, rememberedName); }
token = ''; ui.name.value = profile ? '' : (localStorage.getItem(storage.name) || '');
closeRecovery(); showScreen('connect'); setConnection('Готово к подключению');
}
function closeRecovery() {
window.clearTimeout(recoveryHoldTimer); recoveryHoldTimer = undefined; recoveryAction = undefined; recoveryPending = false;
ui.recoveryDialog.hidden = true; ui.recoveryButton.setAttribute('aria-expanded', 'false');
if (recoveryOpener) recoveryOpener.focus();
}
function recoveryIcon(symbol) { setSpriteIcon(ui.recoveryScopeIcon, symbol); }
function showRecoveryConfirmation(action) {
recoveryAction = action; ui.recoveryChoices.hidden = true; ui.recoveryConfirmation.hidden = false;
const game = action === 'game'; const profile = action === 'profile';
recoveryIcon(game ? 'icon-players' : 'icon-person');
ui.recoveryConfirmTitle.textContent = game ? 'Сбросить всю игру?' : profile ? 'Сбросить мой профиль?' : 'Выйти из игры?';
ui.recoveryConfirmText.textContent = game ? 'Все игроки вернутся к началу.' : profile ? 'На этом устройстве очистятся имя и данные.' : 'Вы вернётесь к подключению. Имя останется.';
ui.recoveryConfirmLabel.textContent = game ? 'Удерживать для сброса' : profile ? 'Сбросить профиль' : 'Выйти';
ui.recoveryHoldNote.hidden = !game; ui.recoveryConfirm.classList.remove('holding'); ui.recoveryConfirm.disabled = false;
ui.recoveryCancel.focus();
}
function openRecovery() {
if (!token) return; recoveryOpener = document.activeElement; ui.recoveryDialog.hidden = false; ui.recoveryButton.setAttribute('aria-expanded', 'true');
ui.recoveryChoices.hidden = false; ui.recoveryConfirmation.hidden = true; ui.recoveryGlobal.hidden = !isPlayer(); ui.recoveryClose.focus();
}
async function submitRecovery() {
if (!recoveryAction || recoveryPending) return;
recoveryPending = true; ui.recoveryConfirm.disabled = true; ui.recoveryLive.textContent = 'Выполняем действие…';
const action = recoveryAction;
if (action === 'profile' && navigator.onLine === false) {
clearLocalProfile(true);
notify('Профиль очищен на этом устройстве. Место на ESP32 освободится после восстановления связи или таймаута.');
return;
}
try {
if (action === 'leave') await request('/api/session/leave', { method: 'POST', headers: headers(), body: JSON.stringify({ token, gameId: state?.gameId || 0 }) });
else if (action === 'profile') await request('/api/session/profile-reset', { method: 'POST', headers: headers(), body: JSON.stringify({ token, gameId: state?.gameId || 0 }) });
else await request('/api/game/reset', { method: 'POST', headers: headers(), body: JSON.stringify({ token, gameId: state?.gameId || 0 }) });
clearLocalProfile(action !== 'leave');
} catch (error) { recoveryPending = false; ui.recoveryConfirm.disabled = false; ui.recoveryLive.textContent = 'Не получилось. Можно повторить или отменить.'; notify(error.message, true); }
} }
function setConnection(text, status) { function setConnection(text, status) {
@@ -109,6 +337,7 @@
return payload && payload.type === 'state' && Array.isArray(payload.boards) && payload.boards.length === 2 && return payload && payload.type === 'state' && Array.isArray(payload.boards) && payload.boards.length === 2 &&
payload.boards.every(board => typeof board === 'string' && /^[01234]{100}$/.test(board)) && payload.boards.every(board => typeof board === 'string' && /^[01234]{100}$/.test(board)) &&
typeof payload.version === 'number' && typeof payload.gameId === 'number' && typeof payload.version === 'number' && typeof payload.gameId === 'number' &&
['player1', 'player2', 'spectator'].includes(payload.viewer) &&
(payload.winner === null || payload.winner === 0 || payload.winner === 1) && Array.isArray(payload.statistics) && (payload.winner === null || payload.winner === 0 || payload.winner === 1) && Array.isArray(payload.statistics) &&
Array.isArray(payload.players) && payload.players.length === 2 && payload.players.every(name => typeof name === 'string' && name.length <= 80) && Array.isArray(payload.players) && payload.players.length === 2 && payload.players.every(name => typeof name === 'string' && name.length <= 80) &&
payload.statistics.length === 2 && payload.statistics.every(entry => Array.isArray(entry) && entry.length === 4 && payload.statistics.length === 2 && payload.statistics.every(entry => Array.isArray(entry) && entry.length === 4 &&
@@ -118,12 +347,16 @@
function acceptState(payload) { function acceptState(payload) {
if (!safeState(payload)) { showError('Получено неполное состояние игры.'); return; } if (!safeState(payload)) { showError('Получено неполное состояние игры.'); return; }
const hadGap = state && payload.version > state.version + 1; const hadGap = state && payload.version > state.version + 1;
const previousState = state;
state = payload; state = payload;
role = payload.viewer; role = payload.viewer;
const validBoardKeys = role === 'spectator' ? [boardKeys.player1, boardKeys.player2] : [boardKeys.own, boardKeys.opponent];
if (!validBoardKeys.includes(activeBoard)) activeBoard = validBoardKeys[0];
if (hadGap) pollState(); if (hadGap) pollState();
selectedTarget = selectedTarget && payload.boards[opponentIndex()][selectedTarget.y * 10 + selectedTarget.x] === '0' ? selectedTarget : undefined; selectedTarget = selectedTarget && payload.boards[opponentIndex()][selectedTarget.y * 10 + selectedTarget.x] === '0' ? selectedTarget : undefined;
document.title = `Морской бой — версия ${payload.version}`; document.title = `Морской бой — версия ${payload.version}`;
render(); render();
showFeedback(globalThis.BattleshipTargetInteraction.detectFeedback(previousState, payload, role));
} }
function isPlayer() { return role === 'player1' || role === 'player2'; } function isPlayer() { return role === 'player1' || role === 'player2'; }
@@ -180,10 +413,10 @@
} }
const fleetClasses = [ const fleetClasses = [
{ length: 4, count: 1, symbol: 'ship-4-battleship', label: 'Линкор', className: 'battleship' }, { length: 4, count: 1, symbol: 'ship-4-battleship', viewBox: '0 0 192 50', label: 'Линкор', className: 'battleship' },
{ length: 3, count: 2, symbol: 'ship-3-cruiser', label: 'Крейсер', className: 'cruiser' }, { length: 3, count: 2, symbol: 'ship-3-cruiser', viewBox: '0 0 144 40', label: 'Крейсер', className: 'cruiser' },
{ length: 2, count: 3, symbol: 'ship-2-destroyer', label: 'Эсминец', className: 'destroyer' }, { length: 2, count: 3, symbol: 'ship-2-destroyer', viewBox: '0 0 96 30', label: 'Эсминец', className: 'destroyer' },
{ length: 1, count: 4, symbol: 'ship-1-cutter', label: 'Катер', className: 'cutter' }, { length: 1, count: 4, symbol: 'ship-1-cutter', viewBox: '0 0 48 26', label: 'Катер', className: 'cutter' },
]; ];
function sunkShipLengths(board) { function sunkShipLengths(board) {
@@ -222,8 +455,8 @@
const ship = document.createElement('span'); const ship = document.createElement('span');
ship.className = `fleet-ship ${shipClass.className}${sunk ? ' sunk' : ''}`; ship.className = `fleet-ship ${shipClass.className}${sunk ? ' sunk' : ''}`;
ship.setAttribute('role', 'img'); ship.setAttribute('aria-label', `${shipClass.label}${sunk ? 'потоплен' : 'цел'}`); ship.setAttribute('role', 'img'); ship.setAttribute('aria-label', `${shipClass.label}${sunk ? 'потоплен' : 'цел'}`);
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('aria-hidden', 'true'); svg.setAttribute('focusable', 'false'); const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('aria-hidden', 'true'); svg.setAttribute('focusable', 'false'); svg.setAttribute('viewBox', shipClass.viewBox); svg.setAttribute('preserveAspectRatio', 'xMidYMax meet');
const use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); use.setAttribute('href', `/ship-sprite.svg#${shipClass.symbol}`); const use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); use.setAttribute('href', `/ship-sprite.svg#${shipClass.symbol}`); use.setAttribute('width', '100%'); use.setAttribute('height', '100%');
svg.append(use); ship.append(svg); ships.append(ship); svg.append(use); ship.append(svg); ships.append(ship);
} }
row.append(ships); fleet.append(row); row.append(ships); fleet.append(row);
@@ -233,7 +466,7 @@
function boardElement(index, title, targetable, result) { function boardElement(index, title, targetable, result) {
const board = document.createElement('section'); const board = document.createElement('section');
board.className = 'board'; board.className = `board${targetable ? ' is-action' : ''}`;
board.dataset.board = String(index); board.dataset.board = String(index);
const heading = document.createElement('h3'); const heading = document.createElement('h3');
heading.textContent = title; heading.textContent = title;
@@ -282,18 +515,19 @@
function boardDefinitions(result = false) { function boardDefinitions(result = false) {
if (role === 'spectator' || result) return [ if (role === 'spectator' || result) return [
{ index: 0, title: `Поле: ${playerLabel(0)}`, key: 'player1', targetable: false }, { index: 0, title: `Поле: ${playerLabel(0)}`, key: boardKeys.player1, targetable: false },
{ index: 1, title: `Поле: ${playerLabel(1)}`, key: 'player2', targetable: false } { index: 1, title: `Поле: ${playerLabel(1)}`, key: boardKeys.player2, targetable: false }
]; ];
return [ return [
{ index: ownIndex(), title: 'Моё поле', key: 'own', targetable: false }, { index: ownIndex(), title: 'Моё поле', key: boardKeys.own, targetable: false },
{ index: opponentIndex(), title: state.mode === 'bot' ? 'Поле ESP32' : `Поле: ${playerName(opponentIndex())}`, key: 'opponent', targetable: canShoot() } { index: opponentIndex(), title: state.mode === 'bot' ? 'Поле ESP32' : `Поле: ${playerName(opponentIndex())}`, key: boardKeys.opponent, targetable: canShoot() }
]; ];
} }
function renderBoards(container, result = false) { function renderBoards(container, result = false) {
container.replaceChildren(); container.replaceChildren();
const definitions = boardDefinitions(result); const definitions = boardDefinitions(result);
if (!result && !definitions.some(definition => definition.key === activeBoard)) activeBoard = definitions[0].key;
definitions.forEach(definition => { definitions.forEach(definition => {
const board = boardElement(definition.index, definition.title, definition.targetable, result); const board = boardElement(definition.index, definition.title, definition.targetable, result);
board.hidden = !result && definition.key !== activeBoard; board.hidden = !result && definition.key !== activeBoard;
@@ -303,7 +537,7 @@
ui.tabs.replaceChildren(); ui.tabs.replaceChildren();
definitions.forEach(definition => { definitions.forEach(definition => {
const tab = document.createElement('button'); const tab = document.createElement('button');
tab.type = 'button'; tab.role = 'tab'; tab.textContent = definition.key === 'opponent' ? playerLabel(definition.index) : definition.title; tab.type = 'button'; tab.role = 'tab'; tab.textContent = definition.key === boardKeys.opponent ? playerLabel(definition.index) : definition.title;
tab.title = definition.title; tab.setAttribute('aria-label', definition.title); tab.title = definition.title; tab.setAttribute('aria-label', definition.title);
tab.setAttribute('aria-selected', String(definition.key === activeBoard)); tab.setAttribute('aria-selected', String(definition.key === activeBoard));
tab.addEventListener('click', () => { activeBoard = definition.key; renderGame(); }); tab.addEventListener('click', () => { activeBoard = definition.key; renderGame(); });
@@ -320,8 +554,9 @@
ui.modeControls.hidden = !playerOne; ui.modeControls.hidden = !playerOne;
if (playerOne) startLobbyInfoPolling(); if (playerOne) startLobbyInfoPolling();
else stopLobbyInfoPolling(); else stopLobbyInfoPolling();
const playerTwoReady = info?.player2Available === false; const playerTwoReady = state.players[1].trim() !== '' || info?.player2Available === false;
const canStart = playerOne && (state.mode === 'bot' || playerTwoReady); const canStart = playerOne && (state.mode === 'bot' || playerTwoReady);
setSpriteIcon(ui.lobbyIcon, canStart ? 'icon-check' : 'icon-hourglass');
document.querySelectorAll('.mode-button').forEach(button => { document.querySelectorAll('.mode-button').forEach(button => {
const selected = button.dataset.mode === state.mode; const selected = button.dataset.mode === state.mode;
button.setAttribute('aria-checked', String(selected)); button.setAttribute('aria-checked', String(selected));
@@ -336,15 +571,21 @@
if (!state) return; if (!state) return;
stopLobbyInfoPolling(); stopLobbyInfoPolling();
showScreen('game'); showScreen('game');
if (canShoot() && !selectedTarget) activeBoard = 'opponent'; if (canShoot() && !selectedTarget) activeBoard = boardKeys.opponent;
ui.turn.textContent = canShoot() ? 'Ваш ход' : `Ходит ${playerLabel(state.turn === 'player2' ? 1 : 0)}`; const turnPlayer = playerLabel(state.turn === 'player2' ? 1 : 0);
const watching = role === 'spectator';
ui.turn.textContent = canShoot() ? 'Ваш ход' : watching ? `Наблюдаем за боем · Ходит ${turnPlayer}` : `Ходит ${turnPlayer}`;
ui.wins.textContent = `${playerLabel(0)} ${state.wins[0]} : ${state.wins[1]} ${playerLabel(1)}`; ui.wins.textContent = `${playerLabel(0)} ${state.wins[0]} : ${state.wins[1]} ${playerLabel(1)}`;
renderBoards(ui.boards); renderBoards(ui.boards);
const available = canShoot(); const available = canShoot();
ui.cue.classList.toggle('is-ready', available && !selectedTarget);
ui.cue.classList.toggle('has-target', Boolean(available && selectedTarget));
setSpriteIcon(ui.cueIcon, available ? (selectedTarget ? 'icon-blast' : 'icon-target') : 'icon-hourglass');
ui.cueText.textContent = available ? (selectedTarget ? 'Жми «Огонь»!' : 'Твой ход! Выбери клетку') : watching ? 'Наблюдаем за боем' : 'Ждём соперника…';
ui.shotControls.hidden = !isPlayer(); ui.shotControls.hidden = !isPlayer();
ui.target.textContent = firePending ? 'Выстрел отправляется…' : available ? (selectedTarget ? `Цель: ${columns[selectedTarget.x]}${selectedTarget.y + 1}` : 'Выберите клетку на поле соперника.') : 'Ожидайте своего хода.'; ui.target.textContent = firePending ? 'Выстрел отправляется…' : available ? (selectedTarget ? `Цель: ${columns[selectedTarget.x]}${selectedTarget.y + 1}` : 'Выберите клетку на поле соперника.') : 'Ожидайте своего хода.';
ui.fire.disabled = !selectedTarget || !canFireTarget(selectedTarget); ui.fire.disabled = !selectedTarget || !canFireTarget(selectedTarget);
ui.fire.textContent = firePending ? 'Выстрел…' : 'Огонь'; ui.fire.querySelector('span:last-child').textContent = firePending ? 'Летит…' : 'Огонь!';
ui.cancel.disabled = !selectedTarget || firePending; ui.cancel.disabled = !selectedTarget || firePending;
ui.abort.hidden = !(role === 'player1' && state.mode === 'human' && state.phase === 'in_progress'); ui.abort.hidden = !(role === 'player1' && state.mode === 'human' && state.phase === 'in_progress');
} }
@@ -355,12 +596,13 @@
if (statisticsGameId !== state.gameId) refreshStatistics(); if (statisticsGameId !== state.gameId) refreshStatistics();
const waiting = state.phase === 'rematch_wait'; const waiting = state.phase === 'rematch_wait';
const winner = state.winner === ownIndex() && isPlayer() ? 'Вы победили. ' : state.winner === 0 || state.winner === 1 ? `Победил ${playerLabel(state.winner)}. ` : ''; const winner = state.winner === ownIndex() && isPlayer() ? 'Вы победили. ' : state.winner === 0 || state.winner === 1 ? `Победил ${playerLabel(state.winner)}. ` : '';
setSpriteIcon(ui.resultIcon, isPlayer() && state.winner === ownIndex() ? 'icon-trophy' : 'icon-repeat');
const matchSummary = state.statistics.map((entry, index) => `${playerLabel(index)}: выстрелы ${entry[0]}, попадания ${entry[1]}, промахи ${entry[2]}, потоплено ${entry[3]}.`).join(' '); 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(' ') || ''; 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.result.textContent = `${winner}${matchSummary} ${cumulative} ${waiting ? 'Ожидается подтверждение повторной игры.' : 'Поля раскрыты. Можно подтвердить повторную игру.'}`;
ui.rematch.hidden = !isPlayer(); ui.rematch.hidden = !isPlayer();
ui.rematch.disabled = !isPlayer(); ui.rematch.disabled = !isPlayer();
ui.rematch.textContent = waiting ? 'Подтвердить повторно' : 'Сыграть ещё'; ui.rematch.querySelector('span:last-child').textContent = waiting ? 'Я готов!' : 'Ещё раз!';
renderBoards(ui.resultBoards, true); renderBoards(ui.resultBoards, true);
} }
@@ -422,6 +664,8 @@
if (message.type === 'state') { if (message.type === 'state') {
acceptState(message); retryIndex = 0; stopPolling(); setConnection('Синхронизация онлайн', 'online'); acceptState(message); retryIndex = 0; stopPolling(); setConnection('Синхронизация онлайн', 'online');
if (!heartbeatTimer) heartbeatTimer = window.setInterval(() => { if (socket?.readyState === WebSocket.OPEN) socket.send('{"type":"ping"}'); }, 15000); if (!heartbeatTimer) heartbeatTimer = window.setInterval(() => { if (socket?.readyState === WebSocket.OPEN) socket.send('{"type":"ping"}'); }, 15000);
} else if (message.type === 'reset') {
clearLocalProfile(true);
} else if (message.ok === false) notify(apiError(message), true); } else if (message.ok === false) notify(apiError(message), true);
} catch (_) { socket.close(); } } catch (_) { socket.close(); }
}; };
@@ -436,9 +680,13 @@
el('join-form').addEventListener('submit', event => { el('join-form').addEventListener('submit', event => {
event.preventDefault(); event.preventDefault();
const requestedRole = info?.player1Available ? 'player1' : info?.player2Available ? 'player2' : 'spectator'; join('player');
join(requestedRole);
}); });
document.querySelectorAll('.avatar-button').forEach(button => button.addEventListener('click', () => {
ui.name.value = button.dataset.name;
document.querySelectorAll('.avatar-button').forEach(option => option.setAttribute('aria-pressed', String(option === button)));
el('join-player').focus();
}));
el('join-spectator').addEventListener('click', () => join('spectator')); 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 }))); 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.start.addEventListener('click', () => post('/api/game/start', { token, gameId: state.gameId }));
@@ -447,9 +695,35 @@
ui.abort.addEventListener('click', () => post('/api/game/abort', { token, gameId: state.gameId })); ui.abort.addEventListener('click', () => post('/api/game/abort', { token, gameId: state.gameId }));
ui.rematch.addEventListener('click', () => post('/api/game/rematch', { token, gameId: state.gameId })); ui.rematch.addEventListener('click', () => post('/api/game/rematch', { token, gameId: state.gameId }));
el('retry-button').addEventListener('click', () => { refreshInfo(); pollState(); connectSocket(); }); el('retry-button').addEventListener('click', () => { refreshInfo(); pollState(); connectSocket(); });
ui.recoveryButton.addEventListener('click', openRecovery);
ui.recoveryClose.addEventListener('click', closeRecovery);
ui.recoveryCancel.addEventListener('click', () => { recoveryAction = undefined; ui.recoveryConfirmation.hidden = true; ui.recoveryChoices.hidden = false; ui.recoveryClose.focus(); });
ui.recoveryChoices.addEventListener('click', event => {
const choice = event.target.closest('[data-recovery-action]');
if (choice && !choice.hidden) showRecoveryConfirmation(choice.dataset.recoveryAction);
});
ui.recoveryConfirm.addEventListener('click', event => { if (recoveryAction !== 'game' || event.detail === 0) submitRecovery(); });
ui.recoveryConfirm.addEventListener('pointerdown', event => {
if (recoveryAction !== 'game' || event.button !== 0 || recoveryPending) return;
ui.recoveryConfirm.classList.add('holding'); recoveryHoldTimer = window.setTimeout(submitRecovery, 2000);
});
['pointerup', 'pointercancel', 'pointerleave'].forEach(type => ui.recoveryConfirm.addEventListener(type, () => {
if (recoveryHoldTimer) { window.clearTimeout(recoveryHoldTimer); recoveryHoldTimer = undefined; ui.recoveryConfirm.classList.remove('holding'); ui.recoveryLive.textContent = 'Удерживание отменено.'; }
}));
document.addEventListener('keydown', event => {
if (ui.recoveryDialog.hidden) return;
if (event.key === 'Escape') { event.preventDefault(); closeRecovery(); }
else if (event.key === 'Tab') {
const items = [...ui.recoveryDialog.querySelectorAll('button:not([hidden]):not(:disabled)')];
const first = items[0]; const last = items[items.length - 1];
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
}
});
async function boot() { async function boot() {
ui.name.value = localStorage.getItem(storage.name) || ''; ui.name.value = localStorage.getItem(storage.name) || '';
document.querySelectorAll('.avatar-button').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.name === ui.name.value)));
setConnection('Проверяем ESP32…'); setConnection('Проверяем ESP32…');
await refreshInfo(); await refreshInfo();
if (!token) { showScreen('connect'); setConnection('Готово к подключению'); return; } if (!token) { showScreen('connect'); setConnection('Готово к подключению'); return; }
+29 -15
View File
@@ -8,58 +8,72 @@
<link rel="stylesheet" href="/styles.css"> <link rel="stylesheet" href="/styles.css">
</head> </head>
<body> <body>
<div id="game-effects" class="game-effects" aria-live="assertive" aria-atomic="true" hidden>
<div id="effect-burst" class="effect-burst" aria-hidden="true"></div>
<div class="effect-card"><small id="effect-badge" class="effect-badge" hidden></small><span id="effect-icon" class="effect-icon" aria-hidden="true"></span><strong id="effect-text"></strong></div>
</div>
<main class="app-shell"> <main class="app-shell">
<header class="app-header"> <header class="app-header">
<div><p class="eyebrow">ESP32-C6 · локальная игра</p><h1>Морской бой</h1></div> <div><p class="eyebrow">ESP32-C6 · локальная игра</p><h1><svg class="ui-icon title-icon" aria-hidden="true"><use href="/ship-sprite.svg#ship-2-destroyer"></use></svg> Морской бой</h1></div>
<p id="connection-status" class="connection-status" role="status">Подключение…</p> <div class="header-actions"><p id="connection-status" class="connection-status" role="status">Подключение…</p><button id="recovery-menu-button" type="button" class="recovery-menu-button" aria-haspopup="dialog" aria-expanded="false" hidden><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-lifebuoy"></use></svg><span>Помощь</span></button></div>
</header> </header>
<p id="notice" class="notice" aria-live="polite" hidden></p> <p id="notice" class="notice" aria-live="polite" hidden></p>
<section id="screen-connect" class="screen" aria-labelledby="connect-title"> <section id="screen-connect" class="screen" aria-labelledby="connect-title">
<div class="connection-layout"> <div class="connection-layout">
<div class="connection-form"> <div class="connection-form">
<h2 id="connect-title">Подключение к игре</h2> <h2 id="connect-title">Выбери героя</h2>
<p>Введите имя, чтобы занять место игрока или наблюдать за партией.</p> <p class="kid-hint"><span class="step-number" aria-hidden="true">1</span> Нажми на картинку</p>
<div id="avatar-picker" class="avatar-picker" role="group" aria-label="Выбор героя">
<button type="button" class="avatar-button" data-name="Капитан" aria-label="Капитан"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-captain"></use></svg><small>Капитан</small></button>
<button type="button" class="avatar-button" data-name="Дельфин" aria-label="Дельфин"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-dolphin"></use></svg><small>Дельфин</small></button>
<button type="button" class="avatar-button" data-name="Осьминог" aria-label="Осьминог"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-octopus"></use></svg><small>Осьминог</small></button>
<button type="button" class="avatar-button" data-name="Акула" aria-label="Акула"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-shark"></use></svg><small>Акула</small></button>
</div>
<form id="join-form" class="stack-form"> <form id="join-form" class="stack-form">
<label for="display-name">Ваше имя</label> <label for="display-name">Имя героя</label>
<input id="display-name" name="name" type="text" maxlength="80" autocomplete="name" required> <input id="display-name" name="name" type="text" maxlength="80" autocomplete="name" placeholder="Можно написать своё" required>
<div class="button-row"><button id="join-player" type="submit">Играть</button><button id="join-spectator" type="button" class="secondary">Наблюдать</button></div> <p class="kid-hint"><span class="step-number" aria-hidden="true">2</span> Нажми большую кнопку</p>
<div class="button-row"><button id="join-player" type="submit" class="icon-button primary-action"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-gamepad"></use></svg><span>Играть</span></button><button id="join-spectator" type="button" class="secondary icon-button"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-eye"></use></svg><span>Смотреть</span></button></div>
</form> </form>
</div> </div>
<aside class="connection-support" aria-labelledby="join-help-title"> <aside class="connection-support" aria-labelledby="join-help-title">
<h3 id="join-help-title">Как присоединиться</h3> <div class="support-illustration" aria-hidden="true"><svg class="ui-icon"><use href="/ship-sprite.svg#icon-wave"></use></svg><svg class="ui-icon ship-wide"><use href="/ship-sprite.svg#ship-2-destroyer"></use></svg><svg class="ui-icon"><use href="/ship-sprite.svg#icon-blast"></use></svg></div>
<ol class="join-steps"><li>Введите имя для этой партии.</li><li>Выберите место игрока или режим наблюдателя.</li><li>Все обновления появятся сразу после подключения.</li></ol> <h3 id="join-help-title">Как играть</h3>
<ol class="join-steps"><li><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-target"></use></svg> Выбери клетку.</li><li><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-blast"></use></svg> Нажми «Огонь».</li><li><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-trophy"></use></svg> Потопи все корабли.</li></ol>
<div class="availability-panel"><p class="eyebrow">Доступные места</p><p id="availability" class="muted" aria-live="polite">Проверяем доступные места…</p></div> <div class="availability-panel"><p class="eyebrow">Доступные места</p><p id="availability" class="muted" aria-live="polite">Проверяем доступные места…</p></div>
</aside> </aside>
</div> </div>
</section> </section>
<section id="screen-lobby" class="screen" aria-labelledby="lobby-title" hidden> <section id="screen-lobby" class="screen" aria-labelledby="lobby-title" hidden>
<h2 id="lobby-title">Лобби</h2><p id="lobby-description"></p> <div class="lobby-hero"><span id="lobby-icon" class="hero-icon" aria-hidden="true"><svg class="ui-icon"><use href="/ship-sprite.svg#icon-hourglass"></use></svg></span><div><h2 id="lobby-title">Готовимся к бою</h2><p id="lobby-description"></p></div></div>
<section id="mode-controls" class="lobby-mode-section" aria-labelledby="mode-title" hidden> <section id="mode-controls" class="lobby-mode-section" aria-labelledby="mode-title" hidden>
<h3 id="mode-title">Режим игры</h3> <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> <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><svg class="ui-icon mode-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-players"></use></svg><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><svg class="ui-icon mode-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-robot"></use></svg><span>С роботом</span></button></div>
<p id="mode-description" class="muted" aria-live="polite"></p> <p id="mode-description" class="muted" aria-live="polite"></p>
<button id="start-game" type="button" aria-describedby="mode-description">Начать игру</button> <button id="start-game" type="button" class="icon-button primary-action" aria-describedby="mode-description"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-rocket"></use></svg><span>Начать бой</span></button>
</section> </section>
<p id="lobby-help" class="muted"></p> <p id="lobby-help" class="muted"></p>
</section> </section>
<section id="screen-game" class="screen" aria-labelledby="game-title" hidden> <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 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="action-cue" class="action-cue" role="status"><span id="action-cue-icon" class="cue-icon" aria-hidden="true"><svg class="ui-icon"><use href="/ship-sprite.svg#icon-hourglass"></use></svg></span><strong id="action-cue-text">Ждём…</strong></div>
<div id="board-tabs" class="board-tabs" role="tablist" aria-label="Выбор поля"></div> <div id="board-tabs" class="board-tabs" role="tablist" aria-label="Выбор поля"></div>
<div id="shot-controls" class="shot-controls" hidden><p id="target-status" class="muted">Выберите клетку на поле соперника.</p><button id="fire-button" type="button" class="icon-button fire-button" disabled><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-blast"></use></svg><span>Огонь!</span></button><button id="cancel-target" type="button" class="secondary icon-button" disabled><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-repeat"></use></svg><span>Назад</span></button></div>
<div id="boards" class="boards"></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> <button id="abort-game" type="button" class="text-button abort-button" hidden>Отменить партию</button>
</section> </section>
<section id="screen-result" class="screen" aria-labelledby="result-title" hidden> <section id="screen-result" class="screen" aria-labelledby="result-title" hidden>
<h2 id="result-title">Партия завершена</h2><p id="result-description"></p> <div class="result-hero"><span id="result-icon" class="hero-icon" aria-hidden="true"><svg class="ui-icon"><use href="/ship-sprite.svg#icon-trophy"></use></svg></span><h2 id="result-title">Партия завершена</h2></div><p id="result-description"></p>
<div id="result-boards" class="boards result-boards"></div><button id="rematch-button" type="button">Сыграть ещё</button> <div id="result-boards" class="boards result-boards"></div><button id="rematch-button" type="button" class="icon-button primary-action"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-repeat"></use></svg><span>Ещё раз!</span></button>
</section> </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-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> <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> </main>
<div id="recovery-dialog" class="recovery-overlay" role="dialog" aria-modal="true" aria-labelledby="recovery-title" hidden><section class="recovery-dialog"><button id="recovery-close" class="text-button recovery-close" type="button" aria-label="Закрыть меню">×</button><h2 id="recovery-title">Помощь с игрой</h2><p id="recovery-description" class="muted">Выберите, что нужно сделать.</p><div id="recovery-choices" class="recovery-choices"><button type="button" data-recovery-action="leave"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-person"></use></svg><span><strong>Выйти из игры</strong><small>Имя останется на этом устройстве.</small></span></button><button type="button" data-recovery-action="profile"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-person"></use></svg><span><strong>Сбросить мой профиль</strong><small>Очистить имя и данные этого устройства.</small></span></button><button id="recovery-global-choice" type="button" data-recovery-action="game" class="recovery-global"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-players"></use></svg><span><strong>Сбросить всю игру</strong><small>Вернуть всех к началу.</small></span></button></div><div id="recovery-confirmation" class="recovery-confirmation" hidden><span id="recovery-scope-icon" class="recovery-scope-icon" aria-hidden="true"></span><h3 id="recovery-confirm-title"></h3><p id="recovery-confirm-text"></p><p id="recovery-hold-note" class="muted" hidden>Удерживайте кнопку 2 секунды.</p><div class="recovery-confirm-actions"><button id="recovery-cancel" type="button" class="secondary">Отмена</button><button id="recovery-confirm" type="button" class="recovery-confirm"><span id="recovery-confirm-label"></span><span id="recovery-hold-progress" class="recovery-hold-progress" aria-hidden="true"></span></button></div></div><p id="recovery-live" class="sr-only" aria-live="polite"></p></section></div>
<script src="/target_interaction.js" defer></script> <script src="/target_interaction.js" defer></script>
<script src="/app.js" defer></script> <script src="/app.js" defer></script>
</body> </body>
+22 -4
View File
@@ -1,6 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ship-1-cutter" viewBox="0 0 40 22"><path d="M3 15 9 6h13l7 9-5 3H8z"/><path d="M14 6V3h5v3M31 14h6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></symbol> <symbol id="ship-1-cutter" viewBox="0 0 48 26"><path d="M2 19 8 13h25l10 3-5 5H7zM18 13l3-6h7l3 6zM26 7l1-5h2l1 5zM32 13l2-5h3l2 5zM5 17h7l-1-3H8z"/></symbol>
<symbol id="ship-2-destroyer" viewBox="0 0 72 26"><path d="M3 18 13 11h39l14 5-7 5H11z"/><path d="M23 11V5h13l5 6M45 11V7h6v4M61 16h8" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linejoin="round" stroke-linecap="round"/></symbol> <symbol id="ship-2-destroyer" viewBox="0 0 96 30"><path d="M2 22 12 15h62l17 4-8 6H10zM20 15l4-7h14l5 7zM42 15l2-10h2l2 10zM50 15l2-9h6l3 9zM62 15l2-9h6l3 9zM6 19h9l-1-4H9zM79 19h9l-1-4h-5zM46 8l-4-6h2l5 6z"/></symbol>
<symbol id="ship-3-cruiser" viewBox="0 0 104 32"><path d="M3 22 15 13h63l19 7-9 7H13z"/><path d="M31 13V5h19l7 8M60 13V8h10v5M76 14h11M84 10v4" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linejoin="round" stroke-linecap="round"/></symbol> <symbol id="ship-3-cruiser" viewBox="0 0 144 40"><path d="M2 29 15 19h99l25 5-10 9H13zM27 19l5-10h28l7 10zM65 19l3-14h3l3 14zM78 19l3-12h9l4 12zM96 19l3-12h9l4 12zM13 23h11l-2-5h-6zM47 22h10l-1-5h-7zM112 23h11l-2-5h-7zM71 7l-6-6h3l7 6z"/></symbol>
<symbol id="ship-4-battleship" viewBox="0 0 136 38"><path d="M3 27 17 15h84l28 9-11 9H14z"/><path d="M30 15V5h25l8 10M68 15V8h16v7M91 16h15M100 11v5M15 22h13M111 20h14" fill="none" stroke="currentColor" stroke-width="3" stroke-linejoin="round" stroke-linecap="round"/></symbol> <symbol id="ship-4-battleship" viewBox="0 0 192 50"><path d="M2 36 18 23h135l34 7-13 11H14zM34 23l7-13h38l9 13zM84 23l4-17h5l4 17zM102 23l4-15h13l5 15zM125 23l4-15h13l5 15zM20 29h16l-2-8H24zM43 27h17l-2-8H48zM143 29h17l-2-8h-10zM163 30h18l-3-8h-11zM92 7l-8-7h4l9 7zM91 12h17v4H91z"/></symbol>
<symbol id="icon-captain" viewBox="0 0 64 64"><path d="M13 23h38l-4-9H36l-4-7-4 7H17zm6 5a13 13 0 1 0 26 0zm-7 29c2-12 9-18 20-18s18 6 20 18z"/></symbol>
<symbol id="icon-dolphin" viewBox="0 0 64 64"><path d="M5 38c12-18 27-25 45-19l9-7-2 13 5 7-12-2c-9 15-23 20-38 14l-8 8 2-13zm27-19-5-12 13 10zm15 10 4-2-4-2z"/></symbol>
<symbol id="icon-octopus" viewBox="0 0 64 64"><path d="M14 30a18 18 0 1 1 36 0v9c0 7-8 9-12 4-3 7-11 7-14 0-5 6-12 2-12-4zm11-2a3 3 0 1 0 0-6 3 3 0 0 0 0 6m14 0a3 3 0 1 0 0-6 3 3 0 0 0 0 6"/></symbol>
<symbol id="icon-shark" viewBox="0 0 64 64"><path d="M4 34c13-16 29-20 46-11L60 14l-2 15 4 10-13-5C34 47 19 48 4 39l7-3zm30-11-2-13 11 10zm14 8 4-2-4-2z"/></symbol>
<symbol id="icon-gamepad" viewBox="0 0 64 64"><path d="M16 19h32c9 0 14 23 8 29-4 4-11-5-15-9H23c-4 4-11 13-15 9-6-6-1-29 8-29m1 8v5h-5v5h5v5h5v-5h5v-5h-5v-5zm25 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8m9-6a4 4 0 1 0 0 8 4 4 0 0 0 0-8"/></symbol>
<symbol id="icon-eye" viewBox="0 0 64 64"><path d="M3 32C14 14 24 11 32 11s18 3 29 21C50 50 40 53 32 53S14 50 3 32m19 0a10 10 0 1 0 20 0 10 10 0 0 0-20 0m6 0a4 4 0 1 0 8 0 4 4 0 0 0-8 0"/></symbol>
<symbol id="icon-target" viewBox="0 0 64 64"><path d="M29 3h6v9a20 20 0 0 1 17 17h9v6h-9a20 20 0 0 1-17 17v9h-6v-9a20 20 0 0 1-17-17H3v-6h9a20 20 0 0 1 17-17zm3 15a14 14 0 1 0 0 28 14 14 0 0 0 0-28m0 8a6 6 0 1 0 0 12 6 6 0 0 0 0-12"/></symbol>
<symbol id="icon-blast" viewBox="0 0 64 64"><path d="m32 2 6 17 15-9-7 16 17 4-17 6 10 15-18-7-5 18-6-18-16 9 8-17-18-4 18-6-9-15 17 8z"/></symbol>
<symbol id="icon-hourglass" viewBox="0 0 64 64"><path d="M13 5h38v7c0 10-6 17-13 20 7 3 13 10 13 20v7H13v-7c0-10 6-17 13-20-7-3-13-10-13-20zm8 7c0 8 5 13 11 16 6-3 11-8 11-16zm0 40h22c0-8-5-13-11-16-6 3-11 8-11 16"/></symbol>
<symbol id="icon-check" viewBox="0 0 64 64"><path d="M32 3a29 29 0 1 0 0 58 29 29 0 0 0 0-58m-5 43L13 32l6-6 8 8 18-18 6 6z"/></symbol>
<symbol id="icon-robot" viewBox="0 0 64 64"><path d="M29 4h6v8h12a9 9 0 0 1 9 9v28a9 9 0 0 1-9 9H17a9 9 0 0 1-9-9V21a9 9 0 0 1 9-9h12zm-9 19a5 5 0 1 0 0 10 5 5 0 0 0 0-10m24 0a5 5 0 1 0 0 10 5 5 0 0 0 0-10M19 42v6h26v-6z"/></symbol>
<symbol id="icon-players" viewBox="0 0 64 64"><path d="M20 7a10 10 0 1 0 0 20 10 10 0 0 0 0-20m24 0a10 10 0 1 0 0 20 10 10 0 0 0 0-20M3 56c1-17 8-25 17-25 6 0 10 3 12 8 2-5 6-8 12-8 9 0 16 8 17 25z"/></symbol>
<symbol id="icon-rocket" viewBox="0 0 64 64"><path d="M42 4c10-3 17-1 18 0 1 1 3 8 0 18L39 43l-18-18zM16 30 7 33 2 47l17-8zm18 18-3 9 14 5 3-17zM18 44l-9 9 2 2 2 2 2 2 9-9zm29-29a6 6 0 1 0 0 12 6 6 0 0 0 0-12"/></symbol>
<symbol id="icon-trophy" viewBox="0 0 64 64"><path d="M15 5h34v8h10v11c0 9-7 16-17 16-2 3-5 5-7 6v7h12v7H17v-7h12v-7c-2-1-5-3-7-6C12 40 5 33 5 24V13h10zm34 14v13c3-1 5-4 5-8v-5zM10 19v5c0 4 2 7 5 8V19z"/></symbol>
<symbol id="icon-repeat" viewBox="0 0 64 64"><path d="M9 18h34l-7-7 7-7 19 18-19 18-7-7 7-7H9zm46 28H21l7 7-7 7L2 42l19-18 7 7-7 7h34z"/></symbol>
<symbol id="icon-wave" viewBox="0 0 64 64"><path d="M2 25c8-8 15-8 23 0s15 8 23 0 13-7 14-6v11c-5-3-8-1-14 5-8 8-15 8-23 0s-15-8-23 0zm0 19c8-8 15-8 23 0s15 8 23 0 13-7 14-6v11c-5-3-8-1-14 5-8 8-15 8-23 0s-15-8-23 0z"/></symbol>
<symbol id="icon-lifebuoy" viewBox="0 0 64 64"><path d="M32 3a29 29 0 1 0 0 58 29 29 0 0 0 0-58m0 10a19 19 0 1 1 0 38 19 19 0 0 1 0-38m-5 10v6h-6v6h6v6h10v-6h6v-6h-6v-6z"/></symbol>
<symbol id="icon-person" viewBox="0 0 64 64"><path d="M32 5a13 13 0 1 0 0 26 13 13 0 0 0 0-26M9 59c2-16 11-24 23-24s21 8 23 24z"/></symbol>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

+37 -1
View File
@@ -1,5 +1,6 @@
:root { color-scheme: dark; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #061827; color: #f1f7ff; } :root { color-scheme: dark; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #061827; color: #f1f7ff; }
* { box-sizing: border-box; } * { box-sizing: border-box; }
[hidden] { display: none !important; }
body { margin: 0; min-width: 20rem; background: radial-gradient(circle at top, #104c75, #061827 45rem); } body { margin: 0; min-width: 20rem; background: radial-gradient(circle at top, #104c75, #061827 45rem); }
button, input { font: inherit; } 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 { min-height: 2.75rem; padding: .55rem .9rem; border: 0; border-radius: .65rem; background: #3ec6f0; color: #032035; font-weight: 750; cursor: pointer; }
@@ -23,7 +24,7 @@ h1, h2, h3, p { margin-top: 0; } h1 { margin-bottom: .2rem; font-size: clamp(1.7
.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; } .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; } .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; } .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; }
.fleet-status { --fleet-unit: 1.3rem; display: grid; gap: .3rem; margin-top: .7rem; padding-top: .6rem; border-top: 1px solid rgb(80 128 156 / 55%); } .fleet-row { display: grid; grid-template-columns: 4.65rem minmax(0, 1fr); align-items: center; gap: .45rem; min-height: 1.65rem; } .fleet-class { color: #b8d3e6; font-size: .72rem; font-weight: 700; } .fleet-ships { display: flex; flex-wrap: wrap; align-items: center; gap: .3rem; min-width: 0; } .fleet-ship { position: relative; display: inline-grid; place-items: center; height: 1.55rem; color: #d9edf7; } .fleet-ship svg { display: block; width: 100%; max-height: 100%; fill: currentColor; } .fleet-ship.cutter { width: var(--fleet-unit); } .fleet-ship.destroyer { width: calc(var(--fleet-unit) * 2); } .fleet-ship.cruiser { width: calc(var(--fleet-unit) * 3); } .fleet-ship.battleship { width: calc(var(--fleet-unit) * 4); height: 1.7rem; } .fleet-ship.sunk { color: #9bb0bd; opacity: .48; } .fleet-ship.sunk::before, .fleet-ship.sunk::after { position: absolute; z-index: 1; width: 112%; height: 0; border-top: .16rem solid #ff5f55; border-radius: 99rem; content: ""; opacity: 1; pointer-events: none; } .fleet-ship.sunk::before { transform: rotate(31deg); } .fleet-ship.sunk::after { transform: rotate(-31deg); } .fleet-status { --fleet-unit: 1.45rem; display: grid; gap: .34rem; margin-top: .7rem; padding-top: .6rem; border-top: 1px solid rgb(80 128 156 / 55%); } .fleet-row { display: grid; grid-template-columns: 4.65rem minmax(0, 1fr); align-items: end; gap: .5rem; min-height: 1.8rem; } .fleet-class { align-self: center; color: #b8d3e6; font-size: .72rem; font-weight: 700; } .fleet-ships { display: flex; flex-wrap: nowrap; align-items: end; gap: .42rem; min-width: 0; } .fleet-ship { position: relative; display: inline-grid; place-items: end center; height: 1.7rem; color: #d9edf7; } .fleet-ship svg { display: block; width: 100%; max-height: 100%; fill: currentColor; } .fleet-ship.cutter { width: var(--fleet-unit); height: 1.2rem; } .fleet-ship.destroyer { width: calc(var(--fleet-unit) * 2); height: 1.4rem; } .fleet-ship.cruiser { width: calc(var(--fleet-unit) * 3); height: 1.6rem; } .fleet-ship.battleship { width: calc(var(--fleet-unit) * 4); height: 1.8rem; } .fleet-ship.sunk { color: #9bb0bd; } .fleet-ship.sunk svg { opacity: .48; } .fleet-ship.sunk::before, .fleet-ship.sunk::after { position: absolute; z-index: 1; width: 108%; height: 0; border-top: .16rem solid #ff5f55; border-radius: 99rem; content: ""; opacity: .96; pointer-events: none; } .fleet-ship.sunk::before { transform: rotate(31deg); } .fleet-ship.sunk::after { transform: rotate(-31deg); }
.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; } .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: 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 (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; } }
@@ -31,3 +32,38 @@ h1, h2, h3, p { margin-top: 0; } h1 { margin-bottom: .2rem; font-size: clamp(1.7
#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; } #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 (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; } } @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; } }
.ui-icon { display: inline-block; width: 1.6em; height: 1.6em; flex: 0 0 auto; fill: currentColor; vertical-align: -.32em; } .title-icon { width: 1.25em; } .icon-button { display: inline-flex; align-items: center; justify-content: center; gap: .55rem; } .icon-button > .ui-icon { width: 1.55rem; height: 1.55rem; }
.header-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: end; gap: .5rem; } .recovery-menu-button { display: inline-flex; align-items: center; justify-content: center; gap: .35rem; min-width: 2.75rem; min-height: 2.75rem; padding: .45rem .65rem; background: #31536e; color: #f1f7ff; } .recovery-menu-button .ui-icon { width: 1.25rem; height: 1.25rem; }
.recovery-overlay { position: fixed; z-index: 120; inset: 0; display: grid; place-items: center; padding: max(1rem, env(safe-area-inset-top)) max(1rem, env(safe-area-inset-right)) max(1rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left)); background: rgb(2 13 23 / 76%); } .recovery-overlay[hidden] { display: none; } .recovery-dialog { position: relative; width: min(100%, 31rem); max-height: min(42rem, 100svh - 2rem); overflow: auto; padding: 1.25rem; border: 1px solid #6096b5; border-radius: 1rem; background: #08273e; box-shadow: 0 1.5rem 4rem #000; } .recovery-dialog h2 { margin-right: 2rem; } .recovery-close { position: absolute; top: .55rem; right: .75rem; color: #d9edf7 !important; font-size: 1.8rem; text-decoration: none !important; } .recovery-choices { display: grid; gap: .65rem; } .recovery-choices button { display: grid; grid-template-columns: 2.4rem minmax(0, 1fr); align-items: center; gap: .65rem; min-height: 4.35rem; border: 1px solid #4d7f9d; background: #123b58; color: #f1f7ff; text-align: left; } .recovery-choices button .ui-icon { width: 2rem; height: 2rem; color: #b9ecff; } .recovery-choices small { display: block; margin-top: .12rem; color: #bfd9e8; font-weight: 500; } .recovery-global { margin-top: .5rem; border-style: dashed !important; background: #293a49 !important; } .recovery-global .ui-icon { color: #ffd48b !important; } .recovery-confirmation { display: grid; gap: .75rem; } .recovery-scope-icon .ui-icon { width: 2.75rem; height: 2.75rem; color: #b9ecff; } .recovery-confirm-actions { display: grid; grid-template-columns: 1fr 1fr; gap: .65rem; } .recovery-confirm { position: relative; overflow: hidden; background: #c75c4e; color: #fff; } .recovery-hold-progress { position: absolute; inset: auto 0 0; height: .28rem; width: 0; background: #ffe56a; } .recovery-confirm.holding .recovery-hold-progress { width: 100%; transition: width 2s linear; } .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.kid-hint { display: flex; align-items: center; gap: .5rem; margin: .8rem 0 .55rem; color: #dff6ff; font-size: 1.05rem; font-weight: 800; } .step-number { display: inline-grid; place-items: center; width: 1.7rem; height: 1.7rem; border-radius: 50%; background: #ffe56a; color: #08233d; font-size: 1rem; }
.avatar-picker { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: .55rem; margin-bottom: .85rem; }
.avatar-button { display: grid; place-items: center; gap: .25rem; min-height: 5.4rem; padding: .45rem; border: 2px solid #41708d; background: #123b58; color: #f1f7ff; }
.avatar-button > .ui-icon { width: clamp(2.4rem, 9vw, 3.4rem); height: clamp(2.4rem, 9vw, 3.4rem); color: #c8e9f8; } .avatar-button:nth-child(2) .ui-icon { color: #65e3ff; } .avatar-button:nth-child(3) .ui-icon { color: #d5a2ff; } .avatar-button:nth-child(4) .ui-icon { color: #a7d8e8; } .avatar-button small { max-width: 100%; overflow: hidden; font-size: .72rem; text-overflow: ellipsis; }
.avatar-button[aria-pressed="true"] { border-color: #ffe56a; background: #176285; box-shadow: 0 0 0 .2rem rgb(255 229 106 / 25%); transform: translateY(-.12rem); }
.support-illustration { display: flex; align-items: end; justify-content: center; gap: .8rem; margin-bottom: .8rem; color: #61d9ff; } .support-illustration .ui-icon { width: 3rem; height: 3rem; } .support-illustration .ship-wide { width: 6rem; color: #e0f3ff; }
.join-steps { list-style: none; padding-left: 0; } .join-steps li { display: flex; align-items: center; gap: .55rem; font-weight: 700; } .join-steps .ui-icon { width: 1.5rem; height: 1.5rem; color: #ffe56a; }
.primary-action:not(:disabled) { animation: action-pulse 1.35s ease-in-out infinite; box-shadow: 0 .35rem 1.25rem rgb(62 198 240 / 28%); }
.lobby-hero, .result-hero { display: flex; align-items: center; gap: .85rem; } .hero-icon .ui-icon { width: clamp(2.8rem, 10vw, 4.5rem); height: clamp(2.8rem, 10vw, 4.5rem); color: #ffe56a; } .lobby-hero h2, .result-hero h2 { margin-bottom: .2rem; }
.mode-button { grid-template-columns: 1.2rem 2.4rem minmax(0, 1fr); min-height: 4rem; } .mode-icon { width: 2rem; height: 2rem; color: #c8e9f8; }
.action-cue { display: flex; align-items: center; justify-content: center; gap: .65rem; width: min(100%, 34rem); min-height: 3.4rem; margin: .55rem auto; padding: .55rem .9rem; border: 2px solid #41708d; border-radius: .9rem; background: #123b58; font-size: clamp(1.05rem, 4vw, 1.35rem); letter-spacing: .03em; }
.cue-icon .ui-icon { width: 2rem; height: 2rem; } .action-cue.is-ready { border-color: #ffe56a; background: #145b4b; color: #fffbd7; animation: cue-pulse 1.1s ease-in-out infinite; } .action-cue.has-target { border-color: #ff9d3d; background: #6a3d14; }
.board.is-action { border-color: #ffe56a; box-shadow: 0 0 0 .15rem rgb(255 229 106 / 25%), 0 .65rem 1.5rem rgb(0 0 0 / 12%) !important; }
.board.is-action .cell.target:nth-child(3n) { animation: target-ripple 1.8s ease-in-out infinite; }
.fire-button:not(:disabled) { background: #ff9d3d; color: #2d1600; animation: fire-pulse .85s ease-in-out infinite; }
.shot-controls { padding: .65rem; border: 1px solid #315a75; border-radius: .8rem; background: rgb(7 31 50 / 94%); }
.game-effects { position: fixed; z-index: 100; inset: 0; display: grid; place-items: center; overflow: hidden; pointer-events: none; }
.game-effects[hidden] { display: none; } .effect-card { position: relative; z-index: 2; display: grid; place-items: center; min-width: min(84vw, 20rem); padding: 1.25rem; border: .2rem solid #fff; border-radius: 1.4rem; background: rgb(5 25 42 / 92%); color: #fff; font-size: clamp(1.35rem, 7vw, 2.25rem); text-align: center; box-shadow: 0 1.5rem 5rem #000; animation: reward-pop .9s cubic-bezier(.2, 1.5, .5, 1) both; }
.effect-badge { margin-bottom: .35rem; padding: .3rem .75rem; border-radius: 99rem; background: #ffe56a; color: #08233d; font-size: clamp(.85rem, 4vw, 1.15rem); font-weight: 950; letter-spacing: .08em; transform: rotate(-3deg); animation: badge-slam .5s cubic-bezier(.15, 1.7, .35, 1) both; } .effect-badge[hidden] { display: none; }
.effect-icon .ui-icon { width: clamp(5rem, 24vw, 9rem); height: clamp(5rem, 24vw, 9rem); color: #ffe56a; filter: drop-shadow(0 .3rem .5rem rgb(0 0 0 / 35%)); }
.game-effects.effect-hit { background: rgb(255 174 0 / 20%); } .game-effects.effect-damage { background: rgb(255 20 20 / 38%); animation: screen-danger .55s ease-in-out 2; } .game-effects.effect-miss { background: rgb(39 186 255 / 17%); } .game-effects.effect-victory { background: rgb(255 220 44 / 20%); }
.effect-burst span { position: absolute; left: 50%; top: 50%; font-size: clamp(1.4rem, 6vw, 2.8rem); animation: burst-away 1.2s ease-out both; animation-delay: var(--delay); transform: rotate(var(--rotate)); }
.burst-bubbles span { color: #8eeaff; animation-name: bubble-away; } .burst-sparks span { color: #ff9d3d; animation-duration: .8s; } .burst-stars span { color: #ffe56a; }
.motion-bounce .effect-card { animation-name: reward-bounce; } .motion-spin .effect-card { animation-name: reward-spin; } .motion-swoop .effect-card { animation-name: reward-swoop; } .motion-zoom .effect-card { animation-name: reward-zoom; } .motion-combo .effect-card { animation: reward-combo 1s cubic-bezier(.15, 1.45, .35, 1) both; }
.combo-power { background: radial-gradient(circle, rgb(255 229 106 / 42%), rgb(255 68 0 / 20%) 45%, rgb(0 0 0 / 8%)); } .combo-power .effect-card { border-color: #ffe56a; box-shadow: 0 0 2rem #ff9d3d, 0 1.5rem 5rem #000; } .combo-power .effect-icon { animation: combo-icon .32s ease-in-out 3 alternate; }
body.fx-damage .app-shell { animation: ship-shake .5s ease-in-out 2; } body.fx-hit .app-shell { animation: screen-glow .75s ease-out; }
@keyframes action-pulse { 50% { filter: brightness(1.16); transform: scale(1.025); } } @keyframes cue-pulse { 50% { box-shadow: 0 0 0 .35rem rgb(255 229 106 / 18%); transform: scale(1.015); } } @keyframes target-ripple { 50% { filter: brightness(1.35); } } @keyframes fire-pulse { 50% { box-shadow: 0 0 0 .35rem rgb(255 157 61 / 25%); transform: scale(1.035); } }
@keyframes reward-pop { 0% { opacity: 0; transform: scale(.35) rotate(-8deg); } 45% { opacity: 1; transform: scale(1.12) rotate(3deg); } 100% { transform: scale(1) rotate(0); } } @keyframes reward-bounce { 0% { opacity: 0; transform: translateY(55vh) scale(.8); } 55% { opacity: 1; transform: translateY(-1.2rem) scale(1.06); } 75% { transform: translateY(.5rem) scale(.98); } 100% { transform: none; } } @keyframes reward-spin { 0% { opacity: 0; transform: scale(.2) rotate(-210deg); } 65% { opacity: 1; transform: scale(1.12) rotate(12deg); } 100% { transform: none; } } @keyframes reward-swoop { 0% { opacity: 0; transform: translateX(-110vw) rotate(-14deg); } 65% { opacity: 1; transform: translateX(1rem) rotate(2deg); } 100% { transform: none; } } @keyframes reward-zoom { 0% { opacity: 0; transform: scale(2.4); filter: blur(.5rem); } 65% { opacity: 1; transform: scale(.92); filter: blur(0); } 100% { transform: scale(1); } } @keyframes reward-combo { 0% { opacity: 0; transform: scale(.1) rotate(-18deg); } 35% { opacity: 1; transform: scale(1.28) rotate(7deg); } 58% { transform: scale(.9) rotate(-3deg); } 78% { transform: scale(1.08) rotate(1deg); } 100% { transform: none; } } @keyframes badge-slam { from { opacity: 0; transform: translateY(-4rem) scale(2) rotate(8deg); } } @keyframes combo-icon { to { transform: scale(1.18) rotate(8deg); filter: brightness(1.35); } } @keyframes screen-danger { 50% { background: rgb(255 20 20 / 58%); } } @keyframes ship-shake { 20%, 60% { transform: translateX(-.45rem); } 40%, 80% { transform: translateX(.45rem); } } @keyframes screen-glow { 50% { filter: brightness(1.28) saturate(1.25); } } @keyframes burst-away { to { opacity: 0; translate: var(--x) var(--y); rotate: var(--spin); scale: .7; } } @keyframes bubble-away { to { opacity: 0; translate: var(--x) calc(var(--y) - 22vh); scale: 1.7; } }
@media (max-width: 43.99rem) { .app-shell { padding-top: .75rem; } .app-header .eyebrow { display: none; } .app-header h1 { font-size: 1.65rem; } .avatar-picker { gap: .35rem; } .avatar-button { min-height: 4.8rem; padding-inline: .2rem; } .connection-support { display: none; } #screen-game .shot-controls { grid-template-columns: 1fr 1fr; position: sticky; z-index: 8; top: .25rem; margin-bottom: .6rem; box-shadow: 0 .5rem 1.4rem rgb(0 0 0 / 35%); } #screen-game .shot-controls p { grid-column: 1 / -1; text-align: center; } #screen-game #fire-button, #screen-game #cancel-target { min-height: 3.4rem; } }
@media (max-width: 43.99rem) { .header-actions { justify-content: space-between; width: 100%; } .recovery-menu-button span { font-size: .85rem; } .recovery-dialog { width: 100%; padding: 1rem; } .recovery-confirm-actions { grid-template-columns: 1fr; } }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .001ms !important; animation-iteration-count: 1 !important; } }
+44 -1
View File
@@ -43,7 +43,50 @@
}; };
} }
const api = { createTargetActivator }; function detectFeedback(previous, next, viewer) {
if (!previous || !next || previous.gameId !== next.gameId || next.version <= previous.version) return undefined;
const player = viewer === 'player1' ? 0 : viewer === 'player2' ? 1 : -1;
if (previous.phase !== next.phase && next.phase === 'finished') {
if (player < 0) return { type: 'finish' };
return { type: next.winner === player ? 'victory' : 'defeat' };
}
if (previous.phase !== 'in_progress' && next.phase === 'in_progress') return { type: 'start' };
if (!Array.isArray(previous.boards) || !Array.isArray(next.boards)) return undefined;
let best;
for (let board = 0; board < 2; board += 1) {
if (typeof previous.boards[board] !== 'string' || typeof next.boards[board] !== 'string') continue;
for (let cell = 0; cell < 100; cell += 1) {
const before = previous.boards[board][cell];
const after = next.boards[board][cell];
if (before === after) continue;
const perspective = player < 0 ? 'watch' : board === player ? 'damage' : 'attack';
if (after === '4') best = { type: perspective === 'damage' ? 'sunk-damage' : perspective === 'attack' ? 'sunk' : 'watch-sunk' };
else if (!best && after === '3') best = { type: perspective === 'damage' ? 'damage' : perspective === 'attack' ? 'hit' : 'watch-hit' };
else if (!best && after === '2') best = { type: perspective === 'attack' ? 'miss' : perspective === 'damage' ? 'dodged' : 'watch-miss' };
}
}
if (best) return best;
if (player >= 0 && previous.turn !== next.turn && next.turn === viewer) return { type: 'turn' };
return undefined;
}
function createReactionPicker(catalog, random = Math.random) {
const previousByType = new Map();
return type => {
const variants = catalog[type];
if (!Array.isArray(variants) || variants.length === 0) return undefined;
if (variants.length === 1) return variants[0];
const previous = previousByType.get(type);
const candidateCount = previous === undefined ? variants.length : variants.length - 1;
let index = Math.min(candidateCount - 1, Math.max(0, Math.floor(random() * candidateCount)));
if (previous !== undefined && index >= previous) index += 1;
previousByType.set(type, index);
return variants[index];
};
}
const api = { createTargetActivator, detectFeedback, createReactionPicker };
if (typeof module !== 'undefined' && module.exports) module.exports = api; if (typeof module !== 'undefined' && module.exports) module.exports = api;
else globalThis.BattleshipTargetInteraction = api; else globalThis.BattleshipTargetInteraction = api;
})(); })();
+20 -1
View File
@@ -38,7 +38,7 @@ Failure (maximum 160 encoded bytes):
``` ```
`code` is one of `MALFORMED_JSON`, `PAYLOAD_TOO_LARGE`, `INVALID_NAME`, `code` is one of `MALFORMED_JSON`, `PAYLOAD_TOO_LARGE`, `INVALID_NAME`,
`INVALID_ROLE`, `INVALID_MODE`, `INVALID_COORDINATE`, `UNAUTHORIZED`, `INVALID_ROLE`, `INVALID_MODE`, `INVALID_COORDINATE`, `UNAUTHORIZED`, `SESSION_INVALIDATED`,
`NO_PLAYER_SLOT`, `NO_SPECTATOR_SLOT`, `FORBIDDEN_ROLE`, `WRONG_PHASE`, `NO_PLAYER_SLOT`, `NO_SPECTATOR_SLOT`, `FORBIDDEN_ROLE`, `WRONG_PHASE`,
`NOT_YOUR_TURN`, `CELL_ALREADY_SHOT`, `STALE_GAME`, or `SERVER_BUSY`. `NOT_YOUR_TURN`, `CELL_ALREADY_SHOT`, `STALE_GAME`, or `SERVER_BUSY`.
`message` is Russian and at most 80 UTF-8 bytes. `message` is Russian and at most 80 UTF-8 bytes.
@@ -51,11 +51,14 @@ Failure (maximum 160 encoded bytes):
| `GET /api/health` | 128 B target | diagnostics without secrets, 320 B; includes reset reason | | `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/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/session/resume` | 96 B body | `{token}`; role and state metadata, 192 B |
| `POST /api/session/leave` | 80 B body | `{token,gameId}`; releases only that session |
| `POST /api/session/profile-reset` | 80 B body | `{token,gameId}`; server release for local profile reset |
| `POST /api/game/config` | 96 B body | `{token,gameId,mode}`; common envelope | | `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/start` | 80 B body | `{token,gameId}`; common envelope |
| `POST /api/game/shot` | 96 B body | `{token,gameId,x,y}`; 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/rematch` | 80 B body | `{token,gameId}`; common envelope |
| `POST /api/game/abort` | 80 B body | `{token,gameId}`; common envelope | | `POST /api/game/abort` | 80 B body | `{token,gameId}`; common envelope |
| `POST /api/game/reset` | 80 B body | `{token,gameId}`; player-only full RAM reset |
| `GET /api/state?version=N` | 128 B target | one role-safe state, 768 B | | `GET /api/state?version=N` | 128 B target | one role-safe state, 768 B |
| `GET /api/statistics` | 128 B target | role and bounded match/cumulative counters | | `GET /api/statistics` | 128 B target | role and bounded match/cumulative counters |
@@ -63,6 +66,22 @@ 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 in `X-Session-Token`; absence creates a spectator-safe view. It is never a URL
parameter. parameter.
For the primary “Play” action, `requestedRole: "player"` atomically assigns
the first available player slot (`player1`, then `player2`). This avoids a
client-side availability race; the response and every authorized state snapshot
contain the assigned concrete role.
`leave` and `profile-reset` release only the requesting session; profile data is
cleared by the browser in Milestone 022. A player leaving an active match aborts
that match and returns remaining valid players to the lobby. `game/reset` is
available only to an authenticated player and atomically invalidates every
session, clears match and cumulative statistics, and starts a fresh game
generation. Successful recovery responses contain `resetReason` (`session_left`,
`profile_reset`, or `game_reset`) and `generation`. Invalidated-token state
polling returns `SESSION_INVALIDATED` with the same bounded recovery metadata.
The recovery generation and `gameId` make delayed commands stale; retrying a
completed recovery is harmless and cannot mutate a newly registered session.
## Role-safe state event ## Role-safe state event
The HTTP state response and WebSocket `state` event use this single 768-byte The HTTP state response and WebSocket `state` event use this single 768-byte
+3
View File
@@ -274,10 +274,13 @@ struct Board {
| `GET` | `/api/info` | Состояние устройства и доступность мест без скрытых данных | | `GET` | `/api/info` | Состояние устройства и доступность мест без скрытых данных |
| `POST` | `/api/session/join` | Вход по имени и желаемой роли | | `POST` | `/api/session/join` | Вход по имени и желаемой роли |
| `POST` | `/api/session/resume` | Восстановление роли по токену | | `POST` | `/api/session/resume` | Восстановление роли по токену |
| `POST` | `/api/session/leave` | Освобождение только текущей сессии |
| `POST` | `/api/session/profile-reset` | Освобождение сессии перед локальной очисткой профиля |
| `POST` | `/api/game/config` | Выбор режима игроком 1 | | `POST` | `/api/game/config` | Выбор режима игроком 1 |
| `POST` | `/api/game/start` | Запуск готовой партии | | `POST` | `/api/game/start` | Запуск готовой партии |
| `POST` | `/api/game/shot` | Выстрел по координатам | | `POST` | `/api/game/shot` | Выстрел по координатам |
| `POST` | `/api/game/rematch` | Подтверждение повторной игры | | `POST` | `/api/game/rematch` | Подтверждение повторной игры |
| `POST` | `/api/game/reset` | Аварийный полный сброс игровой памяти игроком |
| `GET` | `/api/state?version=N` | Снимок разрешённого состояния и резервный опрос | | `GET` | `/api/state?version=N` | Снимок разрешённого состояния и резервный опрос |
| `GET` | `/api/health` | Проверка доступности сервера | | `GET` | `/api/health` | Проверка доступности сервера |
+40
View File
@@ -0,0 +1,40 @@
# Если игра застряла
Кнопка с спасательным кругом «Помощь» доступна после подключения к игре. Она
не стреляет и не меняет поле сама по себе: сначала выберите действие, затем
подтвердите его.
## Выйти из игры
Выберите «Выйти из игры», чтобы освободить своё место. Имя на этом устройстве
останется, поэтому можно быстро подключиться снова. Если игрок выходит во
время партии, партия безопасно прекращается и оставшийся игрок возвращается в
лобби.
## Сбросить мой профиль
Этот пункт удаляет только данные текущего браузера: имя, выбранного героя и
токен. Данные других игроков не меняются. Без связи профиль всё равно можно
очистить локально; место на ESP32 освободится после восстановления связи или
обычного таймаута.
## Сбросить всю игру
Этот пункт виден только игроку. Он возвращает всех пользователей к первому
экрану и удаляет текущую партию и статистику текущего запуска. Для защиты от
случайного нажатия подтверждение нужно удерживать около двух секунд.
Старые вкладки и токены после этого не могут изменить новую партию.
## Что не удаляется
Эти действия не стирают Wi‑Fi, пароль сети, прошивку, файлы игры, flash или
настройки платы. Перезагрузка ESP32 не нужна.
## Проверка на устройстве
Проверьте с двумя игроками и зрителем выход, локальный профиль, полный сброс
из лобби/игры/результата, WebSocket и HTTP fallback. После 50 смешанных
циклов сравните `/api/health`: свободная куча, largest free block, число
клиентов и причина перезагрузки не должны показывать утечку, зависание или
перезагрузку.
+44 -1
View File
@@ -73,6 +73,24 @@ paths:
'400': { $ref: '#/components/responses/BadRequest' } '400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' } '401': { $ref: '#/components/responses/Unauthorized' }
'413': { $ref: '#/components/responses/PayloadTooLarge' } '413': { $ref: '#/components/responses/PayloadTooLarge' }
/api/session/leave:
post:
summary: Release only the authenticated session
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/GameRequest' } } }
responses: &recoveryResponses
'200': { $ref: '#/components/responses/RecoveryAccepted' }
'400': { $ref: '#/components/responses/BadRequest' }
'409': { $ref: '#/components/responses/Conflict' }
'413': { $ref: '#/components/responses/PayloadTooLarge' }
/api/session/profile-reset:
post:
summary: Release the authenticated session for a browser-local profile reset
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/GameRequest' } } }
responses: *recoveryResponses
/api/game/config: /api/game/config:
post: post:
summary: Set the game mode (Player 1, lobby only) summary: Set the game mode (Player 1, lobby only)
@@ -131,6 +149,18 @@ paths:
schema: schema:
$ref: '#/components/schemas/GameRequest' $ref: '#/components/schemas/GameRequest'
responses: *commandResponses responses: *commandResponses
/api/game/reset:
post:
summary: Player-only full in-memory game reset
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/GameRequest' } } }
responses:
'200': { $ref: '#/components/responses/RecoveryAccepted' }
'400': { $ref: '#/components/responses/BadRequest' }
'403': { $ref: '#/components/responses/Forbidden' }
'409': { $ref: '#/components/responses/Conflict' }
'413': { $ref: '#/components/responses/PayloadTooLarge' }
/api/state: /api/state:
get: get:
summary: Get a complete role-safe state snapshot summary: Get a complete role-safe state snapshot
@@ -175,6 +205,11 @@ components:
schema: { $ref: '#/components/schemas/Token' } schema: { $ref: '#/components/schemas/Token' }
description: Omit only when a spectator-safe public view is intended. description: Omit only when a spectator-safe public view is intended.
responses: responses:
RecoveryAccepted:
description: Recovery completed or an idempotent retry observed the same completed recovery.
content:
application/json:
schema: { $ref: '#/components/schemas/RecoveryAccepted' }
CommandAccepted: CommandAccepted:
description: Command accepted by the authoritative application layer. description: Command accepted by the authoritative application layer.
content: content:
@@ -205,7 +240,8 @@ components:
description: Opaque, reboot-scoped session token. description: Opaque, reboot-scoped session token.
Role: Role:
type: string type: string
enum: [player1, player2, spectator] enum: [player1, player2, spectator, player]
description: '`player` atomically assigns the first available player slot; responses always contain player1 or player2.'
Mode: Mode:
type: string type: string
enum: [human, bot] enum: [human, bot]
@@ -269,6 +305,13 @@ components:
- type: object - type: object
required: [role] required: [role]
properties: { role: { $ref: '#/components/schemas/Role' } } properties: { role: { $ref: '#/components/schemas/Role' } }
RecoveryAccepted:
type: object
required: [ok, resetReason, generation]
properties:
ok: { type: boolean, enum: [true] }
resetReason: { type: string, enum: [session_left, profile_reset, game_reset] }
generation: { $ref: '#/components/schemas/GameId' }
Info: Info:
type: object type: object
required: [ok, phase, gameId, version, player1Available, player2Available, player1Name, player2Name, spectatorsAvailable] required: [ok, phase, gameId, version, player1Available, player2Available, player1Name, player2Name, spectatorsAvailable]
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="ru"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Морской бой — лист проверки силуэтов</title><style>body{margin:0;padding:24px;background:#061827;color:#f1f7ff;font:16px system-ui,sans-serif}.sheet{max-width:900px;margin:auto}.panel{margin:18px 0;padding:18px;border:1px solid #41708d;border-radius:12px;background:#071f32}.ships{display:flex;align-items:end;gap:18px;flex-wrap:wrap}.ship{position:relative;display:inline-grid;place-items:end center;color:#d9edf7}.ship svg{display:block;width:100%;height:100%;fill:currentColor}.cutter{width:46px;height:25px}.destroyer{width:92px;height:29px}.cruiser{width:138px;height:38px}.battleship{width:184px;height:48px}.sunk{color:#9bb0bd}.sunk svg{opacity:.48}.sunk::before,.sunk::after{position:absolute;width:108%;border-top:3px solid #ff5f55;border-radius:99px;content:""}.sunk::before{transform:rotate(31deg)}.sunk::after{transform:rotate(-31deg)}.fleet{width:min(100%,320px);display:grid;gap:7px}.row{display:grid;grid-template-columns:76px 1fr;align-items:end;gap:8px}.row .ships{gap:7px;flex-wrap:nowrap}.row .cutter{width:23px;height:12px}.row .destroyer{width:46px;height:22px}.row .cruiser{width:70px;height:26px}.row .battleship{width:93px;height:29px}</style><body><main class="sheet"><h1>Лист проверки силуэтов флота</h1><p>Production sprite: увеличенный вид, потопленные варианты и фактический компактный мобильный размер.</p><section class="panel"><h2>Классы</h2><div class="ships"><span class="ship cutter"><svg viewBox="0 0 48 26"><use href="../data/ship-sprite.svg#ship-1-cutter" width="100%" height="100%"/></svg></span><span class="ship destroyer sunk"><svg viewBox="0 0 96 30"><use href="../data/ship-sprite.svg#ship-2-destroyer" width="100%" height="100%"/></svg></span><span class="ship cruiser"><svg viewBox="0 0 144 40"><use href="../data/ship-sprite.svg#ship-3-cruiser" width="100%" height="100%"/></svg></span><span class="ship battleship sunk"><svg viewBox="0 0 192 50"><use href="../data/ship-sprite.svg#ship-4-battleship" width="100%" height="100%"/></svg></span></div></section><section class="panel"><h2>Полный флот — 320 px</h2><div class="fleet"><div class="row"><b>Линкор</b><div class="ships"><span class="ship battleship"><svg viewBox="0 0 192 50"><use href="../data/ship-sprite.svg#ship-4-battleship" width="100%" height="100%"/></svg></span></div></div><div class="row"><b>Крейсер</b><div class="ships"><span class="ship cruiser"><svg viewBox="0 0 144 40"><use href="../data/ship-sprite.svg#ship-3-cruiser" width="100%" height="100%"/></svg></span><span class="ship cruiser sunk"><svg viewBox="0 0 144 40"><use href="../data/ship-sprite.svg#ship-3-cruiser" width="100%" height="100%"/></svg></span></div></div><div class="row"><b>Эсминец</b><div class="ships"><span class="ship destroyer"><svg viewBox="0 0 96 30"><use href="../data/ship-sprite.svg#ship-2-destroyer" width="100%" height="100%"/></svg></span><span class="ship destroyer"><svg viewBox="0 0 96 30"><use href="../data/ship-sprite.svg#ship-2-destroyer" width="100%" height="100%"/></svg></span><span class="ship destroyer sunk"><svg viewBox="0 0 96 30"><use href="../data/ship-sprite.svg#ship-2-destroyer" width="100%" height="100%"/></svg></span></div></div><div class="row"><b>Катер</b><div class="ships"><span class="ship cutter"><svg viewBox="0 0 48 26"><use href="../data/ship-sprite.svg#ship-1-cutter" width="100%" height="100%"/></svg></span><span class="ship cutter"><svg viewBox="0 0 48 26"><use href="../data/ship-sprite.svg#ship-1-cutter" width="100%" height="100%"/></svg></span><span class="ship cutter sunk"><svg viewBox="0 0 48 26"><use href="../data/ship-sprite.svg#ship-1-cutter" width="100%" height="100%"/></svg></span><span class="ship cutter"><svg viewBox="0 0 48 26"><use href="../data/ship-sprite.svg#ship-1-cutter" width="100%" height="100%"/></svg></span></div></div></div></section></main></body></html>
+2
View File
@@ -18,5 +18,7 @@ lifecycle_result_t application_resume(application_t *application,
const uint8_t token[kSessionTokenBytes], uint8_t *session_index); const uint8_t token[kSessionTokenBytes], uint8_t *session_index);
bool application_session_for_token(const application_t *application, bool application_session_for_token(const application_t *application,
const uint8_t token[kSessionTokenBytes], uint8_t *session_index); const uint8_t token[kSessionTokenBytes], uint8_t *session_index);
lifecycle_result_t application_leave(application_t *application, uint8_t session_index, uint32_t game_id,
recovery_reason_t reason);
lifecycle_result_t application_submit(application_t *application, const app_command_t *command); lifecycle_result_t application_submit(application_t *application, const app_command_t *command);
bool application_bot_take_turn(application_t *application); bool application_bot_take_turn(application_t *application);
+1 -1
View File
@@ -7,7 +7,7 @@
#include "game_types.h" #include "game_types.h"
typedef uint8_t command_type_t; typedef uint8_t command_type_t;
enum { COMMAND_CONFIG, COMMAND_START, COMMAND_SHOT, COMMAND_REMATCH, COMMAND_ABORT }; enum { COMMAND_CONFIG, COMMAND_START, COMMAND_SHOT, COMMAND_REMATCH, COMMAND_ABORT, COMMAND_LEAVE, COMMAND_PROFILE_RESET, COMMAND_RESET };
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 { 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; typedef struct { app_command_t entries[kCommandQueueCapacity]; uint8_t head; uint8_t tail; uint8_t count; } command_queue_t;
+7
View File
@@ -24,6 +24,9 @@ enum {
LIFECYCLE_RESULT_GENERATION_FAILED, LIFECYCLE_RESULT_GENERATION_FAILED,
}; };
typedef uint8_t recovery_reason_t;
enum { RECOVERY_REASON_NONE, RECOVERY_REASON_SESSION_LEFT, RECOVERY_REASON_PROFILE_RESET, RECOVERY_REASON_GAME_RESET };
typedef struct { typedef struct {
game_engine_t game; game_engine_t game;
session_manager_t sessions; session_manager_t sessions;
@@ -34,6 +37,8 @@ typedef struct {
bool bot_reserved; bool bot_reserved;
bool rematch_confirmed[kPlayerCapacity]; bool rematch_confirmed[kPlayerCapacity];
uint32_t next_game_id; uint32_t next_game_id;
uint32_t recovery_generation;
recovery_reason_t recovery_reason;
} game_lifecycle_t; } game_lifecycle_t;
void game_lifecycle_init(game_lifecycle_t *lifecycle, random_source_t random); void game_lifecycle_init(game_lifecycle_t *lifecycle, random_source_t random);
@@ -44,6 +49,8 @@ lifecycle_result_t game_lifecycle_resume(game_lifecycle_t *lifecycle,
const uint8_t token[kSessionTokenBytes], uint8_t *session_index); 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_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_leave(game_lifecycle_t *lifecycle, uint8_t session_index);
lifecycle_result_t game_lifecycle_reset(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id);
void game_lifecycle_set_recovery_reason(game_lifecycle_t *lifecycle, recovery_reason_t reason);
lifecycle_result_t game_lifecycle_configure(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); 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_start(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id);
+1 -1
View File
@@ -10,7 +10,7 @@ enum { CELL_UNKNOWN, CELL_WATER, CELL_SHIP, CELL_MISS, CELL_HIT };
typedef uint8_t phase_t; typedef uint8_t phase_t;
enum { PHASE_LOBBY, PHASE_PREPARING, PHASE_IN_PROGRESS, PHASE_FINISHED, PHASE_REMATCH_WAIT }; enum { PHASE_LOBBY, PHASE_PREPARING, PHASE_IN_PROGRESS, PHASE_FINISHED, PHASE_REMATCH_WAIT };
typedef uint8_t role_t; typedef uint8_t role_t;
enum { ROLE_PLAYER_1, ROLE_PLAYER_2, ROLE_SPECTATOR }; enum { ROLE_PLAYER_1, ROLE_PLAYER_2, ROLE_SPECTATOR, ROLE_AUTO_PLAYER };
typedef uint8_t game_mode_t; typedef uint8_t game_mode_t;
enum { MODE_HUMAN, MODE_BOT }; enum { MODE_HUMAN, MODE_BOT };
+3
View File
@@ -13,11 +13,14 @@ typedef enum {
HTTP_API_ROUTE_HEALTH, HTTP_API_ROUTE_HEALTH,
HTTP_API_ROUTE_JOIN, HTTP_API_ROUTE_JOIN,
HTTP_API_ROUTE_RESUME, HTTP_API_ROUTE_RESUME,
HTTP_API_ROUTE_LEAVE,
HTTP_API_ROUTE_PROFILE_RESET,
HTTP_API_ROUTE_CONFIG, HTTP_API_ROUTE_CONFIG,
HTTP_API_ROUTE_START, HTTP_API_ROUTE_START,
HTTP_API_ROUTE_SHOT, HTTP_API_ROUTE_SHOT,
HTTP_API_ROUTE_REMATCH, HTTP_API_ROUTE_REMATCH,
HTTP_API_ROUTE_ABORT, HTTP_API_ROUTE_ABORT,
HTTP_API_ROUTE_RESET,
HTTP_API_ROUTE_STATE, HTTP_API_ROUTE_STATE,
HTTP_API_ROUTE_STATISTICS, HTTP_API_ROUTE_STATISTICS,
} http_api_route_t; } http_api_route_t;
+1 -1
View File
@@ -7,7 +7,7 @@ from pathlib import Path
ROOT = Path(env.subst("$PROJECT_DIR")) ROOT = Path(env.subst("$PROJECT_DIR"))
DATA = ROOT / "data" DATA = ROOT / "data"
ASSETS = ("index.html", "styles.css", "app.js", "ship-sprite.svg") ASSETS = ("index.html", "styles.css", "app.js", "target_interaction.js", "ship-sprite.svg")
def minify_html(source): def minify_html(source):
+18
View File
@@ -37,6 +37,14 @@ bool application_session_for_token(const application_t *application,
return application != NULL && session_manager_find(&application->lifecycle.sessions, token, session_index); return application != NULL && session_manager_find(&application->lifecycle.sessions, token, session_index);
} }
lifecycle_result_t application_leave(application_t *application, uint8_t session_index, uint32_t game_id,
recovery_reason_t reason) {
if (application == NULL || application->lifecycle.game.state.game_id != game_id) return LIFECYCLE_RESULT_STALE_GAME;
const lifecycle_result_t result = game_lifecycle_leave(&application->lifecycle, session_index);
if (result == LIFECYCLE_RESULT_OK) game_lifecycle_set_recovery_reason(&application->lifecycle, reason);
return result;
}
lifecycle_result_t application_submit(application_t *application, const app_command_t *command) { lifecycle_result_t application_submit(application_t *application, const app_command_t *command) {
if (!application_enqueue(application, command)) return LIFECYCLE_RESULT_GENERATION_FAILED; if (!application_enqueue(application, command)) return LIFECYCLE_RESULT_GENERATION_FAILED;
app_command_t next = {0}; app_command_t next = {0};
@@ -52,6 +60,16 @@ lifecycle_result_t application_submit(application_t *application, const app_comm
return game_lifecycle_rematch(&application->lifecycle, next.session_index, next.game_id); return game_lifecycle_rematch(&application->lifecycle, next.session_index, next.game_id);
case COMMAND_ABORT: case COMMAND_ABORT:
return game_lifecycle_abort(&application->lifecycle, next.session_index, next.game_id); return game_lifecycle_abort(&application->lifecycle, next.session_index, next.game_id);
case COMMAND_LEAVE:
return application_leave(application, next.session_index, next.game_id, RECOVERY_REASON_SESSION_LEFT);
case COMMAND_PROFILE_RESET:
return application_leave(application, next.session_index, next.game_id, RECOVERY_REASON_PROFILE_RESET);
case COMMAND_RESET:
{
const lifecycle_result_t result = game_lifecycle_reset(&application->lifecycle, next.session_index, next.game_id);
if (result == LIFECYCLE_RESULT_OK) application->command_queue = (command_queue_t){0};
return result;
}
default: default:
return LIFECYCLE_RESULT_GENERATION_FAILED; return LIFECYCLE_RESULT_GENERATION_FAILED;
} }
+51 -5
View File
@@ -68,11 +68,24 @@ static lifecycle_result_t start_current_game(game_lifecycle_t *lifecycle) {
return LIFECYCLE_RESULT_OK; return LIFECYCLE_RESULT_OK;
} }
static void return_to_lobby(game_lifecycle_t *lifecycle) {
const uint32_t version = lifecycle->game.state.version + 1U;
const uint32_t game_id = lifecycle->next_game_id++;
game_engine_init(&lifecycle->game);
lifecycle->game.state.game_id = game_id;
lifecycle->game.state.version = version;
lifecycle->bot_reserved = false;
lifecycle->rematch_confirmed[0] = false;
lifecycle->rematch_confirmed[1] = false;
bot_player_cancel_turn(&lifecycle->bot);
}
void game_lifecycle_init(game_lifecycle_t *lifecycle, random_source_t random) { void game_lifecycle_init(game_lifecycle_t *lifecycle, random_source_t random) {
if (lifecycle == NULL) return; if (lifecycle == NULL) return;
memset(lifecycle, 0, sizeof(*lifecycle)); memset(lifecycle, 0, sizeof(*lifecycle));
lifecycle->random = random; lifecycle->random = random;
lifecycle->next_game_id = 2U; lifecycle->next_game_id = 2U;
lifecycle->recovery_generation = 1U;
game_engine_init(&lifecycle->game); game_engine_init(&lifecycle->game);
lifecycle->game.state.game_id = 1U; lifecycle->game.state.game_id = 1U;
session_manager_init(&lifecycle->sessions); session_manager_init(&lifecycle->sessions);
@@ -86,16 +99,18 @@ void game_lifecycle_set_bot_scheduler(game_lifecycle_t *lifecycle, scheduler_t s
lifecycle_result_t game_lifecycle_join(game_lifecycle_t *lifecycle, role_t requested_role, const char *name, lifecycle_result_t game_lifecycle_join(game_lifecycle_t *lifecycle, role_t requested_role, const char *name,
uint8_t *session_index) { uint8_t *session_index) {
if (lifecycle == NULL) return LIFECYCLE_RESULT_UNAUTHORIZED; if (lifecycle == NULL) return LIFECYCLE_RESULT_UNAUTHORIZED;
if ((requested_role == ROLE_PLAYER_1 || requested_role == ROLE_PLAYER_2) && lifecycle->game.state.phase != PHASE_LOBBY) { if ((requested_role == ROLE_PLAYER_1 || requested_role == ROLE_PLAYER_2 || requested_role == ROLE_AUTO_PLAYER) && lifecycle->game.state.phase != PHASE_LOBBY) {
return LIFECYCLE_RESULT_NO_PLAYER_SLOT; return LIFECYCLE_RESULT_NO_PLAYER_SLOT;
} }
if (requested_role == ROLE_PLAYER_2 && !session_manager_player_present(&lifecycle->sessions, 0U)) { if (requested_role == ROLE_PLAYER_2 && !session_manager_player_present(&lifecycle->sessions, 0U)) {
return LIFECYCLE_RESULT_NO_PLAYER_SLOT; return LIFECYCLE_RESULT_NO_PLAYER_SLOT;
} }
if (requested_role == ROLE_PLAYER_2 && (lifecycle->game.state.mode == MODE_BOT || lifecycle->bot_reserved)) { if ((requested_role == ROLE_PLAYER_2 || requested_role == ROLE_AUTO_PLAYER) && (lifecycle->game.state.mode == MODE_BOT || lifecycle->bot_reserved)) {
return LIFECYCLE_RESULT_NO_PLAYER_SLOT; return LIFECYCLE_RESULT_NO_PLAYER_SLOT;
} }
return session_result(session_manager_join(&lifecycle->sessions, requested_role, name, lifecycle->random, session_index)); const lifecycle_result_t result = session_result(session_manager_join(&lifecycle->sessions, requested_role, name, lifecycle->random, session_index));
if (result == LIFECYCLE_RESULT_OK) ++lifecycle->game.state.version;
return result;
} }
lifecycle_result_t game_lifecycle_resume(game_lifecycle_t *lifecycle, lifecycle_result_t game_lifecycle_resume(game_lifecycle_t *lifecycle,
@@ -110,8 +125,39 @@ lifecycle_result_t game_lifecycle_disconnect(game_lifecycle_t *lifecycle, uint8_
} }
lifecycle_result_t game_lifecycle_leave(game_lifecycle_t *lifecycle, uint8_t session_index) { lifecycle_result_t game_lifecycle_leave(game_lifecycle_t *lifecycle, uint8_t session_index) {
return lifecycle == NULL ? LIFECYCLE_RESULT_UNAUTHORIZED : if (lifecycle == NULL || session_index >= kSessionCapacity || !lifecycle->sessions.entries[session_index].occupied) return LIFECYCLE_RESULT_UNAUTHORIZED;
session_result(session_manager_leave(&lifecycle->sessions, session_index, lifecycle->game.state.phase)); const bool player = session_index < kPlayerCapacity && lifecycle->sessions.entries[session_index].role == (role_t)session_index;
if (player && lifecycle->game.state.phase != PHASE_LOBBY) return_to_lobby(lifecycle);
else ++lifecycle->game.state.version;
memset(&lifecycle->sessions.entries[session_index], 0, sizeof(session_t));
++lifecycle->recovery_generation;
lifecycle->recovery_reason = RECOVERY_REASON_SESSION_LEFT;
return LIFECYCLE_RESULT_OK;
}
lifecycle_result_t game_lifecycle_reset(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;
const uint32_t version = lifecycle->game.state.version + 1U;
const uint32_t next_game_id = lifecycle->next_game_id++;
const random_source_t random = lifecycle->random;
const scheduler_t scheduler = lifecycle->bot_scheduler;
bot_player_cancel_turn(&lifecycle->bot);
memset(lifecycle, 0, sizeof(*lifecycle));
lifecycle->random = random;
lifecycle->bot_scheduler = scheduler;
lifecycle->next_game_id = next_game_id + 1U;
lifecycle->recovery_generation = next_game_id;
lifecycle->recovery_reason = RECOVERY_REASON_GAME_RESET;
game_engine_init(&lifecycle->game);
lifecycle->game.state.game_id = next_game_id;
lifecycle->game.state.version = version;
return LIFECYCLE_RESULT_OK;
}
void game_lifecycle_set_recovery_reason(game_lifecycle_t *lifecycle, recovery_reason_t reason) {
if (lifecycle != NULL) lifecycle->recovery_reason = reason;
} }
lifecycle_result_t game_lifecycle_configure(game_lifecycle_t *lifecycle, uint8_t session_index, lifecycle_result_t game_lifecycle_configure(game_lifecycle_t *lifecycle, uint8_t session_index,
+42 -2
View File
@@ -118,6 +118,15 @@ static const char *phase_text(phase_t phase) {
return phase <= PHASE_REMATCH_WAIT ? values[phase] : "lobby"; return phase <= PHASE_REMATCH_WAIT ? values[phase] : "lobby";
} }
static const char *recovery_reason_text(recovery_reason_t reason) {
switch (reason) {
case RECOVERY_REASON_SESSION_LEFT: return "session_left";
case RECOVERY_REASON_PROFILE_RESET: return "profile_reset";
case RECOVERY_REASON_GAME_RESET: return "game_reset";
default: return "session_invalidated";
}
}
static void response_write(http_api_response_t *response, uint16_t status, const char *format, ...) { static void response_write(http_api_response_t *response, uint16_t status, const char *format, ...) {
va_list arguments; va_list arguments;
va_start(arguments, format); va_start(arguments, format);
@@ -153,6 +162,16 @@ static void response_lifecycle_error(http_api_response_t *response, lifecycle_re
} }
} }
static void response_recovery(http_api_response_t *response, const game_lifecycle_t *lifecycle) {
response_write(response, 200U, "{\"ok\":true,\"resetReason\":\"%s\",\"generation\":%" PRIu32 "}",
recovery_reason_text(lifecycle->recovery_reason), lifecycle->recovery_generation);
}
static void response_invalidated(http_api_response_t *response, const game_lifecycle_t *lifecycle) {
response_write(response, 401U, "{\"ok\":false,\"code\":\"SESSION_INVALIDATED\",\"resetReason\":\"%s\",\"generation\":%" PRIu32 "}",
recovery_reason_text(lifecycle->recovery_reason), lifecycle->recovery_generation);
}
static bool expect_post_body(const http_api_request_t *request, size_t maximum, http_api_response_t *response, static bool expect_post_body(const http_api_request_t *request, size_t maximum, http_api_response_t *response,
uint32_t version) { uint32_t version) {
if (request->method != HTTP_API_POST || !request->content_type_json) { if (request->method != HTTP_API_POST || !request->content_type_json) {
@@ -187,6 +206,7 @@ static bool parse_join(const http_api_request_t *request, char name[kDisplayName
if (strcmp(role_value, "player1") == 0) *role = ROLE_PLAYER_1; 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, "player2") == 0) *role = ROLE_PLAYER_2;
else if (strcmp(role_value, "spectator") == 0) *role = ROLE_SPECTATOR; else if (strcmp(role_value, "spectator") == 0) *role = ROLE_SPECTATOR;
else if (strcmp(role_value, "player") == 0) *role = ROLE_AUTO_PLAYER;
else *role = (role_t)UINT8_MAX; else *role = (role_t)UINT8_MAX;
return true; return true;
} }
@@ -289,10 +309,14 @@ bool http_api_handle(http_api_t *api, const http_api_request_t *request, http_ap
uint8_t token[kSessionTokenBytes]; uint8_t token[kSessionTokenBytes];
uint8_t session_index = 0; uint8_t session_index = 0;
if (request->session_token != NULL && request->session_token[0] != '\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)) { if (!parse_token(request->session_token, token)) {
response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version); response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version);
return true; return true;
} }
if (!application_session_for_token(api->application, token, &session_index)) {
response_invalidated(response, lifecycle);
return true;
}
viewer = lifecycle->sessions.entries[session_index].role; viewer = lifecycle->sessions.entries[session_index].role;
} }
if (!state_presenter_write_lifecycle(lifecycle, viewer, response->body, sizeof(response->body), &response->body_length)) { if (!state_presenter_write_lifecycle(lifecycle, viewer, response->body, sizeof(response->body), &response->body_length)) {
@@ -330,7 +354,7 @@ bool http_api_handle(http_api_t *api, const http_api_request_t *request, http_ap
response_error(response, 400U, "MALFORMED_JSON", "Некорректный JSON", version); response_error(response, 400U, "MALFORMED_JSON", "Некорректный JSON", version);
return true; return true;
} }
if (role > ROLE_SPECTATOR) { if (role > ROLE_AUTO_PLAYER) {
response_error(response, 400U, "INVALID_ROLE", "Некорректная роль", version); response_error(response, 400U, "INVALID_ROLE", "Некорректная роль", version);
return true; return true;
} }
@@ -374,6 +398,18 @@ bool http_api_handle(http_api_t *api, const http_api_request_t *request, http_ap
return true; return true;
} }
if (!application_session_for_token(api->application, token, &command.session_index)) { if (!application_session_for_token(api->application, token, &command.session_index)) {
if (request->route == HTTP_API_ROUTE_LEAVE || request->route == HTTP_API_ROUTE_PROFILE_RESET) {
response_recovery(response, lifecycle);
return true;
}
if (request->route == HTTP_API_ROUTE_RESET && lifecycle->recovery_reason == RECOVERY_REASON_GAME_RESET) {
response_recovery(response, lifecycle);
return true;
}
if (request->route == HTTP_API_ROUTE_RESET) {
response_error(response, 403U, "FORBIDDEN_ROLE", "Роль не может выполнить действие", version);
return true;
}
response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version); response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version);
return true; return true;
} }
@@ -383,11 +419,15 @@ bool http_api_handle(http_api_t *api, const http_api_request_t *request, http_ap
case HTTP_API_ROUTE_SHOT: command.type = COMMAND_SHOT; 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_REMATCH: command.type = COMMAND_REMATCH; break;
case HTTP_API_ROUTE_ABORT: command.type = COMMAND_ABORT; break; case HTTP_API_ROUTE_ABORT: command.type = COMMAND_ABORT; break;
case HTTP_API_ROUTE_LEAVE: command.type = COMMAND_LEAVE; break;
case HTTP_API_ROUTE_PROFILE_RESET: command.type = COMMAND_PROFILE_RESET; break;
case HTTP_API_ROUTE_RESET: command.type = COMMAND_RESET; break;
default: response_error(response, 404U, "MALFORMED_JSON", "Маршрут не найден", version); return true; default: response_error(response, 404U, "MALFORMED_JSON", "Маршрут не найден", version); return true;
} }
command.version = version; command.version = version;
const lifecycle_result_t result = application_submit(api->application, &command); const lifecycle_result_t result = application_submit(api->application, &command);
if (result != LIFECYCLE_RESULT_OK) response_lifecycle_error(response, result, lifecycle->game.state.version); if (result != LIFECYCLE_RESULT_OK) response_lifecycle_error(response, result, lifecycle->game.state.version);
else if (request->route == HTTP_API_ROUTE_LEAVE || request->route == HTTP_API_ROUTE_PROFILE_RESET || request->route == HTTP_API_ROUTE_RESET) response_recovery(response, lifecycle);
else response_write(response, 200U, "{\"ok\":true,\"version\":%" PRIu32 ",\"gameId\":%" PRIu32 "}", else response_write(response, 200U, "{\"ok\":true,\"version\":%" PRIu32 ",\"gameId\":%" PRIu32 "}",
lifecycle->game.state.version, lifecycle->game.state.game_id); lifecycle->game.state.version, lifecycle->game.state.game_id);
return true; return true;
+8 -2
View File
@@ -167,11 +167,14 @@ static size_t route_body_limit(http_api_route_t route) {
switch (route) { switch (route) {
case HTTP_API_ROUTE_JOIN: return 192U; case HTTP_API_ROUTE_JOIN: return 192U;
case HTTP_API_ROUTE_RESUME: case HTTP_API_ROUTE_RESUME:
case HTTP_API_ROUTE_LEAVE:
case HTTP_API_ROUTE_PROFILE_RESET:
case HTTP_API_ROUTE_CONFIG: case HTTP_API_ROUTE_CONFIG:
case HTTP_API_ROUTE_SHOT: return 96U; case HTTP_API_ROUTE_SHOT: return 96U;
case HTTP_API_ROUTE_START: case HTTP_API_ROUTE_START:
case HTTP_API_ROUTE_REMATCH: case HTTP_API_ROUTE_REMATCH:
case HTTP_API_ROUTE_ABORT: return 80U; case HTTP_API_ROUTE_ABORT: return 80U;
case HTTP_API_ROUTE_RESET: return 80U;
default: return 0U; default: return 0U;
} }
} }
@@ -337,7 +340,7 @@ static esp_err_t send_static_file(httpd_req_t *request, const char *asset_path)
FILE *file = fopen(path_to_open, "rb"); FILE *file = fopen(path_to_open, "rb");
if (file == NULL) return httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "static asset not found"); 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_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"); httpd_resp_set_hdr(request, "Cache-Control", "no-cache");
if (gzip) { httpd_resp_set_hdr(request, "Content-Encoding", "gzip"); httpd_resp_set_hdr(request, "Vary", "Accept-Encoding"); } 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; 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); while ((read = fread(chunk, 1U, sizeof(chunk), file)) > 0U && result == ESP_OK) result = httpd_resp_send_chunk(request, chunk, read);
@@ -363,7 +366,7 @@ static esp_err_t static_file_handler(httpd_req_t *request) {
static esp_err_t start_http_server(void) { static esp_err_t start_http_server(void) {
httpd_config_t config = HTTPD_DEFAULT_CONFIG(); httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.max_uri_handlers = 16U; config.max_open_sockets = kHttpMaxOpenSockets; config.uri_match_fn = httpd_uri_match_wildcard; config.lru_purge_enable = true; config.max_uri_handlers = 18U; 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"); ESP_RETURN_ON_ERROR(httpd_start(&s_server, &config), kLogTag, "http server start failed");
const httpd_uri_t routes[] = { const httpd_uri_t routes[] = {
{.uri = "/", .method = HTTP_GET, .handler = root_handler}, {.uri = "/", .method = HTTP_GET, .handler = root_handler},
@@ -371,11 +374,14 @@ static esp_err_t start_http_server(void) {
{.uri = "/api/health", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_HEALTH}, {.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/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/session/resume", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_RESUME},
{.uri = "/api/session/leave", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_LEAVE},
{.uri = "/api/session/profile-reset", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_PROFILE_RESET},
{.uri = "/api/game/config", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_CONFIG}, {.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/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/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/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/game/abort", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_ABORT},
{.uri = "/api/game/reset", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_RESET},
{.uri = "/api/state", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_STATE}, {.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 = "/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 = "/ws", .method = HTTP_GET, .handler = websocket_handler, .is_websocket = true},
+3 -1
View File
@@ -48,11 +48,13 @@ session_result_t session_manager_join(session_manager_t *manager, role_t request
char sanitized[kDisplayNameBytes + 1] = {0}; char sanitized[kDisplayNameBytes + 1] = {0};
if (!sanitize_name(name, sanitized)) return SESSION_RESULT_INVALID_NAME; if (!sanitize_name(name, sanitized)) return SESSION_RESULT_INVALID_NAME;
int8_t selected = -1; int8_t selected = -1;
if (requested_role == ROLE_PLAYER_1) selected = first_free(manager, 0, 1); if (requested_role == ROLE_AUTO_PLAYER) selected = first_free(manager, 0, kPlayerCapacity);
else 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_PLAYER_2) selected = first_free(manager, 1, 2);
else if (requested_role == ROLE_SPECTATOR) selected = first_free(manager, kPlayerCapacity, kSessionCapacity); else if (requested_role == ROLE_SPECTATOR) selected = first_free(manager, kPlayerCapacity, kSessionCapacity);
else return SESSION_RESULT_INVALID_ROLE; else return SESSION_RESULT_INVALID_ROLE;
if (selected < 0) return requested_role == ROLE_SPECTATOR ? SESSION_RESULT_NO_SPECTATOR_SLOT : SESSION_RESULT_NO_PLAYER_SLOT; if (selected < 0) return requested_role == ROLE_SPECTATOR ? SESSION_RESULT_NO_SPECTATOR_SLOT : SESSION_RESULT_NO_PLAYER_SLOT;
if (requested_role == ROLE_AUTO_PLAYER) requested_role = (role_t)selected;
session_t candidate = {.role = requested_role, .occupied = true, .connected = true}; session_t candidate = {.role = requested_role, .occupied = true, .connected = true};
memcpy(candidate.name, sanitized, sizeof(candidate.name)); memcpy(candidate.name, sanitized, sizeof(candidate.name));
for (uint8_t offset = 0; offset < kSessionTokenBytes; offset += 4) { for (uint8_t offset = 0; offset < kSessionTokenBytes; offset += 4) {
+17 -1
View File
@@ -21,6 +21,11 @@ static void response_error(char output[kStateMessageCapacity], size_t *output_le
code, message, version); code, message, version);
} }
static const char *recovery_reason_text(recovery_reason_t reason) {
return reason == RECOVERY_REASON_GAME_RESET ? "game_reset" :
reason == RECOVERY_REASON_PROFILE_RESET ? "profile_reset" : "session_left";
}
static bool token_bytes(const char *text, uint8_t token[kSessionTokenBytes]) { static bool token_bytes(const char *text, uint8_t token[kSessionTokenBytes]) {
if (text == NULL || strlen(text) != kSessionTokenBytes * 2U) return false; if (text == NULL || strlen(text) != kSessionTokenBytes * 2U) return false;
for (uint8_t index = 0; index < kSessionTokenBytes; ++index) { for (uint8_t index = 0; index < kSessionTokenBytes; ++index) {
@@ -211,9 +216,20 @@ void sync_service_broadcast(sync_service_t *service, sync_send_fn send, void *co
for (uint8_t index = 0; index < kSessionCapacity; ++index) { for (uint8_t index = 0; index < kSessionCapacity; ++index) {
sync_connection_t *connection = &service->connections[index]; sync_connection_t *connection = &service->connections[index];
if (!connection->active || !connection->authenticated) continue; if (!connection->active || !connection->authenticated) continue;
const session_t *session = &service->application->lifecycle.sessions.entries[connection->session_index];
if (!session->occupied) {
char reset[kStateMessageCapacity] = {0};
size_t reset_length = 0U;
response_write(reset, &reset_length, "{\"type\":\"reset\",\"reason\":\"%s\",\"generation\":%" PRIu32 "}",
recovery_reason_text(service->application->lifecycle.recovery_reason),
service->application->lifecycle.recovery_generation);
(void)send(context, connection->client_id, reset, reset_length);
*connection = (sync_connection_t){0};
continue;
}
char payload[kStateMessageCapacity]; char payload[kStateMessageCapacity];
size_t length = 0U; size_t length = 0U;
const role_t role = service->application->lifecycle.sessions.entries[connection->session_index].role; const role_t role = session->role;
if (!state_presenter_write_lifecycle(&service->application->lifecycle, role, payload, sizeof(payload), &length) || 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}; !send(context, connection->client_id, payload, length)) *connection = (sync_connection_t){0};
} }
+46
View File
@@ -104,10 +104,56 @@ static void test_bot_lifecycle_and_stale_actions(void) {
assert(memcmp(&before, &lifecycle, sizeof(lifecycle)) == 0); assert(memcmp(&before, &lifecycle, sizeof(lifecycle)) == 0);
} }
static void test_leave_and_full_reset(void) {
test_random_t random = {.value = 23U};
game_lifecycle_t lifecycle = new_lifecycle(&random);
uint8_t player_1 = 0;
uint8_t player_2 = 0;
uint8_t spectator = 0;
join_players(&lifecycle, &player_1, &player_2);
assert(game_lifecycle_join(&lifecycle, ROLE_SPECTATOR, "Watch", &spectator) == LIFECYCLE_RESULT_OK);
const uint32_t lobby_game_id = lifecycle.game.state.game_id;
assert(game_lifecycle_leave(&lifecycle, spectator) == LIFECYCLE_RESULT_OK);
assert(!lifecycle.sessions.entries[spectator].occupied && lifecycle.game.state.game_id == lobby_game_id);
assert(game_lifecycle_start(&lifecycle, player_1, lobby_game_id) == LIFECYCLE_RESULT_OK);
assert(game_lifecycle_leave(&lifecycle, player_2) == LIFECYCLE_RESULT_OK);
assert(!lifecycle.sessions.entries[player_2].occupied && lifecycle.sessions.entries[player_1].occupied);
assert(lifecycle.game.state.phase == PHASE_LOBBY && lifecycle.game.state.game_id != lobby_game_id);
const uint32_t reset_game_id = lifecycle.game.state.game_id;
lifecycle.cumulative[0].games = 3U;
assert(game_lifecycle_reset(&lifecycle, player_1, reset_game_id) == LIFECYCLE_RESULT_OK);
assert(lifecycle.game.state.phase == PHASE_LOBBY && lifecycle.game.state.game_id != reset_game_id);
assert(!lifecycle.sessions.entries[0].occupied && !lifecycle.sessions.entries[1].occupied);
assert(lifecycle.cumulative[0].games == 0U && lifecycle.recovery_reason == RECOVERY_REASON_GAME_RESET);
assert(game_lifecycle_reset(&lifecycle, player_1, reset_game_id) == LIFECYCLE_RESULT_FORBIDDEN_ROLE);
}
static void test_fifty_mixed_recovery_cycles(void) {
test_random_t random = {.value = 41U};
game_lifecycle_t lifecycle = new_lifecycle(&random);
for (uint8_t cycle = 0; cycle < 50U; ++cycle) {
uint8_t player = kSessionCapacity;
assert(game_lifecycle_join(&lifecycle, ROLE_AUTO_PLAYER, "Alice", &player) == LIFECYCLE_RESULT_OK && player == 0U);
const uint32_t game_id = lifecycle.game.state.game_id;
if (cycle % 3U == 0U) {
assert(game_lifecycle_leave(&lifecycle, player) == LIFECYCLE_RESULT_OK);
} else if (cycle % 3U == 1U) {
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(game_lifecycle_leave(&lifecycle, player) == LIFECYCLE_RESULT_OK);
} else {
assert(game_lifecycle_reset(&lifecycle, player, game_id) == LIFECYCLE_RESULT_OK);
}
assert(lifecycle.game.state.phase == PHASE_LOBBY && !lifecycle.sessions.entries[0].occupied);
}
}
int main(void) { int main(void) {
test_capacity_sanitize_and_resume(); test_capacity_sanitize_and_resume();
test_human_lifecycle_and_guards(); test_human_lifecycle_and_guards();
test_bot_lifecycle_and_stale_actions(); test_bot_lifecycle_and_stale_actions();
test_leave_and_full_reset();
test_fifty_mixed_recovery_cycles();
puts("game lifecycle tests passed"); puts("game lifecycle tests passed");
return 0; return 0;
} }
+27
View File
@@ -186,9 +186,36 @@ static void test_sessions_commands_and_state(void) {
expect_code(&response, 503U, "SERVER_BUSY"); expect_code(&response, 503U, "SERVER_BUSY");
} }
static void test_recovery_routes(void) {
application_t application;
test_random_t random = {.value = 31U};
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);
char player_token[33]; token_from_response(&response, player_token);
char command[96];
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1}", player_token);
response = call(&api, HTTP_API_ROUTE_LEAVE, HTTP_API_POST, command, NULL);
assert(response.status == 200U && strstr(response.body, "session_left") != NULL);
response = call(&api, HTTP_API_ROUTE_LEAVE, HTTP_API_POST, command, NULL);
assert(response.status == 200U && strstr(response.body, "session_left") != NULL);
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"Alice\",\"requestedRole\":\"player1\"}", NULL);
token_from_response(&response, player_token);
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1}", player_token);
response = call(&api, HTTP_API_ROUTE_RESET, HTTP_API_POST, command, NULL);
assert(response.status == 200U && strstr(response.body, "game_reset") != NULL);
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, player_token);
assert(response.status == 401U && strstr(response.body, "SESSION_INVALIDATED") != NULL && strstr(response.body, "game_reset") != NULL);
response = call(&api, HTTP_API_ROUTE_RESET, HTTP_API_POST,
"{\"token\":\"00000000000000000000000000000000\",\"gameId\":1}", NULL);
assert(response.status == 200U && strstr(response.body, "game_reset") != NULL);
}
int main(void) { int main(void) {
test_public_routes_and_parse_limits(); test_public_routes_and_parse_limits();
test_sessions_commands_and_state(); test_sessions_commands_and_state();
test_recovery_routes();
puts("http api tests passed"); puts("http api tests passed");
return 0; return 0;
} }
+86 -4
View File
@@ -96,6 +96,9 @@ static void test_human_game_journey(void) {
resume(&api, player_tokens[0], "player1"); resume(&api, player_tokens[0], "player1");
resume(&api, player_tokens[1], "player2"); resume(&api, player_tokens[1], "player2");
resume(&api, spectator_token, "spectator"); resume(&api, spectator_token, "spectator");
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, spectator_token);
assert(response.status == 200U && strstr(response.body, "\"phase\":\"lobby\"") != NULL &&
strstr(response.body, "\"viewer\":\"spectator\"") != NULL);
const uint32_t game_id = application.lifecycle.game.state.game_id; const uint32_t game_id = application.lifecycle.game.state.game_id;
char config[96]; char config[96];
snprintf(config, sizeof(config), "{\"token\":\"%s\",\"gameId\":%u,\"mode\":\"human\"}", snprintf(config, sizeof(config), "{\"token\":\"%s\",\"gameId\":%u,\"mode\":\"human\"}",
@@ -105,14 +108,21 @@ static void test_human_game_journey(void) {
command(&api, HTTP_API_ROUTE_START, player_tokens[0], game_id); command(&api, HTTP_API_ROUTE_START, player_tokens[0], game_id);
assert(application.lifecycle.game.state.phase == PHASE_IN_PROGRESS); assert(application.lifecycle.game.state.phase == PHASE_IN_PROGRESS);
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"Новый зритель\",\"requestedRole\":\"spectator\"}", NULL);
assert(response.status == 200U && strstr(response.body, "\"role\":\"spectator\"") != NULL);
char in_progress_spectator_token[33];
token_from_response(&response, in_progress_spectator_token);
resume(&api, in_progress_spectator_token, "spectator");
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, player_tokens[0]); 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(response.status == 200U && board_start(response.body, 0)[0] <= '3');
assert_hidden(response.body, 1U); assert_hidden(response.body, 1U);
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, player_tokens[1]); response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, player_tokens[1]);
assert(response.status == 200U); assert(response.status == 200U);
assert_hidden(response.body, 0U); assert_hidden(response.body, 0U);
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, spectator_token); response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, in_progress_spectator_token);
assert(response.status == 200U); assert(response.status == 200U && strstr(response.body, "\"viewer\":\"spectator\"") != NULL);
assert_hidden(response.body, 0U); assert_hidden(response.body, 0U);
assert_hidden(response.body, 1U); assert_hidden(response.body, 1U);
@@ -124,7 +134,7 @@ static void test_human_game_journey(void) {
while (application.lifecycle.game.state.phase == PHASE_IN_PROGRESS) { while (application.lifecycle.game.state.phase == PHASE_IN_PROGRESS) {
const coordinate_t target = first_cell(&application.lifecycle.game.state.boards[winner ^ 1U], CELL_SHIP); const coordinate_t target = first_cell(&application.lifecycle.game.state.boards[winner ^ 1U], CELL_SHIP);
shot(&api, player_tokens[winner], game_id, target); shot(&api, player_tokens[winner], game_id, target);
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, spectator_token); response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, in_progress_spectator_token);
saw_sunk = saw_sunk || strchr(board_start(response.body, winner ^ 1U), '4') != NULL; saw_sunk = saw_sunk || strchr(board_start(response.body, winner ^ 1U), '4') != NULL;
} }
assert(saw_sunk); assert(saw_sunk);
@@ -133,11 +143,21 @@ static void test_human_game_journey(void) {
assert(application.lifecycle.game.state.statistics[winner].hits == 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[winner].ships_sunk == 10U);
assert(application.lifecycle.game.state.statistics[first_player].misses == 1U); assert(application.lifecycle.game.state.statistics[first_player].misses == 1U);
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, spectator_token); response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, in_progress_spectator_token);
assert(response.status == 200U && strstr(response.body, "\"winner\":") != NULL); assert(response.status == 200U && strstr(response.body, "\"winner\":") != NULL);
assert(strstr(response.body, "\"statistics\":[") != NULL); assert(strstr(response.body, "\"statistics\":[") != NULL);
assert(strchr(board_start(response.body, 0U), '1') != NULL || strchr(board_start(response.body, 1U), '1') != NULL); assert(strchr(board_start(response.body, 0U), '1') != NULL || strchr(board_start(response.body, 1U), '1') != NULL);
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"Финишный зритель\",\"requestedRole\":\"spectator\"}", NULL);
assert(response.status == 200U && strstr(response.body, "\"role\":\"spectator\"") != NULL);
char finished_spectator_token[33];
token_from_response(&response, finished_spectator_token);
resume(&api, finished_spectator_token, "spectator");
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, finished_spectator_token);
assert(response.status == 200U && strstr(response.body, "\"phase\":\"finished\"") != NULL &&
strstr(response.body, "\"viewer\":\"spectator\"") != NULL);
resume(&api, player_tokens[0], "player1"); resume(&api, player_tokens[0], "player1");
resume(&api, player_tokens[1], "player2"); resume(&api, player_tokens[1], "player2");
command(&api, HTTP_API_ROUTE_REMATCH, player_tokens[0], game_id); command(&api, HTTP_API_ROUTE_REMATCH, player_tokens[0], game_id);
@@ -149,10 +169,72 @@ static void test_human_game_journey(void) {
assert(game_lifecycle_disconnect(&application.lifecycle, 1U) == LIFECYCLE_RESULT_OK); 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); command(&api, HTTP_API_ROUTE_ABORT, player_tokens[0], application.lifecycle.game.state.game_id);
assert(application.lifecycle.game.state.phase == PHASE_LOBBY); assert(application.lifecycle.game.state.phase == PHASE_LOBBY);
char recovery[80];
snprintf(recovery, sizeof(recovery), "{\"token\":\"%s\",\"gameId\":%u}", in_progress_spectator_token,
(unsigned int)application.lifecycle.game.state.game_id);
response = call(&api, HTTP_API_ROUTE_PROFILE_RESET, HTTP_API_POST, recovery, NULL);
assert(response.status == 200U);
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"После профиля\",\"requestedRole\":\"spectator\"}", NULL);
assert(response.status == 200U && strstr(response.body, "\"role\":\"spectator\"") != NULL);
char recovery_spectator_token[33];
token_from_response(&response, recovery_spectator_token);
snprintf(recovery, sizeof(recovery), "{\"token\":\"%s\",\"gameId\":%u}", recovery_spectator_token,
(unsigned int)application.lifecycle.game.state.game_id);
response = call(&api, HTTP_API_ROUTE_LEAVE, HTTP_API_POST, recovery, NULL);
assert(response.status == 200U);
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"После выхода\",\"requestedRole\":\"spectator\"}", NULL);
assert(response.status == 200U && strstr(response.body, "\"role\":\"spectator\"") != NULL);
command(&api, HTTP_API_ROUTE_RESET, player_tokens[0], application.lifecycle.game.state.game_id);
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"После сброса\",\"requestedRole\":\"spectator\"}", NULL);
assert(response.status == 200U && strstr(response.body, "\"role\":\"spectator\"") != NULL);
}
static void test_atomic_two_player_lobby(void) {
test_random_t random = {.value = 91U};
application_t application;
application_init(&application, (random_source_t){.next_u32 = next_random, .context = &random});
http_api_t api;
http_api_init(&api, &application);
http_api_response_t response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"Алиса\",\"requestedRole\":\"player\"}", NULL);
assert(response.status == 200U && strstr(response.body, "\"role\":\"player1\"") != NULL);
char player_1[33]; token_from_response(&response, player_1);
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"Борис\",\"requestedRole\":\"player\"}", NULL);
assert(response.status == 200U && strstr(response.body, "\"role\":\"player2\"") != NULL);
char player_2[33]; token_from_response(&response, player_2);
const uint32_t game_id = application.lifecycle.game.state.game_id;
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, player_1);
assert(response.status == 200U && strstr(response.body, "\"viewer\":\"player1\"") != NULL && strstr(response.body, "Алиса") != NULL && strstr(response.body, "Борис") != NULL);
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, player_2);
assert(response.status == 200U && strstr(response.body, "\"viewer\":\"player2\"") != NULL);
char config[96];
snprintf(config, sizeof(config), "{\"token\":\"%s\",\"gameId\":%u,\"mode\":\"human\"}", player_2, (unsigned int)game_id);
response = call(&api, HTTP_API_ROUTE_CONFIG, HTTP_API_POST, config, NULL);
assert(response.status == 403U);
char start[80]; snprintf(start, sizeof(start), "{\"token\":\"%s\",\"gameId\":%u}", player_2, (unsigned int)game_id);
response = call(&api, HTTP_API_ROUTE_START, HTTP_API_POST, start, NULL); assert(response.status == 403U);
snprintf(config, sizeof(config), "{\"token\":\"%s\",\"gameId\":%u,\"mode\":\"human\"}", player_1, (unsigned int)game_id);
response = call(&api, HTTP_API_ROUTE_CONFIG, HTTP_API_POST, config, NULL); assert(response.status == 200U);
response = call(&api, HTTP_API_ROUTE_START, HTTP_API_POST, start, NULL); assert(response.status == 403U);
snprintf(start, sizeof(start), "{\"token\":\"%s\",\"gameId\":%u}", player_1, (unsigned int)game_id);
response = call(&api, HTTP_API_ROUTE_START, HTTP_API_POST, start, NULL); assert(response.status == 200U && application.lifecycle.game.state.phase == PHASE_IN_PROGRESS);
response = call(&api, HTTP_API_ROUTE_LEAVE, HTTP_API_POST, (snprintf(start, sizeof(start), "{\"token\":\"%s\",\"gameId\":%u}", player_2, (unsigned int)game_id), start), NULL);
assert(response.status == 200U && !application.lifecycle.sessions.entries[1].occupied);
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST, "{\"name\":\"Вера\",\"requestedRole\":\"player\"}", NULL);
assert(response.status == 200U && strstr(response.body, "\"role\":\"player2\"") != NULL);
resume(&api, player_1, "player1");
} }
int main(void) { int main(void) {
test_human_game_journey(); test_human_game_journey();
test_atomic_two_player_lobby();
puts("human game integration tests passed"); puts("human game integration tests passed");
return 0; return 0;
} }
+18
View File
@@ -124,9 +124,27 @@ static void test_timeout_ping_and_command(void) {
assert(changed && !close && output_length == 0U && application.lifecycle.game.state.mode == MODE_BOT); assert(changed && !close && output_length == 0U && application.lifecycle.game.state.mode == MODE_BOT);
} }
static void test_reset_notification_invalidates_authenticated_client(void) {
test_random_t random = {.value = 17U};
application_t application;
application_init(&application, (random_source_t){.next_u32 = next_random, .context = &random});
uint8_t player = 0;
assert(application_join(&application, ROLE_PLAYER_1, "Alice", &player) == LIFECYCLE_RESULT_OK);
sync_service_t service;
sync_service_init(&service, &application);
assert(sync_service_open(&service, 0, 0U));
hello(&service, 0, application.lifecycle.sessions.entries[player].token, 0U);
assert(game_lifecycle_reset(&application.lifecycle, player, 1U) == LIFECYCLE_RESULT_OK);
capture_t capture = {.fail_client = -1};
sync_service_broadcast(&service, capture_send, &capture);
assert(capture.sends[0] == 1U && strstr(capture.payloads[0], "\"type\":\"reset\"") != NULL);
assert(strstr(capture.payloads[0], "game_reset") != NULL && !service.connections[0].active);
}
int main(void) { int main(void) {
test_authentication_visibility_and_backpressure(); test_authentication_visibility_and_backpressure();
test_timeout_ping_and_command(); test_timeout_ping_and_command();
test_reset_notification_invalidates_authenticated_client();
puts("sync service tests passed"); puts("sync service tests passed");
return 0; return 0;
} }
+18
View File
@@ -0,0 +1,18 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const app = fs.readFileSync(path.join(__dirname, '../../data/app.js'), 'utf8');
test('Play requests the server-authoritative next player slot', () => {
assert.match(app, /join\('player'\)/);
assert.doesNotMatch(app, /info\?\.player1Available \? 'player1'/);
});
test('lobby controls are visible only to player1 and enable when player2 is present', () => {
assert.match(app, /const playerOne = role === 'player1'/);
assert.match(app, /ui\.modeControls\.hidden = !playerOne/);
assert.match(app, /state\.players\[1\]\.trim\(\) !== '' \|\| info\?\.player2Available === false/);
assert.match(app, /const canStart = playerOne && \(state\.mode === 'bot' \|\| playerTwoReady\)/);
});
+28
View File
@@ -0,0 +1,28 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const app = fs.readFileSync(path.join(__dirname, '../../data/app.js'), 'utf8');
const page = fs.readFileSync(path.join(__dirname, '../../data/index.html'), 'utf8');
test('recovery UI is unavailable on registration and role-gates the global action', () => {
assert.match(app, /name === 'connect' \|\| !token/);
assert.match(app, /ui\.recoveryGlobal\.hidden = !isPlayer\(\)/);
assert.match(page, /id="recovery-menu-button"[^>]*hidden/);
});
test('recovery actions use distinct routes and clear the intended local scope', () => {
assert.match(app, /\/api\/session\/leave/);
assert.match(app, /\/api\/session\/profile-reset/);
assert.match(app, /\/api\/game\/reset/);
assert.match(app, /key\.startsWith\('battleship\.'\)/);
assert.match(app, /clearLocalProfile\(action !== 'leave'\)/);
});
test('global reset needs a timed hold while keyboard confirmation remains available', () => {
assert.match(app, /window\.setTimeout\(submitRecovery, 2000\)/);
assert.match(app, /pointercancel/);
assert.match(app, /event\.detail === 0/);
assert.match(app, /event\.key === 'Escape'/);
});
+12
View File
@@ -17,6 +17,7 @@ test('ship sprite contains four independently drawn class symbols', () => {
assert.equal(new Set(symbols).size, ids.length); assert.equal(new Set(symbols).size, ids.length);
assert.ok(Buffer.byteLength(sprite) < 15 * 1024); assert.ok(Buffer.byteLength(sprite) < 15 * 1024);
assert.ok(zlib.gzipSync(sprite).length < 5 * 1024); assert.ok(zlib.gzipSync(sprite).length < 5 * 1024);
assert.doesNotMatch(sprite, /stroke=/);
}); });
test('fleet CSS provides a shared red sunk cross overlay', () => { test('fleet CSS provides a shared red sunk cross overlay', () => {
@@ -24,4 +25,15 @@ test('fleet CSS provides a shared red sunk cross overlay', () => {
assert.match(css, /\.fleet-ship\.sunk::before, \.fleet-ship\.sunk::after/); assert.match(css, /\.fleet-ship\.sunk::before, \.fleet-ship\.sunk::after/);
assert.match(css, /border-top: \.16rem solid #ff5f55/); assert.match(css, /border-top: \.16rem solid #ff5f55/);
assert.match(css, /border-radius: 99rem/); assert.match(css, /border-radius: 99rem/);
assert.match(css, /\.fleet-ship\.sunk svg \{ opacity: \.48; \}/);
});
test('fleet renderer gives every external symbol its own viewport', () => {
const app = fs.readFileSync(path.join(__dirname, '../../data/app.js'), 'utf8');
assert.match(app, /viewBox: '0 0 192 50'/);
assert.match(app, /viewBox: '0 0 144 40'/);
assert.match(app, /viewBox: '0 0 96 30'/);
assert.match(app, /viewBox: '0 0 48 26'/);
assert.match(app, /svg\.setAttribute\('viewBox', shipClass\.viewBox\)/);
assert.match(app, /preserveAspectRatio', 'xMidYMax meet'/);
}); });
+31
View File
@@ -0,0 +1,31 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const app = fs.readFileSync(path.join(__dirname, '../../data/app.js'), 'utf8');
const css = fs.readFileSync(path.join(__dirname, '../../data/styles.css'), 'utf8');
test('spectator state selects a public player board by default and retains only spectator board keys', () => {
assert.match(app, /const boardKeys = Object\.freeze\(\{ own: 'own', opponent: 'opponent', player1: 'player1', player2: 'player2' \}\)/);
assert.match(app, /const validBoardKeys = role === 'spectator' \? \[boardKeys\.player1, boardKeys\.player2\] : \[boardKeys\.own, boardKeys\.opponent\]/);
assert.match(app, /if \(!validBoardKeys\.includes\(activeBoard\)\) activeBoard = validBoardKeys\[0\]/);
assert.match(app, /key: boardKeys\.player1/);
assert.match(app, /key: boardKeys\.player2/);
});
test('spectator boards have working tabs and no player firing controls', () => {
assert.match(app, /tab\.addEventListener\('click', \(\) => \{ activeBoard = definition\.key; renderGame\(\); \}\)/);
assert.match(app, /ui\.shotControls\.hidden = !isPlayer\(\)/);
assert.match(app, /const watching = role === 'spectator'/);
assert.match(app, /Наблюдаем за боем · Ходит \$\{turnPlayer\}/);
assert.match(app, /watching \? 'Наблюдаем за боем' : 'Ждём соперника…'/);
assert.match(css, /\.board\[hidden\]\s*\{\s*display:\s*none;/);
assert.match(css, /\.board\[hidden\]\s*\{\s*display:\s*block;/);
});
test('WebSocket and HTTP state updates share spectator role and board-tab reconciliation', () => {
assert.match(app, /async function pollState\(\)[\s\S]*?acceptState\(payload\);/);
assert.match(app, /if \(message\.type === 'state'\) \{\s*acceptState\(message\);/);
assert.match(app, /role = payload\.viewer;\s*const validBoardKeys = role === 'spectator'/);
});
+40 -1
View File
@@ -1,6 +1,6 @@
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const test = require('node:test'); const test = require('node:test');
const { createTargetActivator } = require('../../data/target_interaction.js'); const { createTargetActivator, detectFeedback, createReactionPicker } = require('../../data/target_interaction.js');
test('a single tap selects a target without firing', () => { test('a single tap selects a target without firing', () => {
const selected = []; const selected = [];
@@ -47,3 +47,42 @@ test('out-of-turn or already-targeted cells cannot select or fire', () => {
assert.equal(selected, 0); assert.equal(selected, 0);
assert.equal(shots, 0); assert.equal(shots, 0);
}); });
function gameState(version, boards, overrides = {}) {
return { version, gameId: 7, phase: 'in_progress', turn: 'player1', winner: null, boards, ...overrides };
}
test('feedback distinguishes a hit on the opponent from damage to the player', () => {
const water = '0'.repeat(100);
const opponentHit = `${water.slice(0, 12)}3${water.slice(13)}`;
assert.equal(detectFeedback(gameState(1, [water, water]), gameState(2, [water, opponentHit]), 'player1').type, 'hit');
assert.equal(detectFeedback(gameState(1, [water, water]), gameState(2, [opponentHit, water]), 'player1').type, 'damage');
});
test('sinking and victory feedback take priority over ordinary cell changes', () => {
const water = '0'.repeat(100);
const sunk = `44${water.slice(2)}`;
assert.equal(detectFeedback(gameState(1, [water, water]), gameState(2, [water, sunk]), 'player1').type, 'sunk');
const finished = gameState(3, [water, sunk], { phase: 'finished', winner: 0 });
assert.equal(detectFeedback(gameState(2, [water, water]), finished, 'player1').type, 'victory');
assert.equal(detectFeedback(gameState(2, [water, water]), finished, 'player2').type, 'defeat');
});
test('duplicate snapshots do not repeat rewards', () => {
const water = '0'.repeat(100);
assert.equal(detectFeedback(gameState(2, [water, water]), gameState(2, [water, water]), 'player1'), undefined);
});
test('reaction picker avoids an immediate repeat for the same event', () => {
const picker = createReactionPicker({ hit: ['one', 'two', 'three'] }, () => 0);
assert.equal(picker('hit'), 'one');
assert.equal(picker('hit'), 'two');
assert.equal(picker('hit'), 'one');
});
test('reaction picker handles single and missing catalogs', () => {
const picker = createReactionPicker({ miss: ['splash'] }, () => 0.9);
assert.equal(picker('miss'), 'splash');
assert.equal(picker('miss'), 'splash');
assert.equal(picker('unknown'), undefined);
});