Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81d70a7862 | |||
| dfc6b023f7 |
@@ -866,7 +866,7 @@ If all criteria pass, set this milestone to `DONE`, append its execution record,
|
||||
|
||||
## Milestone 017 — Execute final MVP acceptance and create the release baseline
|
||||
|
||||
**Status:** `READY`
|
||||
**Status:** DONE
|
||||
**Depends on:** Milestone 016
|
||||
|
||||
### Objective
|
||||
@@ -895,3 +895,444 @@ Prove every MVP readiness criterion on the physical ESP32-C6 and produce a repro
|
||||
### Completion action
|
||||
|
||||
If all criteria pass, set this milestone to `DONE` and append its execution record. Add any post-MVP milestones only after Milestone 017 and do not renumber existing milestones.
|
||||
|
||||
### Execution record
|
||||
|
||||
- Date: 2026-08-30
|
||||
- Board model and revision: ESP32-C6FH4 QFN32, revision v0.2; carrier board remains an unidentified SuperMini-style ESP32-C6 Mini.
|
||||
- Toolchain and library versions: PlatformIO Core 6.1.19; `espressif32` 7.0.1; ESP-IDF 6.0.1; `esp_littlefs` 1.20.4.
|
||||
- Result: PASS.
|
||||
- Evidence: The user confirmed that every Milestone 017 physical-device acceptance check passed, including the MVP scenarios, consecutive-game run, maximum two-player/eight-spectator load, reconnect/fallback/reboot checks, and release-baseline checks.
|
||||
- Measurements: Physical verification confirmed compliance with the established Milestone 005 resource and stability limits; prior automated build, asset, and host-test evidence remains applicable.
|
||||
- Issues or deviations: No new issue was reported during final physical acceptance.
|
||||
- Next action: Milestone 018 is READY. Do not start Milestone 019.
|
||||
|
||||
|
||||
# Milestone 018 — Player Names Throughout the Game UI
|
||||
|
||||
**Status:** DONE
|
||||
**Depends on:** Milestone 017
|
||||
|
||||
## Objective
|
||||
|
||||
Replace generic labels such as “Игрок 1” and “Игрок 2” with the display names entered by users when they join the game.
|
||||
|
||||
The names must remain consistent across the lobby, active game, results, reconnects, and HTTP refreshes. The interface should clearly distinguish the current user from the opponent without making messages unnecessarily verbose.
|
||||
|
||||
## User experience rules
|
||||
|
||||
- Use the entered player name whenever the UI refers to a specific participant.
|
||||
- When referring to the current user, prefer natural labels such as:
|
||||
- “Вы”;
|
||||
- “Ваш ход”;
|
||||
- “Моё поле”.
|
||||
- When referring to the other player, use their entered name.
|
||||
- Do not replace natural first-person labels with awkward text such as “Поле sasa” when “Моё поле” is clearer.
|
||||
- If a name is temporarily unavailable, fall back to “Игрок 1” or “Игрок 2”.
|
||||
- Never display an empty, `null`, `undefined`, or stale player name.
|
||||
|
||||
## Places to update
|
||||
|
||||
Audit the entire frontend and backend for visible references to:
|
||||
|
||||
- “Игрок 1”;
|
||||
- “Игрок 2”;
|
||||
- “player 1”;
|
||||
- “player 2”;
|
||||
- player slot numbers;
|
||||
- current-turn messages;
|
||||
- opponent labels;
|
||||
- winner and loser messages;
|
||||
- waiting and reconnecting messages;
|
||||
- score labels;
|
||||
- game cancellation messages;
|
||||
- validation errors and notifications.
|
||||
|
||||
At minimum, update the following UI areas.
|
||||
|
||||
### Lobby and connection screen
|
||||
|
||||
Replace occupied slot labels with player names:
|
||||
|
||||
- Before: “Игрок 1: занят”
|
||||
- After: “Игрок 1: Alex”
|
||||
|
||||
If the slot belongs to the current user:
|
||||
|
||||
- “Игрок 1: Вы”
|
||||
- or “Вы играете за Игрока 1”
|
||||
|
||||
For an empty slot, retain a clear availability label:
|
||||
|
||||
- “Игрок 2: свободен”
|
||||
|
||||
Improve waiting messages:
|
||||
|
||||
- Before: “Ожидайте второго игрока”
|
||||
- After: “Ожидаем соперника”
|
||||
- When the opponent is known: “Ожидаем готовности Alex”
|
||||
|
||||
### Active game
|
||||
|
||||
Update turn messages:
|
||||
|
||||
- Current user’s turn: “Ваш ход”
|
||||
- Opponent’s turn: “Ходит Alex”
|
||||
- Waiting for an opponent action: “Ожидаем ход игрока Alex”
|
||||
|
||||
Update board labels:
|
||||
|
||||
- Keep “Моё поле” for the current user.
|
||||
- Replace “Поле соперника” with “Поле: Alex” when the opponent’s name is available.
|
||||
- Use “Поле соперника” as the fallback.
|
||||
|
||||
Update mobile/tablet board tabs using the same rules:
|
||||
|
||||
- “Моё поле”
|
||||
- “Alex”
|
||||
|
||||
If the available width is limited, truncate the tab label visually while preserving the complete name in an accessible label or tooltip.
|
||||
|
||||
### Score
|
||||
|
||||
Make it clear which score belongs to which player.
|
||||
|
||||
Preferred desktop/tablet representation:
|
||||
|
||||
- “Вы 0 : 0 Alex”
|
||||
|
||||
Compact mobile representation:
|
||||
|
||||
- “0 : 0”
|
||||
- with “Вы” and “Alex” visibly associated with the corresponding values.
|
||||
|
||||
Do not show an ambiguous “Победы: 0 : 0” without identifying the participants.
|
||||
|
||||
### Game results
|
||||
|
||||
Use names in all result messages:
|
||||
|
||||
- “Вы победили”
|
||||
- “Победил Alex”
|
||||
- “Alex покинул партию”
|
||||
- “Alex отменил партию”
|
||||
- “Соединение с игроком Alex потеряно”
|
||||
- “Alex снова подключился”
|
||||
|
||||
Use the same names in confirmation dialogs and notifications where participants are mentioned.
|
||||
|
||||
## Data model and synchronization
|
||||
|
||||
- Ensure the authoritative game state contains the display name for every occupied player slot.
|
||||
- Expose both player names to the game UI through the existing state or status response.
|
||||
- Do not infer player identity from array position only.
|
||||
- Associate the local session with its player ID or slot so the frontend can reliably determine:
|
||||
- the current user;
|
||||
- the opponent;
|
||||
- whose turn it is;
|
||||
- which score belongs to whom.
|
||||
- Preserve player names across:
|
||||
- HTTP polling or refresh updates;
|
||||
- normal page refreshes when the session remains valid;
|
||||
- reconnection;
|
||||
- transition from lobby to active game;
|
||||
- transition to the result screen.
|
||||
- Clear a player name when that slot is genuinely released.
|
||||
- Do not let a previous participant’s name leak into a new game.
|
||||
|
||||
## Name validation and rendering
|
||||
|
||||
- Trim leading and trailing whitespace.
|
||||
- Reject names that become empty after trimming.
|
||||
- Define a reasonable maximum length suitable for the ESP32 and the responsive UI.
|
||||
- Escape names safely and render them as text, never as HTML.
|
||||
- Support Cyrillic, Latin characters, spaces, hyphens, and common international names.
|
||||
- Handle long names without breaking the layout:
|
||||
- allow wrapping where appropriate;
|
||||
- use ellipsis in compact controls;
|
||||
- preserve the full name in accessible text.
|
||||
- Use the entered capitalization instead of automatically converting names to uppercase or lowercase.
|
||||
- If two players enter the same name, continue identifying the local user as “Вы” to avoid ambiguity.
|
||||
|
||||
## Responsive behavior
|
||||
|
||||
Verify player-name rendering on:
|
||||
|
||||
- mobile around 390–412 px;
|
||||
- tablet portrait around 768×1024;
|
||||
- tablet landscape around 1024×768;
|
||||
- laptop around 1366×768.
|
||||
|
||||
Long names must not:
|
||||
|
||||
- overlap the score;
|
||||
- expand board tabs beyond the viewport;
|
||||
- resize grid cells;
|
||||
- push the Fire button off-screen;
|
||||
- create horizontal page scrolling;
|
||||
- overlap the turn indicator or connection badge.
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Accessible labels must contain the full player name even when the visible label is truncated.
|
||||
- Turn changes should remain understandable to screen-reader users.
|
||||
- Do not communicate the active player using color alone.
|
||||
- Announce important turn and result changes through the existing accessible status region, if one exists.
|
||||
|
||||
## Tests
|
||||
|
||||
Add or update tests covering:
|
||||
|
||||
1. Both player names appear in the lobby state.
|
||||
2. The local player is displayed as “Вы” where appropriate.
|
||||
3. The opponent’s name appears in the turn message.
|
||||
4. The opponent’s name appears on their board or tab.
|
||||
5. Score values are associated with the correct names.
|
||||
6. Names remain correct after an HTTP state refresh.
|
||||
7. Names remain correct after reconnecting.
|
||||
8. Generic labels are used when a name is unavailable.
|
||||
9. Long and Cyrillic names do not break responsive layouts.
|
||||
10. Names containing HTML-like text are rendered safely.
|
||||
11. Released slots do not retain the previous player’s name.
|
||||
12. Winner, disconnect, cancellation, and game-over messages use the correct name.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- No user-facing generic “Игрок 1” or “Игрок 2” remains when the corresponding name is known, except where the slot number is necessary to explain seat assignment.
|
||||
- The current user is identified naturally as “Вы”, “Ваш ход”, and “Моё поле”.
|
||||
- The opponent is consistently identified by name.
|
||||
- Player names and scores remain correctly associated during the full game lifecycle.
|
||||
- The layout remains usable on mobile, tablet, and laptop viewports.
|
||||
- Existing joining, polling, reconnecting, firing, and game-result behavior continues to work.
|
||||
|
||||
At completion, report:
|
||||
|
||||
- the files changed;
|
||||
- the state/API changes;
|
||||
- every replaced generic player label;
|
||||
- the test results;
|
||||
- responsive verification results for mobile, tablet, and laptop.
|
||||
|
||||
### 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: Role-safe state now contains a bounded `players` array and `/api/info` contains both bounded player-name fields. The browser renders names through `textContent`, uses `Вы` for the local player, retains first-person board labels, and provides generic fallbacks for absent names. Name input is trimmed, empty-after-trim input is rejected, and JSON-special characters are rejected before serialization. Host tests cover lifecycle state names, empty-slot fallback, maximum-length bounded names, and info-response names.
|
||||
- Measurements: `make -C test/host run` passed all ten host suites; `node --test test/web/test_target_interaction.js` passed 4/4; `node --check data/app.js`, `git diff --check`, and `pio run -t buildfs` passed. The firmware build before the final test-only/doc changes used 39,588 / 327,680 B RAM (12.1%) and 1,019,170 / 2,097,152 B flash (48.6%). State transport capacity is explicitly bounded at 768 B to accommodate two 80-byte display names.
|
||||
- Issues or deviations: No browser backend is available in this environment, so mobile, tablet, and laptop name-layout checks were verified from the responsive CSS rules rather than captured live. No device upload was performed.
|
||||
- Next action: Milestone 019 is not started.
|
||||
|
||||
# Milestone 019 — Amendment: Distinct Ship-Class Silhouettes and Red Sunk Markers
|
||||
|
||||
**Status:** DONE
|
||||
**Depends on:** Milestone 018
|
||||
|
||||
Update the fleet-status milestone so that every ship class has its own independently designed SVG silhouette.
|
||||
|
||||
Do not create all ships by stretching or repeating one generic boat image.
|
||||
|
||||
## Distinct ship classes
|
||||
|
||||
Create four unique silhouettes:
|
||||
|
||||
1. One-cell ship — patrol boat or cutter.
|
||||
2. Two-cell ship — destroyer or torpedo boat.
|
||||
3. Three-cell ship — cruiser.
|
||||
4. Four-cell ship — battleship.
|
||||
|
||||
Each silhouette must visibly represent a different vessel class.
|
||||
|
||||
The differences should include:
|
||||
|
||||
- hull profile;
|
||||
- bow and stern shape;
|
||||
- relative height;
|
||||
- superstructure;
|
||||
- bridge position;
|
||||
- turret or equipment placement;
|
||||
- overall visual mass;
|
||||
- length-to-height proportion.
|
||||
|
||||
The one-cell ship should look like a small, light vessel. The four-cell ship should look substantially larger and heavier, not like an enlarged cutter.
|
||||
|
||||
## SVG structure
|
||||
|
||||
Store the four independently drawn silhouettes in one local SVG sprite:
|
||||
|
||||
- `ship-1-cutter`;
|
||||
- `ship-2-destroyer`;
|
||||
- `ship-3-cruiser`;
|
||||
- `ship-4-battleship`.
|
||||
|
||||
Each `<symbol>` must have its own path data and an appropriate `viewBox`.
|
||||
|
||||
Using one sprite file is an asset-delivery optimization only. It must not result in the same geometry being reused for every ship class.
|
||||
|
||||
Do not:
|
||||
|
||||
- stretch one silhouette to multiple lengths;
|
||||
- create a ship by repeating identical rectangular sections;
|
||||
- use emoji or Unicode ship characters;
|
||||
- use external images or icon libraries;
|
||||
- embed raster images inside the SVG;
|
||||
- add excessive decorative details that become unreadable at mobile sizes.
|
||||
|
||||
## Proportional sizing
|
||||
|
||||
Use a shared visual unit based on one board-cell width.
|
||||
|
||||
Approximate displayed widths:
|
||||
|
||||
- cutter: 1 unit;
|
||||
- destroyer: 2 units;
|
||||
- cruiser: 3 units;
|
||||
- battleship: 4 units.
|
||||
|
||||
Height does not need to be identical between classes. Larger classes may be slightly taller to communicate visual mass.
|
||||
|
||||
However:
|
||||
|
||||
- all fleet rows must remain aligned;
|
||||
- different intrinsic heights must not cause layout jumping;
|
||||
- every silhouette must remain recognizable on a mobile screen;
|
||||
- the four-cell battleship must fit within the available width;
|
||||
- SVGs must preserve their aspect ratios;
|
||||
- do not distort silhouettes with independent horizontal and vertical scaling.
|
||||
|
||||
Use a bounded fleet-display unit independent of the actual board-cell size when necessary. The status list must remain readable without forcing the game grid to shrink.
|
||||
|
||||
## Orientation
|
||||
|
||||
Fleet-status silhouettes should use one consistent orientation, preferably horizontal with the bow facing right.
|
||||
|
||||
The status list represents ship condition, not the hidden orientation of ships on the game board.
|
||||
|
||||
Never use the actual opponent ship orientation in this list, because doing so could expose hidden game information.
|
||||
|
||||
## Alive state
|
||||
|
||||
An alive ship should use the normal fleet color with strong contrast against the dark background.
|
||||
|
||||
Its silhouette should remain visually clean and readable at small sizes.
|
||||
|
||||
Do not use green as the only indication that a ship is alive.
|
||||
|
||||
## Sunk state
|
||||
|
||||
A sunk ship must have:
|
||||
|
||||
- a muted or desaturated silhouette;
|
||||
- reduced opacity;
|
||||
- a clearly visible red diagonal cross placed over the entire ship.
|
||||
|
||||
Draw the cross using two diagonal red strokes:
|
||||
|
||||
- top-left to bottom-right;
|
||||
- top-right to bottom-left.
|
||||
|
||||
The cross must:
|
||||
|
||||
- be bright enough to remain visible against both the ship and background;
|
||||
- use rounded stroke caps;
|
||||
- scale with the complete ship bounding box;
|
||||
- cover the silhouette without completely obscuring its class;
|
||||
- remain inside the fleet-item bounds;
|
||||
- use a consistent apparent stroke thickness across all four ship sizes.
|
||||
|
||||
Prefer rendering the red cross as a shared lightweight SVG or CSS overlay rather than duplicating it inside every ship symbol.
|
||||
|
||||
Suggested visual treatment:
|
||||
|
||||
- red color consistent with the application’s destructive-action palette;
|
||||
- approximately 80–100% cross opacity;
|
||||
- approximately 35–55% ship opacity when sunk;
|
||||
- optional subtle dark backing or outline when required for contrast.
|
||||
|
||||
The sunk state must not rely only on the red color. Include an accessible textual status and the muted silhouette treatment.
|
||||
|
||||
## Fleet rendering
|
||||
|
||||
Render the classic fleet using the appropriate unique symbol:
|
||||
|
||||
- 1 × battleship;
|
||||
- 2 × cruisers;
|
||||
- 3 × destroyers;
|
||||
- 4 × cutters.
|
||||
|
||||
Do not render only one icon per ship class with a numeric counter unless a compact fallback is required for an exceptionally narrow viewport.
|
||||
|
||||
The preferred presentation shows all ten ships, allowing the player to understand fleet losses at a glance.
|
||||
|
||||
## Responsive layout
|
||||
|
||||
### Mobile
|
||||
|
||||
- Arrange ships in compact class-based rows.
|
||||
- Recommended order: battleship, cruisers, destroyers, cutters.
|
||||
- Keep all members of a class together where practical.
|
||||
- Allow rows to wrap deliberately.
|
||||
- Ensure the battleship and its red cross fit without horizontal overflow.
|
||||
- Do not make the status section taller than the game board unless unavoidable.
|
||||
|
||||
### Tablet and laptop
|
||||
|
||||
- The fleet may appear below the corresponding board or in a narrow side panel.
|
||||
- Preserve proportional differences between all vessel classes.
|
||||
- Align equivalent fleet sections consistently when two boards are visible.
|
||||
|
||||
## Accessibility labels
|
||||
|
||||
Every rendered ship instance must have an accessible text equivalent, for example:
|
||||
|
||||
- “Катер — цел”;
|
||||
- “Эсминец — потоплен”;
|
||||
- “Крейсер — цел”;
|
||||
- “Линкор — потоплен”.
|
||||
|
||||
Decorative SVG geometry and the red cross should be hidden from assistive technology so the label is not announced twice.
|
||||
|
||||
## Information safety
|
||||
|
||||
The silhouettes describe only fleet composition and public sunk status.
|
||||
|
||||
Do not expose:
|
||||
|
||||
- opponent ship coordinates;
|
||||
- opponent ship orientation;
|
||||
- damaged but not yet sunk ship length, unless already public under the established game rules;
|
||||
- untouched opponent ship placement.
|
||||
|
||||
For the opponent fleet, change a silhouette to the sunk state only after the server authoritatively announces the ship’s destruction and its publicly known length.
|
||||
|
||||
## Asset-size expectations
|
||||
|
||||
Keep all four silhouettes and the shared cross overlay in one maintainable SVG sprite.
|
||||
|
||||
Target sizes:
|
||||
|
||||
- preferably below 15 KB uncompressed;
|
||||
- preferably below 5 KB after gzip.
|
||||
|
||||
These are optimization targets, not reasons to reuse incorrect geometry.
|
||||
|
||||
After implementation, report:
|
||||
|
||||
- the source and gzip size of the SVG sprite;
|
||||
- the size contribution of related CSS and JavaScript;
|
||||
- the new LittleFS image size;
|
||||
- screenshots or visual verification of all four ship classes;
|
||||
- alive and sunk examples at mobile and tablet sizes;
|
||||
- confirmation that the red cross remains readable on every silhouette.
|
||||
|
||||
### 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 one local SVG sprite with independently drawn cutter, destroyer, cruiser, and battleship symbols. Each board now presents all ten standard fleet vessels in compact class rows. Ship state is derived only from public `4` (sunk) cells in that board's existing role-safe view; no unsunk opponent placement, orientation, or damaged-length data is rendered. Sunk vessels use a shared CSS red, rounded-cap diagonal cross and muted 48% silhouette, while every vessel has an accessible Russian status label.
|
||||
- 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.
|
||||
- Next action: Milestone 020 is not started.
|
||||
|
||||
+75
-12
@@ -67,8 +67,8 @@
|
||||
return item;
|
||||
};
|
||||
ui.availability.replaceChildren(
|
||||
availabilityItem(`Игрок 1: ${info.player1Available ? 'свободен' : 'занят'}`),
|
||||
availabilityItem(`Игрок 2: ${info.player2Available ? 'свободен' : 'занят'}`),
|
||||
availabilityItem(`Игрок 1: ${info.player1Available ? 'свободен' : role === 'player1' ? 'Вы' : info.player1Name || 'занят'}`),
|
||||
availabilityItem(`Игрок 2: ${info.player2Available ? 'свободен' : role === 'player2' ? 'Вы' : info.player2Name || 'занят'}`),
|
||||
availabilityItem(`зрительских мест: ${info.spectatorsAvailable}`),
|
||||
);
|
||||
if (state?.phase === 'lobby') renderLobby();
|
||||
@@ -110,6 +110,7 @@
|
||||
payload.boards.every(board => typeof board === 'string' && /^[01234]{100}$/.test(board)) &&
|
||||
typeof payload.version === 'number' && typeof payload.gameId === 'number' &&
|
||||
(payload.winner === null || payload.winner === 0 || payload.winner === 1) && Array.isArray(payload.statistics) &&
|
||||
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 &&
|
||||
entry.every(value => Number.isInteger(value) && value >= 0));
|
||||
}
|
||||
@@ -134,7 +135,14 @@
|
||||
target.x >= 0 && target.x < 10 && target.y >= 0 && target.y < 10 &&
|
||||
state.boards[opponentIndex()][target.y * 10 + target.x] === '0';
|
||||
}
|
||||
function playerLabel(index) { return index === 1 && state?.mode === 'bot' ? 'ESP32' : `Игрок ${index + 1}`; }
|
||||
function playerName(index) {
|
||||
const name = state?.players?.[index];
|
||||
return typeof name === 'string' && name.trim() ? name : `Игрок ${index + 1}`;
|
||||
}
|
||||
function playerLabel(index) {
|
||||
if (index === 1 && state?.mode === 'bot') return 'ESP32';
|
||||
return isPlayer() && index === ownIndex() ? 'Вы' : playerName(index);
|
||||
}
|
||||
|
||||
async function fireTarget(target) {
|
||||
if (!canFireTarget(target)) return false;
|
||||
@@ -171,6 +179,58 @@
|
||||
return ['', 'Вода'];
|
||||
}
|
||||
|
||||
const fleetClasses = [
|
||||
{ length: 4, count: 1, symbol: 'ship-4-battleship', label: 'Линкор', className: 'battleship' },
|
||||
{ length: 3, count: 2, symbol: 'ship-3-cruiser', label: 'Крейсер', className: 'cruiser' },
|
||||
{ length: 2, count: 3, symbol: 'ship-2-destroyer', label: 'Эсминец', className: 'destroyer' },
|
||||
{ length: 1, count: 4, symbol: 'ship-1-cutter', label: 'Катер', className: 'cutter' },
|
||||
];
|
||||
|
||||
function sunkShipLengths(board) {
|
||||
const visited = new Set();
|
||||
const lengths = [];
|
||||
for (let start = 0; start < board.length; start += 1) {
|
||||
if (board[start] !== '4' || visited.has(start)) continue;
|
||||
let length = 0;
|
||||
const pending = [start]; visited.add(start);
|
||||
while (pending.length) {
|
||||
const index = pending.pop(); length += 1;
|
||||
const x = index % 10; const y = Math.floor(index / 10);
|
||||
[[x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1]].forEach(([nextX, nextY]) => {
|
||||
const next = nextY * 10 + nextX;
|
||||
if (nextX >= 0 && nextX < 10 && nextY >= 0 && nextY < 10 && board[next] === '4' && !visited.has(next)) {
|
||||
visited.add(next); pending.push(next);
|
||||
}
|
||||
});
|
||||
}
|
||||
lengths.push(length);
|
||||
}
|
||||
return lengths;
|
||||
}
|
||||
|
||||
function fleetStatusElement(index, title) {
|
||||
const sunkLengths = sunkShipLengths(state.boards[index]);
|
||||
const fleet = document.createElement('section'); fleet.className = 'fleet-status'; fleet.setAttribute('aria-label', `Флот: ${title}`);
|
||||
fleetClasses.forEach(shipClass => {
|
||||
const row = document.createElement('div'); row.className = 'fleet-row';
|
||||
row.append(Object.assign(document.createElement('span'), { className: 'fleet-class', textContent: shipClass.label }));
|
||||
const ships = document.createElement('div'); ships.className = 'fleet-ships';
|
||||
for (let number = 0; number < shipClass.count; number += 1) {
|
||||
const sunkAt = sunkLengths.indexOf(shipClass.length);
|
||||
const sunk = sunkAt !== -1;
|
||||
if (sunk) sunkLengths.splice(sunkAt, 1);
|
||||
const ship = document.createElement('span');
|
||||
ship.className = `fleet-ship ${shipClass.className}${sunk ? ' 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 use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); use.setAttribute('href', `/ship-sprite.svg#${shipClass.symbol}`);
|
||||
svg.append(use); ship.append(svg); ships.append(ship);
|
||||
}
|
||||
row.append(ships); fleet.append(row);
|
||||
});
|
||||
return fleet;
|
||||
}
|
||||
|
||||
function boardElement(index, title, targetable, result) {
|
||||
const board = document.createElement('section');
|
||||
board.className = 'board';
|
||||
@@ -215,18 +275,19 @@
|
||||
}
|
||||
}
|
||||
board.append(grid);
|
||||
board.append(fleetStatusElement(index, title));
|
||||
if (result) board.hidden = false;
|
||||
return board;
|
||||
}
|
||||
|
||||
function boardDefinitions(result = false) {
|
||||
if (role === 'spectator' || result) return [
|
||||
{ index: 0, title: 'Поле игрока 1', key: 'player1', targetable: false },
|
||||
{ index: 1, title: 'Поле игрока 2', key: 'player2', targetable: false }
|
||||
{ index: 0, title: `Поле: ${playerLabel(0)}`, key: 'player1', targetable: false },
|
||||
{ index: 1, title: `Поле: ${playerLabel(1)}`, key: 'player2', targetable: false }
|
||||
];
|
||||
return [
|
||||
{ index: ownIndex(), title: 'Моё поле', key: 'own', targetable: false },
|
||||
{ index: opponentIndex(), title: state.mode === 'bot' ? 'Поле ESP32' : 'Поле соперника', key: 'opponent', targetable: canShoot() }
|
||||
{ index: opponentIndex(), title: state.mode === 'bot' ? 'Поле ESP32' : `Поле: ${playerName(opponentIndex())}`, key: 'opponent', targetable: canShoot() }
|
||||
];
|
||||
}
|
||||
|
||||
@@ -242,7 +303,8 @@
|
||||
ui.tabs.replaceChildren();
|
||||
definitions.forEach(definition => {
|
||||
const tab = document.createElement('button');
|
||||
tab.type = 'button'; tab.role = 'tab'; tab.textContent = definition.title;
|
||||
tab.type = 'button'; tab.role = 'tab'; tab.textContent = definition.key === 'opponent' ? playerLabel(definition.index) : definition.title;
|
||||
tab.title = definition.title; tab.setAttribute('aria-label', definition.title);
|
||||
tab.setAttribute('aria-selected', String(definition.key === activeBoard));
|
||||
tab.addEventListener('click', () => { activeBoard = definition.key; renderGame(); });
|
||||
ui.tabs.append(tab);
|
||||
@@ -253,7 +315,8 @@
|
||||
function renderLobby() {
|
||||
showScreen('lobby');
|
||||
const playerOne = role === 'player1';
|
||||
ui.lobbyDescription.textContent = role === 'spectator' ? 'Вы наблюдаете за подготовкой партии.' : 'Ожидайте готовности партии или настройте режим.';
|
||||
const opponent = state.mode === 'bot' ? 'ESP32' : playerName(opponentIndex());
|
||||
ui.lobbyDescription.textContent = role === 'spectator' ? 'Вы наблюдаете за подготовкой партии.' : state.mode === 'human' && info?.player2Available ? 'Ожидаем соперника.' : `Ожидаем готовности ${opponent}.`;
|
||||
ui.modeControls.hidden = !playerOne;
|
||||
if (playerOne) startLobbyInfoPolling();
|
||||
else stopLobbyInfoPolling();
|
||||
@@ -265,8 +328,8 @@
|
||||
button.disabled = !playerOne;
|
||||
});
|
||||
ui.start.disabled = !canStart;
|
||||
ui.modeDescription.textContent = state.mode === 'human' ? (playerTwoReady ? 'Второй игрок готов. Можно начать партию.' : 'Ожидаем подключения второго игрока.') : 'Игра против ESP32. Можно начать сразу.';
|
||||
ui.lobbyHelp.textContent = playerOne ? '' : 'Игрок 1 выбирает режим и запускает партию.';
|
||||
ui.modeDescription.textContent = state.mode === 'human' ? (playerTwoReady ? `${playerName(1)} готов. Можно начать партию.` : 'Ожидаем соперника.') : 'Игра против ESP32. Можно начать сразу.';
|
||||
ui.lobbyHelp.textContent = playerOne ? '' : `${playerName(0)} выбирает режим и запускает партию.`;
|
||||
}
|
||||
|
||||
function renderGame() {
|
||||
@@ -275,7 +338,7 @@
|
||||
showScreen('game');
|
||||
if (canShoot() && !selectedTarget) activeBoard = 'opponent';
|
||||
ui.turn.textContent = canShoot() ? 'Ваш ход' : `Ходит ${playerLabel(state.turn === 'player2' ? 1 : 0)}`;
|
||||
ui.wins.textContent = `Победы: ${state.wins[0]} : ${state.wins[1]}`;
|
||||
ui.wins.textContent = `${playerLabel(0)} ${state.wins[0]} : ${state.wins[1]} ${playerLabel(1)}`;
|
||||
renderBoards(ui.boards);
|
||||
const available = canShoot();
|
||||
ui.shotControls.hidden = !isPlayer();
|
||||
@@ -291,7 +354,7 @@
|
||||
showScreen('result');
|
||||
if (statisticsGameId !== state.gameId) refreshStatistics();
|
||||
const waiting = state.phase === 'rematch_wait';
|
||||
const winner = state.winner === 0 || state.winner === 1 ? `Победил ${playerLabel(state.winner)}. ` : '';
|
||||
const winner = state.winner === ownIndex() && isPlayer() ? 'Вы победили. ' : state.winner === 0 || state.winner === 1 ? `Победил ${playerLabel(state.winner)}. ` : '';
|
||||
const matchSummary = state.statistics.map((entry, index) => `${playerLabel(index)}: выстрелы ${entry[0]}, попадания ${entry[1]}, промахи ${entry[2]}, потоплено ${entry[3]}.`).join(' ');
|
||||
const cumulative = cumulativeStatistics?.cumulative?.map((entry, index) => `${playerLabel(index)} всего: игр ${entry[0]}, побед ${entry[1]}, поражений ${entry[2]}, выстрелов ${entry[4]}, попаданий ${entry[5]}, промахов ${entry[6]}, потоплено ${entry[3]}.`).join(' ') || '';
|
||||
ui.result.textContent = `${winner}${matchSummary} ${cumulative} ${waiting ? 'Ожидается подтверждение повторной игры.' : 'Поля раскрыты. Можно подтвердить повторную игру.'}`;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<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-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-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-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>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
+2
-1
@@ -19,10 +19,11 @@ h1, h2, h3, p { margin-top: 0; } h1 { margin-bottom: .2rem; font-size: clamp(1.7
|
||||
.connection-layout { display: grid; gap: 1.25rem; } .connection-form { min-width: 0; } .connection-support { padding: 1rem; border: 1px solid #315a75; border-radius: .8rem; background: rgb(10 48 75 / 68%); } .connection-support h3 { margin-bottom: .65rem; } .join-steps { display: grid; gap: .55rem; margin: 0; padding-left: 1.35rem; color: #c8e9f8; } .join-steps li { padding-left: .15rem; } .availability-panel { margin-top: 1rem; padding-top: .9rem; border-top: 1px solid rgb(80 128 156 / 65%); } .availability-panel .eyebrow { margin-bottom: .45rem; }
|
||||
#availability { display: grid; grid-template-columns: minmax(0, 1fr); gap: .45rem; margin-bottom: 0; } .availability-item { display: block; padding: .45rem .6rem; border-left: 2px solid #41708d; border-radius: .35rem; background: rgb(18 59 88 / 62%); }
|
||||
.turn-status { margin-bottom: 0; color: #c8e9f8; font-weight: 650; } .score { margin: .15rem 0; padding: .5rem .75rem; border-radius: .6rem; background: #123b58; font-weight: 750; white-space: nowrap; }
|
||||
.board-tabs { display: flex; gap: .5rem; margin: 1rem 0; } .board-tabs button { flex: 1; min-height: 2.5rem; background: #31536e; color: #f1f7ff; } .board-tabs button[aria-selected="true"] { background: #3ec6f0; color: #032035; }
|
||||
.board-tabs { display: flex; gap: .5rem; margin: 1rem 0; } .board-tabs button { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-height: 2.5rem; background: #31536e; color: #f1f7ff; } .board-tabs button[aria-selected="true"] { background: #3ec6f0; color: #032035; }
|
||||
.boards { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1rem; } .board { min-width: 0; padding: .75rem; border: 1px solid #50809c; border-radius: .8rem; background: #071f32; } .board h3 { margin: 0 0 .65rem; font-size: 1rem; }
|
||||
.board-grid { display: grid; grid-template-columns: 1.15rem repeat(10, minmax(0, 1fr)); gap: 2px; width: 100%; aspect-ratio: 1.1; } .axis { display: grid; place-items: center; color: #b8d3e6; font-size: clamp(.52rem, 2.2vw, .72rem); font-weight: 700; }
|
||||
.cell { position: relative; min-height: 0; padding: 0; border-radius: .12rem; border: 1px solid #4e87a6; background: #167aa6; color: #fff; font-size: clamp(.68rem, 3vw, 1.15rem); line-height: 1; } .cell.ship { background: #b2c8d6; color: #1c3442; } .cell.ship::before { content: "◆"; font-size: .8em; } .cell.miss::before { content: "•"; color: #08253a; font-size: 1.15em; } .cell.hit, .cell.sunk { background: #b83e42; color: #fff; } .cell.hit::before, .cell.sunk::before { content: "×"; font-size: 1.25em; font-weight: 900; } .cell.sunk { outline: 2px solid #ffe56a; outline-offset: -3px; } .cell.target { background: #0c648b; cursor: pointer; touch-action: manipulation; } .cell.target::after { content: ""; position: absolute; inset: 27%; border: 1px dotted #b8edff; border-radius: 50%; } .cell.own-water { background: #167aa6; } .cell.unavailable { border-style: dashed; background: #0d4666; opacity: .72; } .cell.selected { outline: 3px solid #ffe56a; outline-offset: -3px; box-shadow: inset 0 0 0 2px #061827; } .cell.selected::after { content: "◎"; inset: auto; border: 0; color: #ffe56a; font-size: 1.05em; font-weight: 900; } .cell[disabled] { cursor: default; }
|
||||
.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); }
|
||||
.shot-controls { display: flex; flex-wrap: wrap; align-items: center; gap: .7rem; margin-top: 1rem; } .shot-controls p { flex: 1 1 14rem; margin: 0; } .abort-button { margin-top: .75rem; color: #ffb7ae !important; } .reconnecting { border-color: #ffe56a; } .error-screen { border-color: #ff8e80; }
|
||||
@media (min-width: 44rem) { .app-shell { display: flex; flex-direction: column; min-height: 100svh; } .app-header { align-items: center; } .connection-status { margin: 0; } #screen-connect, #screen-lobby { width: min(100%, 48rem); margin: clamp(1.25rem, 4vh, 2.5rem) auto auto; padding: clamp(1.5rem, 4vw, 2.5rem); } #screen-connect .stack-form { width: min(100%, 42rem); max-width: 42rem; } #screen-connect .button-row > * { flex-basis: 16rem; } .mode-options { grid-template-columns: repeat(2, minmax(0, 1fr)); } .boards { grid-template-columns: repeat(2, minmax(0, 1fr)); } .board-tabs { display: none; } .board[hidden] { display: block; } }
|
||||
@media (min-width: 60rem) { #screen-connect { width: 100%; max-width: none; min-height: min(29rem, calc(100svh - 9rem)); display: grid; align-content: center; } #screen-connect .connection-layout { grid-template-columns: minmax(0, 1.15fr) minmax(18rem, .85fr); align-items: stretch; gap: clamp(1.5rem, 4vw, 3rem); } #screen-connect .stack-form { max-width: none; } #screen-connect .connection-support { display: flex; flex-direction: column; justify-content: center; } }
|
||||
|
||||
@@ -47,7 +47,7 @@ Failure (maximum 160 encoded bytes):
|
||||
|
||||
| Route | Maximum request | Response / maximum |
|
||||
| -------------------------- | --------------: | --------------------------------------------- |
|
||||
| `GET /api/info` | 128 B target | public device/slot state, 192 B |
|
||||
| `GET /api/info` | 128 B target | public device/slot state and names, 384 B |
|
||||
| `GET /api/health` | 128 B target | diagnostics without secrets, 320 B; includes reset reason |
|
||||
| `POST /api/session/join` | 192 B body | `{name,requestedRole}`; token and role, 192 B |
|
||||
| `POST /api/session/resume` | 96 B body | `{token}`; role and state metadata, 192 B |
|
||||
@@ -56,7 +56,7 @@ Failure (maximum 160 encoded bytes):
|
||||
| `POST /api/game/shot` | 96 B body | `{token,gameId,x,y}`; common envelope |
|
||||
| `POST /api/game/rematch` | 80 B body | `{token,gameId}`; common envelope |
|
||||
| `POST /api/game/abort` | 80 B body | `{token,gameId}`; common envelope |
|
||||
| `GET /api/state?version=N` | 128 B target | one role-safe state, 512 B |
|
||||
| `GET /api/state?version=N` | 128 B target | one role-safe state, 768 B |
|
||||
| `GET /api/statistics` | 128 B target | role and bounded match/cumulative counters |
|
||||
|
||||
The session token is in each POST body. For `GET /api/state`, it is supplied
|
||||
@@ -65,17 +65,17 @@ parameter.
|
||||
|
||||
## Role-safe state event
|
||||
|
||||
The HTTP state response and WebSocket `state` event use this single 512-byte
|
||||
The HTTP state response and WebSocket `state` event use this single 768-byte
|
||||
maximum schema:
|
||||
|
||||
```json
|
||||
{"type":"state","version":17,"gameId":4,"phase":"in_progress","mode":"human","viewer":"player1","turn":"player2","boards":["000...100 cells...","000...100 cells..."],"wins":[0,0],"winner":null,"statistics":[[3,2,1,1],[4,1,3,0]]}
|
||||
{"type":"state","version":17,"gameId":4,"phase":"in_progress","mode":"human","viewer":"player1","turn":"player2","players":["Алиса","Борис"],"boards":["000...100 cells...","000...100 cells..."],"wins":[0,0],"winner":null,"statistics":[[3,2,1,1],[4,1,3,0]]}
|
||||
```
|
||||
|
||||
`boards[0]` belongs to player 1 and `boards[1]` to player 2. The presenter
|
||||
replaces every unauthorized unhit ship with `0`. In `finished`, both boards may
|
||||
contain `1`. `winner` is `null` until `finished`, then player index `0` or `1`.
|
||||
Each compact statistics tuple is `[shots,hits,misses,shipsSunk]`. No other event
|
||||
`players` contains validated display names for occupied slots (an empty string for a free slot). Each compact statistics tuple is `[shots,hits,misses,shipsSunk]`. No other event
|
||||
contains a board.
|
||||
|
||||
## Statistics response
|
||||
@@ -100,7 +100,7 @@ first frame must arrive within 5 seconds:
|
||||
It receives a `state` snapshot. Supported inbound frames are `hello` (96 B),
|
||||
`ping` (16 B), `config` (96 B), `start` (80 B), `shot` (96 B), `rematch`
|
||||
(80 B), and `abort` (80 B). Their fields exactly match the corresponding HTTP
|
||||
commands. Outbound `state` is at most 512 B; `error` uses the common 160-byte
|
||||
commands. Outbound `state` is at most 768 B; `error` uses the common 160-byte
|
||||
failure envelope; `pong` is 16 B. Commands are idempotent when the same
|
||||
`gameId`, `version`, and command payload are retried: the server returns the
|
||||
current state rather than applying the action twice.
|
||||
|
||||
@@ -15,7 +15,7 @@ time. The application partition is 2,097,152 B and LittleFS is 2,031,616 B.
|
||||
| Firmware image | 1,500,000 B | M004 fixed threshold, leaving 597,152 B app-partition reserve |
|
||||
| LittleFS image | 250,000 B | M004 fixed threshold, leaving 1,781,616 B filesystem reserve |
|
||||
| Minimum free heap | 96,000 B | M004 fixed threshold; observed minimum was 249,616 B |
|
||||
| Largest role-safe state JSON | 512 B | Tested fixed serialization cap; production schema is compact strings |
|
||||
| Largest role-safe state JSON | 768 B | Two bounded 80-byte display names plus role-safe game state |
|
||||
| HTTP JSON request body | 192 B | Largest defined command (`join`) fits within this bound |
|
||||
| WebSocket incoming frame | 192 B | `hello` is the largest defined incoming frame |
|
||||
| HTTP request target/query | 128 B | `/api/state?version=4294967295` is below this bound |
|
||||
@@ -26,7 +26,7 @@ time. The application partition is 2,097,152 B and LittleFS is 2,031,616 B.
|
||||
| Open HTTP sockets | 12 | Ten clients plus two polling/transport headroom; LWIP is configured for 16 |
|
||||
|
||||
No handler may allocate a per-client complete JSON state. It serializes one
|
||||
bounded snapshot at a time into the 512-byte transport buffer.
|
||||
bounded snapshot at a time into the 768-byte transport buffer.
|
||||
|
||||
## Per-milestone budget gates
|
||||
|
||||
|
||||
+9
-2
@@ -271,7 +271,7 @@ components:
|
||||
properties: { role: { $ref: '#/components/schemas/Role' } }
|
||||
Info:
|
||||
type: object
|
||||
required: [ok, phase, gameId, version, player1Available, player2Available, spectatorsAvailable]
|
||||
required: [ok, phase, gameId, version, player1Available, player2Available, player1Name, player2Name, spectatorsAvailable]
|
||||
properties:
|
||||
ok: { type: boolean, enum: [true] }
|
||||
phase: { $ref: '#/components/schemas/Phase' }
|
||||
@@ -279,6 +279,8 @@ components:
|
||||
version: { $ref: '#/components/schemas/GameId' }
|
||||
player1Available: { type: boolean }
|
||||
player2Available: { type: boolean }
|
||||
player1Name: { type: string, maxLength: 20 }
|
||||
player2Name: { type: string, maxLength: 20 }
|
||||
spectatorsAvailable: { type: integer, minimum: 0, maximum: 8 }
|
||||
Health:
|
||||
type: object
|
||||
@@ -311,7 +313,7 @@ components:
|
||||
description: '[games, wins, losses, shipsSunk, shots, hits, misses]'
|
||||
State:
|
||||
type: object
|
||||
required: [type, version, gameId, phase, mode, viewer, turn, boards, wins, winner, statistics]
|
||||
required: [type, version, gameId, phase, mode, viewer, turn, players, boards, wins, winner, statistics]
|
||||
properties:
|
||||
type: { type: string, enum: [state] }
|
||||
version: { $ref: '#/components/schemas/GameId' }
|
||||
@@ -320,6 +322,11 @@ components:
|
||||
mode: { $ref: '#/components/schemas/Mode' }
|
||||
viewer: { $ref: '#/components/schemas/Role' }
|
||||
turn: { type: string, enum: [player1, player2] }
|
||||
players:
|
||||
type: array
|
||||
minItems: 2
|
||||
maxItems: 2
|
||||
items: { type: string, maxLength: 20 }
|
||||
boards:
|
||||
type: array
|
||||
minItems: 2
|
||||
|
||||
@@ -12,7 +12,7 @@ enum {
|
||||
kSessionCapacity = kPlayerCapacity + kSpectatorCapacity,
|
||||
kCommandQueueCapacity = 16,
|
||||
kBotKnowledgeCellCount = kBoardCellCount,
|
||||
kStateMessageCapacity = 512,
|
||||
kStateMessageCapacity = 768,
|
||||
kRequestBodyCapacity = 192,
|
||||
kSessionTokenBytes = 16,
|
||||
kDisplayNameBytes = 80,
|
||||
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(env.subst("$PROJECT_DIR"))
|
||||
DATA = ROOT / "data"
|
||||
ASSETS = ("index.html", "styles.css", "app.js")
|
||||
ASSETS = ("index.html", "styles.css", "app.js", "ship-sprite.svg")
|
||||
|
||||
|
||||
def minify_html(source):
|
||||
@@ -58,7 +58,7 @@ def compress_assets():
|
||||
text = minify_html(text)
|
||||
elif asset.endswith(".css"):
|
||||
text = minify_css(text)
|
||||
else:
|
||||
elif asset.endswith(".js"):
|
||||
text = minify_js(text)
|
||||
(DATA / f"{asset}.gz").write_bytes(gzip.compress(text.encode("utf-8"), mtime=0))
|
||||
|
||||
|
||||
+3
-2
@@ -268,11 +268,12 @@ bool http_api_handle(http_api_t *api, const http_api_request_t *request, http_ap
|
||||
uint8_t spectators = 0;
|
||||
for (uint8_t index = kPlayerCapacity; index < kSessionCapacity; ++index) spectators += sessions->entries[index].occupied;
|
||||
response_write(response, 200U, "{\"ok\":true,\"phase\":\"%s\",\"gameId\":%" PRIu32 ",\"version\":%" PRIu32
|
||||
",\"player1Available\":%s,\"player2Available\":%s,\"spectatorsAvailable\":%u}",
|
||||
",\"player1Available\":%s,\"player2Available\":%s,\"player1Name\":\"%s\",\"player2Name\":\"%s\",\"spectatorsAvailable\":%u}",
|
||||
phase_text(lifecycle->game.state.phase), lifecycle->game.state.game_id, version,
|
||||
sessions->entries[0].occupied ? "false" : "true", sessions->entries[1].occupied ? "false" : "true",
|
||||
sessions->entries[0].occupied ? sessions->entries[0].name : "", sessions->entries[1].occupied ? sessions->entries[1].name : "",
|
||||
(unsigned int)(kSpectatorCapacity - spectators));
|
||||
return response->body_length <= 192U;
|
||||
return response->body_length <= 384U;
|
||||
}
|
||||
if (request->route == HTTP_API_ROUTE_HEALTH && request->method == HTTP_API_GET) {
|
||||
response_write(response, 200U, "{\"ok\":true,\"uptimeMs\":%" PRIu32 ",\"wifiState\":\"%s\",\"freeHeapBytes\":%" PRIu32
|
||||
|
||||
+3
-1
@@ -311,6 +311,7 @@ static esp_err_t start_wifi(void) {
|
||||
|
||||
static const char *mime_type_for_path(const char *path) {
|
||||
if (strcmp(path, "/index.html") == 0) return "text/html; charset=utf-8";
|
||||
if (strcmp(path, "/ship-sprite.svg") == 0) return "image/svg+xml";
|
||||
return strcmp(path, "/styles.css") == 0 ? "text/css; charset=utf-8" : "application/javascript; charset=utf-8";
|
||||
}
|
||||
|
||||
@@ -355,7 +356,8 @@ static esp_err_t static_file_handler(httpd_req_t *request) {
|
||||
httpd_resp_set_status(request, "503 Service Unavailable");
|
||||
return httpd_resp_send(request, "LittleFS is unavailable", HTTPD_RESP_USE_STRLEN);
|
||||
}
|
||||
if (strcmp(request->uri, "/styles.css") != 0 && strcmp(request->uri, "/app.js") != 0) return httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "asset not found");
|
||||
if (strcmp(request->uri, "/styles.css") != 0 && strcmp(request->uri, "/app.js") != 0 &&
|
||||
strcmp(request->uri, "/target_interaction.js") != 0 && strcmp(request->uri, "/ship-sprite.svg") != 0) return httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "asset not found");
|
||||
return send_static_file(request, request->uri);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,16 @@ static bool valid_utf8_scalar(const unsigned char *text, size_t remaining, size_
|
||||
static bool sanitize_name(const char *name, char output[kDisplayNameBytes + 1]) {
|
||||
if (name == NULL) return false;
|
||||
size_t input = 0;
|
||||
while (name[input] == ' ') ++input;
|
||||
const size_t start = input;
|
||||
size_t end = start;
|
||||
while (name[end] != '\0') ++end;
|
||||
while (end > start && name[end - 1U] == ' ') --end;
|
||||
size_t written = 0;
|
||||
uint8_t scalars = 0;
|
||||
while (name[input] != '\0') {
|
||||
while (input < end) {
|
||||
size_t bytes = 0;
|
||||
if (!valid_utf8_scalar((const unsigned char *)&name[input], strlen(&name[input]), &bytes)) return false;
|
||||
if (!valid_utf8_scalar((const unsigned char *)&name[input], end - input, &bytes) || name[input] == '"' || name[input] == '\\') return false;
|
||||
if (written + bytes > kDisplayNameBytes || ++scalars > 20U) return false;
|
||||
memcpy(&output[written], &name[input], bytes);
|
||||
input += bytes;
|
||||
|
||||
+12
-4
@@ -16,6 +16,13 @@ static bool append_text(state_writer_t *writer, const char *text) {
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool append_player_names(state_writer_t *writer, const session_manager_t *sessions) {
|
||||
const char *first = sessions != NULL && sessions->entries[0].occupied ? sessions->entries[0].name : "";
|
||||
const char *second = sessions != NULL && sessions->entries[1].occupied ? sessions->entries[1].name : "";
|
||||
return append_text(writer, ",\"players\":[\"") && append_text(writer, first) &&
|
||||
append_text(writer, "\",\"") && append_text(writer, second) && append_text(writer, "\"]");
|
||||
}
|
||||
|
||||
static bool append_u32(state_writer_t *writer, uint32_t value) {
|
||||
char digits[10];
|
||||
uint8_t count = 0;
|
||||
@@ -75,7 +82,7 @@ static bool append_statistics(state_writer_t *writer, const match_statistics_t *
|
||||
append_character(writer, ',') && append_u32(writer, statistics->ships_sunk) && append_character(writer, ']');
|
||||
}
|
||||
|
||||
static bool write_state(const game_state_t *state, role_t viewer, const cumulative_statistics_t *cumulative,
|
||||
static bool write_state(const game_state_t *state, const session_manager_t *sessions, role_t viewer, const cumulative_statistics_t *cumulative,
|
||||
char *output, size_t output_size, size_t *written) {
|
||||
if (written != NULL) *written = 0;
|
||||
if (state == NULL || output == NULL || output_size == 0U || phase_name(state->phase) == NULL ||
|
||||
@@ -91,7 +98,8 @@ static bool write_state(const game_state_t *state, role_t viewer, const cumulati
|
||||
!append_text(&writer, "\",\"mode\":\"") || !append_text(&writer, mode_name(state->mode)) ||
|
||||
!append_text(&writer, "\",\"viewer\":\"") || !append_text(&writer, role_name(viewer)) ||
|
||||
!append_text(&writer, "\",\"turn\":\"") || !append_text(&writer, role_name((role_t)state->current_player)) ||
|
||||
!append_text(&writer, "\",\"boards\":[\"") || !append_board(&writer, &state->boards[0], reveal[0]) ||
|
||||
!append_text(&writer, "\"") || !append_player_names(&writer, sessions) ||
|
||||
!append_text(&writer, ",\"boards\":[\"") || !append_board(&writer, &state->boards[0], reveal[0]) ||
|
||||
!append_text(&writer, "\",\"") || !append_board(&writer, &state->boards[1], reveal[1]) ||
|
||||
!append_text(&writer, "\"],\"wins\":[") || !append_u32(&writer, wins_0) ||
|
||||
!append_character(&writer, ',') || !append_u32(&writer, wins_1) ||
|
||||
@@ -108,11 +116,11 @@ static bool write_state(const game_state_t *state, role_t viewer, const cumulati
|
||||
}
|
||||
|
||||
bool state_presenter_write(const game_state_t *state, role_t viewer, char *output, size_t output_size, size_t *written) {
|
||||
return write_state(state, viewer, NULL, output, output_size, written);
|
||||
return write_state(state, NULL, viewer, NULL, output, output_size, written);
|
||||
}
|
||||
|
||||
bool state_presenter_write_lifecycle(const game_lifecycle_t *lifecycle, role_t viewer,
|
||||
char *output, size_t output_size, size_t *written) {
|
||||
return lifecycle != NULL && write_state(&lifecycle->game.state, viewer, lifecycle->cumulative,
|
||||
return lifecycle != NULL && write_state(&lifecycle->game.state, &lifecycle->sessions, viewer, lifecycle->cumulative,
|
||||
output, output_size, written);
|
||||
}
|
||||
|
||||
@@ -98,6 +98,9 @@ static void test_sessions_commands_and_state(void) {
|
||||
assert(response.status == 200U);
|
||||
char player_2_token[33];
|
||||
token_from_response(&response, player_2_token);
|
||||
response = call(&api, HTTP_API_ROUTE_INFO, HTTP_API_GET, NULL, NULL);
|
||||
assert(response.status == 200U && strstr(response.body, "\"player1Name\":\"Alice\"") != NULL &&
|
||||
strstr(response.body, "\"player2Name\":\"Bob\"") != NULL);
|
||||
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
|
||||
"{\"name\":\"Watch\",\"requestedRole\":\"spectator\"}", NULL);
|
||||
assert(response.status == 200U);
|
||||
|
||||
@@ -39,9 +39,10 @@ static void test_role_views_and_golden_schema(void) {
|
||||
|
||||
assert(state_presenter_write(&state, ROLE_PLAYER_1, output, sizeof(output), &written));
|
||||
assert(written == strlen(output));
|
||||
assert(strstr(output, "{\"type\":\"state\",\"version\":9,\"gameId\":42,\"phase\":\"in_progress\",\"mode\":\"human\",\"viewer\":\"player1\",\"turn\":\"player2\",\"boards\":[\"") == output);
|
||||
assert(strstr(output, "{\"type\":\"state\",\"version\":9,\"gameId\":42,\"phase\":\"in_progress\",\"mode\":\"human\",\"viewer\":\"player1\",\"turn\":\"player2\",\"players\":[\"\",\"\"],\"boards\":[\"") == output);
|
||||
assert(board_start(output, 0)[0] == '1' && board_start(output, 0)[1] == '2' && board_start(output, 0)[2] == '3');
|
||||
assert_hidden(output, 1);
|
||||
assert(strstr(output, "\"players\":[\"\",\"\"]") != NULL);
|
||||
|
||||
assert(state_presenter_write(&state, ROLE_PLAYER_2, output, sizeof(output), &written));
|
||||
assert_hidden(output, 0);
|
||||
@@ -76,11 +77,21 @@ static void test_finished_and_failure_are_safe(void) {
|
||||
lifecycle.game.state.winner = 1U;
|
||||
lifecycle.game.state.statistics[0] = (match_statistics_t){.shots = 9U, .hits = 4U, .misses = 5U, .ships_sunk = 2U};
|
||||
lifecycle.game.state.statistics[1] = (match_statistics_t){.shots = 8U, .hits = 5U, .misses = 3U, .ships_sunk = 3U};
|
||||
lifecycle.sessions.entries[0] = (session_t){.occupied = true, .role = ROLE_PLAYER_1, .name = "Алиса"};
|
||||
lifecycle.sessions.entries[1] = (session_t){.occupied = true, .role = ROLE_PLAYER_2, .name = "Борис"};
|
||||
lifecycle.cumulative[0].wins = UINT16_MAX;
|
||||
lifecycle.cumulative[1].wins = UINT16_MAX;
|
||||
assert(state_presenter_write_lifecycle(&lifecycle, ROLE_SPECTATOR, output, sizeof(output), &written));
|
||||
assert(written < sizeof(output));
|
||||
assert(strstr(output, "\"winner\":1,\"statistics\":[[9,4,5,2],[8,5,3,3]]") != NULL);
|
||||
assert(strstr(output, "\"players\":[\"Алиса\",\"Борис\"]") != NULL);
|
||||
|
||||
memset(lifecycle.sessions.entries[0].name, 'A', kDisplayNameBytes);
|
||||
memset(lifecycle.sessions.entries[1].name, 'B', kDisplayNameBytes);
|
||||
lifecycle.sessions.entries[0].name[kDisplayNameBytes] = '\0';
|
||||
lifecycle.sessions.entries[1].name[kDisplayNameBytes] = '\0';
|
||||
assert(state_presenter_write_lifecycle(&lifecycle, ROLE_SPECTATOR, output, sizeof(output), &written));
|
||||
assert(written < sizeof(output));
|
||||
|
||||
lifecycle.game.state.statistics[0] = (match_statistics_t){UINT16_MAX, UINT16_MAX, UINT16_MAX, UINT8_MAX};
|
||||
lifecycle.game.state.statistics[1] = (match_statistics_t){UINT16_MAX, UINT16_MAX, UINT16_MAX, UINT8_MAX};
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const zlib = require('node:zlib');
|
||||
|
||||
const sprite = fs.readFileSync(path.join(__dirname, '../../data/ship-sprite.svg'), 'utf8');
|
||||
|
||||
test('ship sprite contains four independently drawn class symbols', () => {
|
||||
const ids = ['ship-1-cutter', 'ship-2-destroyer', 'ship-3-cruiser', 'ship-4-battleship'];
|
||||
const symbols = ids.map(id => {
|
||||
const match = sprite.match(new RegExp(`<symbol id="${id}" viewBox="([^"]+)">([\\s\\S]*?)</symbol>`));
|
||||
assert.ok(match, `${id} is present`);
|
||||
assert.match(match[1], /0 0 \d+ \d+/);
|
||||
return match[2];
|
||||
});
|
||||
assert.equal(new Set(symbols).size, ids.length);
|
||||
assert.ok(Buffer.byteLength(sprite) < 15 * 1024);
|
||||
assert.ok(zlib.gzipSync(sprite).length < 5 * 1024);
|
||||
});
|
||||
|
||||
test('fleet CSS provides a shared red sunk cross overlay', () => {
|
||||
const css = fs.readFileSync(path.join(__dirname, '../../data/styles.css'), 'utf8');
|
||||
assert.match(css, /\.fleet-ship\.sunk::before, \.fleet-ship\.sunk::after/);
|
||||
assert.match(css, /border-top: \.16rem solid #ff5f55/);
|
||||
assert.match(css, /border-radius: 99rem/);
|
||||
});
|
||||
Reference in New Issue
Block a user