Compare commits
8 Commits
d30eb59225
...
f39f60553f
| Author | SHA1 | Date | |
|---|---|---|---|
| f39f60553f | |||
| 64f1cb35cc | |||
| 50ed341474 | |||
| fd978f323d | |||
| bad0803a6a | |||
| 4ea5280f61 | |||
| c6544716e7 | |||
| 17ccc0cb0f |
@@ -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 implementation’s 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
@@ -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…');
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
@@ -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>Настройка Wi‑Fi</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>
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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
@@ -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; } }
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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' }
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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; }
|
||||
@@ -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; }
|
||||
@@ -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); }
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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'/);
|
||||
});
|
||||
Reference in New Issue
Block a user