feat: add player Names Throughout the Game UI
This commit is contained in:
@@ -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
|
## Milestone 017 — Execute final MVP acceptance and create the release baseline
|
||||||
|
|
||||||
**Status:** `READY`
|
**Status:** DONE
|
||||||
**Depends on:** Milestone 016
|
**Depends on:** Milestone 016
|
||||||
|
|
||||||
### Objective
|
### Objective
|
||||||
@@ -895,3 +895,231 @@ Prove every MVP readiness criterion on the physical ESP32-C6 and produce a repro
|
|||||||
### Completion action
|
### 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.
|
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.
|
||||||
|
|||||||
+22
-12
@@ -67,8 +67,8 @@
|
|||||||
return item;
|
return item;
|
||||||
};
|
};
|
||||||
ui.availability.replaceChildren(
|
ui.availability.replaceChildren(
|
||||||
availabilityItem(`Игрок 1: ${info.player1Available ? 'свободен' : 'занят'}`),
|
availabilityItem(`Игрок 1: ${info.player1Available ? 'свободен' : role === 'player1' ? 'Вы' : info.player1Name || 'занят'}`),
|
||||||
availabilityItem(`Игрок 2: ${info.player2Available ? 'свободен' : 'занят'}`),
|
availabilityItem(`Игрок 2: ${info.player2Available ? 'свободен' : role === 'player2' ? 'Вы' : info.player2Name || 'занят'}`),
|
||||||
availabilityItem(`зрительских мест: ${info.spectatorsAvailable}`),
|
availabilityItem(`зрительских мест: ${info.spectatorsAvailable}`),
|
||||||
);
|
);
|
||||||
if (state?.phase === 'lobby') renderLobby();
|
if (state?.phase === 'lobby') renderLobby();
|
||||||
@@ -110,6 +110,7 @@
|
|||||||
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' &&
|
||||||
(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) &&
|
||||||
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 &&
|
||||||
entry.every(value => Number.isInteger(value) && value >= 0));
|
entry.every(value => Number.isInteger(value) && value >= 0));
|
||||||
}
|
}
|
||||||
@@ -134,7 +135,14 @@
|
|||||||
target.x >= 0 && target.x < 10 && target.y >= 0 && target.y < 10 &&
|
target.x >= 0 && target.x < 10 && target.y >= 0 && target.y < 10 &&
|
||||||
state.boards[opponentIndex()][target.y * 10 + target.x] === '0';
|
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) {
|
async function fireTarget(target) {
|
||||||
if (!canFireTarget(target)) return false;
|
if (!canFireTarget(target)) return false;
|
||||||
@@ -221,12 +229,12 @@
|
|||||||
|
|
||||||
function boardDefinitions(result = false) {
|
function boardDefinitions(result = false) {
|
||||||
if (role === 'spectator' || result) return [
|
if (role === 'spectator' || result) return [
|
||||||
{ index: 0, title: 'Поле игрока 1', key: 'player1', targetable: false },
|
{ index: 0, title: `Поле: ${playerLabel(0)}`, key: 'player1', targetable: false },
|
||||||
{ index: 1, title: 'Поле игрока 2', key: 'player2', targetable: false }
|
{ index: 1, title: `Поле: ${playerLabel(1)}`, key: 'player2', targetable: false }
|
||||||
];
|
];
|
||||||
return [
|
return [
|
||||||
{ index: ownIndex(), title: 'Моё поле', key: 'own', targetable: false },
|
{ 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 +250,8 @@
|
|||||||
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.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.setAttribute('aria-selected', String(definition.key === activeBoard));
|
||||||
tab.addEventListener('click', () => { activeBoard = definition.key; renderGame(); });
|
tab.addEventListener('click', () => { activeBoard = definition.key; renderGame(); });
|
||||||
ui.tabs.append(tab);
|
ui.tabs.append(tab);
|
||||||
@@ -253,7 +262,8 @@
|
|||||||
function renderLobby() {
|
function renderLobby() {
|
||||||
showScreen('lobby');
|
showScreen('lobby');
|
||||||
const playerOne = role === 'player1';
|
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;
|
ui.modeControls.hidden = !playerOne;
|
||||||
if (playerOne) startLobbyInfoPolling();
|
if (playerOne) startLobbyInfoPolling();
|
||||||
else stopLobbyInfoPolling();
|
else stopLobbyInfoPolling();
|
||||||
@@ -265,8 +275,8 @@
|
|||||||
button.disabled = !playerOne;
|
button.disabled = !playerOne;
|
||||||
});
|
});
|
||||||
ui.start.disabled = !canStart;
|
ui.start.disabled = !canStart;
|
||||||
ui.modeDescription.textContent = state.mode === 'human' ? (playerTwoReady ? 'Второй игрок готов. Можно начать партию.' : 'Ожидаем подключения второго игрока.') : 'Игра против ESP32. Можно начать сразу.';
|
ui.modeDescription.textContent = state.mode === 'human' ? (playerTwoReady ? `${playerName(1)} готов. Можно начать партию.` : 'Ожидаем соперника.') : 'Игра против ESP32. Можно начать сразу.';
|
||||||
ui.lobbyHelp.textContent = playerOne ? '' : 'Игрок 1 выбирает режим и запускает партию.';
|
ui.lobbyHelp.textContent = playerOne ? '' : `${playerName(0)} выбирает режим и запускает партию.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderGame() {
|
function renderGame() {
|
||||||
@@ -275,7 +285,7 @@
|
|||||||
showScreen('game');
|
showScreen('game');
|
||||||
if (canShoot() && !selectedTarget) activeBoard = 'opponent';
|
if (canShoot() && !selectedTarget) activeBoard = 'opponent';
|
||||||
ui.turn.textContent = canShoot() ? 'Ваш ход' : `Ходит ${playerLabel(state.turn === 'player2' ? 1 : 0)}`;
|
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);
|
renderBoards(ui.boards);
|
||||||
const available = canShoot();
|
const available = canShoot();
|
||||||
ui.shotControls.hidden = !isPlayer();
|
ui.shotControls.hidden = !isPlayer();
|
||||||
@@ -291,7 +301,7 @@
|
|||||||
showScreen('result');
|
showScreen('result');
|
||||||
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 === 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 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 ? 'Ожидается подтверждение повторной игры.' : 'Поля раскрыты. Можно подтвердить повторную игру.'}`;
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ 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; }
|
.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%); }
|
#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; }
|
.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; }
|
.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; }
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ Failure (maximum 160 encoded bytes):
|
|||||||
|
|
||||||
| Route | Maximum request | Response / maximum |
|
| 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 |
|
| `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 |
|
||||||
@@ -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/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 |
|
||||||
| `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 |
|
| `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
|
The session token is in each POST body. For `GET /api/state`, it is supplied
|
||||||
@@ -65,17 +65,17 @@ parameter.
|
|||||||
|
|
||||||
## Role-safe state event
|
## 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:
|
maximum schema:
|
||||||
|
|
||||||
```json
|
```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
|
`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
|
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`.
|
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.
|
contains a board.
|
||||||
|
|
||||||
## Statistics response
|
## 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),
|
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`
|
`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
|
(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
|
failure envelope; `pong` is 16 B. Commands are idempotent when the same
|
||||||
`gameId`, `version`, and command payload are retried: the server returns the
|
`gameId`, `version`, and command payload are retried: the server returns the
|
||||||
current state rather than applying the action twice.
|
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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
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
|
## Per-milestone budget gates
|
||||||
|
|
||||||
|
|||||||
+9
-2
@@ -271,7 +271,7 @@ components:
|
|||||||
properties: { role: { $ref: '#/components/schemas/Role' } }
|
properties: { role: { $ref: '#/components/schemas/Role' } }
|
||||||
Info:
|
Info:
|
||||||
type: object
|
type: object
|
||||||
required: [ok, phase, gameId, version, player1Available, player2Available, spectatorsAvailable]
|
required: [ok, phase, gameId, version, player1Available, player2Available, player1Name, player2Name, spectatorsAvailable]
|
||||||
properties:
|
properties:
|
||||||
ok: { type: boolean, enum: [true] }
|
ok: { type: boolean, enum: [true] }
|
||||||
phase: { $ref: '#/components/schemas/Phase' }
|
phase: { $ref: '#/components/schemas/Phase' }
|
||||||
@@ -279,6 +279,8 @@ components:
|
|||||||
version: { $ref: '#/components/schemas/GameId' }
|
version: { $ref: '#/components/schemas/GameId' }
|
||||||
player1Available: { type: boolean }
|
player1Available: { type: boolean }
|
||||||
player2Available: { type: boolean }
|
player2Available: { type: boolean }
|
||||||
|
player1Name: { type: string, maxLength: 20 }
|
||||||
|
player2Name: { type: string, maxLength: 20 }
|
||||||
spectatorsAvailable: { type: integer, minimum: 0, maximum: 8 }
|
spectatorsAvailable: { type: integer, minimum: 0, maximum: 8 }
|
||||||
Health:
|
Health:
|
||||||
type: object
|
type: object
|
||||||
@@ -311,7 +313,7 @@ components:
|
|||||||
description: '[games, wins, losses, shipsSunk, shots, hits, misses]'
|
description: '[games, wins, losses, shipsSunk, shots, hits, misses]'
|
||||||
State:
|
State:
|
||||||
type: object
|
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:
|
properties:
|
||||||
type: { type: string, enum: [state] }
|
type: { type: string, enum: [state] }
|
||||||
version: { $ref: '#/components/schemas/GameId' }
|
version: { $ref: '#/components/schemas/GameId' }
|
||||||
@@ -320,6 +322,11 @@ components:
|
|||||||
mode: { $ref: '#/components/schemas/Mode' }
|
mode: { $ref: '#/components/schemas/Mode' }
|
||||||
viewer: { $ref: '#/components/schemas/Role' }
|
viewer: { $ref: '#/components/schemas/Role' }
|
||||||
turn: { type: string, enum: [player1, player2] }
|
turn: { type: string, enum: [player1, player2] }
|
||||||
|
players:
|
||||||
|
type: array
|
||||||
|
minItems: 2
|
||||||
|
maxItems: 2
|
||||||
|
items: { type: string, maxLength: 20 }
|
||||||
boards:
|
boards:
|
||||||
type: array
|
type: array
|
||||||
minItems: 2
|
minItems: 2
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ enum {
|
|||||||
kSessionCapacity = kPlayerCapacity + kSpectatorCapacity,
|
kSessionCapacity = kPlayerCapacity + kSpectatorCapacity,
|
||||||
kCommandQueueCapacity = 16,
|
kCommandQueueCapacity = 16,
|
||||||
kBotKnowledgeCellCount = kBoardCellCount,
|
kBotKnowledgeCellCount = kBoardCellCount,
|
||||||
kStateMessageCapacity = 512,
|
kStateMessageCapacity = 768,
|
||||||
kRequestBodyCapacity = 192,
|
kRequestBodyCapacity = 192,
|
||||||
kSessionTokenBytes = 16,
|
kSessionTokenBytes = 16,
|
||||||
kDisplayNameBytes = 80,
|
kDisplayNameBytes = 80,
|
||||||
|
|||||||
+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;
|
uint8_t spectators = 0;
|
||||||
for (uint8_t index = kPlayerCapacity; index < kSessionCapacity; ++index) spectators += sessions->entries[index].occupied;
|
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
|
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,
|
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 ? "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));
|
(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) {
|
if (request->route == HTTP_API_ROUTE_HEALTH && request->method == HTTP_API_GET) {
|
||||||
response_write(response, 200U, "{\"ok\":true,\"uptimeMs\":%" PRIu32 ",\"wifiState\":\"%s\",\"freeHeapBytes\":%" PRIu32
|
response_write(response, 200U, "{\"ok\":true,\"uptimeMs\":%" PRIu32 ",\"wifiState\":\"%s\",\"freeHeapBytes\":%" PRIu32
|
||||||
|
|||||||
@@ -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]) {
|
static bool sanitize_name(const char *name, char output[kDisplayNameBytes + 1]) {
|
||||||
if (name == NULL) return false;
|
if (name == NULL) return false;
|
||||||
size_t input = 0;
|
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;
|
size_t written = 0;
|
||||||
uint8_t scalars = 0;
|
uint8_t scalars = 0;
|
||||||
while (name[input] != '\0') {
|
while (input < end) {
|
||||||
size_t bytes = 0;
|
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;
|
if (written + bytes > kDisplayNameBytes || ++scalars > 20U) return false;
|
||||||
memcpy(&output[written], &name[input], bytes);
|
memcpy(&output[written], &name[input], bytes);
|
||||||
input += bytes;
|
input += bytes;
|
||||||
|
|||||||
+12
-4
@@ -16,6 +16,13 @@ static bool append_text(state_writer_t *writer, const char *text) {
|
|||||||
return true;
|
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) {
|
static bool append_u32(state_writer_t *writer, uint32_t value) {
|
||||||
char digits[10];
|
char digits[10];
|
||||||
uint8_t count = 0;
|
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, ']');
|
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) {
|
char *output, size_t output_size, size_t *written) {
|
||||||
if (written != NULL) *written = 0;
|
if (written != NULL) *written = 0;
|
||||||
if (state == NULL || output == NULL || output_size == 0U || phase_name(state->phase) == NULL ||
|
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, "\",\"mode\":\"") || !append_text(&writer, mode_name(state->mode)) ||
|
||||||
!append_text(&writer, "\",\"viewer\":\"") || !append_text(&writer, role_name(viewer)) ||
|
!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, "\",\"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, "\",\"") || !append_board(&writer, &state->boards[1], reveal[1]) ||
|
||||||
!append_text(&writer, "\"],\"wins\":[") || !append_u32(&writer, wins_0) ||
|
!append_text(&writer, "\"],\"wins\":[") || !append_u32(&writer, wins_0) ||
|
||||||
!append_character(&writer, ',') || !append_u32(&writer, wins_1) ||
|
!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) {
|
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,
|
bool state_presenter_write_lifecycle(const game_lifecycle_t *lifecycle, role_t viewer,
|
||||||
char *output, size_t output_size, size_t *written) {
|
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);
|
output, output_size, written);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ static void test_sessions_commands_and_state(void) {
|
|||||||
assert(response.status == 200U);
|
assert(response.status == 200U);
|
||||||
char player_2_token[33];
|
char player_2_token[33];
|
||||||
token_from_response(&response, player_2_token);
|
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,
|
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
|
||||||
"{\"name\":\"Watch\",\"requestedRole\":\"spectator\"}", NULL);
|
"{\"name\":\"Watch\",\"requestedRole\":\"spectator\"}", NULL);
|
||||||
assert(response.status == 200U);
|
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(state_presenter_write(&state, ROLE_PLAYER_1, output, sizeof(output), &written));
|
||||||
assert(written == strlen(output));
|
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(board_start(output, 0)[0] == '1' && board_start(output, 0)[1] == '2' && board_start(output, 0)[2] == '3');
|
||||||
assert_hidden(output, 1);
|
assert_hidden(output, 1);
|
||||||
|
assert(strstr(output, "\"players\":[\"\",\"\"]") != NULL);
|
||||||
|
|
||||||
assert(state_presenter_write(&state, ROLE_PLAYER_2, output, sizeof(output), &written));
|
assert(state_presenter_write(&state, ROLE_PLAYER_2, output, sizeof(output), &written));
|
||||||
assert_hidden(output, 0);
|
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.winner = 1U;
|
||||||
lifecycle.game.state.statistics[0] = (match_statistics_t){.shots = 9U, .hits = 4U, .misses = 5U, .ships_sunk = 2U};
|
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.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[0].wins = UINT16_MAX;
|
||||||
lifecycle.cumulative[1].wins = UINT16_MAX;
|
lifecycle.cumulative[1].wins = UINT16_MAX;
|
||||||
assert(state_presenter_write_lifecycle(&lifecycle, ROLE_SPECTATOR, output, sizeof(output), &written));
|
assert(state_presenter_write_lifecycle(&lifecycle, ROLE_SPECTATOR, output, sizeof(output), &written));
|
||||||
assert(written < sizeof(output));
|
assert(written < sizeof(output));
|
||||||
assert(strstr(output, "\"winner\":1,\"statistics\":[[9,4,5,2],[8,5,3,3]]") != NULL);
|
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[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};
|
lifecycle.game.state.statistics[1] = (match_statistics_t){UINT16_MAX, UINT16_MAX, UINT16_MAX, UINT8_MAX};
|
||||||
|
|||||||
Reference in New Issue
Block a user