Compare commits

..

8 Commits

34 changed files with 1516 additions and 88 deletions
+200 -8
View File
@@ -60,7 +60,7 @@ Evidence should include the exact test scenario, relevant logs, reset reasons, r
## Milestone 000 — Identify the board and lock the technical baseline
**Status:** `DONE`
**Status:** `DONE`
**Depends on:** none
### Objective
@@ -114,7 +114,7 @@ If all criteria pass, set this milestone to `DONE`, append its execution record,
## Milestone 001 — Prove build, flashing, and stable basic operation
**Status:** `DONE`
**Status:** `DONE`
**Depends on:** Milestone 000
### Objective
@@ -908,7 +908,7 @@ If all criteria pass, set this milestone to `DONE` and append its execution reco
- Next action: Milestone 018 is READY. Do not start Milestone 019.
# Milestone 018 — Player Names Throughout the Game UI
## Milestone 018 — Player Names Throughout the Game UI
**Status:** DONE
**Depends on:** Milestone 017
@@ -1124,7 +1124,7 @@ At completion, report:
- 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
## Milestone 019 — Amendment: Distinct Ship-Class Silhouettes and Red Sunk Markers
**Status:** DONE
**Depends on:** Milestone 018
@@ -1338,7 +1338,7 @@ After implementation, report:
- Next action: Milestone 020 is not started.
# Milestone 020 — Amendment: Recognizable Early-20th-Century Warship Silhouettes
## Milestone 020 — Amendment: Recognizable Early-20th-Century Warship Silhouettes
**Status:** DONE
**Depends on:** Milestone 019
@@ -1848,7 +1848,7 @@ When all criteria pass, set Milestone 023 to `DONE` and append its execution rec
## Milestone 024 — Build a bounded browser Web Audio engine and sound controls
**Status:** `BLOCKED`
**Status:** `DONE`
**Depends on:** Milestone 023
### Objective
@@ -1946,11 +1946,19 @@ Add browser/unit tests with a fake or instrumented audio context covering at lea
When all criteria pass, set Milestone 024 to `DONE`, append its execution record, and change Milestone 025 from `BLOCKED` to `READY`. Do not start Milestone 025 in the same task unless explicitly requested.
### Execution record
- Date: 2026-08-31
- Result: PASS.
- Evidence: Added a framework-free `web_audio.js` module with deferred user-gesture activation, one reusable noise buffer, an eight-voice limit with priority replacement, capped master gain and compressor, duplicate-version suppression, page-visibility handling, cancellation/reset cleanup, and a silent unsupported-browser fallback. Added Russian sound on/off, volume, reduced-intensity, and recovery-screen mute controls without changing game transport or role behavior. The ESP32 static-file allowlist now serves `/web_audio.js`; the user confirmed HTTP 200 delivery after the firmware and LittleFS update.
- Verification: All ten host suites passed. All six browser suites, including fake/instrumented AudioContext coverage, passed; `node --check data/app.js` and `node --check data/web_audio.js` passed; `git diff --check` passed. Firmware and LittleFS builds passed. Firmware uses 39,604 / 327,680 B RAM (12.1%) and 1,020,956 / 2,097,152 B flash (48.7%). `web_audio.js` is 7,863 B raw and 2,072 B gzip; all compressed browser assets total 24,947 B, within the 250,000 B LittleFS budget.
- Next action: Milestone 025 is ready. Do not start Milestone 026 as part of this task.
---
## Milestone 025 — Create a large randomized library of playful game sounds and audio combos
**Status:** `BLOCKED`
**Status:** `DONE`
**Depends on:** Milestone 024
### Objective
@@ -2104,11 +2112,19 @@ Add deterministic tests using injected randomness and a fake audio clock coverin
When all criteria pass, set Milestone 025 to `DONE`, append its execution record, and change Milestone 026 from `BLOCKED` to `READY`. Do not start Milestone 026 in the same task unless explicitly requested.
### Execution record
- Date: 2026-08-31
- Result: PASS.
- Evidence: Added `data/game_sounds.js`, a bounded original synthesized-preset director with 86 parameterized cue presets: selection (4), shot (8), miss (10), hit (10), sunk (8), dodge (6), incoming damage (6), turn (6), waiting (4), start (6), victory (8), defeat/rematch (5), and five distinct calm recovery cues. It uses a three-entry anti-repeat history, browser randomness only, safe parameter ranges, version deduplication, perspective-safe public event mapping, reconnect summary handling, waiting rate limiting, reduced-intensity suppression, and bounded combo layers. Integrated it with accepted local shots, target selection, authoritative state feedback, rematch, and recovery controls; no game, transport, role, or server-state behavior changed.
- Verification: All ten host suites passed. All seven browser suites passed, including deterministic catalog-count, anti-repeat, priority, perspective, combo, reduced-intensity, recovery, duplicate-version, and 3,000-selection stress coverage. `node --check` passed for `app.js`, `web_audio.js`, and `game_sounds.js`; `git diff --check` passed. Firmware and LittleFS builds passed. Firmware uses 39,604 / 327,680 B RAM (12.1%) and 1,020,994 / 2,097,152 B flash (48.7%). `game_sounds.js` is 4,538 B raw / 1,580 B gzip; the seven compressed browser assets total 27,023 B, within the 250,000 B LittleFS budget.
- Next action: Milestone 026 is ready. Do not start it as part of this task.
---
## Milestone 026 — Validate Web Audio engagement, compatibility, and long-run stability on real devices
**Status:** `BLOCKED`
**Status:** `DONE`
**Depends on:** Milestone 025
### Objective
@@ -2193,3 +2209,179 @@ Do not record children, collect identifying data, or conduct unsupervised testin
### Completion action
When all criteria pass, set Milestone 026 to `DONE` and append its execution record. Add later milestones only after Milestone 026 without renumbering existing milestones.
### Execution record
- Date: 2026-09-01
- Result: PASS.
- Evidence: The user completed the required real-device validation with no issues found: the Android phone Chromium browser, Android tablet/second device, iPhone/iPad Safari when available, and laptop browser matrix; portrait and landscape; sound on/muted/quiet/normal/loud/reduced-intensity; WebSocket and HTTP polling fallback; explicit activation, preference persistence, immediate mute, target/shot/result/combo/recovery cues, background/foreground handling, reset behavior, spectator privacy, and silent fallback. The required 30-shot anti-repeat observation, 20 representative sound-enabled games, 60-minute mixed session, and adult-proxy child-engagement/safety review also passed without browser errors, stuck audio, audio-node/timer growth, transport regression, ESP32 instability, excessive loudness, frightening cues, or hidden-information leakage.
- Measurements: Final validated browser assets remain 27,023 B gzip, within the 250,000 B LittleFS budget. Firmware remains 1,020,994 / 2,097,152 B flash (48.7%) and 39,604 / 327,680 B RAM (12.1%).
- Next action: Milestone 027 remains blocked until explicitly requested.
## Milestone 027 — Introduce the network state model and secure persistent storage
**Status:** `DONE`
**Depends on:** Milestone 026
### Objective
Create a testable foundation for switching between an external network and the fallback access point without coupling game logic to network callbacks.
### Work
- Define explicit states and transitions for startup without configuration, connection to the saved network, the 30-second connection window, access-point operation, validation of new credentials, successful switching, and reconnection after network loss.
- Separate network control and credential persistence into dedicated components with bounded inputs.
- Store one SSID/password pair in persistent memory using the facilities of the current ESP32 stack.
- Implement loading, atomic replacement, and deletion of credentials; treat a corrupt or incomplete record as absent configuration.
- Exclude the password from logs, API responses, and diagnostic structures.
- Use non-blocking connection timing so that the game loop, HTTP/WebSocket handling, and watchdog are not stalled for 30 seconds.
- Add automated tests for state transitions, timeouts, corrupt records, successful persistence, and deletion.
### Acceptance criteria
- The device selects fallback mode when no valid record exists.
- With a valid record, the device starts a connection attempt and enables fallback after 30 seconds without success.
- Loss of an established connection starts reconnection and the same 30-second fallback transition.
- Failed validation of new credentials does not replace the previously saved configuration.
- After successful validation, persistent storage contains only one current network profile.
- Automated tests verify state transitions without a real 30-second wait and without depending on ESP32 radio hardware.
- No test output, log, or API response exposes the password in plaintext.
### Completion action
When all criteria pass, set this milestone to `DONE` and change Milestone 006 to `READY`.
### Execution record
- Date: 2026-09-01
- Result: PASS.
- Evidence: Added a bounded, host-testable network state model for fallback, saved-network connection, 30-second timeout, validation, successful replacement, and reconnect transitions. Added versioned and checksummed credential records, an ESP-IDF NVS single-record store with committed replacement/deletion, and a host memory backend that exercises load, corruption rejection, replacement, and deletion without radio hardware. Credentials are not logged or included in an API/diagnostic structure.
- Verification: `make -C test/host run` passed all 11 host suites, including `test_network_foundation`; `pio run -e esp32-c6-devkitm-1` passed with the three new firmware sources compiled.
- Next action: Milestone 028 remains blocked and was not started.
---
## Milestone 028 — Implement the open fallback access point and captive portal
**Status:** `DONE`
**Depends on:** Milestone 027
### Objective
Give users access to the game and external-network configuration through the open `Battleship-open` network whenever the saved external connection is unavailable.
### Work
- Start an open access point with the exact SSID `Battleship-open` only in fallback mode.
- Implement DNS redirection and captive-portal detection responses for supported phones and tablets.
- Keep the configuration page available through the access point's direct local address when the captive-portal window does not open automatically.
- Add a dedicated network configuration screen served entirely by the ESP32 without a CDN or internet dependency.
- Display Wi-Fi scan results and support manual entry of a hidden SSID.
- Bound SSID length, password length, and request-body size; handle empty, oversized, and incorrectly encoded values safely.
- Do not include the password in redisplayed data, API responses, or page state.
- Keep the game HTTP/WebSocket interface available through the fallback network.
### Acceptance criteria
- A phone can discover the open network with the exact name `Battleship-open` and connect without a password.
- The captive portal opens the local configuration page on the tested target devices; the direct local address remains a working fallback for the client.
- The network list can be refreshed without stalling the server, and a hidden SSID can be entered manually.
- Invalid and oversized requests are rejected without a restart or a persistent memory leak.
- Two players and the spectator capacity established by Milestone 004 can open the game interface through the access point within the confirmed resource limits.
- The password does not appear in HTML, JSON, WebSocket messages, or logs.
### Completion action
When all criteria pass, set this milestone to `DONE` and change Milestone 007 to `READY`.
### Execution record
- Date: 2026-09-01
- Result: PASS.
- Evidence: The user completed the required physical fallback-network checks with no issues: discovery and open connection to `Battleship-open`, captive-portal redirection and direct setup-page access, responsive scanning/manual hidden-SSID entry, game HTTP/WebSocket access for players and spectators, bounded invalid-input handling, and no credential disclosure.
- Verification: Host captive-portal and network-foundation tests, browser tests/syntax checks, firmware build, and LittleFS build passed during implementation.
- Next action: Milestone 029 remains blocked and was not started.
---
## Milestone 029 — Validate credentials, persist them, and switch without rebooting
**Status:** `DONE`
**Depends on:** Milestone 028
### Objective
Complete the configuration flow: validate a new network, save it only after success, and switch to it without rebooting the device.
### Work
- Add operations for retrieving network status, starting a scan, validating new credentials, and deleting saved configuration.
- Make the configuration screen available without authorization through both the fallback access point and the regular web interface.
- During validation, preserve a usable path back to the configuration page until the result is known.
- After a successful connection, persist the profile, report success to the browser, disable the fallback access point, and continue on the external network without rebooting.
- After a failed attempt, preserve the saved profile and keep a configuration path available for another attempt.
- After deleting the profile, enter fallback mode and ensure the deleted values are not restored after reboot.
- Prevent conflicting scan, validation, deletion, and mode-switch operations from running concurrently.
- Add clear Russian-language UI states for scanning, connecting, success, failure, and retry.
### Acceptance criteria
- Valid credentials are verified, saved, and used without rebooting the ESP32.
- New credentials are absent from persistent storage until validation succeeds.
- An incorrect password or unavailable SSID does not destroy the previously working configuration.
- The browser receives confirmation before the fallback access point is disabled, along with instructions for reopening the game on the external network.
- Configuration and deletion work through both agreed paths and require no token or other authorization.
- Repeated and concurrent requests lead to a deterministic state without corrupting storage or blocking the server.
- After configuration deletion and a cold reboot, the device starts `Battleship-open`.
### Accepted security limitation
Any client with network access to the device can change or delete its Wi-Fi configuration without authorization. User documentation must state this agreed behavior and explain that the open access point and local HTTP connection do not protect the submitted password from a nearby network observer.
### Completion action
When all criteria pass, set this milestone to `DONE` and change Milestone 008 to `READY`.
### Execution record
- Date: 2026-09-01
- Result: PASS.
- Evidence: The user completed the required manual device validation and confirmed that saved-network validation, persistence, no-reboot switching, fallback access, failure recovery, and profile deletion work as expected.
- Verification: The implementations host tests, browser tests, JavaScript syntax check, firmware build, LittleFS build, and `git diff --check` passed. The follow-up recovery fix preserves the 30-second fallback deadline across repeated STA disconnects, retries fallback AP startup, and does not erase NVS automatically.
- Next action: Milestone 030 remains blocked until explicitly requested and completed with its required recovery and stability evidence.
---
## Milestone 030 — Verify recovery, game continuity, and resource stability
**Status:** `BLOCKED`
**Depends on:** Milestone 029
### Objective
Prove on the target ESP32-C6 Mini that configuration survives reboot, fallback works during failures, and the current game survives network-mode transitions.
### Work
- Test a cold boot both without configuration and with a previously saved working configuration.
- During an active game, disable the external network, verify reconnection attempts, and confirm that `Battleship-open` appears after 30 seconds.
- Reconnect clients through the fallback access point and continue the same game without resetting its in-memory state.
- Configure a working external network through the fallback interface, wait for the no-reboot transition, and continue the same game after clients reconnect.
- Test configuration changes and deletion through the regular web interface.
- Test an incorrect password, hidden SSID, unavailable DHCP, disappearing network, corrupt stored record, and repeated mode transitions.
- Repeat the Milestone 004 load scenario with the network state machine, DNS, and captive portal enabled; measure firmware size, minimum free heap, response time, reset reasons, and memory trends.
- Update user documentation for first boot, configuration, the direct access address, network changes, credential deletion, fallback behavior, and the accepted security limitations.
### Acceptance criteria
- After a cold reboot, the device restores the connection from saved credentials without user action.
- When the saved network is absent or unavailable, `Battleship-open` appears within the agreed time.
- The active game's identifier, version, and state are not reset during transitions between the external network and fallback access point.
- After reconnecting, browsers receive a current snapshot and can continue the game under the existing session rules.
- Twenty cycles of network loss, fallback, and recovery complete without watchdog resets, unexpected reboots, or a persistent decline in free memory.
- Resource measurements remain within the budgets established by Milestone 004, or any deviation is documented and approved separately before changing a budget.
- The instructions allow a new user to configure the device without firmware-embedded Wi-Fi credentials.
### Completion action
Set this milestone to `DONE` only after documented automated and on-device verification and after adding an execution record in the format defined by the main plan.
+42 -10
View File
@@ -13,8 +13,15 @@
result: el('result-description'), error: el('error-description'), lobbyIcon: el('lobby-icon'), resultIcon: el('result-icon'),
cue: el('action-cue'), cueIcon: el('action-cue-icon'), cueText: el('action-cue-text'), effects: el('game-effects'),
effectIcon: el('effect-icon'), effectText: el('effect-text'), effectBurst: el('effect-burst'), effectBadge: el('effect-badge'),
recoveryButton: el('recovery-menu-button'), recoveryDialog: el('recovery-dialog'), recoveryClose: el('recovery-close'), recoveryChoices: el('recovery-choices'), recoveryGlobal: el('recovery-global-choice'), recoveryConfirmation: el('recovery-confirmation'), recoveryCancel: el('recovery-cancel'), recoveryConfirm: el('recovery-confirm'), recoveryConfirmTitle: el('recovery-confirm-title'), recoveryConfirmText: el('recovery-confirm-text'), recoveryConfirmLabel: el('recovery-confirm-label'), recoveryHoldNote: el('recovery-hold-note'), recoveryScopeIcon: el('recovery-scope-icon'), recoveryLive: el('recovery-live')
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'),
soundToggle: el('sound-toggle'), soundToggleLabel: el('sound-toggle-label'), soundSettings: el('sound-settings'), soundVolume: el('sound-volume'), soundReduced: el('sound-reduced'), recoverySoundMute: el('recovery-sound-mute')
};
const silentAudio = {
getPreferences: () => ({ enabled: false, volume: 'normal', reduced: false }),
setEnabled: async () => false, setVolume: () => {}, setReduced: () => {}, play: () => false, setPageHidden: () => {}, cleanup: () => {}
};
const audio = globalThis.BattleshipAudio?.createAudioEngine?.() || silentAudio;
const sounds = globalThis.BattleshipSounds?.createSoundDirector?.({ engine: audio }) || { play: () => false, reset: () => {} };
let token = localStorage.getItem(storage.token) || '';
let role = '';
let info;
@@ -170,7 +177,7 @@
}
}
function showFeedback(event) {
function showFeedback(event, version, summary = false) {
const content = pickReaction(event?.type);
if (!content) return;
let { motion, burst } = content;
@@ -189,6 +196,7 @@
void document.body.offsetWidth;
document.body.classList.add(kind === 'damage' ? 'fx-damage' : 'fx-hit');
if (navigator.vibrate) navigator.vibrate(vibration);
sounds.play(event.type, { version, combo: hitStreak, reduced: audio.getPreferences().reduced, summary });
effectTimer = window.setTimeout(() => {
ui.effects.hidden = true; document.body.classList.remove('fx-hit', 'fx-damage');
}, kind === 'victory' ? 2200 : combo ? 1800 : 1350);
@@ -212,9 +220,10 @@
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;
activeBoard = 'own'; hitStreak = 0; state = undefined; role = ''; info = undefined; targetActivator = undefined; sounds.reset(); audio.cleanup();
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));
const soundKeys = new Set(['battleship.soundEnabled', 'battleship.soundVolume', 'battleship.soundReduced']);
Object.keys(localStorage).filter(key => key.startsWith('battleship.') && !soundKeys.has(key)).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('Готово к подключению');
@@ -239,7 +248,7 @@
}
function openRecovery() {
if (!token) return; recoveryOpener = document.activeElement; ui.recoveryDialog.hidden = false; ui.recoveryButton.setAttribute('aria-expanded', 'true');
if (!token) return; sounds.play('recovery-open', { reduced: audio.getPreferences().reduced }); 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();
}
@@ -256,7 +265,7 @@
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');
sounds.play(`recovery-${action}`, { reduced: audio.getPreferences().reduced }); clearLocalProfile(action !== 'leave');
} catch (error) { recoveryPending = false; ui.recoveryConfirm.disabled = false; ui.recoveryLive.textContent = 'Не получилось. Можно повторить или отменить.'; notify(error.message, true); }
}
@@ -271,6 +280,16 @@
ui.notice.classList.toggle('error', error);
}
function syncSoundControls() {
const preferences = audio.getPreferences();
ui.soundToggle.setAttribute('aria-pressed', String(preferences.enabled));
ui.soundToggleLabel.textContent = preferences.enabled ? 'Звук включён' : 'Звук выключен';
ui.soundSettings.hidden = !preferences.enabled;
ui.recoverySoundMute.hidden = !preferences.enabled;
ui.soundVolume.value = preferences.volume;
ui.soundReduced.checked = preferences.reduced;
}
function apiError(payload, fallback) {
return payload?.message || fallback || 'Сервер временно недоступен.';
}
@@ -356,7 +375,7 @@
selectedTarget = selectedTarget && payload.boards[opponentIndex()][selectedTarget.y * 10 + selectedTarget.x] === '0' ? selectedTarget : undefined;
document.title = `Морской бой — версия ${payload.version}`;
render();
showFeedback(globalThis.BattleshipTargetInteraction.detectFeedback(previousState, payload, role));
showFeedback(globalThis.BattleshipTargetInteraction.detectFeedback(previousState, payload, role), payload.version, hadGap);
}
function isPlayer() { return role === 'player1' || role === 'player2'; }
@@ -384,6 +403,7 @@
renderGame();
try {
await post('/api/game/shot', { token, gameId: state.gameId, ...target });
sounds.play('shot', { reduced: audio.getPreferences().reduced });
return true;
} catch (_) {
return false;
@@ -397,7 +417,7 @@
if (!targetActivator) {
targetActivator = globalThis.BattleshipTargetInteraction.createTargetActivator({
canFire: canFireTarget,
select: nextTarget => { selectedTarget = nextTarget; renderGame(); },
select: nextTarget => { selectedTarget = nextTarget; sounds.play('select', { reduced: audio.getPreferences().reduced }); renderGame(); },
fire: fireTarget,
});
}
@@ -693,11 +713,20 @@
ui.fire.addEventListener('click', () => { if (selectedTarget) fireTarget(selectedTarget); });
ui.cancel.addEventListener('click', () => { selectedTarget = undefined; renderGame(); });
ui.abort.addEventListener('click', () => post('/api/game/abort', { token, gameId: state.gameId }));
ui.rematch.addEventListener('click', () => post('/api/game/rematch', { token, gameId: state.gameId }));
ui.rematch.addEventListener('click', () => { post('/api/game/rematch', { token, gameId: state.gameId }).then(() => sounds.play('rematch', { reduced: audio.getPreferences().reduced })).catch(() => {}); });
el('retry-button').addEventListener('click', () => { refreshInfo(); pollState(); connectSocket(); });
ui.recoveryButton.addEventListener('click', openRecovery);
ui.soundToggle.addEventListener('click', async () => {
const nextEnabled = !audio.getPreferences().enabled;
await audio.setEnabled(nextEnabled);
if (nextEnabled) audio.play('test');
syncSoundControls();
});
ui.soundVolume.addEventListener('change', () => { audio.setVolume(ui.soundVolume.value); syncSoundControls(); });
ui.soundReduced.addEventListener('change', () => { audio.setReduced(ui.soundReduced.checked); syncSoundControls(); });
ui.recoverySoundMute.addEventListener('click', async () => { await audio.setEnabled(false); syncSoundControls(); });
ui.recoveryClose.addEventListener('click', closeRecovery);
ui.recoveryCancel.addEventListener('click', () => { recoveryAction = undefined; ui.recoveryConfirmation.hidden = true; ui.recoveryChoices.hidden = false; ui.recoveryClose.focus(); });
ui.recoveryCancel.addEventListener('click', () => { sounds.play('recovery-cancel', { reduced: audio.getPreferences().reduced }); 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);
@@ -720,8 +749,11 @@
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
}
});
document.addEventListener('visibilitychange', () => { audio.setPageHidden(document.hidden); });
window.addEventListener('pagehide', () => audio.cleanup(), { once: true });
async function boot() {
syncSoundControls();
ui.name.value = localStorage.getItem(storage.name) || '';
document.querySelectorAll('.avatar-button').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.name === ui.name.value)));
setConnection('Проверяем ESP32…');
+55
View File
@@ -0,0 +1,55 @@
(() => {
const COUNTS = Object.freeze({ select: 4, shot: 8, miss: 10, hit: 10, sunk: 8, dodged: 6, damage: 6, turn: 6, waiting: 4, start: 6, victory: 8, defeat: 5, recovery: 5 });
const PRIORITY = Object.freeze({ waiting: 0, select: 0, recovery: 1, shot: 2, turn: 3, miss: 4, dodged: 4, damage: 5, hit: 5, sunk: 6, start: 6, defeat: 7, victory: 8, summary: 8 });
const BASE = Object.freeze({ select: [540, 100], shot: [190, 220], miss: [410, 260], hit: [155, 280], sunk: [120, 720], dodged: [460, 190], damage: [135, 260], turn: [620, 160], waiting: [350, 120], start: [250, 460], victory: [330, 850], defeat: [310, 430], recovery: [480, 150] });
function presetsFor(family, count) {
const [frequency, durationMs] = BASE[family];
return Array.from({ length: count }, (_, index) => ({
id: `${family}-${index + 1}`, family, priority: PRIORITY[family], frequency: Math.max(100, frequency + ((index * 67) % 240) - 90),
endFrequency: Math.max(100, frequency + ((index * 43) % 180) - 70), durationMs: Math.min(900, durationMs + (index % 4) * 35),
filterHz: 900 + (index * 347) % 2600, gain: family === 'select' || family === 'waiting' ? 0.18 : family === 'recovery' ? 0.14 : 0.7,
wave: ['sine', 'triangle', 'square', 'sawtooth'][index % 4], noise: ['miss', 'hit', 'sunk', 'damage', 'dodged'].includes(family) && index % 3 === 1,
pan: ((index % 5) - 2) * 0.18, delayMs: index % 3 === 2 ? 28 : 0
}));
}
const CATALOG = Object.freeze(Object.fromEntries(Object.entries(COUNTS).map(([family, count]) => [family, presetsFor(family, count)])));
const EVENT_FAMILY = Object.freeze({ 'watch-miss': 'miss', 'watch-hit': 'hit', 'watch-sunk': 'sunk', 'sunk-damage': 'damage', finish: 'victory', rematch: 'defeat', summary: 'victory', 'recovery-open': 'recovery', 'recovery-cancel': 'recovery', 'recovery-leave': 'recovery', 'recovery-profile': 'recovery', 'recovery-game': 'recovery' });
const RECOVERY_PRESET = Object.freeze({ 'recovery-open': 0, 'recovery-cancel': 1, 'recovery-leave': 2, 'recovery-profile': 3, 'recovery-game': 4 });
function createSoundDirector({ engine, random = Math.random, now = () => Date.now(), historyLimit = 3, waitingIntervalMs = 12000 } = {}) {
const history = new Map();
let lastVersion = -1; let lastWaitingMs = -waitingIntervalMs;
function select(family) {
const presets = CATALOG[family] || [];
if (!presets.length) return undefined;
const previous = history.get(family) || [];
const options = presets.filter(preset => !previous.includes(preset.id));
const pool = options.length ? options : presets.filter(preset => preset.id !== previous.at(-1));
const preset = pool[Math.min(pool.length - 1, Math.max(0, Math.floor(random() * pool.length)))];
history.set(family, [...previous, preset.id].slice(-historyLimit));
return preset;
}
function play(event, { version, combo = 0, reduced = false, summary = false } = {}) {
if (Number.isInteger(version)) { if (version <= lastVersion) return false; lastVersion = version; }
let family = summary ? 'summary' : EVENT_FAMILY[event] || event;
if (family === 'summary') family = 'victory';
if (!CATALOG[family]) return false;
if (family === 'waiting') { if (reduced || now() - lastWaitingMs < waitingIntervalMs) return false; lastWaitingMs = now(); }
const forcedPreset = RECOVERY_PRESET[event];
const preset = forcedPreset === undefined ? select(family) : CATALOG.recovery[forcedPreset];
if (!preset) return false;
const played = engine?.playPreset?.(preset);
if (!played) return false;
if (!reduced && (event === 'hit' || event === 'sunk') && combo >= 2) {
const comboPreset = { ...select(combo >= 4 ? 'victory' : combo === 3 ? 'turn' : 'hit'), id: `combo-${combo}`, priority: PRIORITY.sunk, gain: 0.12, durationMs: combo >= 4 ? 380 : 160, delayMs: 55 };
engine.playPreset(comboPreset);
}
return { preset, combo: combo >= 4 ? 'ultra' : combo === 3 ? 'mega' : combo === 2 ? 'combo' : undefined };
}
return { play, select, getCatalog: () => CATALOG, getHistory: family => [...(history.get(family) || [])], reset: () => { history.clear(); lastVersion = -1; lastWaitingMs = -waitingIntervalMs; }, getPriority: event => PRIORITY[EVENT_FAMILY[event] || event] || 0 };
}
const api = { createSoundDirector, CATALOG, COUNTS, PRIORITY };
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else globalThis.BattleshipSounds = api;
})();
+5 -2
View File
@@ -15,8 +15,9 @@
<main class="app-shell">
<header class="app-header">
<div><p class="eyebrow">ESP32-C6 · локальная игра</p><h1><svg class="ui-icon title-icon" aria-hidden="true"><use href="/ship-sprite.svg#ship-2-destroyer"></use></svg> Морской бой</h1></div>
<div class="header-actions"><p id="connection-status" class="connection-status" role="status">Подключение…</p><button id="recovery-menu-button" type="button" class="recovery-menu-button" aria-haspopup="dialog" aria-expanded="false" hidden><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-lifebuoy"></use></svg><span>Помощь</span></button></div>
<div class="header-actions"><p id="connection-status" class="connection-status" role="status">Подключение…</p><button id="sound-toggle" type="button" class="sound-toggle" aria-pressed="false" aria-controls="sound-settings"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-speaker"></use></svg><span id="sound-toggle-label">Звук выключен</span></button><button id="recovery-menu-button" type="button" class="recovery-menu-button" aria-haspopup="dialog" aria-expanded="false" hidden><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-lifebuoy"></use></svg><span>Помощь</span></button></div>
</header>
<section id="sound-settings" class="sound-settings" aria-label="Настройки звука" hidden><label for="sound-volume">Громкость</label><select id="sound-volume"><option value="quiet">Тихо</option><option value="normal">Обычно</option><option value="loud">Громко</option></select><label class="sound-reduced"><input id="sound-reduced" type="checkbox"> Мягкие звуки</label></section>
<p id="notice" class="notice" aria-live="polite" hidden></p>
<section id="screen-connect" class="screen" aria-labelledby="connect-title">
@@ -73,8 +74,10 @@
<section id="screen-reconnecting" class="screen reconnecting" aria-labelledby="reconnecting-title" hidden><h2 id="reconnecting-title">Восстанавливаем связь</h2><p>Состояние игры обновляется через HTTP. WebSocket подключится автоматически.</p></section>
<section id="screen-error" class="screen error-screen" aria-labelledby="error-title" hidden><h2 id="error-title">Не удалось продолжить</h2><p id="error-description"></p><button id="retry-button" type="button">Повторить</button></section>
</main>
<div id="recovery-dialog" class="recovery-overlay" role="dialog" aria-modal="true" aria-labelledby="recovery-title" hidden><section class="recovery-dialog"><button id="recovery-close" class="text-button recovery-close" type="button" aria-label="Закрыть меню">×</button><h2 id="recovery-title">Помощь с игрой</h2><p id="recovery-description" class="muted">Выберите, что нужно сделать.</p><div id="recovery-choices" class="recovery-choices"><button type="button" data-recovery-action="leave"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-person"></use></svg><span><strong>Выйти из игры</strong><small>Имя останется на этом устройстве.</small></span></button><button type="button" data-recovery-action="profile"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-person"></use></svg><span><strong>Сбросить мой профиль</strong><small>Очистить имя и данные этого устройства.</small></span></button><button id="recovery-global-choice" type="button" data-recovery-action="game" class="recovery-global"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-players"></use></svg><span><strong>Сбросить всю игру</strong><small>Вернуть всех к началу.</small></span></button></div><div id="recovery-confirmation" class="recovery-confirmation" hidden><span id="recovery-scope-icon" class="recovery-scope-icon" aria-hidden="true"></span><h3 id="recovery-confirm-title"></h3><p id="recovery-confirm-text"></p><p id="recovery-hold-note" class="muted" hidden>Удерживайте кнопку 2 секунды.</p><div class="recovery-confirm-actions"><button id="recovery-cancel" type="button" class="secondary">Отмена</button><button id="recovery-confirm" type="button" class="recovery-confirm"><span id="recovery-confirm-label"></span><span id="recovery-hold-progress" class="recovery-hold-progress" aria-hidden="true"></span></button></div></div><p id="recovery-live" class="sr-only" aria-live="polite"></p></section></div>
<div id="recovery-dialog" class="recovery-overlay" role="dialog" aria-modal="true" aria-labelledby="recovery-title" hidden><section class="recovery-dialog"><button id="recovery-close" class="text-button recovery-close" type="button" aria-label="Закрыть меню">×</button><h2 id="recovery-title">Помощь с игрой</h2><p id="recovery-description" class="muted">Выберите, что нужно сделать.</p><button id="recovery-sound-mute" type="button" class="secondary recovery-sound-mute" hidden>Выключить звук</button><div id="recovery-choices" class="recovery-choices"><button type="button" data-recovery-action="leave"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-person"></use></svg><span><strong>Выйти из игры</strong><small>Имя останется на этом устройстве.</small></span></button><button type="button" data-recovery-action="profile"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-person"></use></svg><span><strong>Сбросить мой профиль</strong><small>Очистить имя и данные этого устройства.</small></span></button><button id="recovery-global-choice" type="button" data-recovery-action="game" class="recovery-global"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-players"></use></svg><span><strong>Сбросить всю игру</strong><small>Вернуть всех к началу.</small></span></button></div><div id="recovery-confirmation" class="recovery-confirmation" hidden><span id="recovery-scope-icon" class="recovery-scope-icon" aria-hidden="true"></span><h3 id="recovery-confirm-title"></h3><p id="recovery-confirm-text"></p><p id="recovery-hold-note" class="muted" hidden>Удерживайте кнопку 2 секунды.</p><div class="recovery-confirm-actions"><button id="recovery-cancel" type="button" class="secondary">Отмена</button><button id="recovery-confirm" type="button" class="recovery-confirm"><span id="recovery-confirm-label"></span><span id="recovery-hold-progress" class="recovery-hold-progress" aria-hidden="true"></span></button></div></div><p id="recovery-live" class="sr-only" aria-live="polite"></p></section></div>
<script src="/target_interaction.js" defer></script>
<script src="/web_audio.js" defer></script>
<script src="/game_sounds.js" defer></script>
<script src="/app.js" defer></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #041a2b; color: #e8f7ff; } body { margin: 0; padding: max(1.25rem, env(safe-area-inset-top)) max(1.25rem, env(safe-area-inset-right)) max(1.25rem, env(safe-area-inset-bottom)) max(1.25rem, env(safe-area-inset-left)); background: radial-gradient(circle at top, #0e4563, #041a2b 58%); } main { width: min(100%, 38rem); margin: 0 auto; } .eyebrow { color: #8fdbf3; font-size: .78rem; font-weight: 800; letter-spacing: .08em; } h1 { margin: .25rem 0 .5rem; } section, form { margin-top: 1.1rem; padding: 1rem; border: 1px solid #3a7593; border-radius: .8rem; background: rgb(5 31 50 / 88%); } .section-heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } h2 { margin: 0; font-size: 1.1rem; } .networks { display: grid; gap: .5rem; } .network { width: 100%; min-height: 2.75rem; border: 1px solid #548eaa; border-radius: .5rem; background: #123d58; color: #effaff; text-align: left; } label, input, button { display: block; width: 100%; box-sizing: border-box; } label { margin-top: .85rem; font-weight: 700; } input, button { min-height: 2.75rem; margin-top: .35rem; border: 1px solid #609bbb; border-radius: .5rem; padding: .55rem .7rem; font: inherit; } input { background: #061f33; color: #effaff; } button { background: #54c2e5; color: #062033; font-weight: 800; cursor: pointer; } button:focus-visible, input:focus-visible, a:focus-visible { outline: 3px solid #ffe56a; outline-offset: 3px; } .hint, #scan-status, #network-status { color: #c1ddea; font-size: .9rem; } .secondary { margin-top: 1rem; background: #315a75; color: #effaff; } a { color: #9ce8ff; }
+35
View File
@@ -0,0 +1,35 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#08233d">
<title>Настройка сети — Морской бой</title>
<link rel="stylesheet" href="/setup.css">
</head>
<body>
<main>
<p class="eyebrow">BATTLESHIP-OPEN · ЛОКАЛЬНАЯ СЕТЬ</p>
<h1>Настройка WiFi</h1>
<p>Игра доступна в этой открытой сети. Выберите домашнюю сеть или укажите скрытую вручную.</p>
<section aria-labelledby="networks-title">
<div class="section-heading"><h2 id="networks-title">Доступные сети</h2><button id="refresh" type="button">Обновить</button></div>
<p id="scan-status" role="status">Нажмите «Обновить», чтобы найти сети.</p>
<div id="networks" class="networks" aria-live="polite"></div>
</section>
<form id="network-form">
<h2>Скрытая или другая сеть</h2>
<label for="ssid">Название сети (SSID)</label>
<input id="ssid" name="ssid" maxlength="32" autocomplete="off" required>
<label for="password">Пароль</label>
<input id="password" name="password" type="password" maxlength="63" autocomplete="new-password">
<p class="hint">Пароль не отображается после отправки. Пока идёт проверка, оставайтесь в Battleship-open.</p>
<button type="submit">Проверить и сохранить</button>
</form>
<button id="delete-network" type="button" class="secondary">Удалить сохранённую сеть</button>
<p id="network-status" role="status"></p>
<p><a href="/">Открыть игру</a></p>
</main>
<script src="/setup.js" defer></script>
</body>
</html>
+45
View File
@@ -0,0 +1,45 @@
(() => {
const networks = document.querySelector('#networks');
const status = document.querySelector('#scan-status');
const ssid = document.querySelector('#ssid');
const refresh = document.querySelector('#refresh');
const form = document.querySelector('#network-form');
const password = document.querySelector('#password');
const networkStatus = document.querySelector('#network-status');
const remove = document.querySelector('#delete-network');
let timer = 0;
const scan = async () => {
clearTimeout(timer); refresh.disabled = true; status.textContent = 'Ищем сети…';
try {
const response = await fetch('/api/network/scan', { cache: 'no-store' });
const result = await response.json();
if (!response.ok) throw new Error('scan unavailable');
if (result.state === 'scanning') { timer = setTimeout(scan, 700); return; }
const names = [...new Set(result.networks || [])];
networks.replaceChildren(...names.map(name => { const button = document.createElement('button'); button.className = 'network'; button.type = 'button'; button.textContent = name; button.addEventListener('click', () => { ssid.value = name; ssid.focus(); }); return button; }));
status.textContent = names.length ? 'Выберите сеть или введите её вручную.' : 'Сети не найдены. Введите скрытую сеть вручную.';
} catch (_) { status.textContent = 'Не удалось выполнить поиск. Введите сеть вручную.'; }
finally { if (!timer) refresh.disabled = false; }
};
const updateStatus = async () => {
try {
const result = await fetch('/api/network/status', { cache: 'no-store' }).then(response => response.json());
networkStatus.textContent = result.message || '';
if (result.state === 'validating') setTimeout(updateStatus, 1000);
} catch (_) { networkStatus.textContent = 'Не удалось получить состояние сети.'; }
};
refresh.addEventListener('click', scan);
form.addEventListener('submit', async event => {
event.preventDefault();
try {
const response = await fetch('/api/network/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ssid: ssid.value, password: password.value }) });
const result = await response.json(); password.value = ''; networkStatus.textContent = result.message || 'Проверяем подключение…';
if (response.ok) updateStatus();
} catch (_) { password.value = ''; networkStatus.textContent = 'Не удалось начать проверку. Повторите попытку.'; }
});
remove.addEventListener('click', async () => {
try { const result = await fetch('/api/network/delete', { method: 'POST' }).then(response => response.json()); networkStatus.textContent = result.message || 'Сеть удалена.'; }
catch (_) { networkStatus.textContent = 'Не удалось удалить сеть.'; }
});
updateStatus();
})();
+1
View File
@@ -21,4 +21,5 @@
<symbol id="icon-wave" viewBox="0 0 64 64"><path d="M2 25c8-8 15-8 23 0s15 8 23 0 13-7 14-6v11c-5-3-8-1-14 5-8 8-15 8-23 0s-15-8-23 0zm0 19c8-8 15-8 23 0s15 8 23 0 13-7 14-6v11c-5-3-8-1-14 5-8 8-15 8-23 0s-15-8-23 0z"/></symbol>
<symbol id="icon-lifebuoy" viewBox="0 0 64 64"><path d="M32 3a29 29 0 1 0 0 58 29 29 0 0 0 0-58m0 10a19 19 0 1 1 0 38 19 19 0 0 1 0-38m-5 10v6h-6v6h6v6h10v-6h6v-6h-6v-6z"/></symbol>
<symbol id="icon-person" viewBox="0 0 64 64"><path d="M32 5a13 13 0 1 0 0 26 13 13 0 0 0 0-26M9 59c2-16 11-24 23-24s21 8 23 24z"/></symbol>
<symbol id="icon-speaker" viewBox="0 0 64 64"><path d="M5 25h13l16-13v40L18 39H5zm36-5c7 5 7 19 0 24l-4-6c3-3 3-9 0-12zm8-9c14 11 14 31 0 42l-4-6c10-8 10-22 0-30z"/></symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

+2 -1
View File
@@ -35,6 +35,7 @@ h1, h2, h3, p { margin-top: 0; } h1 { margin-bottom: .2rem; font-size: clamp(1.7
.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; }
.sound-toggle { 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; } .sound-toggle[aria-pressed="true"] { background: #145b4b; color: #d9fff0; } .sound-toggle .ui-icon { width: 1.25rem; height: 1.25rem; } .sound-settings { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem .8rem; margin: .5rem 0 0 auto; padding: .55rem .7rem; border: 1px solid #315a75; border-radius: .7rem; background: rgb(7 31 50 / 90%); color: #c8e9f8; font-size: .88rem; } .sound-settings select { min-height: 2.3rem; padding: .3rem .45rem; border: 1px solid #6096b5; border-radius: .45rem; background: #082a41; color: #f1f7ff; } .sound-reduced { display: inline-flex; align-items: center; gap: .35rem; }
.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; }
@@ -65,5 +66,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 (max-width: 43.99rem) { .header-actions { justify-content: space-between; width: 100%; } .recovery-menu-button span, .sound-toggle span { font-size: .85rem; } .sound-settings { margin-inline: 0; } .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; } }
+120
View File
@@ -0,0 +1,120 @@
(() => {
const MAX_VOICES = 8;
const MAX_GAIN = 0.28;
const VOLUME_GAINS = Object.freeze({ quiet: 0.1, normal: 0.18, loud: MAX_GAIN });
const PREFERENCES = Object.freeze({ enabled: 'battleship.soundEnabled', volume: 'battleship.soundVolume', reduced: 'battleship.soundReduced' });
function safeGet(storage, key) { try { return storage?.getItem(key); } catch (_) { return null; } }
function safeSet(storage, key, value) { try { storage?.setItem(key, value); } catch (_) { /* Preferences are optional. */ } }
function validVolume(value) { return Object.hasOwn(VOLUME_GAINS, value) ? value : 'normal'; }
function createSilentEngine(storage) {
const preferences = { enabled: safeGet(storage, PREFERENCES.enabled) === 'true', volume: validVolume(safeGet(storage, PREFERENCES.volume)), reduced: safeGet(storage, PREFERENCES.reduced) === 'true' };
const save = () => { safeSet(storage, PREFERENCES.enabled, String(preferences.enabled)); safeSet(storage, PREFERENCES.volume, preferences.volume); safeSet(storage, PREFERENCES.reduced, String(preferences.reduced)); };
return {
getPreferences: () => ({ ...preferences }),
enable: async () => { preferences.enabled = true; save(); return false; },
setEnabled: async enabled => { preferences.enabled = Boolean(enabled); save(); return false; },
setVolume: volume => { preferences.volume = validVolume(volume); save(); },
setReduced: reduced => { preferences.reduced = Boolean(reduced); save(); },
play: () => false, cancel: () => {}, setPageHidden: () => {}, cleanup: () => {}, getVoiceCount: () => 0, getNoiseBuffer: () => undefined
};
}
function createAudioEngine({ AudioContext: Context = globalThis.AudioContext || globalThis.webkitAudioContext, storage = globalThis.localStorage, timer = globalThis, now = () => Date.now() } = {}) {
if (!Context) return createSilentEngine(storage);
const preferences = { enabled: safeGet(storage, PREFERENCES.enabled) === 'true', volume: validVolume(safeGet(storage, PREFERENCES.volume)), reduced: safeGet(storage, PREFERENCES.reduced) === 'true' };
let context; let master; let limiter; let noiseBuffer; let hidden = false; let lastVersion = -1;
const voices = new Set();
const save = () => { safeSet(storage, PREFERENCES.enabled, String(preferences.enabled)); safeSet(storage, PREFERENCES.volume, preferences.volume); safeSet(storage, PREFERENCES.reduced, String(preferences.reduced)); };
const disconnect = node => { try { node?.disconnect(); } catch (_) { /* Node was already released. */ } };
function updateGain() { if (master) master.gain.setValueAtTime(VOLUME_GAINS[preferences.volume], context.currentTime); }
function ensureContext() {
if (context) return true;
try {
context = new Context();
master = context.createGain(); master.gain.setValueAtTime(VOLUME_GAINS[preferences.volume], context.currentTime);
limiter = context.createDynamicsCompressor(); limiter.threshold.setValueAtTime(-18, context.currentTime); limiter.knee.setValueAtTime(12, context.currentTime); limiter.ratio.setValueAtTime(8, context.currentTime);
master.connect(limiter); limiter.connect(context.destination);
return true;
} catch (_) { context = undefined; master = undefined; limiter = undefined; return false; }
}
function ensureNoise() {
if (noiseBuffer) return noiseBuffer;
noiseBuffer = context.createBuffer(1, Math.max(1, Math.floor(context.sampleRate * 0.18)), context.sampleRate);
const samples = noiseBuffer.getChannelData(0);
for (let index = 0; index < samples.length; index += 1) samples[index] = ((index * 1103515245 + 12345) >>> 16) / 32768 - 0.5;
return noiseBuffer;
}
function release(voice) {
if (!voices.delete(voice)) return;
timer.clearTimeout(voice.timeout);
try { voice.source.stop(); } catch (_) { /* It may have ended naturally. */ }
disconnect(voice.source); disconnect(voice.filter); disconnect(voice.gain); disconnect(voice.panner);
}
function makeVoice(priority, durationMs, sourceFactory, settings = {}) {
if (!preferences.enabled || hidden || !ensureContext() || voices.size >= MAX_VOICES && !replaceVoice(priority)) return false;
const startedAt = context.currentTime + Math.max(0, settings.delayMs || 0) / 1000;
const duration = Math.max(0.1, Math.min(durationMs, 2500) / 1000) * (preferences.reduced ? 0.65 : 1);
let source; let gain; let filter; let panner;
try {
source = sourceFactory(); gain = context.createGain(); filter = context.createBiquadFilter();
filter.type = 'lowpass'; filter.frequency.setValueAtTime(settings.filterHz || 2400, startedAt);
const peak = Math.min(0.75, Math.max(0.04, settings.gain || 0.55));
gain.gain.setValueAtTime(0.0001, startedAt); gain.gain.exponentialRampToValueAtTime(peak, startedAt + 0.012); gain.gain.exponentialRampToValueAtTime(0.0001, startedAt + duration);
if (typeof context.createStereoPanner === 'function') { panner = context.createStereoPanner(); panner.pan.setValueAtTime(Math.max(-0.4, Math.min(0.4, settings.pan || 0)), startedAt); }
source.connect(filter); filter.connect(gain); if (panner) { gain.connect(panner); panner.connect(master); } else gain.connect(master); source.start(startedAt); source.stop(startedAt + duration + 0.02);
} catch (_) { disconnect(source); disconnect(filter); disconnect(gain); disconnect(panner); return false; }
const voice = { source, filter, gain, panner, priority, timeout: undefined };
voice.timeout = timer.setTimeout(() => release(voice), Math.ceil(duration * 1000) + 80);
voices.add(voice); return true;
}
function replaceVoice(priority) {
let lowest;
voices.forEach(voice => { if (!lowest || voice.priority < lowest.priority) lowest = voice; });
if (!lowest || lowest.priority > priority) return false;
release(lowest); return true;
}
async function enable() {
preferences.enabled = true; save();
if (!ensureContext()) return false;
try { if (context.state === 'suspended') await context.resume(); } catch (_) { return false; }
return context.state !== 'closed';
}
async function setEnabled(enabled) {
if (!enabled) { preferences.enabled = false; save(); cancel(); return true; }
return enable();
}
function cancel() { [...voices].forEach(release); }
function play(kind = 'test', version) {
if (Number.isInteger(version)) { if (version <= lastVersion) return false; lastVersion = version; }
if (kind === 'noise') return makeVoice(1, 160, () => { const source = context.createBufferSource(); source.buffer = ensureNoise(); return source; });
return makeVoice(kind === 'major' ? 2 : 1, kind === 'major' ? 700 : 180, () => {
const oscillator = context.createOscillator(); oscillator.type = kind === 'major' ? 'triangle' : 'sine'; oscillator.frequency.setValueAtTime(kind === 'major' ? 392 : 660, context.currentTime); oscillator.frequency.exponentialRampToValueAtTime(kind === 'major' ? 196 : 440, context.currentTime + 0.16); return oscillator;
});
}
function playPreset(preset) {
if (!preset || !preferences.enabled || hidden || !ensureContext()) return false;
return makeVoice(preset.priority || 1, preset.durationMs || 180, () => {
if (preset.noise) { const source = context.createBufferSource(); source.buffer = ensureNoise(); return source; }
const oscillator = context.createOscillator(); oscillator.type = preset.wave || 'sine';
const startedAt = context.currentTime + Math.max(0, preset.delayMs || 0) / 1000;
oscillator.frequency.setValueAtTime(Math.max(100, preset.frequency || 440), startedAt);
oscillator.frequency.exponentialRampToValueAtTime(Math.max(100, preset.endFrequency || preset.frequency || 440), startedAt + Math.min(0.8, (preset.durationMs || 180) / 1000));
return oscillator;
}, preset);
}
async function setPageHidden(nextHidden) {
hidden = Boolean(nextHidden); cancel();
if (!context) return;
try { if (hidden) await context.suspend(); else if (preferences.enabled && context.state === 'suspended') await context.resume(); } catch (_) { /* Sound remains optional. */ }
}
function cleanup() { cancel(); lastVersion = -1; if (context) { try { context.close(); } catch (_) { /* Already closed. */ } } context = undefined; master = undefined; limiter = undefined; noiseBuffer = undefined; }
return { getPreferences: () => ({ ...preferences }), enable, setEnabled, setVolume: volume => { preferences.volume = validVolume(volume); save(); updateGain(); }, setReduced: reduced => { preferences.reduced = Boolean(reduced); save(); }, play, playPreset, cancel, setPageHidden, cleanup, getVoiceCount: () => voices.size, getNoiseBuffer: () => noiseBuffer, getMaxGain: () => MAX_GAIN, now };
}
const api = { createAudioEngine, MAX_VOICES, MAX_GAIN, VOLUME_GAINS, PREFERENCES };
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else globalThis.BattleshipAudio = api;
})();
+9
View File
@@ -49,6 +49,10 @@ Failure (maximum 160 encoded bytes):
| -------------------------- | --------------: | --------------------------------------------- |
| `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/network/status` | 128 B target | unauthenticated state/message; never includes SSID/password |
| `GET /api/network/scan` | 128 B target | unauthenticated bounded scan state and up to 12 SSIDs |
| `POST /api/network/validate` | 160 B body | unauthenticated `{ssid,password}` validation; password is never returned |
| `POST /api/network/delete` | 0 B body | unauthenticated deletion of the saved profile |
| `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/leave` | 80 B body | `{token,gameId}`; releases only that session |
@@ -82,6 +86,11 @@ polling returns `SESSION_INVALIDATED` with the same bounded recovery metadata.
The recovery generation and `gameId` make delayed commands stale; retrying a
completed recovery is harmless and cannot mutate a newly registered session.
Network configuration is deliberately unauthenticated so it works from the
open fallback AP and the regular local address. Any reachable client can change
or delete the saved profile; the open AP and local HTTP do not provide password
confidentiality. Network responses and diagnostics never include the password.
## Role-safe state event
The HTTP state response and WebSocket `state` event use this single 768-byte
+56
View File
@@ -0,0 +1,56 @@
# Проверка звука на устройствах
Этот лист фиксирует Milestone 026. Не записывайте детей и не сохраняйте
персональные данные: достаточно анонимных наблюдений ответственного взрослого.
## Перед началом
1. Загрузите текущие firmware и LittleFS образы, затем выполните жёсткое
обновление страницы.
2. На каждом устройстве откройте лобби, включите звук явным нажатием и
проверьте `Тихо`, `Обычно`, `Громко` и `Мягкие звуки`.
3. Запишите браузер, ориентацию, способ транспорта (WebSocket или HTTP) и
только результат: pass/fail и краткое анонимное замечание.
## Матрица
| Устройство | Браузер | Портрет | Альбом | WS | HTTP | Результат/замечание |
| --------------------------------- | -------------------- | ------- | ------ | --- | ---- | ------------------- |
| Android-телефон | Chromium | | | | | |
| Android-планшет/второе устройство | Chromium | | | | | |
| iPhone/iPad, если доступен | Safari | | | | | |
| Ноутбук | браузер и клавиатура | | | | | |
## Сценарии
- После первого нажатия звук включается; после перезагрузки и resume выбор
сохраняется. Мгновенное выключение прекращает уже играющий звук.
- Различимы выбор цели, выстрел, промах, попадание, потопление, входящий
результат, ход, комбо, победа, поражение, повтор и действия восстановления.
- За 30 выстрелов один и тот же тип не звучит подряд одним пресетом. Комбо
×2, ×3 и ×4+ заметно усиливаются, а `Мягкие звуки` короче и спокойнее.
- Уход в фон, возврат, блокировка/разблокировка и смена вкладки не создают
очередь старых звуков. Потеря WebSocket, HTTP-опрос и восстановление не
повторяют один результат.
- Зритель слышит только публичные попадания/промахи/потопления и не получает
информацию о скрытых кораблях. Leave, profile reset и full reset безопасно
останавливают активный звук.
- При отключённом или заблокированном Web Audio игра остаётся полностью
рабочей и тихой; все события понятны визуально и экранные метки не
дублируются бесполезными объявлениями.
## Длительная проверка
Проведите 20 обычных партий со звуком и один 60-минутный сеанс с выстрелами,
комбо, сбросами, переподключениями и сменой видимости. Зафиксируйте отсутствие
ошибок консоли, застрявших звуков, роста памяти/таймеров, ухудшения доставки,
переполнения голосов и просадки heap ESP32. Отметьте, были ли какие-либо
задержанные или пропущенные важные сигналы.
## Короткая оценка
Ответственный взрослый или взрослый прокси отмечает только: различимы ли
выстрел/промах/попадание/потопление/победа без чтения; помогают ли звуки
вниманию; не слишком ли громки, резки, басовиты или тревожны; легко ли найти
mute и громкость; не провоцируют ли звуки случайные нажатия. При замечании
укажите тип звука, а не сведения о человеке.
+47 -2
View File
@@ -13,8 +13,9 @@ does not replace the locked rules in [MVP.md](MVP.md), the HTTP contract in
`esp_littlefs` 1.20.4.
- USB serial device: normally `/dev/ttyACM0`; confirm with `pio device list`
before an upload.
- A local `include/wifi_config.h`, copied from `include/wifi_config.h.example`.
It is ignored by Git. Never commit the SSID or password.
- No firmware-embedded Wi-Fi credentials are required. On first boot, join the
open `Battleship-open` network and open `http://192.168.4.1/setup` if the
captive portal does not appear.
## Build and upload
@@ -49,6 +50,16 @@ The response includes free/minimum heap, largest free block, connection count,
rejected input count, Wi-Fi state, and reset reason. It must not contain Wi-Fi
credentials or session tokens.
## Network configuration and local-security limitation
The `/setup` screen and `/api/network/*` routes are intentionally unauthenticated
so a new device can be configured through either the fallback AP or its regular
local address. Any client that can reach the device can change or delete the
saved Wi-Fi configuration. The open AP and plain local HTTP do not protect a
submitted password from a nearby network observer. Configure the board only on
a network you trust, and do not use this feature for credentials that require
strong confidentiality.
## MVP acceptance checklist
Record the observed result, device address, browser/device model, and any
@@ -81,11 +92,45 @@ state/delivery latency with the hard limits in `RESOURCE_BUDGET.md`.
state polling and retries WebSocket at 1, 2, 5, then 10 seconds.
- If the board has a new DHCP address, use the router/serial information and
open the new local URL. No fixed IP or external service is required.
- For a saved network that does not become operational, the serial log records
the profile outcome, Wi-Fi disconnect reason, the 30-second timeout, AP
startup result, and STA/AP addresses. `fallback AP active` means that
`Battleship-open` should be available; connect to it and open
`http://192.168.4.1/setup`. These diagnostics never include the password.
- A corrupt or unsupported saved record is left intact for diagnosis but is
ignored for that boot; the board starts `Battleship-open`. Do not erase NVS
or flash as an initial recovery step.
- If LittleFS assets fail to load, repeat `uploadfs` for the confirmed target
port before reflashing firmware. Do not format LittleFS automatically.
- Capture `/api/health`, reset reason, and the exact source revision before
reporting a failure. Never capture or publish tokens or Wi-Fi credentials.
## Network recovery qualification
This target-board checklist is required before declaring the Wi-Fi recovery
milestone complete. It is deliberately manual: radio association, DHCP, AP
visibility, and memory stability cannot be proven by a host build.
Before each case, record `/api/health` where the device is reachable. Record
the `gameId` and `version` of an active game before a network transition and
again after clients reconnect. Do not include credentials, session tokens, or
private network names in the record.
| Case | Required observation |
| --- | --- |
| Saved network cold boot | The board obtains a DHCP address and serves the application without configuration. |
| Missing/deleted, malformed, or unavailable profile | `Battleship-open` is visible no later than 30 seconds after boot or loss of connectivity; `/setup` works at `http://192.168.4.1/setup`. |
| Incorrect password, hidden network, or DHCP unavailable | The previous saved profile remains intact; the fallback AP and configuration page remain usable. |
| External-network loss during an active game | After fallback begins, reconnect clients through the AP and verify the same `gameId` and a current state snapshot. |
| Successful replacement | Keep the fallback connection until the success confirmation, then reconnect through the new DHCP address without rebooting and verify the game remains in RAM. |
| Repeated transition run | Complete 20 external-network-loss/fallback/recovery cycles. Compare health snapshots for reset reason, minimum free heap, largest free block, and response responsiveness. |
During a failed saved-network attempt, serial output must show the disconnect
reason and either a successful STA address or the 30-second fallback timeout.
For fallback, confirm the AP address and `fallback AP active`; report DNS or
HTTP startup failures as failures of this checklist. The firmware must never
erase NVS, LittleFS, or flash while performing these checks.
## Release record template
Complete this only after all physical checks pass:
+14
View File
@@ -40,3 +40,17 @@ bounded snapshot at a time into the 768-byte transport buffer.
Each gate is a maximum permitted consumption or minimum required remaining
heap. A missed gate blocks the next milestone pending a documented decision.
## Milestone 024 browser-audio asset measurement
The bounded Web Audio foundation adds `web_audio.js`: 7,863 B raw and 2,072 B
gzip. The current six compressed web assets total 24,947 B, well below the
250,000 B LittleFS ceiling. The browser performs synthesis locally, so this
change adds no ESP32 RAM allocation, API payload, or firmware-code cost.
## Milestone 025 sound-library asset measurement
The randomized cue director adds `game_sounds.js`: 4,538 B raw and 1,580 B
gzip. The expanded audio engine is 9,194 B raw and 2,335 B gzip. The current
seven compressed web assets total 27,023 B, still well below the 250,000 B
LittleFS ceiling.
+35 -1
View File
@@ -35,6 +35,40 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Health'
/api/network/status:
get:
summary: Read unauthenticated network configuration status without credentials
responses:
'200': { description: Network state and Russian status message. }
/api/network/scan:
get:
summary: Start or retrieve a bounded unauthenticated Wi-Fi scan
responses:
'200': { description: `scanning` or a bounded list of SSIDs; no passwords. }
'409': { $ref: '#/components/responses/Conflict' }
/api/network/validate:
post:
summary: Validate credentials and persist them only after association succeeds
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [ssid, password]
properties:
ssid: { type: string, minLength: 1, maxLength: 32 }
password: { type: string, maxLength: 63, writeOnly: true }
responses:
'200': { description: Validation started; poll network status for result. }
'400': { $ref: '#/components/responses/BadRequest' }
'409': { $ref: '#/components/responses/Conflict' }
/api/network/delete:
post:
summary: Delete the saved network profile and enter fallback mode
responses:
'200': { description: Profile deleted. }
'409': { $ref: '#/components/responses/Conflict' }
/api/session/join:
post:
summary: Create a session and request a role
@@ -331,7 +365,7 @@ components:
properties:
ok: { type: boolean, enum: [true] }
uptimeMs: { $ref: '#/components/schemas/GameId' }
wifiState: { type: string, enum: [not_configured, connecting, connected] }
wifiState: { type: string, enum: [not_configured, connecting, connected, fallback] }
freeHeapBytes: { $ref: '#/components/schemas/GameId' }
minimumFreeHeapBytes: { $ref: '#/components/schemas/GameId' }
largestFreeBlockBytes: { $ref: '#/components/schemas/GameId' }
+11
View File
@@ -0,0 +1,11 @@
#ifndef CAPTIVE_PORTAL_H
#define CAPTIVE_PORTAL_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
bool captive_portal_is_probe_path(const char *path);
bool captive_portal_dns_response(const uint8_t *query, size_t query_length, const uint8_t address[4], uint8_t *response, size_t response_capacity, size_t *response_length);
#endif
+26
View File
@@ -0,0 +1,26 @@
#ifndef NETWORK_CONFIGURATION_H
#define NETWORK_CONFIGURATION_H
#include <stdbool.h>
#include <stdint.h>
#include "network_state.h"
enum { kNetworkValidationWindowMs = 30000U, kNetworkSuccessNoticeMs = 15000U };
typedef enum { NETWORK_CONFIGURATION_IDLE, NETWORK_CONFIGURATION_VALIDATING, NETWORK_CONFIGURATION_SUCCESS, NETWORK_CONFIGURATION_FAILED } network_configuration_state_t;
typedef struct {
network_configuration_state_t state;
network_profile_t previous_profile;
bool had_previous_profile;
uint32_t validation_deadline_ms;
uint32_t success_deadline_ms;
} network_configuration_t;
void network_configuration_init(network_configuration_t *configuration);
bool network_configuration_begin(network_configuration_t *configuration, network_manager_t *manager, const network_profile_t *candidate, uint32_t now_ms);
bool network_configuration_validation_timed_out(const network_configuration_t *configuration, uint32_t now_ms);
network_action_t network_configuration_finish(network_configuration_t *configuration, network_manager_t *manager, bool success, uint32_t now_ms);
bool network_configuration_success_notice_expired(const network_configuration_t *configuration, uint32_t now_ms);
bool network_configuration_busy(const network_configuration_t *configuration);
#endif
+9
View File
@@ -0,0 +1,9 @@
#ifndef NETWORK_CREDENTIAL_STORE_H
#define NETWORK_CREDENTIAL_STORE_H
#include <stdbool.h>
#include "network_state.h"
typedef enum { NETWORK_CREDENTIAL_LOAD_VALID, NETWORK_CREDENTIAL_LOAD_ABSENT, NETWORK_CREDENTIAL_LOAD_INVALID } network_credential_load_result_t;
network_credential_load_result_t network_credential_store_load(network_profile_t *profile);
bool network_credential_store_replace(const network_profile_t *profile);
bool network_credential_store_delete(void);
#endif
+25
View File
@@ -0,0 +1,25 @@
#ifndef NETWORK_CREDENTIALS_H
#define NETWORK_CREDENTIALS_H
#include <stdbool.h>
#include <stdint.h>
#include "network_state.h"
typedef struct { uint32_t magic; uint16_t version; uint16_t reserved; network_profile_t profile; uint32_t checksum; } network_credential_record_t;
typedef bool (*network_credential_read_fn)(void *context, network_credential_record_t *record);
typedef bool (*network_credential_write_fn)(void *context, const network_credential_record_t *record);
typedef bool (*network_credential_delete_fn)(void *context);
typedef struct {
void *context;
network_credential_read_fn read;
network_credential_write_fn write;
network_credential_delete_fn remove;
} network_credential_backend_t;
bool network_credential_encode(const network_profile_t *profile, network_credential_record_t *record);
bool network_credential_decode(const network_credential_record_t *record, network_profile_t *profile);
bool network_credential_load(const network_credential_backend_t *backend, network_profile_t *profile);
bool network_credential_replace(const network_credential_backend_t *backend, const network_profile_t *profile);
bool network_credential_delete(const network_credential_backend_t *backend);
#endif
+21
View File
@@ -0,0 +1,21 @@
#ifndef NETWORK_STATE_H
#define NETWORK_STATE_H
#include <stdbool.h>
#include <stdint.h>
enum { kNetworkSsidBytes = 32, kNetworkPasswordBytes = 63, kNetworkConnectWindowMs = 30000 };
typedef struct { char ssid[kNetworkSsidBytes + 1]; char password[kNetworkPasswordBytes + 1]; } network_profile_t;
typedef enum { NETWORK_STATE_FALLBACK, NETWORK_STATE_CONNECTING, NETWORK_STATE_EXTERNAL, NETWORK_STATE_VALIDATING } network_state_t;
typedef enum { NETWORK_ACTION_NONE, NETWORK_ACTION_CONNECT, NETWORK_ACTION_FALLBACK } network_action_t;
typedef struct { network_state_t state; network_state_t state_before_validation; network_profile_t profile; network_profile_t candidate; uint32_t started_ms; bool has_profile; } network_manager_t;
bool network_profile_valid(const network_profile_t *profile);
network_action_t network_manager_init(network_manager_t *manager, const network_profile_t *profile, uint32_t now_ms);
network_action_t network_manager_tick(network_manager_t *manager, uint32_t now_ms);
network_action_t network_manager_connected(network_manager_t *manager);
network_action_t network_manager_disconnected(network_manager_t *manager, uint32_t now_ms);
bool network_manager_begin_validation(network_manager_t *manager, const network_profile_t *candidate);
network_action_t network_manager_finish_validation(network_manager_t *manager, bool success, uint32_t now_ms);
#endif
+1 -1
View File
@@ -7,7 +7,7 @@ from pathlib import Path
ROOT = Path(env.subst("$PROJECT_DIR"))
DATA = ROOT / "data"
ASSETS = ("index.html", "styles.css", "app.js", "target_interaction.js", "ship-sprite.svg")
ASSETS = ("index.html", "styles.css", "app.js", "target_interaction.js", "web_audio.js", "game_sounds.js", "ship-sprite.svg", "setup.html", "setup.css", "setup.js")
def minify_html(source):
+1 -1
View File
@@ -2,7 +2,7 @@
# without default 'CMakeLists.txt' file.
idf_component_register(
SRCS "main.c" "application.c" "command_queue.c" "fleet_generator.c" "game_engine.c" "bot_player.c" "session_manager.c" "game_lifecycle.c" "state_presenter.c" "http_api.c" "sync_service.c"
SRCS "main.c" "application.c" "command_queue.c" "fleet_generator.c" "game_engine.c" "bot_player.c" "session_manager.c" "game_lifecycle.c" "state_presenter.c" "http_api.c" "sync_service.c" "network_state.c" "network_credentials.c" "network_credential_store.c" "network_configuration.c" "captive_portal.c"
INCLUDE_DIRS "../include"
REQUIRES esp_event esp_http_server esp_netif esp_wifi esp_littlefs nvs_flash
)
+33
View File
@@ -0,0 +1,33 @@
#include "captive_portal.h"
#include <string.h>
bool captive_portal_is_probe_path(const char *path) {
static const char *const paths[] = {"/generate_204", "/gen_204", "/hotspot-detect.html", "/library/test/success.html", "/connecttest.txt", "/ncsi.txt", "/fwlink"};
if (path == NULL) return false;
for (size_t index = 0U; index < sizeof(paths) / sizeof(paths[0]); ++index) if (strcmp(path, paths[index]) == 0) return true;
return false;
}
bool captive_portal_dns_response(const uint8_t *query, size_t query_length, const uint8_t address[4], uint8_t *response, size_t response_capacity, size_t *response_length) {
if (query == NULL || address == NULL || response == NULL || response_length == NULL || query_length < 17U || response_capacity < query_length + 16U) return false;
if (query[2] & 0x80U || query[4] != 0U || query[5] != 1U) return false;
size_t question_end = 12U;
while (question_end < query_length && query[question_end] != 0U) {
const uint8_t label_length = query[question_end++];
if (label_length == 0U || label_length > 63U || question_end + label_length > query_length) return false;
question_end += label_length;
}
if (question_end + 5U > query_length) return false;
question_end += 5U;
memcpy(response, query, question_end);
response[2] = 0x81U; response[3] = 0x80U;
response[6] = 0U; response[7] = 1U;
response[8] = 0U; response[9] = 0U; response[10] = 0U; response[11] = 0U;
uint8_t *answer = response + question_end;
const uint8_t suffix[] = {0xc0U, 0x0cU, 0x00U, 0x01U, 0x00U, 0x01U, 0x00U, 0x00U, 0x00U, 0x3cU, 0x00U, 0x04U};
memcpy(answer, suffix, sizeof(suffix));
memcpy(answer + sizeof(suffix), address, 4U);
*response_length = question_end + sizeof(suffix) + 4U;
return true;
}
+374 -60
View File
@@ -2,9 +2,11 @@
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include "app_config.h"
#include "application.h"
#include "captive_portal.h"
#include "esp_check.h"
#include "esp_event.h"
#include "esp_heap_caps.h"
@@ -18,37 +20,44 @@
#include "freertos/FreeRTOS.h"
#include "freertos/portmacro.h"
#include "http_api.h"
#include "network_credential_store.h"
#include "network_configuration.h"
#include "network_state.h"
#include "nvs_flash.h"
#include "sync_service.h"
#if __has_include("wifi_config.h")
#include "wifi_config.h"
#define WIFI_CONFIG_AVAILABLE 1
#else
#define WIFI_CONFIG_AVAILABLE 0
#endif
#include "lwip/sockets.h"
static const char *const kLogTag = "battleship";
static const char *const kLittlefsBasePath = "/littlefs";
static const char *const kLittlefsPartitionLabel = "littlefs";
static const size_t kFileChunkBytes = 1024U;
static const uint8_t kHttpMaxOpenSockets = 12U;
enum { kHttpMaxOpenSockets = 12U, kFallbackMaxConnections = 10U, kDnsPort = 53U, kScanResultLimit = 12U };
typedef struct { bool configured; bool connected; bool retry_scheduled; uint8_t reconnect_attempt; } wifi_state_t;
typedef struct { bool configured; bool connected; bool fallback_active; } wifi_state_t;
static portMUX_TYPE s_wifi_lock = portMUX_INITIALIZER_UNLOCKED;
static portMUX_TYPE s_network_lock = portMUX_INITIALIZER_UNLOCKED;
static portMUX_TYPE s_configuration_lock = portMUX_INITIALIZER_UNLOCKED;
static portMUX_TYPE s_diagnostics_lock = portMUX_INITIALIZER_UNLOCKED;
static wifi_state_t s_wifi_state = {0};
static bool s_littlefs_mounted;
static httpd_handle_t s_server;
static esp_netif_t *s_station_netif;
static esp_netif_t *s_access_point_netif;
static application_t s_application;
static http_api_t s_api;
static sync_service_t s_sync;
static uint16_t s_rejected_input;
static esp_timer_handle_t s_sync_timer;
static esp_timer_handle_t s_bot_timer;
#if WIFI_CONFIG_AVAILABLE
static esp_timer_handle_t s_reconnect_timer;
#endif
static esp_timer_handle_t s_network_timer;
static network_manager_t s_network_manager;
static network_configuration_t s_configuration;
static bool s_scan_pending;
static bool s_scan_ready;
static bool s_dns_running;
static bool s_validation_waiting_for_disconnect;
enum { kNetworkRequestBytes = 160U, kNetworkResponseBytes = 768U };
static uint32_t platform_random(void *unused) { (void)unused; return esp_random(); }
@@ -75,6 +84,7 @@ static wifi_state_t wifi_state_snapshot(void) {
}
static const char *wifi_state_name(const wifi_state_t *state) {
if (state->fallback_active) return "fallback";
if (!state->configured) return "not_configured";
return state->connected ? "connected" : "connecting";
}
@@ -255,67 +265,232 @@ static esp_err_t websocket_handler(httpd_req_t *request) {
return ESP_OK;
}
#if WIFI_CONFIG_AVAILABLE
static void reconnect_timer_callback(void *unused) {
(void)unused;
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.retry_scheduled = false; portEXIT_CRITICAL(&s_wifi_lock);
if (esp_wifi_connect() != ESP_OK) ESP_LOGW(kLogTag, "wifi connect request failed");
static bool fallback_active(void) {
const wifi_state_t state = wifi_state_snapshot();
return state.fallback_active;
}
static void schedule_reconnect(uint32_t delay_ms) {
bool schedule = false;
portENTER_CRITICAL(&s_wifi_lock);
if (s_wifi_state.configured && !s_wifi_state.connected && !s_wifi_state.retry_scheduled) { s_wifi_state.retry_scheduled = true; schedule = true; }
portEXIT_CRITICAL(&s_wifi_lock);
if (schedule && esp_timer_start_once(s_reconnect_timer, (uint64_t)delay_ms * 1000U) != ESP_OK) {
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.retry_scheduled = false; portEXIT_CRITICAL(&s_wifi_lock);
static void log_netif_ip(const char *name, esp_netif_t *netif) {
esp_netif_ip_info_t info = {0};
if (netif == NULL || esp_netif_get_ip_info(netif, &info) != ESP_OK) { ESP_LOGW(kLogTag, "%s IP unavailable", name); return; }
ESP_LOGI(kLogTag, "%s IP " IPSTR, name, IP2STR(&info.ip));
}
static void captive_dns_task(void *unused) {
(void)unused;
const int socket_fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (socket_fd < 0) { ESP_LOGE(kLogTag, "captive DNS socket failed: errno=%d", errno); s_dns_running = false; vTaskDelete(NULL); return; }
struct sockaddr_in local = {.sin_family = AF_INET, .sin_port = htons(kDnsPort), .sin_addr.s_addr = htonl(INADDR_ANY)};
if (bind(socket_fd, (struct sockaddr *)&local, sizeof(local)) != 0) { ESP_LOGE(kLogTag, "captive DNS bind failed: errno=%d", errno); close(socket_fd); s_dns_running = false; vTaskDelete(NULL); return; }
const struct timeval timeout = {.tv_sec = 1, .tv_usec = 0};
if (setsockopt(socket_fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)) != 0) ESP_LOGW(kLogTag, "captive DNS timeout setup failed: errno=%d", errno);
const uint8_t address[] = {192U, 168U, 4U, 1U};
while (fallback_active()) {
uint8_t query[256]; uint8_t response[272]; struct sockaddr_in client = {0}; socklen_t client_length = sizeof(client);
const int length = recvfrom(socket_fd, query, sizeof(query), 0, (struct sockaddr *)&client, &client_length);
size_t response_length = 0U;
if (length > 0 && captive_portal_dns_response(query, (size_t)length, address, response, sizeof(response), &response_length)) sendto(socket_fd, response, response_length, 0, (struct sockaddr *)&client, client_length);
}
close(socket_fd); s_dns_running = false; vTaskDelete(NULL);
}
static void start_captive_dns(void) {
if (s_dns_running) return;
s_dns_running = true;
if (xTaskCreate(captive_dns_task, "captive_dns", 3072U, NULL, 3U, NULL) != pdPASS) { s_dns_running = false; ESP_LOGW(kLogTag, "captive DNS task unavailable"); }
}
static bool enable_fallback_ap(void) {
wifi_config_t access_point = {0};
snprintf((char *)access_point.ap.ssid, sizeof(access_point.ap.ssid), "%s", "Battleship-open");
access_point.ap.ssid_len = strlen((const char *)access_point.ap.ssid);
access_point.ap.channel = 1U; access_point.ap.max_connection = kFallbackMaxConnections; access_point.ap.authmode = WIFI_AUTH_OPEN;
const esp_err_t config_result = esp_wifi_set_config(WIFI_IF_AP, &access_point);
const esp_err_t mode_result = esp_wifi_set_mode(WIFI_MODE_APSTA);
if (config_result != ESP_OK || mode_result != ESP_OK) {
ESP_LOGE(kLogTag, "fallback AP attempt failed: config=%s mode=%s", esp_err_to_name(config_result), esp_err_to_name(mode_result));
return false;
}
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.fallback_active = true; portEXIT_CRITICAL(&s_wifi_lock);
start_captive_dns();
log_netif_ip("AP", s_access_point_netif);
ESP_LOGI(kLogTag, "fallback AP active");
return true;
}
static void disable_fallback_ap(void) {
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.fallback_active = false; portEXIT_CRITICAL(&s_wifi_lock);
if (esp_wifi_set_mode(WIFI_MODE_STA) != ESP_OK) ESP_LOGW(kLogTag, "fallback access point stop failed");
}
static esp_err_t configure_station_profile(const network_profile_t *profile) {
wifi_config_t station_config = {0};
if (profile != NULL) {
memcpy(station_config.sta.ssid, profile->ssid, kNetworkSsidBytes);
memcpy(station_config.sta.password, profile->password, kNetworkPasswordBytes);
}
return esp_wifi_set_config(WIFI_IF_STA, &station_config);
}
static bool configuration_is_validating(void) {
bool validating;
portENTER_CRITICAL(&s_configuration_lock); validating = network_configuration_busy(&s_configuration); portEXIT_CRITICAL(&s_configuration_lock);
return validating;
}
static const char *configuration_state_name(void) {
network_configuration_state_t state;
portENTER_CRITICAL(&s_configuration_lock); state = s_configuration.state; portEXIT_CRITICAL(&s_configuration_lock);
if (state == NETWORK_CONFIGURATION_VALIDATING) return "validating";
if (state == NETWORK_CONFIGURATION_SUCCESS) return "success";
if (state == NETWORK_CONFIGURATION_FAILED) return "failed";
const wifi_state_t wifi = wifi_state_snapshot();
return wifi.fallback_active ? "fallback" : wifi.connected ? "connected" : "connecting";
}
static const char *configuration_message(void) {
const char *state = configuration_state_name();
if (strcmp(state, "validating") == 0) return "Проверяем подключение к сети…";
if (strcmp(state, "success") == 0) return "Сеть сохранена. Подключите устройство к домашней сети: точка доступа скоро отключится.";
if (strcmp(state, "failed") == 0) return "Не удалось подключиться. Сохранённая сеть не изменена.";
if (strcmp(state, "fallback") == 0) return "Работает точка доступа Battleship-open.";
return strcmp(state, "connected") == 0 ? "Подключено к сохранённой сети." : "Подключаемся к сохранённой сети…";
}
static void apply_network_action(network_action_t action) {
if (action == NETWORK_ACTION_FALLBACK) { ESP_LOGW(kLogTag, "network transition: fallback"); enable_fallback_ap(); }
else if (action == NETWORK_ACTION_CONNECT) {
const esp_err_t result = esp_wifi_connect();
if (result != ESP_OK) ESP_LOGW(kLogTag, "saved network connection request failed: %s", esp_err_to_name(result));
else ESP_LOGI(kLogTag, "network transition: connection requested");
}
}
static network_action_t network_event_action(bool connected) {
network_action_t action;
portENTER_CRITICAL(&s_network_lock);
action = connected ? network_manager_connected(&s_network_manager) : network_manager_disconnected(&s_network_manager, (uint32_t)(esp_timer_get_time() / 1000U));
portEXIT_CRITICAL(&s_network_lock);
return action;
}
static void network_timer_callback(void *unused) {
(void)unused;
const uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000U);
network_action_t action;
portENTER_CRITICAL(&s_network_lock); action = network_manager_tick(&s_network_manager, now_ms); portEXIT_CRITICAL(&s_network_lock);
if (action == NETWORK_ACTION_FALLBACK) ESP_LOGW(kLogTag, "saved network connection timed out after 30 seconds");
apply_network_action(action);
bool must_retry_fallback = false;
portENTER_CRITICAL(&s_network_lock); must_retry_fallback = s_network_manager.state == NETWORK_STATE_FALLBACK; portEXIT_CRITICAL(&s_network_lock);
if (must_retry_fallback && !fallback_active()) { ESP_LOGW(kLogTag, "retrying fallback AP startup"); enable_fallback_ap(); }
bool validation_timed_out = false; bool success_notice_expired = false; network_profile_t previous = {0}; bool had_previous = false;
portENTER_CRITICAL(&s_configuration_lock);
validation_timed_out = network_configuration_validation_timed_out(&s_configuration, now_ms);
success_notice_expired = network_configuration_success_notice_expired(&s_configuration, now_ms);
if (validation_timed_out) { previous = s_configuration.previous_profile; had_previous = s_configuration.had_previous_profile; }
if (success_notice_expired) s_configuration.state = NETWORK_CONFIGURATION_IDLE;
portEXIT_CRITICAL(&s_configuration_lock);
if (validation_timed_out) {
portENTER_CRITICAL(&s_network_lock); portENTER_CRITICAL(&s_configuration_lock);
network_configuration_finish(&s_configuration, &s_network_manager, false, now_ms);
if (had_previous) network_manager_disconnected(&s_network_manager, now_ms);
portEXIT_CRITICAL(&s_configuration_lock); portEXIT_CRITICAL(&s_network_lock);
if (configure_station_profile(had_previous ? &previous : NULL) == ESP_OK && had_previous) esp_wifi_connect();
}
if (success_notice_expired && fallback_active()) disable_fallback_ap();
}
static void validation_connected_work(void *unused) {
(void)unused;
if (!configuration_is_validating()) return;
network_profile_t candidate = {0}; network_profile_t previous = {0}; bool had_previous = false;
portENTER_CRITICAL(&s_network_lock); candidate = s_network_manager.candidate; portEXIT_CRITICAL(&s_network_lock);
const bool persisted = network_credential_store_replace(&candidate);
const uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000U);
portENTER_CRITICAL(&s_network_lock); portENTER_CRITICAL(&s_configuration_lock);
previous = s_configuration.previous_profile; had_previous = s_configuration.had_previous_profile;
network_configuration_finish(&s_configuration, &s_network_manager, persisted, now_ms);
if (persisted) network_manager_connected(&s_network_manager);
else if (had_previous) network_manager_disconnected(&s_network_manager, now_ms);
portEXIT_CRITICAL(&s_configuration_lock); portEXIT_CRITICAL(&s_network_lock);
if (!persisted) {
ESP_LOGW(kLogTag, "network credential storage failed");
esp_wifi_disconnect(); configure_station_profile(had_previous ? &previous : NULL);
if (had_previous) esp_wifi_connect();
}
}
static void wifi_event_handler(void *argument, esp_event_base_t event_base, int32_t event_id, void *event_data) {
(void)argument; (void)event_data;
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) schedule_reconnect(0U);
(void)argument;
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) { ESP_LOGI(kLogTag, "Wi-Fi event: STA started"); apply_network_action(network_event_action(false)); }
else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
uint8_t attempt;
portENTER_CRITICAL(&s_wifi_lock);
if (s_wifi_state.reconnect_attempt < 5U) ++s_wifi_state.reconnect_attempt;
attempt = s_wifi_state.reconnect_attempt; s_wifi_state.connected = false;
portEXIT_CRITICAL(&s_wifi_lock);
schedule_reconnect(1000U << attempt);
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.connected = true; s_wifi_state.reconnect_attempt = 0U; portEXIT_CRITICAL(&s_wifi_lock);
}
const wifi_event_sta_disconnected_t *disconnected = event_data;
ESP_LOGW(kLogTag, "Wi-Fi event: STA disconnected, reason=%u", disconnected == NULL ? 0U : (unsigned int)disconnected->reason);
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.connected = false; portEXIT_CRITICAL(&s_wifi_lock);
if (configuration_is_validating()) {
portENTER_CRITICAL(&s_configuration_lock); s_validation_waiting_for_disconnect = false; portEXIT_CRITICAL(&s_configuration_lock);
} else apply_network_action(network_event_action(false));
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_SCAN_DONE) { ESP_LOGI(kLogTag, "Wi-Fi event: scan complete"); s_scan_pending = false; s_scan_ready = true; }
else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
bool validating = configuration_is_validating();
if (validating) {
bool accepts_ip;
portENTER_CRITICAL(&s_configuration_lock); accepts_ip = !s_validation_waiting_for_disconnect; portEXIT_CRITICAL(&s_configuration_lock);
if (accepts_ip && s_server != NULL) httpd_queue_work(s_server, validation_connected_work, NULL);
} else network_event_action(true);
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.connected = true; portEXIT_CRITICAL(&s_wifi_lock);
log_netif_ip("STA", s_station_netif);
if (!validating && fallback_active()) disable_fallback_ap();
} else if (event_base == WIFI_EVENT) ESP_LOGW(kLogTag, "Wi-Fi event ignored: id=%ld", (long)event_id);
}
#endif
static esp_err_t start_wifi(void) {
#if !WIFI_CONFIG_AVAILABLE
ESP_LOGE(kLogTag, "wifi is not configured; copy include/wifi_config.h.example to include/wifi_config.h");
return ESP_ERR_INVALID_STATE;
#else
network_profile_t profile = {0};
const network_credential_load_result_t load_result = network_credential_store_load(&profile);
const bool has_profile = load_result == NETWORK_CREDENTIAL_LOAD_VALID;
wifi_init_config_t init_config = WIFI_INIT_CONFIG_DEFAULT();
wifi_config_t station_config = {0};
const esp_timer_create_args_t timer_args = {.callback = reconnect_timer_callback, .name = "wifi_reconnect"};
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.configured = true; portEXIT_CRITICAL(&s_wifi_lock);
esp_netif_create_default_wifi_sta();
const uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000U);
portENTER_CRITICAL(&s_network_lock); network_action_t initial_action = network_manager_init(&s_network_manager, has_profile ? &profile : NULL, now_ms); portEXIT_CRITICAL(&s_network_lock);
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.configured = has_profile; portEXIT_CRITICAL(&s_wifi_lock);
if (load_result == NETWORK_CREDENTIAL_LOAD_VALID) ESP_LOGI(kLogTag, "saved network profile accepted");
else if (load_result == NETWORK_CREDENTIAL_LOAD_ABSENT) ESP_LOGW(kLogTag, "saved network profile absent; starting fallback");
else ESP_LOGE(kLogTag, "saved network profile invalid or unsupported; starting fallback");
s_station_netif = esp_netif_create_default_wifi_sta(); s_access_point_netif = esp_netif_create_default_wifi_ap();
if (s_station_netif == NULL || s_access_point_netif == NULL) return ESP_ERR_NO_MEM;
ESP_RETURN_ON_ERROR(esp_wifi_init(&init_config), kLogTag, "wifi initialization failed");
ESP_RETURN_ON_ERROR(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, wifi_event_handler, NULL, NULL), kLogTag, "wifi event registration failed");
ESP_RETURN_ON_ERROR(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, wifi_event_handler, NULL, NULL), kLogTag, "ip event registration failed");
ESP_RETURN_ON_ERROR(esp_timer_create(&timer_args, &s_reconnect_timer), kLogTag, "wifi timer creation failed");
snprintf((char *)station_config.sta.ssid, sizeof(station_config.sta.ssid), "%s", WIFI_CONFIG_SSID);
snprintf((char *)station_config.sta.password, sizeof(station_config.sta.password), "%s", WIFI_CONFIG_PASSWORD);
station_config.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK;
station_config.sta.pmf_cfg.capable = true;
ESP_RETURN_ON_ERROR(esp_wifi_set_mode(WIFI_MODE_STA), kLogTag, "wifi mode setup failed");
ESP_RETURN_ON_ERROR(esp_wifi_set_config(WIFI_IF_STA, &station_config), kLogTag, "wifi configuration failed");
return esp_wifi_start();
#endif
const esp_timer_create_args_t timer_args = {.callback = network_timer_callback, .name = "network_state"};
ESP_RETURN_ON_ERROR(esp_timer_create(&timer_args, &s_network_timer), kLogTag, "network timer creation failed");
if (has_profile) {
memcpy(station_config.sta.ssid, profile.ssid, kNetworkSsidBytes);
memcpy(station_config.sta.password, profile.password, kNetworkPasswordBytes);
const esp_err_t station_result = esp_wifi_set_config(WIFI_IF_STA, &station_config);
if (station_result != ESP_OK) {
ESP_LOGE(kLogTag, "saved network configuration rejected: %s; falling back", esp_err_to_name(station_result));
portENTER_CRITICAL(&s_network_lock); initial_action = network_manager_init(&s_network_manager, NULL, now_ms); portEXIT_CRITICAL(&s_network_lock);
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.configured = false; portEXIT_CRITICAL(&s_wifi_lock);
}
}
esp_err_t mode_result = esp_wifi_set_mode(initial_action == NETWORK_ACTION_FALLBACK ? WIFI_MODE_AP : WIFI_MODE_STA);
if (mode_result != ESP_OK && initial_action != NETWORK_ACTION_FALLBACK) {
ESP_LOGE(kLogTag, "STA mode setup failed: %s; attempting fallback", esp_err_to_name(mode_result));
portENTER_CRITICAL(&s_network_lock); initial_action = network_manager_init(&s_network_manager, NULL, now_ms); portEXIT_CRITICAL(&s_network_lock);
mode_result = esp_wifi_set_mode(WIFI_MODE_AP);
}
ESP_RETURN_ON_ERROR(mode_result, kLogTag, "wifi mode setup failed");
ESP_RETURN_ON_ERROR(esp_wifi_start(), kLogTag, "wifi start failed");
ESP_RETURN_ON_ERROR(esp_timer_start_periodic(s_network_timer, 1000000U), kLogTag, "network timer start failed");
if (initial_action == NETWORK_ACTION_FALLBACK) enable_fallback_ap();
return ESP_OK;
}
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, "/index.html") == 0 || strcmp(path, "/setup.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";
return strcmp(path, "/styles.css") == 0 || strcmp(path, "/setup.css") == 0 ? "text/css; charset=utf-8" : "application/javascript; charset=utf-8";
}
static bool request_accepts_gzip(httpd_req_t *request) {
@@ -348,6 +523,129 @@ static esp_err_t send_static_file(httpd_req_t *request, const char *asset_path)
return result == ESP_OK ? httpd_resp_send_chunk(request, NULL, 0U) : result;
}
static esp_err_t redirect_to_setup(httpd_req_t *request) {
httpd_resp_set_status(request, "302 Found");
httpd_resp_set_hdr(request, "Location", "/setup");
httpd_resp_set_hdr(request, "Cache-Control", "no-store");
return httpd_resp_send(request, NULL, 0U);
}
static esp_err_t setup_handler(httpd_req_t *request) {
if (!s_littlefs_mounted) {
httpd_resp_set_status(request, "503 Service Unavailable");
return httpd_resp_send(request, "LittleFS is unavailable", HTTPD_RESP_USE_STRLEN);
}
return send_static_file(request, "/setup.html");
}
static size_t append_scan_name(char *output, size_t capacity, size_t length, const uint8_t *ssid) {
if (length >= capacity) return length;
for (size_t index = 0U; index < 32U && ssid[index] != '\0' && length + 1U < capacity; ++index) {
const uint8_t value = ssid[index]; output[length++] = value >= 32U && value <= 126U && value != '"' && value != '\\' ? (char)value : '?';
}
output[length++] = '\n'; output[length] = '\0'; return length;
}
static esp_err_t setup_scan_handler(httpd_req_t *request) {
return httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "use /api/network/scan");
}
static void network_response(httpd_req_t *request, uint16_t status, const char *body) {
httpd_resp_set_status(request, status == 200U ? "200 OK" : status == 400U ? "400 Bad Request" : status == 409U ? "409 Conflict" : "503 Service Unavailable");
httpd_resp_set_type(request, "application/json"); httpd_resp_set_hdr(request, "Cache-Control", "no-store");
httpd_resp_send(request, body, HTTPD_RESP_USE_STRLEN);
}
static void network_json_skip(const char **text) { while (**text == ' ' || **text == '\n' || **text == '\r' || **text == '\t') ++*text; }
static bool network_json_string(const char **text, char *output, size_t output_size) {
network_json_skip(text); if (**text != '"' || output_size == 0U) return false; ++*text; size_t length = 0U;
while (**text != '\0' && **text != '"') { const unsigned char value = (unsigned char)*(*text)++; if (value < 0x20U || value == '\\' || length + 1U >= output_size) return false; output[length++] = (char)value; }
if (**text != '"') return false;
++*text; output[length] = '\0'; return true;
}
static bool parse_network_profile(const char *body, network_profile_t *profile) {
if (body == NULL || profile == NULL) return false;
const char *text = body; char key[16] = {0}; bool ssid = false; bool password = false; memset(profile, 0, sizeof(*profile));
network_json_skip(&text); if (*text++ != '{') return false;
for (;;) {
network_json_skip(&text); if (*text == '}') { ++text; break; }
if ((ssid || password) && *text++ != ',') return false;
if (!network_json_string(&text, key, sizeof(key))) return false;
network_json_skip(&text); if (*text++ != ':') return false;
if (strcmp(key, "ssid") == 0 && !ssid) { if (!network_json_string(&text, profile->ssid, sizeof(profile->ssid))) return false; ssid = true; }
else if (strcmp(key, "password") == 0 && !password) { if (!network_json_string(&text, profile->password, sizeof(profile->password))) return false; password = true; }
else return false;
}
network_json_skip(&text); return *text == '\0' && ssid && password && network_profile_valid(profile);
}
static esp_err_t network_status_handler(httpd_req_t *request) {
bool saved;
portENTER_CRITICAL(&s_network_lock); saved = s_network_manager.has_profile; portEXIT_CRITICAL(&s_network_lock);
char body[320]; snprintf(body, sizeof(body), "{\"ok\":true,\"state\":\"%s\",\"hasSavedNetwork\":%s,\"message\":\"%s\"}", configuration_state_name(), saved ? "true" : "false", configuration_message());
network_response(request, 200U, body); return ESP_OK;
}
static esp_err_t network_scan_handler(httpd_req_t *request) {
if (configuration_is_validating()) { network_response(request, 409U, "{\"ok\":false,\"code\":\"NETWORK_BUSY\",\"message\":\"Идёт проверка подключения.\"}"); return ESP_OK; }
if (!s_scan_pending && !s_scan_ready) {
if (esp_wifi_scan_start(NULL, false) != ESP_OK) { network_response(request, 503U, "{\"ok\":false,\"code\":\"SCAN_UNAVAILABLE\",\"message\":\"Поиск сетей сейчас недоступен.\"}"); return ESP_OK; }
s_scan_pending = true;
}
if (s_scan_pending) { network_response(request, 200U, "{\"ok\":true,\"state\":\"scanning\",\"networks\":[]}"); return ESP_OK; }
wifi_ap_record_t records[kScanResultLimit] = {0}; uint16_t count = kScanResultLimit;
if (esp_wifi_scan_get_ap_records(&count, records) != ESP_OK) { network_response(request, 503U, "{\"ok\":false,\"code\":\"SCAN_UNAVAILABLE\",\"message\":\"Поиск сетей сейчас недоступен.\"}"); return ESP_OK; }
s_scan_ready = false;
char body[kNetworkResponseBytes] = "{\"ok\":true,\"state\":\"ready\",\"networks\":["; size_t length = strlen(body);
for (uint16_t index = 0U; index < count && length + kNetworkSsidBytes + 4U < sizeof(body); ++index) {
char name[kNetworkSsidBytes + 1U] = {0}; append_scan_name(name, sizeof(name), 0U, records[index].ssid); name[strcspn(name, "\n")] = '\0';
length += (size_t)snprintf(body + length, sizeof(body) - length, "%s\"%s\"", index == 0U ? "" : ",", name);
}
snprintf(body + length, sizeof(body) - length, "]}"); network_response(request, 200U, body); return ESP_OK;
}
static esp_err_t receive_network_profile(httpd_req_t *request, network_profile_t *profile) {
char body[kNetworkRequestBytes + 1U] = {0};
if (request->content_len <= 0 || request->content_len > kNetworkRequestBytes || !request_has_json_content_type(request)) { record_rejected_input(); network_response(request, 400U, "{\"ok\":false,\"code\":\"MALFORMED_NETWORK\",\"message\":\"Укажите название сети и пароль.\"}"); return ESP_FAIL; }
size_t length = 0U;
while (length < (size_t)request->content_len) { const int received = httpd_req_recv(request, body + length, request->content_len - length); if (received <= 0) { record_rejected_input(); network_response(request, 400U, "{\"ok\":false,\"code\":\"MALFORMED_NETWORK\",\"message\":\"Некорректный запрос.\"}"); return ESP_FAIL; } length += (size_t)received; }
body[length] = '\0';
if (!parse_network_profile(body, profile)) { record_rejected_input(); network_response(request, 400U, "{\"ok\":false,\"code\":\"INVALID_NETWORK\",\"message\":\"Проверьте название сети и пароль.\"}"); return ESP_FAIL; }
return ESP_OK;
}
static esp_err_t network_validate_handler(httpd_req_t *request) {
network_profile_t candidate = {0}; if (receive_network_profile(request, &candidate) != ESP_OK) return ESP_OK;
if (s_scan_pending || configuration_is_validating()) { network_response(request, 409U, "{\"ok\":false,\"code\":\"NETWORK_BUSY\",\"message\":\"Другая операция уже выполняется.\"}"); return ESP_OK; }
const uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000U); bool started;
portENTER_CRITICAL(&s_network_lock); portENTER_CRITICAL(&s_configuration_lock);
started = network_configuration_begin(&s_configuration, &s_network_manager, &candidate, now_ms);
portEXIT_CRITICAL(&s_configuration_lock); portEXIT_CRITICAL(&s_network_lock);
if (!started) { network_response(request, 409U, "{\"ok\":false,\"code\":\"NETWORK_BUSY\",\"message\":\"Другая операция уже выполняется.\"}"); return ESP_OK; }
enable_fallback_ap();
portENTER_CRITICAL(&s_configuration_lock); s_validation_waiting_for_disconnect = true; portEXIT_CRITICAL(&s_configuration_lock);
const esp_err_t disconnect_result = esp_wifi_disconnect();
if (disconnect_result == ESP_ERR_WIFI_NOT_CONNECT) { portENTER_CRITICAL(&s_configuration_lock); s_validation_waiting_for_disconnect = false; portEXIT_CRITICAL(&s_configuration_lock); }
if (configure_station_profile(&candidate) != ESP_OK || esp_wifi_connect() != ESP_OK) {
portENTER_CRITICAL(&s_network_lock); portENTER_CRITICAL(&s_configuration_lock);
network_configuration_finish(&s_configuration, &s_network_manager, false, now_ms);
portEXIT_CRITICAL(&s_configuration_lock); portEXIT_CRITICAL(&s_network_lock);
network_response(request, 503U, "{\"ok\":false,\"code\":\"CONNECT_UNAVAILABLE\",\"message\":\"Не удалось начать проверку сети.\"}"); return ESP_OK;
}
network_response(request, 200U, "{\"ok\":true,\"state\":\"validating\",\"message\":\"Проверяем подключение. Оставайтесь в Battleship-open до подтверждения.\"}"); return ESP_OK;
}
static esp_err_t network_delete_handler(httpd_req_t *request) {
if (request->content_len != 0 || configuration_is_validating() || s_scan_pending) { network_response(request, 409U, "{\"ok\":false,\"code\":\"NETWORK_BUSY\",\"message\":\"Операция сейчас недоступна.\"}"); return ESP_OK; }
if (!network_credential_store_delete()) { network_response(request, 503U, "{\"ok\":false,\"code\":\"STORAGE_UNAVAILABLE\",\"message\":\"Не удалось удалить сохранённую сеть.\"}"); return ESP_OK; }
const uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000U);
portENTER_CRITICAL(&s_network_lock); network_manager_init(&s_network_manager, NULL, now_ms); portEXIT_CRITICAL(&s_network_lock);
portENTER_CRITICAL(&s_configuration_lock); network_configuration_init(&s_configuration); s_validation_waiting_for_disconnect = false; portEXIT_CRITICAL(&s_configuration_lock);
portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.configured = false; s_wifi_state.connected = false; portEXIT_CRITICAL(&s_wifi_lock);
esp_wifi_disconnect(); configure_station_profile(NULL); enable_fallback_ap();
network_response(request, 200U, "{\"ok\":true,\"state\":\"fallback\",\"message\":\"Сохранённая сеть удалена. Battleship-open остаётся доступной.\"}"); return ESP_OK;
}
static esp_err_t root_handler(httpd_req_t *request) {
if (s_littlefs_mounted) return send_static_file(request, "/index.html");
httpd_resp_set_status(request, "503 Service Unavailable");
@@ -359,17 +657,28 @@ 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 (captive_portal_is_probe_path(request->uri)) return redirect_to_setup(request);
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");
strcmp(request->uri, "/target_interaction.js") != 0 && strcmp(request->uri, "/web_audio.js") != 0 &&
strcmp(request->uri, "/game_sounds.js") != 0 &&
strcmp(request->uri, "/ship-sprite.svg") != 0 && strcmp(request->uri, "/setup.css") != 0 &&
strcmp(request->uri, "/setup.js") != 0) return fallback_active() ? redirect_to_setup(request) : httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "asset not found");
return send_static_file(request, request->uri);
}
static esp_err_t start_http_server(void) {
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.max_uri_handlers = 18U; config.max_open_sockets = kHttpMaxOpenSockets; config.uri_match_fn = httpd_uri_match_wildcard; config.lru_purge_enable = true;
ESP_RETURN_ON_ERROR(httpd_start(&s_server, &config), kLogTag, "http server start failed");
config.max_uri_handlers = 25U; config.max_open_sockets = kHttpMaxOpenSockets; config.uri_match_fn = httpd_uri_match_wildcard; config.lru_purge_enable = true;
const esp_err_t start_result = httpd_start(&s_server, &config);
if (start_result != ESP_OK) { ESP_LOGE(kLogTag, "HTTP server startup failed: %s", esp_err_to_name(start_result)); return start_result; }
const httpd_uri_t routes[] = {
{.uri = "/", .method = HTTP_GET, .handler = root_handler},
{.uri = "/setup", .method = HTTP_GET, .handler = setup_handler},
{.uri = "/setup/scan", .method = HTTP_GET, .handler = setup_scan_handler},
{.uri = "/api/network/status", .method = HTTP_GET, .handler = network_status_handler},
{.uri = "/api/network/scan", .method = HTTP_GET, .handler = network_scan_handler},
{.uri = "/api/network/validate", .method = HTTP_POST, .handler = network_validate_handler},
{.uri = "/api/network/delete", .method = HTTP_POST, .handler = network_delete_handler},
{.uri = "/api/info", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_INFO},
{.uri = "/api/health", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_HEALTH},
{.uri = "/api/session/join", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_JOIN},
@@ -387,7 +696,11 @@ static esp_err_t start_http_server(void) {
{.uri = "/ws", .method = HTTP_GET, .handler = websocket_handler, .is_websocket = true},
{.uri = "/*", .method = HTTP_GET, .handler = static_file_handler},
};
for (size_t index = 0; index < sizeof(routes) / sizeof(routes[0]); ++index) ESP_RETURN_ON_ERROR(httpd_register_uri_handler(s_server, &routes[index]), kLogTag, "route registration failed");
for (size_t index = 0; index < sizeof(routes) / sizeof(routes[0]); ++index) {
const esp_err_t register_result = httpd_register_uri_handler(s_server, &routes[index]);
if (register_result != ESP_OK) { ESP_LOGE(kLogTag, "HTTP route registration failed: %s", esp_err_to_name(register_result)); return register_result; }
}
ESP_LOGI(kLogTag, "HTTP server active");
return ESP_OK;
}
@@ -399,12 +712,13 @@ static void mount_littlefs(void) {
void app_main(void) {
esp_err_t result = nvs_flash_init();
if (result == ESP_ERR_NVS_NO_FREE_PAGES || result == ESP_ERR_NVS_NEW_VERSION_FOUND) { ESP_ERROR_CHECK(nvs_flash_erase()); result = nvs_flash_init(); }
ESP_ERROR_CHECK(result); ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_event_loop_create_default());
if (result != ESP_OK) ESP_LOGE(kLogTag, "NVS unavailable (%s); preserving storage and starting fallback", esp_err_to_name(result));
ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_event_loop_create_default());
application_init(&s_application, (random_source_t){.next_u32 = platform_random, .context = NULL});
const esp_timer_create_args_t bot_timer_args = {.callback = bot_timer_callback, .name = "bot_turn"};
ESP_ERROR_CHECK(esp_timer_create(&bot_timer_args, &s_bot_timer));
application_set_bot_scheduler(&s_application, (scheduler_t){.schedule_after_ms = schedule_bot_turn, .context = NULL});
network_configuration_init(&s_configuration);
http_api_init(&s_api, &s_application);
sync_service_init(&s_sync, &s_application);
mount_littlefs();
+25
View File
@@ -0,0 +1,25 @@
#include "network_configuration.h"
#include <string.h>
void network_configuration_init(network_configuration_t *configuration) { memset(configuration, 0, sizeof(*configuration)); }
bool network_configuration_begin(network_configuration_t *configuration, network_manager_t *manager, const network_profile_t *candidate, uint32_t now_ms) {
if (configuration == NULL || manager == NULL || configuration->state == NETWORK_CONFIGURATION_VALIDATING || !network_manager_begin_validation(manager, candidate)) return false;
configuration->previous_profile = manager->profile; configuration->had_previous_profile = manager->has_profile;
configuration->state = NETWORK_CONFIGURATION_VALIDATING; configuration->validation_deadline_ms = now_ms + kNetworkValidationWindowMs;
return true;
}
bool network_configuration_validation_timed_out(const network_configuration_t *configuration, uint32_t now_ms) {
return configuration != NULL && configuration->state == NETWORK_CONFIGURATION_VALIDATING && (int32_t)(now_ms - configuration->validation_deadline_ms) >= 0;
}
network_action_t network_configuration_finish(network_configuration_t *configuration, network_manager_t *manager, bool success, uint32_t now_ms) {
if (configuration == NULL || manager == NULL || configuration->state != NETWORK_CONFIGURATION_VALIDATING) return NETWORK_ACTION_NONE;
const network_action_t action = network_manager_finish_validation(manager, success, now_ms);
configuration->state = success ? NETWORK_CONFIGURATION_SUCCESS : NETWORK_CONFIGURATION_FAILED;
if (success) configuration->success_deadline_ms = now_ms + kNetworkSuccessNoticeMs;
return action;
}
bool network_configuration_success_notice_expired(const network_configuration_t *configuration, uint32_t now_ms) {
return configuration != NULL && configuration->state == NETWORK_CONFIGURATION_SUCCESS && (int32_t)(now_ms - configuration->success_deadline_ms) >= 0;
}
bool network_configuration_busy(const network_configuration_t *configuration) { return configuration != NULL && configuration->state == NETWORK_CONFIGURATION_VALIDATING; }
+9
View File
@@ -0,0 +1,9 @@
#include "network_credential_store.h"
#include "network_credentials.h"
#include "nvs.h"
static const char *const kNamespace = "network";
static const char *const kProfileKey = "profile";
network_credential_load_result_t network_credential_store_load(network_profile_t *profile) { nvs_handle_t handle; network_credential_record_t record; size_t size = sizeof(record); const esp_err_t open_result = nvs_open(kNamespace, NVS_READONLY, &handle); if (open_result == ESP_ERR_NVS_NOT_FOUND) return NETWORK_CREDENTIAL_LOAD_ABSENT; if (open_result != ESP_OK) return NETWORK_CREDENTIAL_LOAD_INVALID; const esp_err_t result = nvs_get_blob(handle, kProfileKey, &record, &size); nvs_close(handle); if (result == ESP_ERR_NVS_NOT_FOUND) return NETWORK_CREDENTIAL_LOAD_ABSENT; return result == ESP_OK && size == sizeof(record) && network_credential_decode(&record, profile) ? NETWORK_CREDENTIAL_LOAD_VALID : NETWORK_CREDENTIAL_LOAD_INVALID; }
bool network_credential_store_replace(const network_profile_t *profile) { network_credential_record_t record; nvs_handle_t handle; if (!network_credential_encode(profile, &record) || nvs_open(kNamespace, NVS_READWRITE, &handle) != ESP_OK) return false; const esp_err_t result = nvs_set_blob(handle, kProfileKey, &record, sizeof(record)); const bool ok = result == ESP_OK && nvs_commit(handle) == ESP_OK; nvs_close(handle); return ok; }
bool network_credential_store_delete(void) { nvs_handle_t handle; if (nvs_open(kNamespace, NVS_READWRITE, &handle) != ESP_OK) return false; const esp_err_t result = nvs_erase_key(handle, kProfileKey); const bool ok = (result == ESP_OK || result == ESP_ERR_NVS_NOT_FOUND) && nvs_commit(handle) == ESP_OK; nvs_close(handle); return ok; }
+10
View File
@@ -0,0 +1,10 @@
#include "network_credentials.h"
#include <stddef.h>
#include <string.h>
enum { kCredentialMagic = 0x42534E57U, kCredentialVersion = 1U };
static uint32_t checksum(const uint8_t *bytes, size_t length) { uint32_t value = 2166136261U; for (size_t i = 0; i < length; ++i) value = (value ^ bytes[i]) * 16777619U; return value; }
bool network_credential_encode(const network_profile_t *profile, network_credential_record_t *record) { if (!network_profile_valid(profile) || record == NULL) return false; memset(record, 0, sizeof(*record)); record->magic = kCredentialMagic; record->version = kCredentialVersion; record->profile = *profile; record->checksum = checksum((const uint8_t *)record, offsetof(network_credential_record_t, checksum)); return true; }
bool network_credential_decode(const network_credential_record_t *record, network_profile_t *profile) { if (record == NULL || profile == NULL || record->magic != kCredentialMagic || record->version != kCredentialVersion || record->checksum != checksum((const uint8_t *)record, offsetof(network_credential_record_t, checksum)) || !network_profile_valid(&record->profile)) return false; *profile = record->profile; return true; }
bool network_credential_load(const network_credential_backend_t *backend, network_profile_t *profile) { network_credential_record_t record; return backend != NULL && backend->read != NULL && backend->read(backend->context, &record) && network_credential_decode(&record, profile); }
bool network_credential_replace(const network_credential_backend_t *backend, const network_profile_t *profile) { network_credential_record_t record; return backend != NULL && backend->write != NULL && network_credential_encode(profile, &record) && backend->write(backend->context, &record); }
bool network_credential_delete(const network_credential_backend_t *backend) { return backend != NULL && backend->remove != NULL && backend->remove(backend->context); }
+34
View File
@@ -0,0 +1,34 @@
#include "network_state.h"
#include <string.h>
bool network_profile_valid(const network_profile_t *profile) {
return profile != NULL && profile->ssid[0] != '\0' && memchr(profile->ssid, '\0', sizeof(profile->ssid)) != NULL &&
memchr(profile->password, '\0', sizeof(profile->password)) != NULL;
}
network_action_t network_manager_init(network_manager_t *manager, const network_profile_t *profile, uint32_t now_ms) {
memset(manager, 0, sizeof(*manager));
if (!network_profile_valid(profile)) { manager->state = NETWORK_STATE_FALLBACK; return NETWORK_ACTION_FALLBACK; }
manager->profile = *profile; manager->has_profile = true; manager->state = NETWORK_STATE_CONNECTING; manager->started_ms = now_ms;
return NETWORK_ACTION_CONNECT;
}
network_action_t network_manager_tick(network_manager_t *manager, uint32_t now_ms) {
if (manager->state == NETWORK_STATE_CONNECTING && (uint32_t)(now_ms - manager->started_ms) >= kNetworkConnectWindowMs) { manager->state = NETWORK_STATE_FALLBACK; return NETWORK_ACTION_FALLBACK; }
return NETWORK_ACTION_NONE;
}
network_action_t network_manager_connected(network_manager_t *manager) { manager->state = NETWORK_STATE_EXTERNAL; return NETWORK_ACTION_NONE; }
network_action_t network_manager_disconnected(network_manager_t *manager, uint32_t now_ms) {
if (!manager->has_profile) { manager->state = NETWORK_STATE_FALLBACK; return NETWORK_ACTION_FALLBACK; }
if (manager->state == NETWORK_STATE_CONNECTING) return NETWORK_ACTION_CONNECT;
manager->state = NETWORK_STATE_CONNECTING; manager->started_ms = now_ms; return NETWORK_ACTION_CONNECT;
}
bool network_manager_begin_validation(network_manager_t *manager, const network_profile_t *candidate) {
if (!network_profile_valid(candidate) || manager->state == NETWORK_STATE_VALIDATING) return false;
manager->candidate = *candidate; manager->state_before_validation = manager->state; manager->state = NETWORK_STATE_VALIDATING; return true;
}
network_action_t network_manager_finish_validation(network_manager_t *manager, bool success, uint32_t now_ms) {
if (manager->state != NETWORK_STATE_VALIDATING) return NETWORK_ACTION_NONE;
if (!success) { manager->state = manager->state_before_validation; return NETWORK_ACTION_NONE; }
manager->profile = manager->candidate; manager->has_profile = true; manager->state = NETWORK_STATE_CONNECTING; manager->started_ms = now_ms; return NETWORK_ACTION_CONNECT;
}
+14 -2
View File
@@ -1,7 +1,7 @@
CC ?= cc
CFLAGS ?= -std=c11 -Wall -Wextra -Werror -I../../include
all: test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_robustness
all: test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_robustness test_network_foundation test_captive_portal test_network_configuration
test_command_queue: test_command_queue.c ../../src/command_queue.c
$(CC) $(CFLAGS) $^ -o $@
@@ -17,6 +17,9 @@ run: all
./test_human_game_integration
./test_bot_game_integration
./test_robustness
./test_network_foundation
./test_captive_portal
./test_network_configuration
test_game_domain: test_game_domain.c ../../src/fleet_generator.c ../../src/game_engine.c
$(CC) $(CFLAGS) $^ -o $@
@@ -46,5 +49,14 @@ test_robustness: test_robustness.c ../../src/http_api.c ../../src/sync_service.c
$(CC) $(CFLAGS) $^ -o $@
test_network_foundation: test_network_foundation.c ../../src/network_state.c ../../src/network_credentials.c
$(CC) $(CFLAGS) $^ -o $@
test_captive_portal: test_captive_portal.c ../../src/captive_portal.c
$(CC) $(CFLAGS) $^ -o $@
test_network_configuration: test_network_configuration.c ../../src/network_configuration.c ../../src/network_state.c
$(CC) $(CFLAGS) $^ -o $@
clean:
rm -f test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_robustness
rm -f test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_robustness test_network_foundation test_captive_portal test_network_configuration
+15
View File
@@ -0,0 +1,15 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "captive_portal.h"
int main(void) {
const uint8_t query[] = {0x12U, 0x34U, 0x01U, 0x00U, 0x00U, 0x01U, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 3U, 'w', 'w', 'w', 7U, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 3U, 'c', 'o', 'm', 0U, 0U, 1U, 0U, 1U};
uint8_t response[80] = {0}; size_t response_length = 0U; const uint8_t address[] = {192U, 168U, 4U, 1U};
assert(captive_portal_is_probe_path("/generate_204")); assert(captive_portal_is_probe_path("/hotspot-detect.html")); assert(!captive_portal_is_probe_path("/app.js"));
assert(captive_portal_dns_response(query, sizeof(query), address, response, sizeof(response), &response_length));
assert(response_length == sizeof(query) + 16U && response[0] == 0x12U && response[1] == 0x34U && response[2] == 0x81U && response[7] == 1U);
assert(memcmp(response + response_length - 4U, address, sizeof(address)) == 0);
assert(!captive_portal_dns_response(query, 12U, address, response, sizeof(response), &response_length));
puts("captive portal tests passed"); return 0;
}
+16
View File
@@ -0,0 +1,16 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "network_configuration.h"
static network_profile_t profile(const char *ssid, const char *password) { network_profile_t value = {0}; snprintf(value.ssid, sizeof(value.ssid), "%s", ssid); snprintf(value.password, sizeof(value.password), "%s", password); return value; }
int main(void) {
network_manager_t manager; network_configuration_t configuration; network_profile_t old = profile("Old", "old-pass"); network_profile_t next = profile("New", "new-pass");
network_manager_init(&manager, &old, 0U); network_configuration_init(&configuration);
assert(network_configuration_begin(&configuration, &manager, &next, 10U)); assert(network_configuration_busy(&configuration));
assert(!network_configuration_begin(&configuration, &manager, &next, 11U)); assert(!network_configuration_validation_timed_out(&configuration, 30009U)); assert(network_configuration_validation_timed_out(&configuration, 30010U));
assert(network_configuration_finish(&configuration, &manager, false, 30010U) == NETWORK_ACTION_NONE); assert(strcmp(manager.profile.ssid, "Old") == 0);
assert(network_configuration_begin(&configuration, &manager, &next, 40000U)); assert(network_configuration_finish(&configuration, &manager, true, 40001U) == NETWORK_ACTION_CONNECT); assert(strcmp(manager.profile.ssid, "New") == 0);
assert(!network_configuration_success_notice_expired(&configuration, 55000U)); assert(network_configuration_success_notice_expired(&configuration, 55001U));
puts("network configuration tests passed"); return 0;
}
+38
View File
@@ -0,0 +1,38 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "network_credentials.h"
#include "network_state.h"
static network_profile_t profile(const char *ssid, const char *password) { network_profile_t value = {0}; snprintf(value.ssid, sizeof(value.ssid), "%s", ssid); snprintf(value.password, sizeof(value.password), "%s", password); return value; }
typedef struct { bool present; network_credential_record_t record; } memory_store_t;
static bool read_record(void *context, network_credential_record_t *record) { memory_store_t *store = context; if (!store->present) return false; *record = store->record; return true; }
static bool write_record(void *context, const network_credential_record_t *record) { memory_store_t *store = context; store->record = *record; store->present = true; return true; }
static bool delete_record(void *context) { memory_store_t *store = context; store->present = false; memset(&store->record, 0, sizeof(store->record)); return true; }
int main(void) {
network_manager_t manager; network_profile_t home = profile("Home", "secret"); network_profile_t next = profile("Next", "newsecret");
assert(network_manager_init(&manager, NULL, 10U) == NETWORK_ACTION_FALLBACK && manager.state == NETWORK_STATE_FALLBACK);
assert(network_manager_init(&manager, &home, 10U) == NETWORK_ACTION_CONNECT);
assert(network_manager_tick(&manager, 30009U) == NETWORK_ACTION_NONE);
assert(network_manager_tick(&manager, 30010U) == NETWORK_ACTION_FALLBACK);
assert(network_manager_disconnected(&manager, 400U) == NETWORK_ACTION_CONNECT);
assert(network_manager_disconnected(&manager, 1000U) == NETWORK_ACTION_CONNECT);
assert(network_manager_tick(&manager, 30400U) == NETWORK_ACTION_FALLBACK);
assert(network_manager_connected(&manager) == NETWORK_ACTION_NONE && manager.state == NETWORK_STATE_EXTERNAL);
assert(network_manager_begin_validation(&manager, &next));
assert(network_manager_finish_validation(&manager, false, 500U) == NETWORK_ACTION_NONE && strcmp(manager.profile.ssid, "Home") == 0);
assert(network_manager_begin_validation(&manager, &next));
assert(network_manager_finish_validation(&manager, true, 600U) == NETWORK_ACTION_CONNECT && strcmp(manager.profile.ssid, "Next") == 0);
network_credential_record_t record; network_profile_t restored = {0};
assert(network_credential_encode(&next, &record)); assert(network_credential_decode(&record, &restored)); assert(strcmp(restored.password, "newsecret") == 0);
record.profile.ssid[0] = 'X'; assert(!network_credential_decode(&record, &restored));
assert(network_credential_encode(&next, &record)); ++record.version; assert(!network_credential_decode(&record, &restored));
assert(network_credential_encode(&next, &record)); record.profile.password[0] = '\0'; assert(!network_credential_decode(&record, &restored));
memory_store_t store = {0}; network_credential_backend_t backend = {&store, read_record, write_record, delete_record};
assert(network_credential_replace(&backend, &home)); assert(network_credential_load(&backend, &restored)); assert(strcmp(restored.ssid, "Home") == 0);
store.record.profile.ssid[0] = 'X'; assert(!network_credential_load(&backend, &restored));
assert(network_credential_replace(&backend, &next)); assert(network_credential_load(&backend, &restored)); assert(strcmp(restored.ssid, "Next") == 0);
assert(network_credential_delete(&backend)); assert(!network_credential_load(&backend, &restored));
network_profile_t invalid = profile("", "x"); assert(!network_profile_valid(&invalid));
puts("network foundation tests passed"); return 0;
}
+63
View File
@@ -0,0 +1,63 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { createSoundDirector, COUNTS, CATALOG } = require('../../data/game_sounds.js');
function recorder() { const played = []; return { played, playPreset: preset => { played.push(preset); return true; } }; }
test('every required event family has its planned minimum of original synthesis presets', () => {
Object.entries(COUNTS).forEach(([family, count]) => {
assert.equal(CATALOG[family].length, count);
assert.equal(new Set(CATALOG[family].map(preset => preset.id)).size, count);
});
});
test('selection is randomized without immediate repeats and keeps bounded history', () => {
const output = recorder(); let sample = 0;
const director = createSoundDirector({ engine: output, random: () => (sample++ % 11) / 11, historyLimit: 3 });
const selected = [];
for (let index = 0; index < 30; index += 1) selected.push(director.play('shot').preset.id);
selected.slice(1).forEach((id, index) => assert.notEqual(id, selected[index]));
assert.ok(director.getHistory('shot').length <= 3);
});
test('preset parameter ranges remain safe through many randomized selections', () => {
const output = recorder(); const director = createSoundDirector({ engine: output, random: Math.random });
for (let index = 0; index < 3000; index += 1) director.play(Object.keys(COUNTS)[index % Object.keys(COUNTS).length]);
output.played.forEach(preset => {
assert.ok(preset.frequency >= 100 && preset.frequency <= 1000);
assert.ok(preset.endFrequency >= 100 && preset.endFrequency <= 1000);
assert.ok(preset.durationMs >= 100 && preset.durationMs <= 900);
assert.ok(preset.filterHz >= 900 && preset.filterHz <= 3500);
assert.ok(Math.abs(preset.pan) <= 0.4 && preset.gain <= 0.75);
});
});
test('state versions, perspectives, priority, waiting rate limit, and reconnect summaries are safe', () => {
const output = recorder(); let clock = 0;
const director = createSoundDirector({ engine: output, random: () => 0, now: () => clock, waitingIntervalMs: 12000 });
assert.ok(director.getPriority('victory') > director.getPriority('sunk'));
assert.ok(director.getPriority('sunk') > director.getPriority('hit'));
assert.ok(director.getPriority('hit') > director.getPriority('miss'));
assert.equal(director.play('watch-hit', { version: 4 }).preset.family, 'hit');
assert.equal(director.play('damage', { version: 4 }), false);
assert.equal(director.play('watch-sunk', { version: 5 }).preset.family, 'sunk');
assert.equal(director.play('waiting').preset.family, 'waiting');
assert.equal(director.play('waiting'), false);
clock = 12001;
assert.equal(director.play('waiting').preset.family, 'waiting');
assert.equal(director.play('summary', { version: 6, summary: true }).preset.family, 'victory');
});
test('combos escalate, reduced intensity suppresses extras, and recovery remains calm', () => {
const output = recorder(); const director = createSoundDirector({ engine: output, random: () => 0.3 });
assert.equal(director.play('hit', { combo: 2 }).combo, 'combo');
assert.equal(director.play('hit', { combo: 3 }).combo, 'mega');
assert.equal(director.play('sunk', { combo: 4 }).combo, 'ultra');
const count = output.played.length;
assert.equal(director.play('hit', { combo: 4, reduced: true }).combo, 'ultra');
assert.equal(output.played.length, count + 1);
assert.equal(director.play('waiting', { reduced: true }), false);
const recovery = ['recovery-open', 'recovery-cancel', 'recovery-leave', 'recovery-profile', 'recovery-game'].map(event => director.play(event).preset);
assert.equal(new Set(recovery.map(preset => preset.id)).size, 5);
recovery.forEach(preset => assert.ok(preset.family === 'recovery' && preset.gain <= 0.16 && preset.durationMs <= 300));
});
+124
View File
@@ -0,0 +1,124 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const { createAudioEngine, MAX_VOICES, MAX_GAIN } = require('../../data/web_audio.js');
function memoryStorage() {
const values = new Map();
return { getItem: key => values.get(key) || null, setItem: (key, value) => values.set(key, String(value)) };
}
function fakeTimer() {
let next = 1;
const tasks = new Map();
return { setTimeout: callback => { const id = next++; tasks.set(id, callback); return id; }, clearTimeout: id => tasks.delete(id), runAll: () => [...tasks.values()].forEach(callback => callback()), size: () => tasks.size };
}
function fakeAudioContext() {
const contexts = [];
const parameter = () => ({ setValueAtTime() {}, exponentialRampToValueAtTime() {} });
const node = () => ({ connected: 0, disconnected: 0, connect() { this.connected += 1; }, disconnect() { this.disconnected += 1; } });
class FakeAudioContext {
constructor() { this.currentTime = 0; this.sampleRate = 1000; this.destination = node(); this.state = 'suspended'; contexts.push(this); }
createGain() { return { ...node(), gain: parameter() }; }
createDynamicsCompressor() { return { ...node(), threshold: parameter(), knee: parameter(), ratio: parameter() }; }
createBiquadFilter() { return { ...node(), frequency: parameter(), type: '' }; }
createOscillator() { return { ...node(), frequency: parameter(), start() {}, stop() { this.stopped = true; } }; }
createBufferSource() { return { ...node(), start() {}, stop() { this.stopped = true; } }; }
createBuffer(_channels, length) { const samples = new Float32Array(length); return { getChannelData: () => samples }; }
async resume() { this.state = 'running'; }
async suspend() { this.state = 'suspended'; }
close() { this.state = 'closed'; }
}
return { FakeAudioContext, contexts };
}
test('does not create an audio context before explicit enablement', () => {
const { FakeAudioContext, contexts } = fakeAudioContext();
const engine = createAudioEngine({ AudioContext: FakeAudioContext, storage: memoryStorage() });
assert.equal(contexts.length, 0);
assert.equal(engine.play('test'), false);
assert.equal(contexts.length, 0);
});
test('enables through a user gesture, persists safe preferences, and caps gain', async () => {
const { FakeAudioContext, contexts } = fakeAudioContext();
const storage = memoryStorage();
const engine = createAudioEngine({ AudioContext: FakeAudioContext, storage });
assert.equal(await engine.enable(), true);
engine.setVolume('loud'); engine.setReduced(true);
assert.equal(contexts.length, 1);
assert.equal(engine.getPreferences().enabled, true);
assert.equal(engine.getPreferences().volume, 'loud');
assert.equal(engine.getPreferences().reduced, true);
assert.equal(engine.getMaxGain(), MAX_GAIN);
assert.ok(MAX_GAIN <= 0.3);
assert.equal(storage.getItem('battleship.soundVolume'), 'loud');
});
test('uses a bounded voice pool, priority replacement, one noise buffer, and cleanup', async () => {
const { FakeAudioContext } = fakeAudioContext();
const timer = fakeTimer();
const engine = createAudioEngine({ AudioContext: FakeAudioContext, storage: memoryStorage(), timer });
await engine.enable();
for (let index = 0; index < MAX_VOICES; index += 1) assert.equal(engine.play('test'), true);
assert.equal(engine.getVoiceCount(), MAX_VOICES);
assert.equal(engine.play('test'), true);
assert.equal(engine.getVoiceCount(), MAX_VOICES);
assert.equal(engine.play('noise'), true);
const noise = engine.getNoiseBuffer();
assert.ok(noise);
engine.play('noise');
assert.equal(engine.getNoiseBuffer(), noise);
timer.runAll();
assert.equal(engine.getVoiceCount(), 0);
assert.equal(timer.size(), 0);
});
test('suppresses duplicate versions and cancels audio for mute, page hide, and reset cleanup', async () => {
const { FakeAudioContext, contexts } = fakeAudioContext();
const engine = createAudioEngine({ AudioContext: FakeAudioContext, storage: memoryStorage(), timer: fakeTimer() });
await engine.enable();
assert.equal(engine.play('test', 4), true);
assert.equal(engine.play('test', 4), false);
assert.equal(engine.getVoiceCount(), 1);
await engine.setPageHidden(true);
assert.equal(engine.getVoiceCount(), 0);
await engine.setPageHidden(false);
await engine.setEnabled(false);
assert.equal(engine.play('test'), false);
engine.cleanup();
assert.equal(contexts[0].state, 'closed');
});
test('falls back silently when Web Audio is unsupported or initialization fails', async () => {
const silent = createAudioEngine({ AudioContext: undefined, storage: memoryStorage() });
assert.equal(await silent.enable(), false);
assert.equal(silent.play('test'), false);
class FailingContext { constructor() { throw new Error('no audio'); } }
const failing = createAudioEngine({ AudioContext: FailingContext, storage: memoryStorage() });
assert.equal(await failing.enable(), false);
assert.equal(failing.play('test'), false);
});
test('the application exposes an explicit Russian sound control and cleans audio without changing transport', () => {
const app = fs.readFileSync(path.join(__dirname, '../../data/app.js'), 'utf8');
const page = fs.readFileSync(path.join(__dirname, '../../data/index.html'), 'utf8');
const firmware = fs.readFileSync(path.join(__dirname, '../../src/main.c'), 'utf8');
assert.match(page, /id="sound-toggle"[^>]*aria-pressed="false"/);
assert.match(page, /id="sound-volume"/);
assert.match(page, /id="sound-reduced"/);
assert.match(page, /id="recovery-sound-mute"/);
assert.match(page, /src="\/web_audio\.js"/);
assert.match(page, /src="\/game_sounds\.js"/);
assert.match(firmware, /strcmp\(request->uri, "\/web_audio\.js"\)/);
assert.match(firmware, /strcmp\(request->uri, "\/game_sounds\.js"\)/);
assert.match(app, /audio\.cleanup\(\)/);
assert.match(app, /globalThis\.BattleshipAudio\?\.createAudioEngine\?\.\(\) \|\| silentAudio/);
assert.match(app, /visibilitychange/);
assert.match(app, /soundKeys = new Set/);
assert.match(app, /ui\.recoverySoundMute\.hidden = !preferences\.enabled/);
assert.match(app, /\/api\/game\/shot/);
assert.match(app, /sounds\.play\('shot'/);
});