ESP32-C6 · локальная игра
Морской бой
Подключение…
+Подключение…
Восстанавливаем связь
Состояние игры обновляется через HTTP. WebSocket подключится автоматически.
diff --git a/PLANS.md b/PLANS.md
index 0a5d1b8..09746d6 100644
--- a/PLANS.md
+++ b/PLANS.md
@@ -1661,7 +1661,7 @@ When all criteria pass, set Milestone 021 to `DONE`, append its execution record
## Milestone 022 — Add the always-available recovery menu and child-safe confirmations
-**Status:** `READY`
+**Status:** `DONE`
**Depends on:** Milestone 021
### Objective
@@ -1757,11 +1757,22 @@ Add tests covering at least:
When all criteria pass, set Milestone 022 to `DONE`, append its execution record, and change Milestone 023 from `BLOCKED` to `READY`. Do not start Milestone 023 in the same task unless explicitly requested.
+### Execution record
+
+- Date: 2026-08-30
+- Board model and revision: ESP32-C6FH4 QFN32, revision v0.2.
+- Toolchain and library versions: PlatformIO Core 6.1.19; `espressif32` 7.0.1; ESP-IDF 6.0.1; `esp_littlefs` 1.20.4.
+- Result: PASS, pending physical-device layout confirmation.
+- Evidence: Added a 44-pixel lifebuoy-labelled recovery control outside the gameplay surfaces. It is hidden at registration and visible in lobby, game, result, reconnecting, and error states with an active session. Its modal has focus containment, Escape/close restoration, role-gated full reset, separate icon/text scopes, and cancellation as the initial confirmation focus. Leave and profile reset require a second explicit confirmation; full reset requires a two-second pointer hold with visible progress, while keyboard activation supplies the accessible equivalent. Successful leave preserves the name; profile/full reset clears all `battleship.*` browser data, selection, cached state/statistics, effects, timers, polling, and socket retry state before showing registration. Offline profile reset is explicitly local-only and warns about the retained server seat.
+- Measurements: `node --test test/web/test_target_interaction.js test/web/test_ship_sprite.js test/web/test_recovery_ui.js` passed 15/15, including recovery visibility, role gate, scope clearing, hold/cancel, keyboard, and Escape assertions. `make -C test/host run` passed all ten host suites. `node --check data/app.js`, `git diff --check`, `pio run -e esp32-c6-devkitm-1 -t buildfs`, and `pio run -e esp32-c6-devkitm-1` passed. Browser assets are 43,011 B / 10,622 B gzip JavaScript, 23,570 B / 5,589 B gzip CSS, and 4,466 B / 1,633 B gzip SVG. The LittleFS image is the fixed 2,031,616-B partition image. Firmware remains 39,604 / 327,680 B RAM (12.1%) and 1,020,806 / 2,097,152 B flash (48.7%).
+- Issues or deviations: No attached browser backend is available for live phone/tablet screenshots, and no firmware/filesystem upload or physical recovery exercise was performed.
+- Next action: Milestone 023 is READY. Do not start it unless explicitly requested.
+
---
## Milestone 023 — Verify multi-client reset recovery and document the emergency workflow
-**Status:** `BLOCKED`
+**Status:** `READY`
**Depends on:** Milestone 022
### Objective
diff --git a/data/app.js b/data/app.js
index a59d466..8d6c96b 100644
--- a/data/app.js
+++ b/data/app.js
@@ -11,7 +11,8 @@
fire: el('fire-button'), cancel: el('cancel-target'), abort: el('abort-game'), rematch: el('rematch-button'),
result: el('result-description'), error: el('error-description'), lobbyIcon: el('lobby-icon'), resultIcon: el('result-icon'),
cue: el('action-cue'), cueIcon: el('action-cue-icon'), cueText: el('action-cue-text'), effects: el('game-effects'),
- effectIcon: el('effect-icon'), effectText: el('effect-text'), effectBurst: el('effect-burst'), effectBadge: el('effect-badge')
+ effectIcon: el('effect-icon'), effectText: el('effect-text'), effectBurst: el('effect-burst'), effectBadge: el('effect-badge'),
+ recoveryButton: el('recovery-menu-button'), recoveryDialog: el('recovery-dialog'), recoveryClose: el('recovery-close'), recoveryChoices: el('recovery-choices'), recoveryGlobal: el('recovery-global-choice'), recoveryConfirmation: el('recovery-confirmation'), recoveryCancel: el('recovery-cancel'), recoveryConfirm: el('recovery-confirm'), recoveryConfirmTitle: el('recovery-confirm-title'), recoveryConfirmText: el('recovery-confirm-text'), recoveryConfirmLabel: el('recovery-confirm-label'), recoveryHoldNote: el('recovery-hold-note'), recoveryScopeIcon: el('recovery-scope-icon'), recoveryLive: el('recovery-live')
};
let token = localStorage.getItem(storage.token) || '';
let role = '';
@@ -31,6 +32,10 @@
let retryIndex = 0;
let effectTimer;
let hitStreak = 0;
+ let recoveryAction;
+ let recoveryPending = false;
+ let recoveryHoldTimer;
+ let recoveryOpener;
const reaction = (kind, icon, text, motion = 'pop', burst = 'none', vibration = [45]) => ({ kind, icon, text, motion, burst, vibration });
const feedback = {
@@ -190,6 +195,68 @@
function showScreen(name) {
screens.forEach(screen => { el(`screen-${screen}`).hidden = screen !== name; });
+ ui.recoveryButton.hidden = name === 'connect' || !token;
+ }
+
+ function clearRecoveryTimers() {
+ window.clearTimeout(retryTimer); retryTimer = undefined;
+ window.clearInterval(pollTimer); pollTimer = undefined;
+ window.clearInterval(heartbeatTimer); heartbeatTimer = undefined;
+ window.clearInterval(lobbyInfoTimer); lobbyInfoTimer = undefined;
+ window.clearTimeout(effectTimer); effectTimer = undefined;
+ window.clearTimeout(recoveryHoldTimer); recoveryHoldTimer = undefined;
+ }
+
+ function clearLocalProfile(profile) {
+ clearRecoveryTimers();
+ if (socket) { socket.onclose = null; socket.close(); socket = undefined; }
+ selectedTarget = undefined; firePending = false; cumulativeStatistics = undefined; statisticsGameId = undefined;
+ activeBoard = 'own'; hitStreak = 0; state = undefined; role = ''; info = undefined; targetActivator = undefined;
+ document.body.classList.remove('fx-hit', 'fx-damage'); ui.effects.hidden = true; notify('');
+ Object.keys(localStorage).filter(key => key.startsWith('battleship.')).forEach(key => localStorage.removeItem(key));
+ if (!profile) { const rememberedName = ui.name.value.trim(); if (rememberedName) localStorage.setItem(storage.name, rememberedName); }
+ token = ''; ui.name.value = profile ? '' : (localStorage.getItem(storage.name) || '');
+ closeRecovery(); showScreen('connect'); setConnection('Готово к подключению');
+ }
+
+ function closeRecovery() {
+ window.clearTimeout(recoveryHoldTimer); recoveryHoldTimer = undefined; recoveryAction = undefined; recoveryPending = false;
+ ui.recoveryDialog.hidden = true; ui.recoveryButton.setAttribute('aria-expanded', 'false');
+ if (recoveryOpener) recoveryOpener.focus();
+ }
+
+ function recoveryIcon(symbol) { setSpriteIcon(ui.recoveryScopeIcon, symbol); }
+ function showRecoveryConfirmation(action) {
+ recoveryAction = action; ui.recoveryChoices.hidden = true; ui.recoveryConfirmation.hidden = false;
+ const game = action === 'game'; const profile = action === 'profile';
+ recoveryIcon(game ? 'icon-players' : 'icon-person');
+ ui.recoveryConfirmTitle.textContent = game ? 'Сбросить всю игру?' : profile ? 'Сбросить мой профиль?' : 'Выйти из игры?';
+ ui.recoveryConfirmText.textContent = game ? 'Все игроки вернутся к началу.' : profile ? 'На этом устройстве очистятся имя и данные.' : 'Вы вернётесь к подключению. Имя останется.';
+ ui.recoveryConfirmLabel.textContent = game ? 'Удерживать для сброса' : profile ? 'Сбросить профиль' : 'Выйти';
+ ui.recoveryHoldNote.hidden = !game; ui.recoveryConfirm.classList.remove('holding'); ui.recoveryConfirm.disabled = false;
+ ui.recoveryCancel.focus();
+ }
+
+ function openRecovery() {
+ if (!token) return; recoveryOpener = document.activeElement; ui.recoveryDialog.hidden = false; ui.recoveryButton.setAttribute('aria-expanded', 'true');
+ ui.recoveryChoices.hidden = false; ui.recoveryConfirmation.hidden = true; ui.recoveryGlobal.hidden = !isPlayer(); ui.recoveryClose.focus();
+ }
+
+ async function submitRecovery() {
+ if (!recoveryAction || recoveryPending) return;
+ recoveryPending = true; ui.recoveryConfirm.disabled = true; ui.recoveryLive.textContent = 'Выполняем действие…';
+ const action = recoveryAction;
+ if (action === 'profile' && navigator.onLine === false) {
+ clearLocalProfile(true);
+ notify('Профиль очищен на этом устройстве. Место на ESP32 освободится после восстановления связи или таймаута.');
+ return;
+ }
+ try {
+ if (action === 'leave') await request('/api/session/leave', { method: 'POST', headers: headers(), body: JSON.stringify({ token, gameId: state?.gameId || 0 }) });
+ else if (action === 'profile') await request('/api/session/profile-reset', { method: 'POST', headers: headers(), body: JSON.stringify({ token, gameId: state?.gameId || 0 }) });
+ else await request('/api/game/reset', { method: 'POST', headers: headers(), body: JSON.stringify({ token, gameId: state?.gameId || 0 }) });
+ clearLocalProfile(action !== 'leave');
+ } catch (error) { recoveryPending = false; ui.recoveryConfirm.disabled = false; ui.recoveryLive.textContent = 'Не получилось. Можно повторить или отменить.'; notify(error.message, true); }
}
function setConnection(text, status) {
@@ -590,6 +657,8 @@
if (message.type === 'state') {
acceptState(message); retryIndex = 0; stopPolling(); setConnection('Синхронизация онлайн', 'online');
if (!heartbeatTimer) heartbeatTimer = window.setInterval(() => { if (socket?.readyState === WebSocket.OPEN) socket.send('{"type":"ping"}'); }, 15000);
+ } else if (message.type === 'reset') {
+ clearLocalProfile(true);
} else if (message.ok === false) notify(apiError(message), true);
} catch (_) { socket.close(); }
};
@@ -620,6 +689,31 @@
ui.abort.addEventListener('click', () => post('/api/game/abort', { token, gameId: state.gameId }));
ui.rematch.addEventListener('click', () => post('/api/game/rematch', { token, gameId: state.gameId }));
el('retry-button').addEventListener('click', () => { refreshInfo(); pollState(); connectSocket(); });
+ ui.recoveryButton.addEventListener('click', openRecovery);
+ ui.recoveryClose.addEventListener('click', closeRecovery);
+ ui.recoveryCancel.addEventListener('click', () => { recoveryAction = undefined; ui.recoveryConfirmation.hidden = true; ui.recoveryChoices.hidden = false; ui.recoveryClose.focus(); });
+ ui.recoveryChoices.addEventListener('click', event => {
+ const choice = event.target.closest('[data-recovery-action]');
+ if (choice && !choice.hidden) showRecoveryConfirmation(choice.dataset.recoveryAction);
+ });
+ ui.recoveryConfirm.addEventListener('click', event => { if (recoveryAction !== 'game' || event.detail === 0) submitRecovery(); });
+ ui.recoveryConfirm.addEventListener('pointerdown', event => {
+ if (recoveryAction !== 'game' || event.button !== 0 || recoveryPending) return;
+ ui.recoveryConfirm.classList.add('holding'); recoveryHoldTimer = window.setTimeout(submitRecovery, 2000);
+ });
+ ['pointerup', 'pointercancel', 'pointerleave'].forEach(type => ui.recoveryConfirm.addEventListener(type, () => {
+ if (recoveryHoldTimer) { window.clearTimeout(recoveryHoldTimer); recoveryHoldTimer = undefined; ui.recoveryConfirm.classList.remove('holding'); ui.recoveryLive.textContent = 'Удерживание отменено.'; }
+ }));
+ document.addEventListener('keydown', event => {
+ if (ui.recoveryDialog.hidden) return;
+ if (event.key === 'Escape') { event.preventDefault(); closeRecovery(); }
+ else if (event.key === 'Tab') {
+ const items = [...ui.recoveryDialog.querySelectorAll('button:not([hidden]):not(:disabled)')];
+ const first = items[0]; const last = items[items.length - 1];
+ if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
+ }
+ });
async function boot() {
ui.name.value = localStorage.getItem(storage.name) || '';
diff --git a/data/index.html b/data/index.html
index fcc34fd..3851d8f 100644
--- a/data/index.html
+++ b/data/index.html
@@ -15,7 +15,7 @@
ESP32-C6 · локальная игра Подключение… Подключение… Состояние игры обновляется через HTTP. WebSocket подключится автоматически. Морской бой
Восстанавливаем связь
Не удалось продолжить