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 · локальная игра

Морской бой

-

Подключение…

+

Подключение…

@@ -73,6 +73,7 @@
+ diff --git a/data/ship-sprite.svg b/data/ship-sprite.svg index d5c67bd..bafbc7c 100644 --- a/data/ship-sprite.svg +++ b/data/ship-sprite.svg @@ -19,4 +19,6 @@ + + diff --git a/data/styles.css b/data/styles.css index 44f6345..427e775 100644 --- a/data/styles.css +++ b/data/styles.css @@ -34,6 +34,8 @@ h1, h2, h3, p { margin-top: 0; } h1 { margin-bottom: .2rem; font-size: clamp(1.7 @media (min-width: 44rem) and (orientation: portrait) and (max-width: 64rem) { #screen-game .boards { grid-template-columns: minmax(0, 1fr); } #screen-game .board-tabs { display: flex; } #screen-game .board[hidden] { display: none; } } .ui-icon { display: inline-block; width: 1.6em; height: 1.6em; flex: 0 0 auto; fill: currentColor; vertical-align: -.32em; } .title-icon { width: 1.25em; } .icon-button { display: inline-flex; align-items: center; justify-content: center; gap: .55rem; } .icon-button > .ui-icon { width: 1.55rem; height: 1.55rem; } +.header-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: end; gap: .5rem; } .recovery-menu-button { display: inline-flex; align-items: center; justify-content: center; gap: .35rem; min-width: 2.75rem; min-height: 2.75rem; padding: .45rem .65rem; background: #31536e; color: #f1f7ff; } .recovery-menu-button .ui-icon { width: 1.25rem; height: 1.25rem; } +.recovery-overlay { position: fixed; z-index: 120; inset: 0; display: grid; place-items: center; padding: max(1rem, env(safe-area-inset-top)) max(1rem, env(safe-area-inset-right)) max(1rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left)); background: rgb(2 13 23 / 76%); } .recovery-overlay[hidden] { display: none; } .recovery-dialog { position: relative; width: min(100%, 31rem); max-height: min(42rem, 100svh - 2rem); overflow: auto; padding: 1.25rem; border: 1px solid #6096b5; border-radius: 1rem; background: #08273e; box-shadow: 0 1.5rem 4rem #000; } .recovery-dialog h2 { margin-right: 2rem; } .recovery-close { position: absolute; top: .55rem; right: .75rem; color: #d9edf7 !important; font-size: 1.8rem; text-decoration: none !important; } .recovery-choices { display: grid; gap: .65rem; } .recovery-choices button { display: grid; grid-template-columns: 2.4rem minmax(0, 1fr); align-items: center; gap: .65rem; min-height: 4.35rem; border: 1px solid #4d7f9d; background: #123b58; color: #f1f7ff; text-align: left; } .recovery-choices button .ui-icon { width: 2rem; height: 2rem; color: #b9ecff; } .recovery-choices small { display: block; margin-top: .12rem; color: #bfd9e8; font-weight: 500; } .recovery-global { margin-top: .5rem; border-style: dashed !important; background: #293a49 !important; } .recovery-global .ui-icon { color: #ffd48b !important; } .recovery-confirmation { display: grid; gap: .75rem; } .recovery-scope-icon .ui-icon { width: 2.75rem; height: 2.75rem; color: #b9ecff; } .recovery-confirm-actions { display: grid; grid-template-columns: 1fr 1fr; gap: .65rem; } .recovery-confirm { position: relative; overflow: hidden; background: #c75c4e; color: #fff; } .recovery-hold-progress { position: absolute; inset: auto 0 0; height: .28rem; width: 0; background: #ffe56a; } .recovery-confirm.holding .recovery-hold-progress { width: 100%; transition: width 2s linear; } .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } .kid-hint { display: flex; align-items: center; gap: .5rem; margin: .8rem 0 .55rem; color: #dff6ff; font-size: 1.05rem; font-weight: 800; } .step-number { display: inline-grid; place-items: center; width: 1.7rem; height: 1.7rem; border-radius: 50%; background: #ffe56a; color: #08233d; font-size: 1rem; } .avatar-picker { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: .55rem; margin-bottom: .85rem; } .avatar-button { display: grid; place-items: center; gap: .25rem; min-height: 5.4rem; padding: .45rem; border: 2px solid #41708d; background: #123b58; color: #f1f7ff; } @@ -63,4 +65,5 @@ body.fx-damage .app-shell { animation: ship-shake .5s ease-in-out 2; } body.fx-h @keyframes action-pulse { 50% { filter: brightness(1.16); transform: scale(1.025); } } @keyframes cue-pulse { 50% { box-shadow: 0 0 0 .35rem rgb(255 229 106 / 18%); transform: scale(1.015); } } @keyframes target-ripple { 50% { filter: brightness(1.35); } } @keyframes fire-pulse { 50% { box-shadow: 0 0 0 .35rem rgb(255 157 61 / 25%); transform: scale(1.035); } } @keyframes reward-pop { 0% { opacity: 0; transform: scale(.35) rotate(-8deg); } 45% { opacity: 1; transform: scale(1.12) rotate(3deg); } 100% { transform: scale(1) rotate(0); } } @keyframes reward-bounce { 0% { opacity: 0; transform: translateY(55vh) scale(.8); } 55% { opacity: 1; transform: translateY(-1.2rem) scale(1.06); } 75% { transform: translateY(.5rem) scale(.98); } 100% { transform: none; } } @keyframes reward-spin { 0% { opacity: 0; transform: scale(.2) rotate(-210deg); } 65% { opacity: 1; transform: scale(1.12) rotate(12deg); } 100% { transform: none; } } @keyframes reward-swoop { 0% { opacity: 0; transform: translateX(-110vw) rotate(-14deg); } 65% { opacity: 1; transform: translateX(1rem) rotate(2deg); } 100% { transform: none; } } @keyframes reward-zoom { 0% { opacity: 0; transform: scale(2.4); filter: blur(.5rem); } 65% { opacity: 1; transform: scale(.92); filter: blur(0); } 100% { transform: scale(1); } } @keyframes reward-combo { 0% { opacity: 0; transform: scale(.1) rotate(-18deg); } 35% { opacity: 1; transform: scale(1.28) rotate(7deg); } 58% { transform: scale(.9) rotate(-3deg); } 78% { transform: scale(1.08) rotate(1deg); } 100% { transform: none; } } @keyframes badge-slam { from { opacity: 0; transform: translateY(-4rem) scale(2) rotate(8deg); } } @keyframes combo-icon { to { transform: scale(1.18) rotate(8deg); filter: brightness(1.35); } } @keyframes screen-danger { 50% { background: rgb(255 20 20 / 58%); } } @keyframes ship-shake { 20%, 60% { transform: translateX(-.45rem); } 40%, 80% { transform: translateX(.45rem); } } @keyframes screen-glow { 50% { filter: brightness(1.28) saturate(1.25); } } @keyframes burst-away { to { opacity: 0; translate: var(--x) var(--y); rotate: var(--spin); scale: .7; } } @keyframes bubble-away { to { opacity: 0; translate: var(--x) calc(var(--y) - 22vh); scale: 1.7; } } @media (max-width: 43.99rem) { .app-shell { padding-top: .75rem; } .app-header .eyebrow { display: none; } .app-header h1 { font-size: 1.65rem; } .avatar-picker { gap: .35rem; } .avatar-button { min-height: 4.8rem; padding-inline: .2rem; } .connection-support { display: none; } #screen-game .shot-controls { grid-template-columns: 1fr 1fr; position: sticky; z-index: 8; top: .25rem; margin-bottom: .6rem; box-shadow: 0 .5rem 1.4rem rgb(0 0 0 / 35%); } #screen-game .shot-controls p { grid-column: 1 / -1; text-align: center; } #screen-game #fire-button, #screen-game #cancel-target { min-height: 3.4rem; } } +@media (max-width: 43.99rem) { .header-actions { justify-content: space-between; width: 100%; } .recovery-menu-button span { font-size: .85rem; } .recovery-dialog { width: 100%; padding: 1rem; } .recovery-confirm-actions { grid-template-columns: 1fr; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .001ms !important; animation-iteration-count: 1 !important; } } diff --git a/test/web/test_recovery_ui.js b/test/web/test_recovery_ui.js new file mode 100644 index 0000000..7f78891 --- /dev/null +++ b/test/web/test_recovery_ui.js @@ -0,0 +1,28 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const app = fs.readFileSync(path.join(__dirname, '../../data/app.js'), 'utf8'); +const page = fs.readFileSync(path.join(__dirname, '../../data/index.html'), 'utf8'); + +test('recovery UI is unavailable on registration and role-gates the global action', () => { + assert.match(app, /name === 'connect' \|\| !token/); + assert.match(app, /ui\.recoveryGlobal\.hidden = !isPlayer\(\)/); + assert.match(page, /id="recovery-menu-button"[^>]*hidden/); +}); + +test('recovery actions use distinct routes and clear the intended local scope', () => { + assert.match(app, /\/api\/session\/leave/); + assert.match(app, /\/api\/session\/profile-reset/); + assert.match(app, /\/api\/game\/reset/); + assert.match(app, /key\.startsWith\('battleship\.'\)/); + assert.match(app, /clearLocalProfile\(action !== 'leave'\)/); +}); + +test('global reset needs a timed hold while keyboard confirmation remains available', () => { + assert.match(app, /window\.setTimeout\(submitRecovery, 2000\)/); + assert.match(app, /pointercancel/); + assert.match(app, /event\.detail === 0/); + assert.match(app, /event\.key === 'Escape'/); +});