Compare commits

..

15 Commits

Author SHA1 Message Date
sasa f39f60553f feat: verify recovery, game continuity, and resource stability 2026-09-01 22:19:23 +03:00
sasa 64f1cb35cc feat: validate credentials, persist them, and switch without rebooting 2026-09-01 22:17:08 +03:00
sasa 50ed341474 feat: implement the open fallback access point and captive portal 2026-09-01 21:41:23 +03:00
sasa fd978f323d feat: validate credentials, persist them, and switch without rebooting 2026-09-01 21:24:55 +03:00
sasa bad0803a6a feat: introduce the network state model and secure persistent storage 2026-09-01 21:04:23 +03:00
sasa 4ea5280f61 feat; validate Web Audio engagement, compatibility, and long-run stability on real devices 2026-08-31 23:58:06 +03:00
sasa c6544716e7 feat: create a large randomized library of playful game sounds and audio combos 2026-08-31 23:55:03 +03:00
sasa 17ccc0cb0f feat: build a bounded browser Web Audio engine and sound controls 2026-08-31 23:39:56 +03:00
sasa d30eb59225 fix: spectator board rendering 2026-08-31 00:14:27 +03:00
sasa 3fcab465f8 feat: verify multi-client reset recovery and document the emergency workflow 2026-08-31 00:04:24 +03:00
sasa 93af738e36 fix: two-player lobby deadlock 2026-08-30 23:42:26 +03:00
sasa d5eb26d330 feat: add the always-available recovery menu and child-safe confirmations 2026-08-30 23:28:47 +03:00
sasa a68023e684 feat: implement safe leave, profile reset, and full game reset semantics 2026-08-30 23:18:07 +03:00
sasa c0c3c73c7a feat: add label random and animations to web interface 2026-08-30 23:10:23 +03:00
sasa 5cb4d8e9f6 feat: create recognizable Early-20th-Century Warship Silhouettes 2026-08-30 01:37:50 +03:00
57 changed files with 3350 additions and 133 deletions
+1053 -4
View File
File diff suppressed because it is too large Load Diff
+328 -22
View File
@@ -1,6 +1,7 @@
(() => {
const columns = ['А', 'Б', 'В', 'Г', 'Д', 'Ж', 'З', 'И', 'Й', 'К'];
const storage = { token: 'battleship.sessionToken', name: 'battleship.displayName' };
const boardKeys = Object.freeze({ own: 'own', opponent: 'opponent', player1: 'player1', player2: 'player2' });
const screens = ['connect', 'lobby', 'game', 'result', 'reconnecting', 'error'];
const el = id => document.getElementById(id);
const ui = {
@@ -9,8 +10,18 @@
start: el('start-game'), boards: el('boards'), resultBoards: el('result-boards'), tabs: el('board-tabs'),
turn: el('turn-status'), wins: el('wins'), shotControls: el('shot-controls'), target: el('target-status'),
fire: el('fire-button'), cancel: el('cancel-target'), abort: el('abort-game'), rematch: el('rematch-button'),
result: el('result-description'), error: el('error-description')
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'),
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;
@@ -27,9 +38,235 @@
let firePending = false;
let targetActivator;
let retryIndex = 0;
let effectTimer;
let hitStreak = 0;
let recoveryAction;
let recoveryPending = false;
let recoveryHoldTimer;
let recoveryOpener;
const reaction = (kind, icon, text, motion = 'pop', burst = 'none', vibration = [45]) => ({ kind, icon, text, motion, burst, vibration });
const feedback = {
start: [
reaction('start', 'ship-4-battleship', 'Бой начинается!', 'swoop', 'bubbles', [80]),
reaction('start', 'icon-rocket', 'Полный вперёд!', 'zoom', 'sparks', [50, 30, 90]),
reaction('start', 'ship-3-cruiser', 'Поднять якоря!', 'bounce', 'bubbles', [90]),
reaction('start', 'icon-target', 'К бою готовы!', 'spin', 'stars', [60, 30, 60]),
],
turn: [
reaction('turn', 'icon-target', 'Твой ход!', 'pop', 'sparks', [80, 40, 80]),
reaction('turn', 'icon-blast', 'Капитан, выбирай!', 'bounce', 'stars', [70, 30, 70]),
reaction('turn', 'icon-target', 'Пора пулять!', 'zoom', 'sparks', [90]),
reaction('turn', 'ship-1-cutter', 'Твоя очередь!', 'swoop', 'bubbles', [60, 30, 80]),
reaction('turn', 'icon-target', 'Где прячется корабль?', 'spin', 'stars', [60]),
reaction('turn', 'icon-target', 'Твой мув!', 'zoom', 'sparks', [70]),
],
hit: [
reaction('hit', 'icon-blast', 'Попадание!', 'pop', 'sparks', [110]),
reaction('hit', 'icon-target', 'Точно в цель!', 'zoom', 'stars', [80, 25, 120]),
reaction('hit', 'icon-blast', 'Нннааа!', 'spin', 'sparks', [130]),
reaction('hit', 'ship-1-cutter', 'Есть контакт!', 'swoop', 'stars', [70, 30, 100]),
reaction('hit', 'icon-blast', 'Мочный выстрел!', 'bounce', 'stars', [90, 30, 120]),
reaction('hit', 'icon-target', 'Прямо в яички!', 'zoom', 'sparks', [120]),
reaction('hit', 'icon-blast', 'КРИТ!', 'zoom', 'sparks', [90, 20, 130]),
reaction('hit', 'icon-trophy', 'ИМБА!', 'spin', 'stars', [80, 30, 120]),
reaction('hit', 'icon-target', 'Лютый пострил!', 'bounce', 'stars', [100]),
reaction('hit', 'icon-blast', 'Опасное попадание!', 'swoop', 'sparks', [100]),
],
sunk: [
reaction('hit', 'icon-trophy', 'Этот уже не похилится!', 'spin', 'stars', [90, 40, 140]),
reaction('hit', 'icon-blast', 'Минус один!', 'zoom', 'sparks', [100, 40, 150]),
reaction('hit', 'icon-trophy', 'Вот это капитан!', 'bounce', 'stars', [80, 30, 80, 30, 140]),
reaction('hit', 'icon-wave', 'Лодка буль-буль!', 'swoop', 'bubbles', [120, 50, 150]),
reaction('hit', 'ship-4-battleship', 'Победа будет наша!', 'pop', 'stars', [90, 40, 130]),
reaction('hit', 'icon-trophy', 'Ты Босс!', 'zoom', 'stars', [100, 40, 160]),
reaction('hit', 'icon-blast', 'Ультра-урон!', 'spin', 'sparks', [110, 40, 150]),
reaction('hit', 'icon-trophy', 'Это было Жопично!', 'bounce', 'stars', [100, 40, 150]),
],
miss: [
reaction('miss', 'icon-wave', 'Мимо!', 'swoop', 'bubbles', [45]),
reaction('miss', 'icon-wave', 'Буль-буль!', 'bounce', 'bubbles', [35]),
reaction('miss', 'icon-target', 'Чуть-чуть не попал!', 'pop', 'bubbles', [40]),
reaction('miss', 'icon-wave', 'Рыбки увернулись!', 'spin', 'bubbles', [30, 20, 30]),
reaction('miss', 'icon-wave', 'Волна поймала снаряд!', 'zoom', 'bubbles', [45]),
reaction('miss', 'icon-target', 'Не угадал!', 'swoop', 'none', [35]),
reaction('miss', 'icon-wave', 'А там ничего нет!', 'spin', 'bubbles', [40]),
reaction('miss', 'icon-target', 'Почти хайлайт!', 'zoom', 'none', [35]),
reaction('miss', 'icon-wave', 'Вода: 1. Снаряд: 0.', 'bounce', 'bubbles', [35]),
],
dodged: [
reaction('miss', 'icon-wave', 'Не попали!', 'swoop', 'bubbles', [40]),
reaction('miss', 'ship-1-cutter', 'В этот раз повезло!', 'bounce', 'stars', [35, 20, 35]),
reaction('miss', 'icon-wave', 'Дуракам везет!', 'pop', 'bubbles', [45]),
reaction('miss', 'ship-2-destroyer', 'Косоглазые!', 'zoom', 'bubbles', [35]),
],
damage: [
reaction('damage', 'icon-blast', 'В нас попали!', 'zoom', 'sparks', [160]),
reaction('damage', 'ship-2-destroyer', 'Нас вычислили!', 'swoop', 'sparks', [140, 40, 100]),
reaction('damage', 'icon-blast', 'Спасайся кто может!', 'spin', 'sparks', [170]),
reaction('damage', 'ship-1-cutter', 'A-a-a ранен!', 'bounce', 'none', [150]),
],
'sunk-damage': [
reaction('damage', 'icon-wave', 'Наш корабль потоплен!', 'swoop', 'bubbles', [180, 60, 180]),
reaction('damage', 'ship-4-battleship', 'Еще не все пропало!', 'bounce', 'sparks', [150, 50, 130]),
reaction('damage', 'icon-repeat', 'Не сдаёмся!', 'zoom', 'stars', [130, 40, 160]),
reaction('damage', 'icon-blast', 'Мы отомстим!', 'spin', 'sparks', [160]),
],
victory: [
reaction('victory', 'icon-trophy', 'Победа!', 'pop', 'stars', [80, 40, 80, 40, 180]),
reaction('victory', 'icon-trophy', 'БОСС моря!', 'spin', 'stars', [70, 30, 70, 30, 170]),
reaction('victory', 'ship-4-battleship', 'Мы их всех утопили!', 'swoop', 'bubbles', [90, 40, 160]),
reaction('victory', 'icon-trophy', 'Вот это победа!', 'bounce', 'stars', [80, 30, 90, 30, 180]),
reaction('victory', 'icon-rocket', 'Капитан — суперзвезда!', 'zoom', 'sparks', [100, 40, 180]),
reaction('victory', 'icon-trophy', 'ЛЕГЕНДА!', 'spin', 'stars', [90, 30, 90, 30, 190]),
reaction('victory', 'icon-trophy', 'Вот это скилл!', 'bounce', 'stars', [80, 30, 180]),
reaction('victory', 'icon-rocket', 'GG! Красиво!', 'zoom', 'sparks', [100, 40, 180]),
],
defeat: [
reaction('damage', 'icon-repeat', 'Поиграем еще раз?', 'spin', 'stars', [120]),
reaction('damage', 'ship-3-cruiser', 'В следующий раз порву!', 'swoop', 'bubbles', [100, 40, 120]),
reaction('damage', 'icon-repeat', 'В плен не сдаемся!', 'bounce', 'stars', [110]),
reaction('damage', 'icon-target', 'Попробуем ещё раз!', 'zoom', 'sparks', [100]),
],
finish: [
reaction('victory', 'icon-trophy', 'Бой окончен!', 'pop', 'stars', [80]),
reaction('victory', 'ship-4-battleship', 'Вот это битва!', 'swoop', 'bubbles', [80]),
reaction('victory', 'icon-trophy', 'Морской бой завершён!', 'spin', 'stars', [80]),
],
'watch-hit': [
reaction('hit', 'icon-blast', 'Уууууу!', 'pop', 'sparks', [80]),
reaction('hit', 'icon-target', 'Попал!', 'zoom', 'stars', [80]),
reaction('hit', 'icon-blast', 'Нннааа!', 'spin', 'sparks', [80]),
],
'watch-sunk': [
reaction('hit', 'icon-trophy', 'Корабль потоплен!', 'bounce', 'stars', [100]),
reaction('hit', 'icon-wave', 'Ушёл под воду!', 'swoop', 'bubbles', [100]),
reaction('hit', 'icon-blast', 'Вот это выстрел!', 'zoom', 'sparks', [100]),
],
'watch-miss': [
reaction('miss', 'icon-wave', 'Мимо!', 'swoop', 'bubbles', [40]),
reaction('miss', 'icon-wave', 'Буль!', 'bounce', 'bubbles', [40]),
reaction('miss', 'icon-target', 'Почти попал!', 'pop', 'none', [40]),
],
};
const pickReaction = globalThis.BattleshipTargetInteraction.createReactionPicker(feedback);
function setSpriteIcon(container, symbol) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'ui-icon'); svg.setAttribute('aria-hidden', 'true');
const use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
use.setAttribute('href', `/ship-sprite.svg#${symbol}`); svg.append(use); container.replaceChildren(svg);
}
function fillBurst(burst) {
ui.effectBurst.replaceChildren(); ui.effectBurst.className = `effect-burst burst-${burst}`;
if (burst === 'none') return;
const symbols = burst === 'bubbles' ? ['○', 'o', '·'] : burst === 'sparks' ? ['✦', '+', '*'] : ['★', '✦', '*'];
const count = burst === 'bubbles' ? 14 : burst === 'stars' ? 22 : 18;
for (let index = 0; index < count; index += 1) {
const particle = document.createElement('span');
const angle = (Math.PI * 2 * index) / count;
const distance = 35 + (index % 4) * 12;
particle.textContent = symbols[index % symbols.length];
particle.style.setProperty('--x', `${Math.cos(angle) * distance}vw`);
particle.style.setProperty('--y', `${Math.sin(angle) * distance}vh`);
particle.style.setProperty('--spin', `${(index % 2 ? 1 : -1) * (140 + index * 13)}deg`);
particle.style.setProperty('--rotate', `${index * 23}deg`);
particle.style.setProperty('--delay', `${(index % 5) * 35}ms`);
ui.effectBurst.append(particle);
}
}
function showFeedback(event, version, summary = false) {
const content = pickReaction(event?.type);
if (!content) return;
let { motion, burst } = content;
const { kind, icon, text, vibration } = content;
if (event.type === 'hit' || event.type === 'sunk') hitStreak += 1;
else if (event.type === 'miss' || event.type === 'start' || event.type === 'victory' || event.type === 'defeat') hitStreak = 0;
const combo = hitStreak >= 2;
if (combo) { motion = hitStreak >= 3 ? 'combo' : motion; burst = 'stars'; }
window.clearTimeout(effectTimer);
ui.effects.className = `game-effects effect-${kind} motion-${motion}${combo ? ' combo-power' : ''}`;
setSpriteIcon(ui.effectIcon, icon); ui.effectText.textContent = text;
ui.effectBadge.hidden = !combo;
ui.effectBadge.textContent = combo ? (hitStreak >= 4 ? `УЛЬТРА-КОМБО ×${hitStreak}` : hitStreak === 3 ? 'МЕГА-КОМБО ×3' : 'КОМБО ×2') : '';
fillBurst(burst); ui.effects.hidden = false;
document.body.classList.remove('fx-hit', 'fx-damage');
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);
}
function showScreen(name) {
screens.forEach(screen => { el(`screen-${screen}`).hidden = screen !== name; });
ui.recoveryButton.hidden = name === 'connect' || !token;
}
function clearRecoveryTimers() {
window.clearTimeout(retryTimer); retryTimer = undefined;
window.clearInterval(pollTimer); pollTimer = undefined;
window.clearInterval(heartbeatTimer); heartbeatTimer = undefined;
window.clearInterval(lobbyInfoTimer); lobbyInfoTimer = undefined;
window.clearTimeout(effectTimer); effectTimer = undefined;
window.clearTimeout(recoveryHoldTimer); recoveryHoldTimer = undefined;
}
function clearLocalProfile(profile) {
clearRecoveryTimers();
if (socket) { socket.onclose = null; socket.close(); socket = undefined; }
selectedTarget = undefined; firePending = false; cumulativeStatistics = undefined; statisticsGameId = undefined;
activeBoard = 'own'; hitStreak = 0; state = undefined; role = ''; info = undefined; targetActivator = undefined; sounds.reset(); audio.cleanup();
document.body.classList.remove('fx-hit', 'fx-damage'); ui.effects.hidden = true; notify('');
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('Готово к подключению');
}
function closeRecovery() {
window.clearTimeout(recoveryHoldTimer); recoveryHoldTimer = undefined; recoveryAction = undefined; recoveryPending = false;
ui.recoveryDialog.hidden = true; ui.recoveryButton.setAttribute('aria-expanded', 'false');
if (recoveryOpener) recoveryOpener.focus();
}
function recoveryIcon(symbol) { setSpriteIcon(ui.recoveryScopeIcon, symbol); }
function showRecoveryConfirmation(action) {
recoveryAction = action; ui.recoveryChoices.hidden = true; ui.recoveryConfirmation.hidden = false;
const game = action === 'game'; const profile = action === 'profile';
recoveryIcon(game ? 'icon-players' : 'icon-person');
ui.recoveryConfirmTitle.textContent = game ? 'Сбросить всю игру?' : profile ? 'Сбросить мой профиль?' : 'Выйти из игры?';
ui.recoveryConfirmText.textContent = game ? 'Все игроки вернутся к началу.' : profile ? 'На этом устройстве очистятся имя и данные.' : 'Вы вернётесь к подключению. Имя останется.';
ui.recoveryConfirmLabel.textContent = game ? 'Удерживать для сброса' : profile ? 'Сбросить профиль' : 'Выйти';
ui.recoveryHoldNote.hidden = !game; ui.recoveryConfirm.classList.remove('holding'); ui.recoveryConfirm.disabled = false;
ui.recoveryCancel.focus();
}
function openRecovery() {
if (!token) return; 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();
}
async function submitRecovery() {
if (!recoveryAction || recoveryPending) return;
recoveryPending = true; ui.recoveryConfirm.disabled = true; ui.recoveryLive.textContent = 'Выполняем действие…';
const action = recoveryAction;
if (action === 'profile' && navigator.onLine === false) {
clearLocalProfile(true);
notify('Профиль очищен на этом устройстве. Место на ESP32 освободится после восстановления связи или таймаута.');
return;
}
try {
if (action === 'leave') await request('/api/session/leave', { method: 'POST', headers: headers(), body: JSON.stringify({ token, gameId: state?.gameId || 0 }) });
else if (action === 'profile') await request('/api/session/profile-reset', { method: 'POST', headers: headers(), body: JSON.stringify({ token, gameId: state?.gameId || 0 }) });
else await request('/api/game/reset', { method: 'POST', headers: headers(), body: JSON.stringify({ token, gameId: state?.gameId || 0 }) });
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); }
}
function setConnection(text, status) {
@@ -43,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 || 'Сервер временно недоступен.';
}
@@ -109,6 +356,7 @@
return payload && payload.type === 'state' && Array.isArray(payload.boards) && payload.boards.length === 2 &&
payload.boards.every(board => typeof board === 'string' && /^[01234]{100}$/.test(board)) &&
typeof payload.version === 'number' && typeof payload.gameId === 'number' &&
['player1', 'player2', 'spectator'].includes(payload.viewer) &&
(payload.winner === null || payload.winner === 0 || payload.winner === 1) && Array.isArray(payload.statistics) &&
Array.isArray(payload.players) && payload.players.length === 2 && payload.players.every(name => typeof name === 'string' && name.length <= 80) &&
payload.statistics.length === 2 && payload.statistics.every(entry => Array.isArray(entry) && entry.length === 4 &&
@@ -118,12 +366,16 @@
function acceptState(payload) {
if (!safeState(payload)) { showError('Получено неполное состояние игры.'); return; }
const hadGap = state && payload.version > state.version + 1;
const previousState = state;
state = payload;
role = payload.viewer;
const validBoardKeys = role === 'spectator' ? [boardKeys.player1, boardKeys.player2] : [boardKeys.own, boardKeys.opponent];
if (!validBoardKeys.includes(activeBoard)) activeBoard = validBoardKeys[0];
if (hadGap) pollState();
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), payload.version, hadGap);
}
function isPlayer() { return role === 'player1' || role === 'player2'; }
@@ -151,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;
@@ -164,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,
});
}
@@ -180,10 +433,10 @@
}
const fleetClasses = [
{ length: 4, count: 1, symbol: 'ship-4-battleship', label: 'Линкор', className: 'battleship' },
{ length: 3, count: 2, symbol: 'ship-3-cruiser', label: 'Крейсер', className: 'cruiser' },
{ length: 2, count: 3, symbol: 'ship-2-destroyer', label: 'Эсминец', className: 'destroyer' },
{ length: 1, count: 4, symbol: 'ship-1-cutter', label: 'Катер', className: 'cutter' },
{ length: 4, count: 1, symbol: 'ship-4-battleship', viewBox: '0 0 192 50', label: 'Линкор', className: 'battleship' },
{ length: 3, count: 2, symbol: 'ship-3-cruiser', viewBox: '0 0 144 40', label: 'Крейсер', className: 'cruiser' },
{ length: 2, count: 3, symbol: 'ship-2-destroyer', viewBox: '0 0 96 30', label: 'Эсминец', className: 'destroyer' },
{ length: 1, count: 4, symbol: 'ship-1-cutter', viewBox: '0 0 48 26', label: 'Катер', className: 'cutter' },
];
function sunkShipLengths(board) {
@@ -222,8 +475,8 @@
const ship = document.createElement('span');
ship.className = `fleet-ship ${shipClass.className}${sunk ? ' sunk' : ''}`;
ship.setAttribute('role', 'img'); ship.setAttribute('aria-label', `${shipClass.label}${sunk ? 'потоплен' : 'цел'}`);
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('aria-hidden', 'true'); svg.setAttribute('focusable', 'false');
const use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); use.setAttribute('href', `/ship-sprite.svg#${shipClass.symbol}`);
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('aria-hidden', 'true'); svg.setAttribute('focusable', 'false'); svg.setAttribute('viewBox', shipClass.viewBox); svg.setAttribute('preserveAspectRatio', 'xMidYMax meet');
const use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); use.setAttribute('href', `/ship-sprite.svg#${shipClass.symbol}`); use.setAttribute('width', '100%'); use.setAttribute('height', '100%');
svg.append(use); ship.append(svg); ships.append(ship);
}
row.append(ships); fleet.append(row);
@@ -233,7 +486,7 @@
function boardElement(index, title, targetable, result) {
const board = document.createElement('section');
board.className = 'board';
board.className = `board${targetable ? ' is-action' : ''}`;
board.dataset.board = String(index);
const heading = document.createElement('h3');
heading.textContent = title;
@@ -282,18 +535,19 @@
function boardDefinitions(result = false) {
if (role === 'spectator' || result) return [
{ index: 0, title: `Поле: ${playerLabel(0)}`, key: 'player1', targetable: false },
{ index: 1, title: `Поле: ${playerLabel(1)}`, key: 'player2', targetable: false }
{ index: 0, title: `Поле: ${playerLabel(0)}`, key: boardKeys.player1, targetable: false },
{ index: 1, title: `Поле: ${playerLabel(1)}`, key: boardKeys.player2, targetable: false }
];
return [
{ index: ownIndex(), title: 'Моё поле', key: 'own', targetable: false },
{ index: opponentIndex(), title: state.mode === 'bot' ? 'Поле ESP32' : `Поле: ${playerName(opponentIndex())}`, key: 'opponent', targetable: canShoot() }
{ index: ownIndex(), title: 'Моё поле', key: boardKeys.own, targetable: false },
{ index: opponentIndex(), title: state.mode === 'bot' ? 'Поле ESP32' : `Поле: ${playerName(opponentIndex())}`, key: boardKeys.opponent, targetable: canShoot() }
];
}
function renderBoards(container, result = false) {
container.replaceChildren();
const definitions = boardDefinitions(result);
if (!result && !definitions.some(definition => definition.key === activeBoard)) activeBoard = definitions[0].key;
definitions.forEach(definition => {
const board = boardElement(definition.index, definition.title, definition.targetable, result);
board.hidden = !result && definition.key !== activeBoard;
@@ -303,7 +557,7 @@
ui.tabs.replaceChildren();
definitions.forEach(definition => {
const tab = document.createElement('button');
tab.type = 'button'; tab.role = 'tab'; tab.textContent = definition.key === 'opponent' ? playerLabel(definition.index) : definition.title;
tab.type = 'button'; tab.role = 'tab'; tab.textContent = definition.key === boardKeys.opponent ? playerLabel(definition.index) : definition.title;
tab.title = definition.title; tab.setAttribute('aria-label', definition.title);
tab.setAttribute('aria-selected', String(definition.key === activeBoard));
tab.addEventListener('click', () => { activeBoard = definition.key; renderGame(); });
@@ -320,8 +574,9 @@
ui.modeControls.hidden = !playerOne;
if (playerOne) startLobbyInfoPolling();
else stopLobbyInfoPolling();
const playerTwoReady = info?.player2Available === false;
const playerTwoReady = state.players[1].trim() !== '' || info?.player2Available === false;
const canStart = playerOne && (state.mode === 'bot' || playerTwoReady);
setSpriteIcon(ui.lobbyIcon, canStart ? 'icon-check' : 'icon-hourglass');
document.querySelectorAll('.mode-button').forEach(button => {
const selected = button.dataset.mode === state.mode;
button.setAttribute('aria-checked', String(selected));
@@ -336,15 +591,21 @@
if (!state) return;
stopLobbyInfoPolling();
showScreen('game');
if (canShoot() && !selectedTarget) activeBoard = 'opponent';
ui.turn.textContent = canShoot() ? 'Ваш ход' : `Ходит ${playerLabel(state.turn === 'player2' ? 1 : 0)}`;
if (canShoot() && !selectedTarget) activeBoard = boardKeys.opponent;
const turnPlayer = playerLabel(state.turn === 'player2' ? 1 : 0);
const watching = role === 'spectator';
ui.turn.textContent = canShoot() ? 'Ваш ход' : watching ? `Наблюдаем за боем · Ходит ${turnPlayer}` : `Ходит ${turnPlayer}`;
ui.wins.textContent = `${playerLabel(0)} ${state.wins[0]} : ${state.wins[1]} ${playerLabel(1)}`;
renderBoards(ui.boards);
const available = canShoot();
ui.cue.classList.toggle('is-ready', available && !selectedTarget);
ui.cue.classList.toggle('has-target', Boolean(available && selectedTarget));
setSpriteIcon(ui.cueIcon, available ? (selectedTarget ? 'icon-blast' : 'icon-target') : 'icon-hourglass');
ui.cueText.textContent = available ? (selectedTarget ? 'Жми «Огонь»!' : 'Твой ход! Выбери клетку') : watching ? 'Наблюдаем за боем' : 'Ждём соперника…';
ui.shotControls.hidden = !isPlayer();
ui.target.textContent = firePending ? 'Выстрел отправляется…' : available ? (selectedTarget ? `Цель: ${columns[selectedTarget.x]}${selectedTarget.y + 1}` : 'Выберите клетку на поле соперника.') : 'Ожидайте своего хода.';
ui.fire.disabled = !selectedTarget || !canFireTarget(selectedTarget);
ui.fire.textContent = firePending ? 'Выстрел…' : 'Огонь';
ui.fire.querySelector('span:last-child').textContent = firePending ? 'Летит…' : 'Огонь!';
ui.cancel.disabled = !selectedTarget || firePending;
ui.abort.hidden = !(role === 'player1' && state.mode === 'human' && state.phase === 'in_progress');
}
@@ -355,12 +616,13 @@
if (statisticsGameId !== state.gameId) refreshStatistics();
const waiting = state.phase === 'rematch_wait';
const winner = state.winner === ownIndex() && isPlayer() ? 'Вы победили. ' : state.winner === 0 || state.winner === 1 ? `Победил ${playerLabel(state.winner)}. ` : '';
setSpriteIcon(ui.resultIcon, isPlayer() && state.winner === ownIndex() ? 'icon-trophy' : 'icon-repeat');
const matchSummary = state.statistics.map((entry, index) => `${playerLabel(index)}: выстрелы ${entry[0]}, попадания ${entry[1]}, промахи ${entry[2]}, потоплено ${entry[3]}.`).join(' ');
const cumulative = cumulativeStatistics?.cumulative?.map((entry, index) => `${playerLabel(index)} всего: игр ${entry[0]}, побед ${entry[1]}, поражений ${entry[2]}, выстрелов ${entry[4]}, попаданий ${entry[5]}, промахов ${entry[6]}, потоплено ${entry[3]}.`).join(' ') || '';
ui.result.textContent = `${winner}${matchSummary} ${cumulative} ${waiting ? 'Ожидается подтверждение повторной игры.' : 'Поля раскрыты. Можно подтвердить повторную игру.'}`;
ui.rematch.hidden = !isPlayer();
ui.rematch.disabled = !isPlayer();
ui.rematch.textContent = waiting ? 'Подтвердить повторно' : 'Сыграть ещё';
ui.rematch.querySelector('span:last-child').textContent = waiting ? 'Я готов!' : 'Ещё раз!';
renderBoards(ui.resultBoards, true);
}
@@ -422,6 +684,8 @@
if (message.type === 'state') {
acceptState(message); retryIndex = 0; stopPolling(); setConnection('Синхронизация онлайн', 'online');
if (!heartbeatTimer) heartbeatTimer = window.setInterval(() => { if (socket?.readyState === WebSocket.OPEN) socket.send('{"type":"ping"}'); }, 15000);
} else if (message.type === 'reset') {
clearLocalProfile(true);
} else if (message.ok === false) notify(apiError(message), true);
} catch (_) { socket.close(); }
};
@@ -436,20 +700,62 @@
el('join-form').addEventListener('submit', event => {
event.preventDefault();
const requestedRole = info?.player1Available ? 'player1' : info?.player2Available ? 'player2' : 'spectator';
join(requestedRole);
join('player');
});
document.querySelectorAll('.avatar-button').forEach(button => button.addEventListener('click', () => {
ui.name.value = button.dataset.name;
document.querySelectorAll('.avatar-button').forEach(option => option.setAttribute('aria-pressed', String(option === button)));
el('join-player').focus();
}));
el('join-spectator').addEventListener('click', () => join('spectator'));
document.querySelectorAll('.mode-button').forEach(button => button.addEventListener('click', () => post('/api/game/config', { token, gameId: state.gameId, mode: button.dataset.mode })));
ui.start.addEventListener('click', () => post('/api/game/start', { token, gameId: state.gameId }));
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', () => { 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);
});
ui.recoveryConfirm.addEventListener('click', event => { if (recoveryAction !== 'game' || event.detail === 0) submitRecovery(); });
ui.recoveryConfirm.addEventListener('pointerdown', event => {
if (recoveryAction !== 'game' || event.button !== 0 || recoveryPending) return;
ui.recoveryConfirm.classList.add('holding'); recoveryHoldTimer = window.setTimeout(submitRecovery, 2000);
});
['pointerup', 'pointercancel', 'pointerleave'].forEach(type => ui.recoveryConfirm.addEventListener(type, () => {
if (recoveryHoldTimer) { window.clearTimeout(recoveryHoldTimer); recoveryHoldTimer = undefined; ui.recoveryConfirm.classList.remove('holding'); ui.recoveryLive.textContent = 'Удерживание отменено.'; }
}));
document.addEventListener('keydown', event => {
if (ui.recoveryDialog.hidden) return;
if (event.key === 'Escape') { event.preventDefault(); closeRecovery(); }
else if (event.key === 'Tab') {
const items = [...ui.recoveryDialog.querySelectorAll('button:not([hidden]):not(:disabled)')];
const first = items[0]; const last = items[items.length - 1];
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
}
});
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…');
await refreshInfo();
if (!token) { showScreen('connect'); setConnection('Готово к подключению'); return; }
+55
View File
@@ -0,0 +1,55 @@
(() => {
const COUNTS = Object.freeze({ select: 4, shot: 8, miss: 10, hit: 10, sunk: 8, dodged: 6, damage: 6, turn: 6, waiting: 4, start: 6, victory: 8, defeat: 5, recovery: 5 });
const PRIORITY = Object.freeze({ waiting: 0, select: 0, recovery: 1, shot: 2, turn: 3, miss: 4, dodged: 4, damage: 5, hit: 5, sunk: 6, start: 6, defeat: 7, victory: 8, summary: 8 });
const BASE = Object.freeze({ select: [540, 100], shot: [190, 220], miss: [410, 260], hit: [155, 280], sunk: [120, 720], dodged: [460, 190], damage: [135, 260], turn: [620, 160], waiting: [350, 120], start: [250, 460], victory: [330, 850], defeat: [310, 430], recovery: [480, 150] });
function presetsFor(family, count) {
const [frequency, durationMs] = BASE[family];
return Array.from({ length: count }, (_, index) => ({
id: `${family}-${index + 1}`, family, priority: PRIORITY[family], frequency: Math.max(100, frequency + ((index * 67) % 240) - 90),
endFrequency: Math.max(100, frequency + ((index * 43) % 180) - 70), durationMs: Math.min(900, durationMs + (index % 4) * 35),
filterHz: 900 + (index * 347) % 2600, gain: family === 'select' || family === 'waiting' ? 0.18 : family === 'recovery' ? 0.14 : 0.7,
wave: ['sine', 'triangle', 'square', 'sawtooth'][index % 4], noise: ['miss', 'hit', 'sunk', 'damage', 'dodged'].includes(family) && index % 3 === 1,
pan: ((index % 5) - 2) * 0.18, delayMs: index % 3 === 2 ? 28 : 0
}));
}
const CATALOG = Object.freeze(Object.fromEntries(Object.entries(COUNTS).map(([family, count]) => [family, presetsFor(family, count)])));
const EVENT_FAMILY = Object.freeze({ 'watch-miss': 'miss', 'watch-hit': 'hit', 'watch-sunk': 'sunk', 'sunk-damage': 'damage', finish: 'victory', rematch: 'defeat', summary: 'victory', 'recovery-open': 'recovery', 'recovery-cancel': 'recovery', 'recovery-leave': 'recovery', 'recovery-profile': 'recovery', 'recovery-game': 'recovery' });
const RECOVERY_PRESET = Object.freeze({ 'recovery-open': 0, 'recovery-cancel': 1, 'recovery-leave': 2, 'recovery-profile': 3, 'recovery-game': 4 });
function createSoundDirector({ engine, random = Math.random, now = () => Date.now(), historyLimit = 3, waitingIntervalMs = 12000 } = {}) {
const history = new Map();
let lastVersion = -1; let lastWaitingMs = -waitingIntervalMs;
function select(family) {
const presets = CATALOG[family] || [];
if (!presets.length) return undefined;
const previous = history.get(family) || [];
const options = presets.filter(preset => !previous.includes(preset.id));
const pool = options.length ? options : presets.filter(preset => preset.id !== previous.at(-1));
const preset = pool[Math.min(pool.length - 1, Math.max(0, Math.floor(random() * pool.length)))];
history.set(family, [...previous, preset.id].slice(-historyLimit));
return preset;
}
function play(event, { version, combo = 0, reduced = false, summary = false } = {}) {
if (Number.isInteger(version)) { if (version <= lastVersion) return false; lastVersion = version; }
let family = summary ? 'summary' : EVENT_FAMILY[event] || event;
if (family === 'summary') family = 'victory';
if (!CATALOG[family]) return false;
if (family === 'waiting') { if (reduced || now() - lastWaitingMs < waitingIntervalMs) return false; lastWaitingMs = now(); }
const forcedPreset = RECOVERY_PRESET[event];
const preset = forcedPreset === undefined ? select(family) : CATALOG.recovery[forcedPreset];
if (!preset) return false;
const played = engine?.playPreset?.(preset);
if (!played) return false;
if (!reduced && (event === 'hit' || event === 'sunk') && combo >= 2) {
const comboPreset = { ...select(combo >= 4 ? 'victory' : combo === 3 ? 'turn' : 'hit'), id: `combo-${combo}`, priority: PRIORITY.sunk, gain: 0.12, durationMs: combo >= 4 ? 380 : 160, delayMs: 55 };
engine.playPreset(comboPreset);
}
return { preset, combo: combo >= 4 ? 'ultra' : combo === 3 ? 'mega' : combo === 2 ? 'combo' : undefined };
}
return { play, select, getCatalog: () => CATALOG, getHistory: family => [...(history.get(family) || [])], reset: () => { history.clear(); lastVersion = -1; lastWaitingMs = -waitingIntervalMs; }, getPriority: event => PRIORITY[EVENT_FAMILY[event] || event] || 0 };
}
const api = { createSoundDirector, CATALOG, COUNTS, PRIORITY };
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else globalThis.BattleshipSounds = api;
})();
+32 -15
View File
@@ -8,59 +8,76 @@
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div id="game-effects" class="game-effects" aria-live="assertive" aria-atomic="true" hidden>
<div id="effect-burst" class="effect-burst" aria-hidden="true"></div>
<div class="effect-card"><small id="effect-badge" class="effect-badge" hidden></small><span id="effect-icon" class="effect-icon" aria-hidden="true"></span><strong id="effect-text"></strong></div>
</div>
<main class="app-shell">
<header class="app-header">
<div><p class="eyebrow">ESP32-C6 · локальная игра</p><h1>Морской бой</h1></div>
<p id="connection-status" class="connection-status" role="status">Подключение…</p>
<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="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">
<div class="connection-layout">
<div class="connection-form">
<h2 id="connect-title">Подключение к игре</h2>
<p>Введите имя, чтобы занять место игрока или наблюдать за партией.</p>
<h2 id="connect-title">Выбери героя</h2>
<p class="kid-hint"><span class="step-number" aria-hidden="true">1</span> Нажми на картинку</p>
<div id="avatar-picker" class="avatar-picker" role="group" aria-label="Выбор героя">
<button type="button" class="avatar-button" data-name="Капитан" aria-label="Капитан"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-captain"></use></svg><small>Капитан</small></button>
<button type="button" class="avatar-button" data-name="Дельфин" aria-label="Дельфин"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-dolphin"></use></svg><small>Дельфин</small></button>
<button type="button" class="avatar-button" data-name="Осьминог" aria-label="Осьминог"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-octopus"></use></svg><small>Осьминог</small></button>
<button type="button" class="avatar-button" data-name="Акула" aria-label="Акула"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-shark"></use></svg><small>Акула</small></button>
</div>
<form id="join-form" class="stack-form">
<label for="display-name">Ваше имя</label>
<input id="display-name" name="name" type="text" maxlength="80" autocomplete="name" required>
<div class="button-row"><button id="join-player" type="submit">Играть</button><button id="join-spectator" type="button" class="secondary">Наблюдать</button></div>
<label for="display-name">Имя героя</label>
<input id="display-name" name="name" type="text" maxlength="80" autocomplete="name" placeholder="Можно написать своё" required>
<p class="kid-hint"><span class="step-number" aria-hidden="true">2</span> Нажми большую кнопку</p>
<div class="button-row"><button id="join-player" type="submit" class="icon-button primary-action"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-gamepad"></use></svg><span>Играть</span></button><button id="join-spectator" type="button" class="secondary icon-button"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-eye"></use></svg><span>Смотреть</span></button></div>
</form>
</div>
<aside class="connection-support" aria-labelledby="join-help-title">
<h3 id="join-help-title">Как присоединиться</h3>
<ol class="join-steps"><li>Введите имя для этой партии.</li><li>Выберите место игрока или режим наблюдателя.</li><li>Все обновления появятся сразу после подключения.</li></ol>
<div class="support-illustration" aria-hidden="true"><svg class="ui-icon"><use href="/ship-sprite.svg#icon-wave"></use></svg><svg class="ui-icon ship-wide"><use href="/ship-sprite.svg#ship-2-destroyer"></use></svg><svg class="ui-icon"><use href="/ship-sprite.svg#icon-blast"></use></svg></div>
<h3 id="join-help-title">Как играть</h3>
<ol class="join-steps"><li><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-target"></use></svg> Выбери клетку.</li><li><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-blast"></use></svg> Нажми «Огонь».</li><li><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-trophy"></use></svg> Потопи все корабли.</li></ol>
<div class="availability-panel"><p class="eyebrow">Доступные места</p><p id="availability" class="muted" aria-live="polite">Проверяем доступные места…</p></div>
</aside>
</div>
</section>
<section id="screen-lobby" class="screen" aria-labelledby="lobby-title" hidden>
<h2 id="lobby-title">Лобби</h2><p id="lobby-description"></p>
<div class="lobby-hero"><span id="lobby-icon" class="hero-icon" aria-hidden="true"><svg class="ui-icon"><use href="/ship-sprite.svg#icon-hourglass"></use></svg></span><div><h2 id="lobby-title">Готовимся к бою</h2><p id="lobby-description"></p></div></div>
<section id="mode-controls" class="lobby-mode-section" aria-labelledby="mode-title" hidden>
<h3 id="mode-title">Режим игры</h3>
<div class="mode-options" role="radiogroup" aria-labelledby="mode-title"><button type="button" role="radio" aria-checked="false" data-mode="human" class="mode-button"><span class="mode-indicator" aria-hidden="true"></span><span>Два игрока</span></button><button type="button" role="radio" aria-checked="false" data-mode="bot" class="mode-button"><span class="mode-indicator" aria-hidden="true"></span><span>Против ESP32</span></button></div>
<div class="mode-options" role="radiogroup" aria-labelledby="mode-title"><button type="button" role="radio" aria-checked="false" data-mode="human" class="mode-button"><span class="mode-indicator" aria-hidden="true"></span><svg class="ui-icon mode-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-players"></use></svg><span>Вдвоём</span></button><button type="button" role="radio" aria-checked="false" data-mode="bot" class="mode-button"><span class="mode-indicator" aria-hidden="true"></span><svg class="ui-icon mode-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-robot"></use></svg><span>С роботом</span></button></div>
<p id="mode-description" class="muted" aria-live="polite"></p>
<button id="start-game" type="button" aria-describedby="mode-description">Начать игру</button>
<button id="start-game" type="button" class="icon-button primary-action" aria-describedby="mode-description"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-rocket"></use></svg><span>Начать бой</span></button>
</section>
<p id="lobby-help" class="muted"></p>
</section>
<section id="screen-game" class="screen" aria-labelledby="game-title" hidden>
<div class="screen-heading"><div><h2 id="game-title">Поле боя</h2><p id="turn-status" class="turn-status"></p></div><p id="wins" class="score" aria-label="Победы"></p></div>
<div id="action-cue" class="action-cue" role="status"><span id="action-cue-icon" class="cue-icon" aria-hidden="true"><svg class="ui-icon"><use href="/ship-sprite.svg#icon-hourglass"></use></svg></span><strong id="action-cue-text">Ждём…</strong></div>
<div id="board-tabs" class="board-tabs" role="tablist" aria-label="Выбор поля"></div>
<div id="shot-controls" class="shot-controls" hidden><p id="target-status" class="muted">Выберите клетку на поле соперника.</p><button id="fire-button" type="button" class="icon-button fire-button" disabled><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-blast"></use></svg><span>Огонь!</span></button><button id="cancel-target" type="button" class="secondary icon-button" disabled><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-repeat"></use></svg><span>Назад</span></button></div>
<div id="boards" class="boards"></div>
<div id="shot-controls" class="shot-controls" hidden><p id="target-status" class="muted">Выберите клетку на поле соперника.</p><button id="fire-button" type="button" disabled>Огонь</button><button id="cancel-target" type="button" class="secondary" disabled>Отменить</button></div>
<button id="abort-game" type="button" class="text-button abort-button" hidden>Отменить партию</button>
</section>
<section id="screen-result" class="screen" aria-labelledby="result-title" hidden>
<h2 id="result-title">Партия завершена</h2><p id="result-description"></p>
<div id="result-boards" class="boards result-boards"></div><button id="rematch-button" type="button">Сыграть ещё</button>
<div class="result-hero"><span id="result-icon" class="hero-icon" aria-hidden="true"><svg class="ui-icon"><use href="/ship-sprite.svg#icon-trophy"></use></svg></span><h2 id="result-title">Партия завершена</h2></div><p id="result-description"></p>
<div id="result-boards" class="boards result-boards"></div><button id="rematch-button" type="button" class="icon-button primary-action"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-repeat"></use></svg><span>Ещё раз!</span></button>
</section>
<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><button id="recovery-sound-mute" type="button" class="secondary recovery-sound-mute" hidden>Выключить звук</button><div id="recovery-choices" class="recovery-choices"><button type="button" data-recovery-action="leave"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-person"></use></svg><span><strong>Выйти из игры</strong><small>Имя останется на этом устройстве.</small></span></button><button type="button" data-recovery-action="profile"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-person"></use></svg><span><strong>Сбросить мой профиль</strong><small>Очистить имя и данные этого устройства.</small></span></button><button id="recovery-global-choice" type="button" data-recovery-action="game" class="recovery-global"><svg class="ui-icon" aria-hidden="true"><use href="/ship-sprite.svg#icon-players"></use></svg><span><strong>Сбросить всю игру</strong><small>Вернуть всех к началу.</small></span></button></div><div id="recovery-confirmation" class="recovery-confirmation" hidden><span id="recovery-scope-icon" class="recovery-scope-icon" aria-hidden="true"></span><h3 id="recovery-confirm-title"></h3><p id="recovery-confirm-text"></p><p id="recovery-hold-note" class="muted" hidden>Удерживайте кнопку 2 секунды.</p><div class="recovery-confirm-actions"><button id="recovery-cancel" type="button" class="secondary">Отмена</button><button id="recovery-confirm" type="button" class="recovery-confirm"><span id="recovery-confirm-label"></span><span id="recovery-hold-progress" class="recovery-hold-progress" aria-hidden="true"></span></button></div></div><p id="recovery-live" class="sr-only" aria-live="polite"></p></section></div>
<script src="/target_interaction.js" defer></script>
<script src="/web_audio.js" defer></script>
<script src="/game_sounds.js" defer></script>
<script src="/app.js" defer></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #041a2b; color: #e8f7ff; } body { margin: 0; padding: max(1.25rem, env(safe-area-inset-top)) max(1.25rem, env(safe-area-inset-right)) max(1.25rem, env(safe-area-inset-bottom)) max(1.25rem, env(safe-area-inset-left)); background: radial-gradient(circle at top, #0e4563, #041a2b 58%); } main { width: min(100%, 38rem); margin: 0 auto; } .eyebrow { color: #8fdbf3; font-size: .78rem; font-weight: 800; letter-spacing: .08em; } h1 { margin: .25rem 0 .5rem; } section, form { margin-top: 1.1rem; padding: 1rem; border: 1px solid #3a7593; border-radius: .8rem; background: rgb(5 31 50 / 88%); } .section-heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } h2 { margin: 0; font-size: 1.1rem; } .networks { display: grid; gap: .5rem; } .network { width: 100%; min-height: 2.75rem; border: 1px solid #548eaa; border-radius: .5rem; background: #123d58; color: #effaff; text-align: left; } label, input, button { display: block; width: 100%; box-sizing: border-box; } label { margin-top: .85rem; font-weight: 700; } input, button { min-height: 2.75rem; margin-top: .35rem; border: 1px solid #609bbb; border-radius: .5rem; padding: .55rem .7rem; font: inherit; } input { background: #061f33; color: #effaff; } button { background: #54c2e5; color: #062033; font-weight: 800; cursor: pointer; } button:focus-visible, input:focus-visible, a:focus-visible { outline: 3px solid #ffe56a; outline-offset: 3px; } .hint, #scan-status, #network-status { color: #c1ddea; font-size: .9rem; } .secondary { margin-top: 1rem; background: #315a75; color: #effaff; } a { color: #9ce8ff; }
+35
View File
@@ -0,0 +1,35 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#08233d">
<title>Настройка сети — Морской бой</title>
<link rel="stylesheet" href="/setup.css">
</head>
<body>
<main>
<p class="eyebrow">BATTLESHIP-OPEN · ЛОКАЛЬНАЯ СЕТЬ</p>
<h1>Настройка WiFi</h1>
<p>Игра доступна в этой открытой сети. Выберите домашнюю сеть или укажите скрытую вручную.</p>
<section aria-labelledby="networks-title">
<div class="section-heading"><h2 id="networks-title">Доступные сети</h2><button id="refresh" type="button">Обновить</button></div>
<p id="scan-status" role="status">Нажмите «Обновить», чтобы найти сети.</p>
<div id="networks" class="networks" aria-live="polite"></div>
</section>
<form id="network-form">
<h2>Скрытая или другая сеть</h2>
<label for="ssid">Название сети (SSID)</label>
<input id="ssid" name="ssid" maxlength="32" autocomplete="off" required>
<label for="password">Пароль</label>
<input id="password" name="password" type="password" maxlength="63" autocomplete="new-password">
<p class="hint">Пароль не отображается после отправки. Пока идёт проверка, оставайтесь в Battleship-open.</p>
<button type="submit">Проверить и сохранить</button>
</form>
<button id="delete-network" type="button" class="secondary">Удалить сохранённую сеть</button>
<p id="network-status" role="status"></p>
<p><a href="/">Открыть игру</a></p>
</main>
<script src="/setup.js" defer></script>
</body>
</html>
+45
View File
@@ -0,0 +1,45 @@
(() => {
const networks = document.querySelector('#networks');
const status = document.querySelector('#scan-status');
const ssid = document.querySelector('#ssid');
const refresh = document.querySelector('#refresh');
const form = document.querySelector('#network-form');
const password = document.querySelector('#password');
const networkStatus = document.querySelector('#network-status');
const remove = document.querySelector('#delete-network');
let timer = 0;
const scan = async () => {
clearTimeout(timer); refresh.disabled = true; status.textContent = 'Ищем сети…';
try {
const response = await fetch('/api/network/scan', { cache: 'no-store' });
const result = await response.json();
if (!response.ok) throw new Error('scan unavailable');
if (result.state === 'scanning') { timer = setTimeout(scan, 700); return; }
const names = [...new Set(result.networks || [])];
networks.replaceChildren(...names.map(name => { const button = document.createElement('button'); button.className = 'network'; button.type = 'button'; button.textContent = name; button.addEventListener('click', () => { ssid.value = name; ssid.focus(); }); return button; }));
status.textContent = names.length ? 'Выберите сеть или введите её вручную.' : 'Сети не найдены. Введите скрытую сеть вручную.';
} catch (_) { status.textContent = 'Не удалось выполнить поиск. Введите сеть вручную.'; }
finally { if (!timer) refresh.disabled = false; }
};
const updateStatus = async () => {
try {
const result = await fetch('/api/network/status', { cache: 'no-store' }).then(response => response.json());
networkStatus.textContent = result.message || '';
if (result.state === 'validating') setTimeout(updateStatus, 1000);
} catch (_) { networkStatus.textContent = 'Не удалось получить состояние сети.'; }
};
refresh.addEventListener('click', scan);
form.addEventListener('submit', async event => {
event.preventDefault();
try {
const response = await fetch('/api/network/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ssid: ssid.value, password: password.value }) });
const result = await response.json(); password.value = ''; networkStatus.textContent = result.message || 'Проверяем подключение…';
if (response.ok) updateStatus();
} catch (_) { password.value = ''; networkStatus.textContent = 'Не удалось начать проверку. Повторите попытку.'; }
});
remove.addEventListener('click', async () => {
try { const result = await fetch('/api/network/delete', { method: 'POST' }).then(response => response.json()); networkStatus.textContent = result.message || 'Сеть удалена.'; }
catch (_) { networkStatus.textContent = 'Не удалось удалить сеть.'; }
});
updateStatus();
})();
+23 -4
View File
@@ -1,6 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ship-1-cutter" viewBox="0 0 40 22"><path d="M3 15 9 6h13l7 9-5 3H8z"/><path d="M14 6V3h5v3M31 14h6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></symbol>
<symbol id="ship-2-destroyer" viewBox="0 0 72 26"><path d="M3 18 13 11h39l14 5-7 5H11z"/><path d="M23 11V5h13l5 6M45 11V7h6v4M61 16h8" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linejoin="round" stroke-linecap="round"/></symbol>
<symbol id="ship-3-cruiser" viewBox="0 0 104 32"><path d="M3 22 15 13h63l19 7-9 7H13z"/><path d="M31 13V5h19l7 8M60 13V8h10v5M76 14h11M84 10v4" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linejoin="round" stroke-linecap="round"/></symbol>
<symbol id="ship-4-battleship" viewBox="0 0 136 38"><path d="M3 27 17 15h84l28 9-11 9H14z"/><path d="M30 15V5h25l8 10M68 15V8h16v7M91 16h15M100 11v5M15 22h13M111 20h14" fill="none" stroke="currentColor" stroke-width="3" stroke-linejoin="round" stroke-linecap="round"/></symbol>
<symbol id="ship-1-cutter" viewBox="0 0 48 26"><path d="M2 19 8 13h25l10 3-5 5H7zM18 13l3-6h7l3 6zM26 7l1-5h2l1 5zM32 13l2-5h3l2 5zM5 17h7l-1-3H8z"/></symbol>
<symbol id="ship-2-destroyer" viewBox="0 0 96 30"><path d="M2 22 12 15h62l17 4-8 6H10zM20 15l4-7h14l5 7zM42 15l2-10h2l2 10zM50 15l2-9h6l3 9zM62 15l2-9h6l3 9zM6 19h9l-1-4H9zM79 19h9l-1-4h-5zM46 8l-4-6h2l5 6z"/></symbol>
<symbol id="ship-3-cruiser" viewBox="0 0 144 40"><path d="M2 29 15 19h99l25 5-10 9H13zM27 19l5-10h28l7 10zM65 19l3-14h3l3 14zM78 19l3-12h9l4 12zM96 19l3-12h9l4 12zM13 23h11l-2-5h-6zM47 22h10l-1-5h-7zM112 23h11l-2-5h-7zM71 7l-6-6h3l7 6z"/></symbol>
<symbol id="ship-4-battleship" viewBox="0 0 192 50"><path d="M2 36 18 23h135l34 7-13 11H14zM34 23l7-13h38l9 13zM84 23l4-17h5l4 17zM102 23l4-15h13l5 15zM125 23l4-15h13l5 15zM20 29h16l-2-8H24zM43 27h17l-2-8H48zM143 29h17l-2-8h-10zM163 30h18l-3-8h-11zM92 7l-8-7h4l9 7zM91 12h17v4H91z"/></symbol>
<symbol id="icon-captain" viewBox="0 0 64 64"><path d="M13 23h38l-4-9H36l-4-7-4 7H17zm6 5a13 13 0 1 0 26 0zm-7 29c2-12 9-18 20-18s18 6 20 18z"/></symbol>
<symbol id="icon-dolphin" viewBox="0 0 64 64"><path d="M5 38c12-18 27-25 45-19l9-7-2 13 5 7-12-2c-9 15-23 20-38 14l-8 8 2-13zm27-19-5-12 13 10zm15 10 4-2-4-2z"/></symbol>
<symbol id="icon-octopus" viewBox="0 0 64 64"><path d="M14 30a18 18 0 1 1 36 0v9c0 7-8 9-12 4-3 7-11 7-14 0-5 6-12 2-12-4zm11-2a3 3 0 1 0 0-6 3 3 0 0 0 0 6m14 0a3 3 0 1 0 0-6 3 3 0 0 0 0 6"/></symbol>
<symbol id="icon-shark" viewBox="0 0 64 64"><path d="M4 34c13-16 29-20 46-11L60 14l-2 15 4 10-13-5C34 47 19 48 4 39l7-3zm30-11-2-13 11 10zm14 8 4-2-4-2z"/></symbol>
<symbol id="icon-gamepad" viewBox="0 0 64 64"><path d="M16 19h32c9 0 14 23 8 29-4 4-11-5-15-9H23c-4 4-11 13-15 9-6-6-1-29 8-29m1 8v5h-5v5h5v5h5v-5h5v-5h-5v-5zm25 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8m9-6a4 4 0 1 0 0 8 4 4 0 0 0 0-8"/></symbol>
<symbol id="icon-eye" viewBox="0 0 64 64"><path d="M3 32C14 14 24 11 32 11s18 3 29 21C50 50 40 53 32 53S14 50 3 32m19 0a10 10 0 1 0 20 0 10 10 0 0 0-20 0m6 0a4 4 0 1 0 8 0 4 4 0 0 0-8 0"/></symbol>
<symbol id="icon-target" viewBox="0 0 64 64"><path d="M29 3h6v9a20 20 0 0 1 17 17h9v6h-9a20 20 0 0 1-17 17v9h-6v-9a20 20 0 0 1-17-17H3v-6h9a20 20 0 0 1 17-17zm3 15a14 14 0 1 0 0 28 14 14 0 0 0 0-28m0 8a6 6 0 1 0 0 12 6 6 0 0 0 0-12"/></symbol>
<symbol id="icon-blast" viewBox="0 0 64 64"><path d="m32 2 6 17 15-9-7 16 17 4-17 6 10 15-18-7-5 18-6-18-16 9 8-17-18-4 18-6-9-15 17 8z"/></symbol>
<symbol id="icon-hourglass" viewBox="0 0 64 64"><path d="M13 5h38v7c0 10-6 17-13 20 7 3 13 10 13 20v7H13v-7c0-10 6-17 13-20-7-3-13-10-13-20zm8 7c0 8 5 13 11 16 6-3 11-8 11-16zm0 40h22c0-8-5-13-11-16-6 3-11 8-11 16"/></symbol>
<symbol id="icon-check" viewBox="0 0 64 64"><path d="M32 3a29 29 0 1 0 0 58 29 29 0 0 0 0-58m-5 43L13 32l6-6 8 8 18-18 6 6z"/></symbol>
<symbol id="icon-robot" viewBox="0 0 64 64"><path d="M29 4h6v8h12a9 9 0 0 1 9 9v28a9 9 0 0 1-9 9H17a9 9 0 0 1-9-9V21a9 9 0 0 1 9-9h12zm-9 19a5 5 0 1 0 0 10 5 5 0 0 0 0-10m24 0a5 5 0 1 0 0 10 5 5 0 0 0 0-10M19 42v6h26v-6z"/></symbol>
<symbol id="icon-players" viewBox="0 0 64 64"><path d="M20 7a10 10 0 1 0 0 20 10 10 0 0 0 0-20m24 0a10 10 0 1 0 0 20 10 10 0 0 0 0-20M3 56c1-17 8-25 17-25 6 0 10 3 12 8 2-5 6-8 12-8 9 0 16 8 17 25z"/></symbol>
<symbol id="icon-rocket" viewBox="0 0 64 64"><path d="M42 4c10-3 17-1 18 0 1 1 3 8 0 18L39 43l-18-18zM16 30 7 33 2 47l17-8zm18 18-3 9 14 5 3-17zM18 44l-9 9 2 2 2 2 2 2 9-9zm29-29a6 6 0 1 0 0 12 6 6 0 0 0 0-12"/></symbol>
<symbol id="icon-trophy" viewBox="0 0 64 64"><path d="M15 5h34v8h10v11c0 9-7 16-17 16-2 3-5 5-7 6v7h12v7H17v-7h12v-7c-2-1-5-3-7-6C12 40 5 33 5 24V13h10zm34 14v13c3-1 5-4 5-8v-5zM10 19v5c0 4 2 7 5 8V19z"/></symbol>
<symbol id="icon-repeat" viewBox="0 0 64 64"><path d="M9 18h34l-7-7 7-7 19 18-19 18-7-7 7-7H9zm46 28H21l7 7-7 7L2 42l19-18 7 7-7 7h34z"/></symbol>
<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: 1.0 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

+38 -1
View File
@@ -1,5 +1,6 @@
:root { color-scheme: dark; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #061827; color: #f1f7ff; }
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body { margin: 0; min-width: 20rem; background: radial-gradient(circle at top, #104c75, #061827 45rem); }
button, input { font: inherit; }
button { min-height: 2.75rem; padding: .55rem .9rem; border: 0; border-radius: .65rem; background: #3ec6f0; color: #032035; font-weight: 750; cursor: pointer; }
@@ -23,7 +24,7 @@ h1, h2, h3, p { margin-top: 0; } h1 { margin-bottom: .2rem; font-size: clamp(1.7
.boards { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1rem; } .board { min-width: 0; padding: .75rem; border: 1px solid #50809c; border-radius: .8rem; background: #071f32; } .board h3 { margin: 0 0 .65rem; font-size: 1rem; }
.board-grid { display: grid; grid-template-columns: 1.15rem repeat(10, minmax(0, 1fr)); gap: 2px; width: 100%; aspect-ratio: 1.1; } .axis { display: grid; place-items: center; color: #b8d3e6; font-size: clamp(.52rem, 2.2vw, .72rem); font-weight: 700; }
.cell { position: relative; min-height: 0; padding: 0; border-radius: .12rem; border: 1px solid #4e87a6; background: #167aa6; color: #fff; font-size: clamp(.68rem, 3vw, 1.15rem); line-height: 1; } .cell.ship { background: #b2c8d6; color: #1c3442; } .cell.ship::before { content: "◆"; font-size: .8em; } .cell.miss::before { content: "•"; color: #08253a; font-size: 1.15em; } .cell.hit, .cell.sunk { background: #b83e42; color: #fff; } .cell.hit::before, .cell.sunk::before { content: "×"; font-size: 1.25em; font-weight: 900; } .cell.sunk { outline: 2px solid #ffe56a; outline-offset: -3px; } .cell.target { background: #0c648b; cursor: pointer; touch-action: manipulation; } .cell.target::after { content: ""; position: absolute; inset: 27%; border: 1px dotted #b8edff; border-radius: 50%; } .cell.own-water { background: #167aa6; } .cell.unavailable { border-style: dashed; background: #0d4666; opacity: .72; } .cell.selected { outline: 3px solid #ffe56a; outline-offset: -3px; box-shadow: inset 0 0 0 2px #061827; } .cell.selected::after { content: "◎"; inset: auto; border: 0; color: #ffe56a; font-size: 1.05em; font-weight: 900; } .cell[disabled] { cursor: default; }
.fleet-status { --fleet-unit: 1.3rem; display: grid; gap: .3rem; margin-top: .7rem; padding-top: .6rem; border-top: 1px solid rgb(80 128 156 / 55%); } .fleet-row { display: grid; grid-template-columns: 4.65rem minmax(0, 1fr); align-items: center; gap: .45rem; min-height: 1.65rem; } .fleet-class { color: #b8d3e6; font-size: .72rem; font-weight: 700; } .fleet-ships { display: flex; flex-wrap: wrap; align-items: center; gap: .3rem; min-width: 0; } .fleet-ship { position: relative; display: inline-grid; place-items: center; height: 1.55rem; color: #d9edf7; } .fleet-ship svg { display: block; width: 100%; max-height: 100%; fill: currentColor; } .fleet-ship.cutter { width: var(--fleet-unit); } .fleet-ship.destroyer { width: calc(var(--fleet-unit) * 2); } .fleet-ship.cruiser { width: calc(var(--fleet-unit) * 3); } .fleet-ship.battleship { width: calc(var(--fleet-unit) * 4); height: 1.7rem; } .fleet-ship.sunk { color: #9bb0bd; opacity: .48; } .fleet-ship.sunk::before, .fleet-ship.sunk::after { position: absolute; z-index: 1; width: 112%; height: 0; border-top: .16rem solid #ff5f55; border-radius: 99rem; content: ""; opacity: 1; pointer-events: none; } .fleet-ship.sunk::before { transform: rotate(31deg); } .fleet-ship.sunk::after { transform: rotate(-31deg); }
.fleet-status { --fleet-unit: 1.45rem; display: grid; gap: .34rem; margin-top: .7rem; padding-top: .6rem; border-top: 1px solid rgb(80 128 156 / 55%); } .fleet-row { display: grid; grid-template-columns: 4.65rem minmax(0, 1fr); align-items: end; gap: .5rem; min-height: 1.8rem; } .fleet-class { align-self: center; color: #b8d3e6; font-size: .72rem; font-weight: 700; } .fleet-ships { display: flex; flex-wrap: nowrap; align-items: end; gap: .42rem; min-width: 0; } .fleet-ship { position: relative; display: inline-grid; place-items: end center; height: 1.7rem; color: #d9edf7; } .fleet-ship svg { display: block; width: 100%; max-height: 100%; fill: currentColor; } .fleet-ship.cutter { width: var(--fleet-unit); height: 1.2rem; } .fleet-ship.destroyer { width: calc(var(--fleet-unit) * 2); height: 1.4rem; } .fleet-ship.cruiser { width: calc(var(--fleet-unit) * 3); height: 1.6rem; } .fleet-ship.battleship { width: calc(var(--fleet-unit) * 4); height: 1.8rem; } .fleet-ship.sunk { color: #9bb0bd; } .fleet-ship.sunk svg { opacity: .48; } .fleet-ship.sunk::before, .fleet-ship.sunk::after { position: absolute; z-index: 1; width: 108%; height: 0; border-top: .16rem solid #ff5f55; border-radius: 99rem; content: ""; opacity: .96; pointer-events: none; } .fleet-ship.sunk::before { transform: rotate(31deg); } .fleet-ship.sunk::after { transform: rotate(-31deg); }
.shot-controls { display: flex; flex-wrap: wrap; align-items: center; gap: .7rem; margin-top: 1rem; } .shot-controls p { flex: 1 1 14rem; margin: 0; } .abort-button { margin-top: .75rem; color: #ffb7ae !important; } .reconnecting { border-color: #ffe56a; } .error-screen { border-color: #ff8e80; }
@media (min-width: 44rem) { .app-shell { display: flex; flex-direction: column; min-height: 100svh; } .app-header { align-items: center; } .connection-status { margin: 0; } #screen-connect, #screen-lobby { width: min(100%, 48rem); margin: clamp(1.25rem, 4vh, 2.5rem) auto auto; padding: clamp(1.5rem, 4vw, 2.5rem); } #screen-connect .stack-form { width: min(100%, 42rem); max-width: 42rem; } #screen-connect .button-row > * { flex-basis: 16rem; } .mode-options { grid-template-columns: repeat(2, minmax(0, 1fr)); } .boards { grid-template-columns: repeat(2, minmax(0, 1fr)); } .board-tabs { display: none; } .board[hidden] { display: block; } }
@media (min-width: 60rem) { #screen-connect { width: 100%; max-width: none; min-height: min(29rem, calc(100svh - 9rem)); display: grid; align-content: center; } #screen-connect .connection-layout { grid-template-columns: minmax(0, 1.15fr) minmax(18rem, .85fr); align-items: stretch; gap: clamp(1.5rem, 4vw, 3rem); } #screen-connect .stack-form { max-width: none; } #screen-connect .connection-support { display: flex; flex-direction: column; justify-content: center; } }
@@ -31,3 +32,39 @@ h1, h2, h3, p { margin-top: 0; } h1 { margin-bottom: .2rem; font-size: clamp(1.7
#screen-game { margin-top: .75rem; padding: 0; border: 0; background: transparent; box-shadow: none; } #screen-game .screen-heading { align-items: center; margin-bottom: .5rem; } #screen-game .screen-heading h2 { margin-bottom: .1rem; } #screen-game .turn-status { color: #e2f7ff; font-size: clamp(1rem, 2.5vw, 1.2rem); font-weight: 800; } #screen-game .score { margin: 0; border: 1px solid #41708d; } #screen-game .board { width: min(100%, 34rem); margin-inline: auto; padding: .65rem; box-shadow: 0 .65rem 1.5rem rgb(0 0 0 / 12%); } #screen-game .board-grid { max-width: 100%; margin-inline: auto; aspect-ratio: 1; grid-template-rows: repeat(11, minmax(0, 1fr)); } #screen-game .shot-controls { width: min(100%, 34rem); margin: .75rem auto 0; } #screen-game #fire-button { min-width: 8rem; } #screen-game .board-tabs { width: min(100%, 34rem); margin: .6rem auto; } #screen-game .board-tabs button { min-height: 2.75rem; }
@media (max-width: 43.99rem) { #screen-game .screen-heading { gap: .55rem; } #screen-game .boards { gap: .65rem; } #screen-game .board { padding: .5rem; border-radius: .65rem; } #screen-game .shot-controls { display: grid; grid-template-columns: 1fr; gap: .55rem; } #screen-game .shot-controls p { margin: 0; } #screen-game #fire-button, #screen-game #cancel-target { width: 100%; min-height: 3rem; } }
@media (min-width: 44rem) and (orientation: portrait) and (max-width: 64rem) { #screen-game .boards { grid-template-columns: minmax(0, 1fr); } #screen-game .board-tabs { display: flex; } #screen-game .board[hidden] { display: none; } }
.ui-icon { display: inline-block; width: 1.6em; height: 1.6em; flex: 0 0 auto; fill: currentColor; vertical-align: -.32em; } .title-icon { width: 1.25em; } .icon-button { display: inline-flex; align-items: center; justify-content: center; gap: .55rem; } .icon-button > .ui-icon { width: 1.55rem; height: 1.55rem; }
.header-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: end; gap: .5rem; } .recovery-menu-button { display: inline-flex; align-items: center; justify-content: center; gap: .35rem; min-width: 2.75rem; min-height: 2.75rem; padding: .45rem .65rem; background: #31536e; color: #f1f7ff; } .recovery-menu-button .ui-icon { width: 1.25rem; height: 1.25rem; }
.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; }
.avatar-button { display: grid; place-items: center; gap: .25rem; min-height: 5.4rem; padding: .45rem; border: 2px solid #41708d; background: #123b58; color: #f1f7ff; }
.avatar-button > .ui-icon { width: clamp(2.4rem, 9vw, 3.4rem); height: clamp(2.4rem, 9vw, 3.4rem); color: #c8e9f8; } .avatar-button:nth-child(2) .ui-icon { color: #65e3ff; } .avatar-button:nth-child(3) .ui-icon { color: #d5a2ff; } .avatar-button:nth-child(4) .ui-icon { color: #a7d8e8; } .avatar-button small { max-width: 100%; overflow: hidden; font-size: .72rem; text-overflow: ellipsis; }
.avatar-button[aria-pressed="true"] { border-color: #ffe56a; background: #176285; box-shadow: 0 0 0 .2rem rgb(255 229 106 / 25%); transform: translateY(-.12rem); }
.support-illustration { display: flex; align-items: end; justify-content: center; gap: .8rem; margin-bottom: .8rem; color: #61d9ff; } .support-illustration .ui-icon { width: 3rem; height: 3rem; } .support-illustration .ship-wide { width: 6rem; color: #e0f3ff; }
.join-steps { list-style: none; padding-left: 0; } .join-steps li { display: flex; align-items: center; gap: .55rem; font-weight: 700; } .join-steps .ui-icon { width: 1.5rem; height: 1.5rem; color: #ffe56a; }
.primary-action:not(:disabled) { animation: action-pulse 1.35s ease-in-out infinite; box-shadow: 0 .35rem 1.25rem rgb(62 198 240 / 28%); }
.lobby-hero, .result-hero { display: flex; align-items: center; gap: .85rem; } .hero-icon .ui-icon { width: clamp(2.8rem, 10vw, 4.5rem); height: clamp(2.8rem, 10vw, 4.5rem); color: #ffe56a; } .lobby-hero h2, .result-hero h2 { margin-bottom: .2rem; }
.mode-button { grid-template-columns: 1.2rem 2.4rem minmax(0, 1fr); min-height: 4rem; } .mode-icon { width: 2rem; height: 2rem; color: #c8e9f8; }
.action-cue { display: flex; align-items: center; justify-content: center; gap: .65rem; width: min(100%, 34rem); min-height: 3.4rem; margin: .55rem auto; padding: .55rem .9rem; border: 2px solid #41708d; border-radius: .9rem; background: #123b58; font-size: clamp(1.05rem, 4vw, 1.35rem); letter-spacing: .03em; }
.cue-icon .ui-icon { width: 2rem; height: 2rem; } .action-cue.is-ready { border-color: #ffe56a; background: #145b4b; color: #fffbd7; animation: cue-pulse 1.1s ease-in-out infinite; } .action-cue.has-target { border-color: #ff9d3d; background: #6a3d14; }
.board.is-action { border-color: #ffe56a; box-shadow: 0 0 0 .15rem rgb(255 229 106 / 25%), 0 .65rem 1.5rem rgb(0 0 0 / 12%) !important; }
.board.is-action .cell.target:nth-child(3n) { animation: target-ripple 1.8s ease-in-out infinite; }
.fire-button:not(:disabled) { background: #ff9d3d; color: #2d1600; animation: fire-pulse .85s ease-in-out infinite; }
.shot-controls { padding: .65rem; border: 1px solid #315a75; border-radius: .8rem; background: rgb(7 31 50 / 94%); }
.game-effects { position: fixed; z-index: 100; inset: 0; display: grid; place-items: center; overflow: hidden; pointer-events: none; }
.game-effects[hidden] { display: none; } .effect-card { position: relative; z-index: 2; display: grid; place-items: center; min-width: min(84vw, 20rem); padding: 1.25rem; border: .2rem solid #fff; border-radius: 1.4rem; background: rgb(5 25 42 / 92%); color: #fff; font-size: clamp(1.35rem, 7vw, 2.25rem); text-align: center; box-shadow: 0 1.5rem 5rem #000; animation: reward-pop .9s cubic-bezier(.2, 1.5, .5, 1) both; }
.effect-badge { margin-bottom: .35rem; padding: .3rem .75rem; border-radius: 99rem; background: #ffe56a; color: #08233d; font-size: clamp(.85rem, 4vw, 1.15rem); font-weight: 950; letter-spacing: .08em; transform: rotate(-3deg); animation: badge-slam .5s cubic-bezier(.15, 1.7, .35, 1) both; } .effect-badge[hidden] { display: none; }
.effect-icon .ui-icon { width: clamp(5rem, 24vw, 9rem); height: clamp(5rem, 24vw, 9rem); color: #ffe56a; filter: drop-shadow(0 .3rem .5rem rgb(0 0 0 / 35%)); }
.game-effects.effect-hit { background: rgb(255 174 0 / 20%); } .game-effects.effect-damage { background: rgb(255 20 20 / 38%); animation: screen-danger .55s ease-in-out 2; } .game-effects.effect-miss { background: rgb(39 186 255 / 17%); } .game-effects.effect-victory { background: rgb(255 220 44 / 20%); }
.effect-burst span { position: absolute; left: 50%; top: 50%; font-size: clamp(1.4rem, 6vw, 2.8rem); animation: burst-away 1.2s ease-out both; animation-delay: var(--delay); transform: rotate(var(--rotate)); }
.burst-bubbles span { color: #8eeaff; animation-name: bubble-away; } .burst-sparks span { color: #ff9d3d; animation-duration: .8s; } .burst-stars span { color: #ffe56a; }
.motion-bounce .effect-card { animation-name: reward-bounce; } .motion-spin .effect-card { animation-name: reward-spin; } .motion-swoop .effect-card { animation-name: reward-swoop; } .motion-zoom .effect-card { animation-name: reward-zoom; } .motion-combo .effect-card { animation: reward-combo 1s cubic-bezier(.15, 1.45, .35, 1) both; }
.combo-power { background: radial-gradient(circle, rgb(255 229 106 / 42%), rgb(255 68 0 / 20%) 45%, rgb(0 0 0 / 8%)); } .combo-power .effect-card { border-color: #ffe56a; box-shadow: 0 0 2rem #ff9d3d, 0 1.5rem 5rem #000; } .combo-power .effect-icon { animation: combo-icon .32s ease-in-out 3 alternate; }
body.fx-damage .app-shell { animation: ship-shake .5s ease-in-out 2; } body.fx-hit .app-shell { animation: screen-glow .75s ease-out; }
@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, .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; } }
+44 -1
View File
@@ -43,7 +43,50 @@
};
}
const api = { createTargetActivator };
function detectFeedback(previous, next, viewer) {
if (!previous || !next || previous.gameId !== next.gameId || next.version <= previous.version) return undefined;
const player = viewer === 'player1' ? 0 : viewer === 'player2' ? 1 : -1;
if (previous.phase !== next.phase && next.phase === 'finished') {
if (player < 0) return { type: 'finish' };
return { type: next.winner === player ? 'victory' : 'defeat' };
}
if (previous.phase !== 'in_progress' && next.phase === 'in_progress') return { type: 'start' };
if (!Array.isArray(previous.boards) || !Array.isArray(next.boards)) return undefined;
let best;
for (let board = 0; board < 2; board += 1) {
if (typeof previous.boards[board] !== 'string' || typeof next.boards[board] !== 'string') continue;
for (let cell = 0; cell < 100; cell += 1) {
const before = previous.boards[board][cell];
const after = next.boards[board][cell];
if (before === after) continue;
const perspective = player < 0 ? 'watch' : board === player ? 'damage' : 'attack';
if (after === '4') best = { type: perspective === 'damage' ? 'sunk-damage' : perspective === 'attack' ? 'sunk' : 'watch-sunk' };
else if (!best && after === '3') best = { type: perspective === 'damage' ? 'damage' : perspective === 'attack' ? 'hit' : 'watch-hit' };
else if (!best && after === '2') best = { type: perspective === 'attack' ? 'miss' : perspective === 'damage' ? 'dodged' : 'watch-miss' };
}
}
if (best) return best;
if (player >= 0 && previous.turn !== next.turn && next.turn === viewer) return { type: 'turn' };
return undefined;
}
function createReactionPicker(catalog, random = Math.random) {
const previousByType = new Map();
return type => {
const variants = catalog[type];
if (!Array.isArray(variants) || variants.length === 0) return undefined;
if (variants.length === 1) return variants[0];
const previous = previousByType.get(type);
const candidateCount = previous === undefined ? variants.length : variants.length - 1;
let index = Math.min(candidateCount - 1, Math.max(0, Math.floor(random() * candidateCount)));
if (previous !== undefined && index >= previous) index += 1;
previousByType.set(type, index);
return variants[index];
};
}
const api = { createTargetActivator, detectFeedback, createReactionPicker };
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else globalThis.BattleshipTargetInteraction = api;
})();
+120
View File
@@ -0,0 +1,120 @@
(() => {
const MAX_VOICES = 8;
const MAX_GAIN = 0.28;
const VOLUME_GAINS = Object.freeze({ quiet: 0.1, normal: 0.18, loud: MAX_GAIN });
const PREFERENCES = Object.freeze({ enabled: 'battleship.soundEnabled', volume: 'battleship.soundVolume', reduced: 'battleship.soundReduced' });
function safeGet(storage, key) { try { return storage?.getItem(key); } catch (_) { return null; } }
function safeSet(storage, key, value) { try { storage?.setItem(key, value); } catch (_) { /* Preferences are optional. */ } }
function validVolume(value) { return Object.hasOwn(VOLUME_GAINS, value) ? value : 'normal'; }
function createSilentEngine(storage) {
const preferences = { enabled: safeGet(storage, PREFERENCES.enabled) === 'true', volume: validVolume(safeGet(storage, PREFERENCES.volume)), reduced: safeGet(storage, PREFERENCES.reduced) === 'true' };
const save = () => { safeSet(storage, PREFERENCES.enabled, String(preferences.enabled)); safeSet(storage, PREFERENCES.volume, preferences.volume); safeSet(storage, PREFERENCES.reduced, String(preferences.reduced)); };
return {
getPreferences: () => ({ ...preferences }),
enable: async () => { preferences.enabled = true; save(); return false; },
setEnabled: async enabled => { preferences.enabled = Boolean(enabled); save(); return false; },
setVolume: volume => { preferences.volume = validVolume(volume); save(); },
setReduced: reduced => { preferences.reduced = Boolean(reduced); save(); },
play: () => false, cancel: () => {}, setPageHidden: () => {}, cleanup: () => {}, getVoiceCount: () => 0, getNoiseBuffer: () => undefined
};
}
function createAudioEngine({ AudioContext: Context = globalThis.AudioContext || globalThis.webkitAudioContext, storage = globalThis.localStorage, timer = globalThis, now = () => Date.now() } = {}) {
if (!Context) return createSilentEngine(storage);
const preferences = { enabled: safeGet(storage, PREFERENCES.enabled) === 'true', volume: validVolume(safeGet(storage, PREFERENCES.volume)), reduced: safeGet(storage, PREFERENCES.reduced) === 'true' };
let context; let master; let limiter; let noiseBuffer; let hidden = false; let lastVersion = -1;
const voices = new Set();
const save = () => { safeSet(storage, PREFERENCES.enabled, String(preferences.enabled)); safeSet(storage, PREFERENCES.volume, preferences.volume); safeSet(storage, PREFERENCES.reduced, String(preferences.reduced)); };
const disconnect = node => { try { node?.disconnect(); } catch (_) { /* Node was already released. */ } };
function updateGain() { if (master) master.gain.setValueAtTime(VOLUME_GAINS[preferences.volume], context.currentTime); }
function ensureContext() {
if (context) return true;
try {
context = new Context();
master = context.createGain(); master.gain.setValueAtTime(VOLUME_GAINS[preferences.volume], context.currentTime);
limiter = context.createDynamicsCompressor(); limiter.threshold.setValueAtTime(-18, context.currentTime); limiter.knee.setValueAtTime(12, context.currentTime); limiter.ratio.setValueAtTime(8, context.currentTime);
master.connect(limiter); limiter.connect(context.destination);
return true;
} catch (_) { context = undefined; master = undefined; limiter = undefined; return false; }
}
function ensureNoise() {
if (noiseBuffer) return noiseBuffer;
noiseBuffer = context.createBuffer(1, Math.max(1, Math.floor(context.sampleRate * 0.18)), context.sampleRate);
const samples = noiseBuffer.getChannelData(0);
for (let index = 0; index < samples.length; index += 1) samples[index] = ((index * 1103515245 + 12345) >>> 16) / 32768 - 0.5;
return noiseBuffer;
}
function release(voice) {
if (!voices.delete(voice)) return;
timer.clearTimeout(voice.timeout);
try { voice.source.stop(); } catch (_) { /* It may have ended naturally. */ }
disconnect(voice.source); disconnect(voice.filter); disconnect(voice.gain); disconnect(voice.panner);
}
function makeVoice(priority, durationMs, sourceFactory, settings = {}) {
if (!preferences.enabled || hidden || !ensureContext() || voices.size >= MAX_VOICES && !replaceVoice(priority)) return false;
const startedAt = context.currentTime + Math.max(0, settings.delayMs || 0) / 1000;
const duration = Math.max(0.1, Math.min(durationMs, 2500) / 1000) * (preferences.reduced ? 0.65 : 1);
let source; let gain; let filter; let panner;
try {
source = sourceFactory(); gain = context.createGain(); filter = context.createBiquadFilter();
filter.type = 'lowpass'; filter.frequency.setValueAtTime(settings.filterHz || 2400, startedAt);
const peak = Math.min(0.75, Math.max(0.04, settings.gain || 0.55));
gain.gain.setValueAtTime(0.0001, startedAt); gain.gain.exponentialRampToValueAtTime(peak, startedAt + 0.012); gain.gain.exponentialRampToValueAtTime(0.0001, startedAt + duration);
if (typeof context.createStereoPanner === 'function') { panner = context.createStereoPanner(); panner.pan.setValueAtTime(Math.max(-0.4, Math.min(0.4, settings.pan || 0)), startedAt); }
source.connect(filter); filter.connect(gain); if (panner) { gain.connect(panner); panner.connect(master); } else gain.connect(master); source.start(startedAt); source.stop(startedAt + duration + 0.02);
} catch (_) { disconnect(source); disconnect(filter); disconnect(gain); disconnect(panner); return false; }
const voice = { source, filter, gain, panner, priority, timeout: undefined };
voice.timeout = timer.setTimeout(() => release(voice), Math.ceil(duration * 1000) + 80);
voices.add(voice); return true;
}
function replaceVoice(priority) {
let lowest;
voices.forEach(voice => { if (!lowest || voice.priority < lowest.priority) lowest = voice; });
if (!lowest || lowest.priority > priority) return false;
release(lowest); return true;
}
async function enable() {
preferences.enabled = true; save();
if (!ensureContext()) return false;
try { if (context.state === 'suspended') await context.resume(); } catch (_) { return false; }
return context.state !== 'closed';
}
async function setEnabled(enabled) {
if (!enabled) { preferences.enabled = false; save(); cancel(); return true; }
return enable();
}
function cancel() { [...voices].forEach(release); }
function play(kind = 'test', version) {
if (Number.isInteger(version)) { if (version <= lastVersion) return false; lastVersion = version; }
if (kind === 'noise') return makeVoice(1, 160, () => { const source = context.createBufferSource(); source.buffer = ensureNoise(); return source; });
return makeVoice(kind === 'major' ? 2 : 1, kind === 'major' ? 700 : 180, () => {
const oscillator = context.createOscillator(); oscillator.type = kind === 'major' ? 'triangle' : 'sine'; oscillator.frequency.setValueAtTime(kind === 'major' ? 392 : 660, context.currentTime); oscillator.frequency.exponentialRampToValueAtTime(kind === 'major' ? 196 : 440, context.currentTime + 0.16); return oscillator;
});
}
function playPreset(preset) {
if (!preset || !preferences.enabled || hidden || !ensureContext()) return false;
return makeVoice(preset.priority || 1, preset.durationMs || 180, () => {
if (preset.noise) { const source = context.createBufferSource(); source.buffer = ensureNoise(); return source; }
const oscillator = context.createOscillator(); oscillator.type = preset.wave || 'sine';
const startedAt = context.currentTime + Math.max(0, preset.delayMs || 0) / 1000;
oscillator.frequency.setValueAtTime(Math.max(100, preset.frequency || 440), startedAt);
oscillator.frequency.exponentialRampToValueAtTime(Math.max(100, preset.endFrequency || preset.frequency || 440), startedAt + Math.min(0.8, (preset.durationMs || 180) / 1000));
return oscillator;
}, preset);
}
async function setPageHidden(nextHidden) {
hidden = Boolean(nextHidden); cancel();
if (!context) return;
try { if (hidden) await context.suspend(); else if (preferences.enabled && context.state === 'suspended') await context.resume(); } catch (_) { /* Sound remains optional. */ }
}
function cleanup() { cancel(); lastVersion = -1; if (context) { try { context.close(); } catch (_) { /* Already closed. */ } } context = undefined; master = undefined; limiter = undefined; noiseBuffer = undefined; }
return { getPreferences: () => ({ ...preferences }), enable, setEnabled, setVolume: volume => { preferences.volume = validVolume(volume); save(); updateGain(); }, setReduced: reduced => { preferences.reduced = Boolean(reduced); save(); }, play, playPreset, cancel, setPageHidden, cleanup, getVoiceCount: () => voices.size, getNoiseBuffer: () => noiseBuffer, getMaxGain: () => MAX_GAIN, now };
}
const api = { createAudioEngine, MAX_VOICES, MAX_GAIN, VOLUME_GAINS, PREFERENCES };
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else globalThis.BattleshipAudio = api;
})();
+29 -1
View File
@@ -38,7 +38,7 @@ Failure (maximum 160 encoded bytes):
```
`code` is one of `MALFORMED_JSON`, `PAYLOAD_TOO_LARGE`, `INVALID_NAME`,
`INVALID_ROLE`, `INVALID_MODE`, `INVALID_COORDINATE`, `UNAUTHORIZED`,
`INVALID_ROLE`, `INVALID_MODE`, `INVALID_COORDINATE`, `UNAUTHORIZED`, `SESSION_INVALIDATED`,
`NO_PLAYER_SLOT`, `NO_SPECTATOR_SLOT`, `FORBIDDEN_ROLE`, `WRONG_PHASE`,
`NOT_YOUR_TURN`, `CELL_ALREADY_SHOT`, `STALE_GAME`, or `SERVER_BUSY`.
`message` is Russian and at most 80 UTF-8 bytes.
@@ -49,13 +49,20 @@ 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 |
| `POST /api/session/profile-reset` | 80 B body | `{token,gameId}`; server release for local profile reset |
| `POST /api/game/config` | 96 B body | `{token,gameId,mode}`; common envelope |
| `POST /api/game/start` | 80 B body | `{token,gameId}`; common envelope |
| `POST /api/game/shot` | 96 B body | `{token,gameId,x,y}`; common envelope |
| `POST /api/game/rematch` | 80 B body | `{token,gameId}`; common envelope |
| `POST /api/game/abort` | 80 B body | `{token,gameId}`; common envelope |
| `POST /api/game/reset` | 80 B body | `{token,gameId}`; player-only full RAM reset |
| `GET /api/state?version=N` | 128 B target | one role-safe state, 768 B |
| `GET /api/statistics` | 128 B target | role and bounded match/cumulative counters |
@@ -63,6 +70,27 @@ The session token is in each POST body. For `GET /api/state`, it is supplied
in `X-Session-Token`; absence creates a spectator-safe view. It is never a URL
parameter.
For the primary “Play” action, `requestedRole: "player"` atomically assigns
the first available player slot (`player1`, then `player2`). This avoids a
client-side availability race; the response and every authorized state snapshot
contain the assigned concrete role.
`leave` and `profile-reset` release only the requesting session; profile data is
cleared by the browser in Milestone 022. A player leaving an active match aborts
that match and returns remaining valid players to the lobby. `game/reset` is
available only to an authenticated player and atomically invalidates every
session, clears match and cumulative statistics, and starts a fresh game
generation. Successful recovery responses contain `resetReason` (`session_left`,
`profile_reset`, or `game_reset`) and `generation`. Invalidated-token state
polling returns `SESSION_INVALIDATED` with the same bounded recovery metadata.
The recovery generation and `gameId` make delayed commands stale; retrying a
completed recovery is harmless and cannot mutate a newly registered session.
Network configuration is deliberately unauthenticated so it works from the
open fallback AP and the regular local address. Any reachable client can change
or delete the saved profile; the open AP and local HTTP do not provide password
confidentiality. Network responses and diagnostics never include the password.
## Role-safe state event
The HTTP state response and WebSocket `state` event use this single 768-byte
+56
View File
@@ -0,0 +1,56 @@
# Проверка звука на устройствах
Этот лист фиксирует Milestone 026. Не записывайте детей и не сохраняйте
персональные данные: достаточно анонимных наблюдений ответственного взрослого.
## Перед началом
1. Загрузите текущие firmware и LittleFS образы, затем выполните жёсткое
обновление страницы.
2. На каждом устройстве откройте лобби, включите звук явным нажатием и
проверьте `Тихо`, `Обычно`, `Громко` и `Мягкие звуки`.
3. Запишите браузер, ориентацию, способ транспорта (WebSocket или HTTP) и
только результат: pass/fail и краткое анонимное замечание.
## Матрица
| Устройство | Браузер | Портрет | Альбом | WS | HTTP | Результат/замечание |
| --------------------------------- | -------------------- | ------- | ------ | --- | ---- | ------------------- |
| Android-телефон | Chromium | | | | | |
| Android-планшет/второе устройство | Chromium | | | | | |
| iPhone/iPad, если доступен | Safari | | | | | |
| Ноутбук | браузер и клавиатура | | | | | |
## Сценарии
- После первого нажатия звук включается; после перезагрузки и resume выбор
сохраняется. Мгновенное выключение прекращает уже играющий звук.
- Различимы выбор цели, выстрел, промах, попадание, потопление, входящий
результат, ход, комбо, победа, поражение, повтор и действия восстановления.
- За 30 выстрелов один и тот же тип не звучит подряд одним пресетом. Комбо
×2, ×3 и ×4+ заметно усиливаются, а `Мягкие звуки` короче и спокойнее.
- Уход в фон, возврат, блокировка/разблокировка и смена вкладки не создают
очередь старых звуков. Потеря WebSocket, HTTP-опрос и восстановление не
повторяют один результат.
- Зритель слышит только публичные попадания/промахи/потопления и не получает
информацию о скрытых кораблях. Leave, profile reset и full reset безопасно
останавливают активный звук.
- При отключённом или заблокированном Web Audio игра остаётся полностью
рабочей и тихой; все события понятны визуально и экранные метки не
дублируются бесполезными объявлениями.
## Длительная проверка
Проведите 20 обычных партий со звуком и один 60-минутный сеанс с выстрелами,
комбо, сбросами, переподключениями и сменой видимости. Зафиксируйте отсутствие
ошибок консоли, застрявших звуков, роста памяти/таймеров, ухудшения доставки,
переполнения голосов и просадки heap ESP32. Отметьте, были ли какие-либо
задержанные или пропущенные важные сигналы.
## Короткая оценка
Ответственный взрослый или взрослый прокси отмечает только: различимы ли
выстрел/промах/попадание/потопление/победа без чтения; помогают ли звуки
вниманию; не слишком ли громки, резки, басовиты или тревожны; легко ли найти
mute и громкость; не провоцируют ли звуки случайные нажатия. При замечании
укажите тип звука, а не сведения о человеке.
+3
View File
@@ -274,10 +274,13 @@ struct Board {
| `GET` | `/api/info` | Состояние устройства и доступность мест без скрытых данных |
| `POST` | `/api/session/join` | Вход по имени и желаемой роли |
| `POST` | `/api/session/resume` | Восстановление роли по токену |
| `POST` | `/api/session/leave` | Освобождение только текущей сессии |
| `POST` | `/api/session/profile-reset` | Освобождение сессии перед локальной очисткой профиля |
| `POST` | `/api/game/config` | Выбор режима игроком 1 |
| `POST` | `/api/game/start` | Запуск готовой партии |
| `POST` | `/api/game/shot` | Выстрел по координатам |
| `POST` | `/api/game/rematch` | Подтверждение повторной игры |
| `POST` | `/api/game/reset` | Аварийный полный сброс игровой памяти игроком |
| `GET` | `/api/state?version=N` | Снимок разрешённого состояния и резервный опрос |
| `GET` | `/api/health` | Проверка доступности сервера |
+40
View File
@@ -0,0 +1,40 @@
# Если игра застряла
Кнопка с спасательным кругом «Помощь» доступна после подключения к игре. Она
не стреляет и не меняет поле сама по себе: сначала выберите действие, затем
подтвердите его.
## Выйти из игры
Выберите «Выйти из игры», чтобы освободить своё место. Имя на этом устройстве
останется, поэтому можно быстро подключиться снова. Если игрок выходит во
время партии, партия безопасно прекращается и оставшийся игрок возвращается в
лобби.
## Сбросить мой профиль
Этот пункт удаляет только данные текущего браузера: имя, выбранного героя и
токен. Данные других игроков не меняются. Без связи профиль всё равно можно
очистить локально; место на ESP32 освободится после восстановления связи или
обычного таймаута.
## Сбросить всю игру
Этот пункт виден только игроку. Он возвращает всех пользователей к первому
экрану и удаляет текущую партию и статистику текущего запуска. Для защиты от
случайного нажатия подтверждение нужно удерживать около двух секунд.
Старые вкладки и токены после этого не могут изменить новую партию.
## Что не удаляется
Эти действия не стирают Wi‑Fi, пароль сети, прошивку, файлы игры, flash или
настройки платы. Перезагрузка ESP32 не нужна.
## Проверка на устройстве
Проверьте с двумя игроками и зрителем выход, локальный профиль, полный сброс
из лобби/игры/результата, WebSocket и HTTP fallback. После 50 смешанных
циклов сравните `/api/health`: свободная куча, largest free block, число
клиентов и причина перезагрузки не должны показывать утечку, зависание или
перезагрузку.
+47 -2
View File
@@ -13,8 +13,9 @@ does not replace the locked rules in [MVP.md](MVP.md), the HTTP contract in
`esp_littlefs` 1.20.4.
- USB serial device: normally `/dev/ttyACM0`; confirm with `pio device list`
before an upload.
- A local `include/wifi_config.h`, copied from `include/wifi_config.h.example`.
It is ignored by Git. Never commit the SSID or password.
- No firmware-embedded Wi-Fi credentials are required. On first boot, join the
open `Battleship-open` network and open `http://192.168.4.1/setup` if the
captive portal does not appear.
## Build and upload
@@ -49,6 +50,16 @@ The response includes free/minimum heap, largest free block, connection count,
rejected input count, Wi-Fi state, and reset reason. It must not contain Wi-Fi
credentials or session tokens.
## Network configuration and local-security limitation
The `/setup` screen and `/api/network/*` routes are intentionally unauthenticated
so a new device can be configured through either the fallback AP or its regular
local address. Any client that can reach the device can change or delete the
saved Wi-Fi configuration. The open AP and plain local HTTP do not protect a
submitted password from a nearby network observer. Configure the board only on
a network you trust, and do not use this feature for credentials that require
strong confidentiality.
## MVP acceptance checklist
Record the observed result, device address, browser/device model, and any
@@ -81,11 +92,45 @@ state/delivery latency with the hard limits in `RESOURCE_BUDGET.md`.
state polling and retries WebSocket at 1, 2, 5, then 10 seconds.
- If the board has a new DHCP address, use the router/serial information and
open the new local URL. No fixed IP or external service is required.
- For a saved network that does not become operational, the serial log records
the profile outcome, Wi-Fi disconnect reason, the 30-second timeout, AP
startup result, and STA/AP addresses. `fallback AP active` means that
`Battleship-open` should be available; connect to it and open
`http://192.168.4.1/setup`. These diagnostics never include the password.
- A corrupt or unsupported saved record is left intact for diagnosis but is
ignored for that boot; the board starts `Battleship-open`. Do not erase NVS
or flash as an initial recovery step.
- If LittleFS assets fail to load, repeat `uploadfs` for the confirmed target
port before reflashing firmware. Do not format LittleFS automatically.
- Capture `/api/health`, reset reason, and the exact source revision before
reporting a failure. Never capture or publish tokens or Wi-Fi credentials.
## Network recovery qualification
This target-board checklist is required before declaring the Wi-Fi recovery
milestone complete. It is deliberately manual: radio association, DHCP, AP
visibility, and memory stability cannot be proven by a host build.
Before each case, record `/api/health` where the device is reachable. Record
the `gameId` and `version` of an active game before a network transition and
again after clients reconnect. Do not include credentials, session tokens, or
private network names in the record.
| Case | Required observation |
| --- | --- |
| Saved network cold boot | The board obtains a DHCP address and serves the application without configuration. |
| Missing/deleted, malformed, or unavailable profile | `Battleship-open` is visible no later than 30 seconds after boot or loss of connectivity; `/setup` works at `http://192.168.4.1/setup`. |
| Incorrect password, hidden network, or DHCP unavailable | The previous saved profile remains intact; the fallback AP and configuration page remain usable. |
| External-network loss during an active game | After fallback begins, reconnect clients through the AP and verify the same `gameId` and a current state snapshot. |
| Successful replacement | Keep the fallback connection until the success confirmation, then reconnect through the new DHCP address without rebooting and verify the game remains in RAM. |
| Repeated transition run | Complete 20 external-network-loss/fallback/recovery cycles. Compare health snapshots for reset reason, minimum free heap, largest free block, and response responsiveness. |
During a failed saved-network attempt, serial output must show the disconnect
reason and either a successful STA address or the 30-second fallback timeout.
For fallback, confirm the AP address and `fallback AP active`; report DNS or
HTTP startup failures as failures of this checklist. The firmware must never
erase NVS, LittleFS, or flash while performing these checks.
## Release record template
Complete this only after all physical checks pass:
+14
View File
@@ -40,3 +40,17 @@ bounded snapshot at a time into the 768-byte transport buffer.
Each gate is a maximum permitted consumption or minimum required remaining
heap. A missed gate blocks the next milestone pending a documented decision.
## Milestone 024 browser-audio asset measurement
The bounded Web Audio foundation adds `web_audio.js`: 7,863 B raw and 2,072 B
gzip. The current six compressed web assets total 24,947 B, well below the
250,000 B LittleFS ceiling. The browser performs synthesis locally, so this
change adds no ESP32 RAM allocation, API payload, or firmware-code cost.
## Milestone 025 sound-library asset measurement
The randomized cue director adds `game_sounds.js`: 4,538 B raw and 1,580 B
gzip. The expanded audio engine is 9,194 B raw and 2,335 B gzip. The current
seven compressed web assets total 27,023 B, still well below the 250,000 B
LittleFS ceiling.
+79 -2
View File
@@ -35,6 +35,40 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Health'
/api/network/status:
get:
summary: Read unauthenticated network configuration status without credentials
responses:
'200': { description: Network state and Russian status message. }
/api/network/scan:
get:
summary: Start or retrieve a bounded unauthenticated Wi-Fi scan
responses:
'200': { description: `scanning` or a bounded list of SSIDs; no passwords. }
'409': { $ref: '#/components/responses/Conflict' }
/api/network/validate:
post:
summary: Validate credentials and persist them only after association succeeds
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [ssid, password]
properties:
ssid: { type: string, minLength: 1, maxLength: 32 }
password: { type: string, maxLength: 63, writeOnly: true }
responses:
'200': { description: Validation started; poll network status for result. }
'400': { $ref: '#/components/responses/BadRequest' }
'409': { $ref: '#/components/responses/Conflict' }
/api/network/delete:
post:
summary: Delete the saved network profile and enter fallback mode
responses:
'200': { description: Profile deleted. }
'409': { $ref: '#/components/responses/Conflict' }
/api/session/join:
post:
summary: Create a session and request a role
@@ -73,6 +107,24 @@ paths:
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'413': { $ref: '#/components/responses/PayloadTooLarge' }
/api/session/leave:
post:
summary: Release only the authenticated session
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/GameRequest' } } }
responses: &recoveryResponses
'200': { $ref: '#/components/responses/RecoveryAccepted' }
'400': { $ref: '#/components/responses/BadRequest' }
'409': { $ref: '#/components/responses/Conflict' }
'413': { $ref: '#/components/responses/PayloadTooLarge' }
/api/session/profile-reset:
post:
summary: Release the authenticated session for a browser-local profile reset
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/GameRequest' } } }
responses: *recoveryResponses
/api/game/config:
post:
summary: Set the game mode (Player 1, lobby only)
@@ -131,6 +183,18 @@ paths:
schema:
$ref: '#/components/schemas/GameRequest'
responses: *commandResponses
/api/game/reset:
post:
summary: Player-only full in-memory game reset
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/GameRequest' } } }
responses:
'200': { $ref: '#/components/responses/RecoveryAccepted' }
'400': { $ref: '#/components/responses/BadRequest' }
'403': { $ref: '#/components/responses/Forbidden' }
'409': { $ref: '#/components/responses/Conflict' }
'413': { $ref: '#/components/responses/PayloadTooLarge' }
/api/state:
get:
summary: Get a complete role-safe state snapshot
@@ -175,6 +239,11 @@ components:
schema: { $ref: '#/components/schemas/Token' }
description: Omit only when a spectator-safe public view is intended.
responses:
RecoveryAccepted:
description: Recovery completed or an idempotent retry observed the same completed recovery.
content:
application/json:
schema: { $ref: '#/components/schemas/RecoveryAccepted' }
CommandAccepted:
description: Command accepted by the authoritative application layer.
content:
@@ -205,7 +274,8 @@ components:
description: Opaque, reboot-scoped session token.
Role:
type: string
enum: [player1, player2, spectator]
enum: [player1, player2, spectator, player]
description: '`player` atomically assigns the first available player slot; responses always contain player1 or player2.'
Mode:
type: string
enum: [human, bot]
@@ -269,6 +339,13 @@ components:
- type: object
required: [role]
properties: { role: { $ref: '#/components/schemas/Role' } }
RecoveryAccepted:
type: object
required: [ok, resetReason, generation]
properties:
ok: { type: boolean, enum: [true] }
resetReason: { type: string, enum: [session_left, profile_reset, game_reset] }
generation: { $ref: '#/components/schemas/GameId' }
Info:
type: object
required: [ok, phase, gameId, version, player1Available, player2Available, player1Name, player2Name, spectatorsAvailable]
@@ -288,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' }
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="ru"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Морской бой — лист проверки силуэтов</title><style>body{margin:0;padding:24px;background:#061827;color:#f1f7ff;font:16px system-ui,sans-serif}.sheet{max-width:900px;margin:auto}.panel{margin:18px 0;padding:18px;border:1px solid #41708d;border-radius:12px;background:#071f32}.ships{display:flex;align-items:end;gap:18px;flex-wrap:wrap}.ship{position:relative;display:inline-grid;place-items:end center;color:#d9edf7}.ship svg{display:block;width:100%;height:100%;fill:currentColor}.cutter{width:46px;height:25px}.destroyer{width:92px;height:29px}.cruiser{width:138px;height:38px}.battleship{width:184px;height:48px}.sunk{color:#9bb0bd}.sunk svg{opacity:.48}.sunk::before,.sunk::after{position:absolute;width:108%;border-top:3px solid #ff5f55;border-radius:99px;content:""}.sunk::before{transform:rotate(31deg)}.sunk::after{transform:rotate(-31deg)}.fleet{width:min(100%,320px);display:grid;gap:7px}.row{display:grid;grid-template-columns:76px 1fr;align-items:end;gap:8px}.row .ships{gap:7px;flex-wrap:nowrap}.row .cutter{width:23px;height:12px}.row .destroyer{width:46px;height:22px}.row .cruiser{width:70px;height:26px}.row .battleship{width:93px;height:29px}</style><body><main class="sheet"><h1>Лист проверки силуэтов флота</h1><p>Production sprite: увеличенный вид, потопленные варианты и фактический компактный мобильный размер.</p><section class="panel"><h2>Классы</h2><div class="ships"><span class="ship cutter"><svg viewBox="0 0 48 26"><use href="../data/ship-sprite.svg#ship-1-cutter" width="100%" height="100%"/></svg></span><span class="ship destroyer sunk"><svg viewBox="0 0 96 30"><use href="../data/ship-sprite.svg#ship-2-destroyer" width="100%" height="100%"/></svg></span><span class="ship cruiser"><svg viewBox="0 0 144 40"><use href="../data/ship-sprite.svg#ship-3-cruiser" width="100%" height="100%"/></svg></span><span class="ship battleship sunk"><svg viewBox="0 0 192 50"><use href="../data/ship-sprite.svg#ship-4-battleship" width="100%" height="100%"/></svg></span></div></section><section class="panel"><h2>Полный флот — 320 px</h2><div class="fleet"><div class="row"><b>Линкор</b><div class="ships"><span class="ship battleship"><svg viewBox="0 0 192 50"><use href="../data/ship-sprite.svg#ship-4-battleship" width="100%" height="100%"/></svg></span></div></div><div class="row"><b>Крейсер</b><div class="ships"><span class="ship cruiser"><svg viewBox="0 0 144 40"><use href="../data/ship-sprite.svg#ship-3-cruiser" width="100%" height="100%"/></svg></span><span class="ship cruiser sunk"><svg viewBox="0 0 144 40"><use href="../data/ship-sprite.svg#ship-3-cruiser" width="100%" height="100%"/></svg></span></div></div><div class="row"><b>Эсминец</b><div class="ships"><span class="ship destroyer"><svg viewBox="0 0 96 30"><use href="../data/ship-sprite.svg#ship-2-destroyer" width="100%" height="100%"/></svg></span><span class="ship destroyer"><svg viewBox="0 0 96 30"><use href="../data/ship-sprite.svg#ship-2-destroyer" width="100%" height="100%"/></svg></span><span class="ship destroyer sunk"><svg viewBox="0 0 96 30"><use href="../data/ship-sprite.svg#ship-2-destroyer" width="100%" height="100%"/></svg></span></div></div><div class="row"><b>Катер</b><div class="ships"><span class="ship cutter"><svg viewBox="0 0 48 26"><use href="../data/ship-sprite.svg#ship-1-cutter" width="100%" height="100%"/></svg></span><span class="ship cutter"><svg viewBox="0 0 48 26"><use href="../data/ship-sprite.svg#ship-1-cutter" width="100%" height="100%"/></svg></span><span class="ship cutter sunk"><svg viewBox="0 0 48 26"><use href="../data/ship-sprite.svg#ship-1-cutter" width="100%" height="100%"/></svg></span><span class="ship cutter"><svg viewBox="0 0 48 26"><use href="../data/ship-sprite.svg#ship-1-cutter" width="100%" height="100%"/></svg></span></div></div></div></section></main></body></html>
+2
View File
@@ -18,5 +18,7 @@ lifecycle_result_t application_resume(application_t *application,
const uint8_t token[kSessionTokenBytes], uint8_t *session_index);
bool application_session_for_token(const application_t *application,
const uint8_t token[kSessionTokenBytes], uint8_t *session_index);
lifecycle_result_t application_leave(application_t *application, uint8_t session_index, uint32_t game_id,
recovery_reason_t reason);
lifecycle_result_t application_submit(application_t *application, const app_command_t *command);
bool application_bot_take_turn(application_t *application);
+11
View File
@@ -0,0 +1,11 @@
#ifndef CAPTIVE_PORTAL_H
#define CAPTIVE_PORTAL_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
bool captive_portal_is_probe_path(const char *path);
bool captive_portal_dns_response(const uint8_t *query, size_t query_length, const uint8_t address[4], uint8_t *response, size_t response_capacity, size_t *response_length);
#endif
+1 -1
View File
@@ -7,7 +7,7 @@
#include "game_types.h"
typedef uint8_t command_type_t;
enum { COMMAND_CONFIG, COMMAND_START, COMMAND_SHOT, COMMAND_REMATCH, COMMAND_ABORT };
enum { COMMAND_CONFIG, COMMAND_START, COMMAND_SHOT, COMMAND_REMATCH, COMMAND_ABORT, COMMAND_LEAVE, COMMAND_PROFILE_RESET, COMMAND_RESET };
typedef struct { command_type_t type; uint8_t session_index; uint32_t game_id; uint32_t version; coordinate_t coordinate; game_mode_t mode; } app_command_t;
typedef struct { app_command_t entries[kCommandQueueCapacity]; uint8_t head; uint8_t tail; uint8_t count; } command_queue_t;
+7
View File
@@ -24,6 +24,9 @@ enum {
LIFECYCLE_RESULT_GENERATION_FAILED,
};
typedef uint8_t recovery_reason_t;
enum { RECOVERY_REASON_NONE, RECOVERY_REASON_SESSION_LEFT, RECOVERY_REASON_PROFILE_RESET, RECOVERY_REASON_GAME_RESET };
typedef struct {
game_engine_t game;
session_manager_t sessions;
@@ -34,6 +37,8 @@ typedef struct {
bool bot_reserved;
bool rematch_confirmed[kPlayerCapacity];
uint32_t next_game_id;
uint32_t recovery_generation;
recovery_reason_t recovery_reason;
} game_lifecycle_t;
void game_lifecycle_init(game_lifecycle_t *lifecycle, random_source_t random);
@@ -44,6 +49,8 @@ lifecycle_result_t game_lifecycle_resume(game_lifecycle_t *lifecycle,
const uint8_t token[kSessionTokenBytes], uint8_t *session_index);
lifecycle_result_t game_lifecycle_disconnect(game_lifecycle_t *lifecycle, uint8_t session_index);
lifecycle_result_t game_lifecycle_leave(game_lifecycle_t *lifecycle, uint8_t session_index);
lifecycle_result_t game_lifecycle_reset(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id);
void game_lifecycle_set_recovery_reason(game_lifecycle_t *lifecycle, recovery_reason_t reason);
lifecycle_result_t game_lifecycle_configure(game_lifecycle_t *lifecycle, uint8_t session_index,
uint32_t game_id, game_mode_t mode);
lifecycle_result_t game_lifecycle_start(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id);
+1 -1
View File
@@ -10,7 +10,7 @@ enum { CELL_UNKNOWN, CELL_WATER, CELL_SHIP, CELL_MISS, CELL_HIT };
typedef uint8_t phase_t;
enum { PHASE_LOBBY, PHASE_PREPARING, PHASE_IN_PROGRESS, PHASE_FINISHED, PHASE_REMATCH_WAIT };
typedef uint8_t role_t;
enum { ROLE_PLAYER_1, ROLE_PLAYER_2, ROLE_SPECTATOR };
enum { ROLE_PLAYER_1, ROLE_PLAYER_2, ROLE_SPECTATOR, ROLE_AUTO_PLAYER };
typedef uint8_t game_mode_t;
enum { MODE_HUMAN, MODE_BOT };
+3
View File
@@ -13,11 +13,14 @@ typedef enum {
HTTP_API_ROUTE_HEALTH,
HTTP_API_ROUTE_JOIN,
HTTP_API_ROUTE_RESUME,
HTTP_API_ROUTE_LEAVE,
HTTP_API_ROUTE_PROFILE_RESET,
HTTP_API_ROUTE_CONFIG,
HTTP_API_ROUTE_START,
HTTP_API_ROUTE_SHOT,
HTTP_API_ROUTE_REMATCH,
HTTP_API_ROUTE_ABORT,
HTTP_API_ROUTE_RESET,
HTTP_API_ROUTE_STATE,
HTTP_API_ROUTE_STATISTICS,
} http_api_route_t;
+26
View File
@@ -0,0 +1,26 @@
#ifndef NETWORK_CONFIGURATION_H
#define NETWORK_CONFIGURATION_H
#include <stdbool.h>
#include <stdint.h>
#include "network_state.h"
enum { kNetworkValidationWindowMs = 30000U, kNetworkSuccessNoticeMs = 15000U };
typedef enum { NETWORK_CONFIGURATION_IDLE, NETWORK_CONFIGURATION_VALIDATING, NETWORK_CONFIGURATION_SUCCESS, NETWORK_CONFIGURATION_FAILED } network_configuration_state_t;
typedef struct {
network_configuration_state_t state;
network_profile_t previous_profile;
bool had_previous_profile;
uint32_t validation_deadline_ms;
uint32_t success_deadline_ms;
} network_configuration_t;
void network_configuration_init(network_configuration_t *configuration);
bool network_configuration_begin(network_configuration_t *configuration, network_manager_t *manager, const network_profile_t *candidate, uint32_t now_ms);
bool network_configuration_validation_timed_out(const network_configuration_t *configuration, uint32_t now_ms);
network_action_t network_configuration_finish(network_configuration_t *configuration, network_manager_t *manager, bool success, uint32_t now_ms);
bool network_configuration_success_notice_expired(const network_configuration_t *configuration, uint32_t now_ms);
bool network_configuration_busy(const network_configuration_t *configuration);
#endif
+9
View File
@@ -0,0 +1,9 @@
#ifndef NETWORK_CREDENTIAL_STORE_H
#define NETWORK_CREDENTIAL_STORE_H
#include <stdbool.h>
#include "network_state.h"
typedef enum { NETWORK_CREDENTIAL_LOAD_VALID, NETWORK_CREDENTIAL_LOAD_ABSENT, NETWORK_CREDENTIAL_LOAD_INVALID } network_credential_load_result_t;
network_credential_load_result_t network_credential_store_load(network_profile_t *profile);
bool network_credential_store_replace(const network_profile_t *profile);
bool network_credential_store_delete(void);
#endif
+25
View File
@@ -0,0 +1,25 @@
#ifndef NETWORK_CREDENTIALS_H
#define NETWORK_CREDENTIALS_H
#include <stdbool.h>
#include <stdint.h>
#include "network_state.h"
typedef struct { uint32_t magic; uint16_t version; uint16_t reserved; network_profile_t profile; uint32_t checksum; } network_credential_record_t;
typedef bool (*network_credential_read_fn)(void *context, network_credential_record_t *record);
typedef bool (*network_credential_write_fn)(void *context, const network_credential_record_t *record);
typedef bool (*network_credential_delete_fn)(void *context);
typedef struct {
void *context;
network_credential_read_fn read;
network_credential_write_fn write;
network_credential_delete_fn remove;
} network_credential_backend_t;
bool network_credential_encode(const network_profile_t *profile, network_credential_record_t *record);
bool network_credential_decode(const network_credential_record_t *record, network_profile_t *profile);
bool network_credential_load(const network_credential_backend_t *backend, network_profile_t *profile);
bool network_credential_replace(const network_credential_backend_t *backend, const network_profile_t *profile);
bool network_credential_delete(const network_credential_backend_t *backend);
#endif
+21
View File
@@ -0,0 +1,21 @@
#ifndef NETWORK_STATE_H
#define NETWORK_STATE_H
#include <stdbool.h>
#include <stdint.h>
enum { kNetworkSsidBytes = 32, kNetworkPasswordBytes = 63, kNetworkConnectWindowMs = 30000 };
typedef struct { char ssid[kNetworkSsidBytes + 1]; char password[kNetworkPasswordBytes + 1]; } network_profile_t;
typedef enum { NETWORK_STATE_FALLBACK, NETWORK_STATE_CONNECTING, NETWORK_STATE_EXTERNAL, NETWORK_STATE_VALIDATING } network_state_t;
typedef enum { NETWORK_ACTION_NONE, NETWORK_ACTION_CONNECT, NETWORK_ACTION_FALLBACK } network_action_t;
typedef struct { network_state_t state; network_state_t state_before_validation; network_profile_t profile; network_profile_t candidate; uint32_t started_ms; bool has_profile; } network_manager_t;
bool network_profile_valid(const network_profile_t *profile);
network_action_t network_manager_init(network_manager_t *manager, const network_profile_t *profile, uint32_t now_ms);
network_action_t network_manager_tick(network_manager_t *manager, uint32_t now_ms);
network_action_t network_manager_connected(network_manager_t *manager);
network_action_t network_manager_disconnected(network_manager_t *manager, uint32_t now_ms);
bool network_manager_begin_validation(network_manager_t *manager, const network_profile_t *candidate);
network_action_t network_manager_finish_validation(network_manager_t *manager, bool success, uint32_t now_ms);
#endif
+1 -1
View File
@@ -7,7 +7,7 @@ from pathlib import Path
ROOT = Path(env.subst("$PROJECT_DIR"))
DATA = ROOT / "data"
ASSETS = ("index.html", "styles.css", "app.js", "ship-sprite.svg")
ASSETS = ("index.html", "styles.css", "app.js", "target_interaction.js", "web_audio.js", "game_sounds.js", "ship-sprite.svg", "setup.html", "setup.css", "setup.js")
def minify_html(source):
+1 -1
View File
@@ -2,7 +2,7 @@
# without default 'CMakeLists.txt' file.
idf_component_register(
SRCS "main.c" "application.c" "command_queue.c" "fleet_generator.c" "game_engine.c" "bot_player.c" "session_manager.c" "game_lifecycle.c" "state_presenter.c" "http_api.c" "sync_service.c"
SRCS "main.c" "application.c" "command_queue.c" "fleet_generator.c" "game_engine.c" "bot_player.c" "session_manager.c" "game_lifecycle.c" "state_presenter.c" "http_api.c" "sync_service.c" "network_state.c" "network_credentials.c" "network_credential_store.c" "network_configuration.c" "captive_portal.c"
INCLUDE_DIRS "../include"
REQUIRES esp_event esp_http_server esp_netif esp_wifi esp_littlefs nvs_flash
)
+18
View File
@@ -37,6 +37,14 @@ bool application_session_for_token(const application_t *application,
return application != NULL && session_manager_find(&application->lifecycle.sessions, token, session_index);
}
lifecycle_result_t application_leave(application_t *application, uint8_t session_index, uint32_t game_id,
recovery_reason_t reason) {
if (application == NULL || application->lifecycle.game.state.game_id != game_id) return LIFECYCLE_RESULT_STALE_GAME;
const lifecycle_result_t result = game_lifecycle_leave(&application->lifecycle, session_index);
if (result == LIFECYCLE_RESULT_OK) game_lifecycle_set_recovery_reason(&application->lifecycle, reason);
return result;
}
lifecycle_result_t application_submit(application_t *application, const app_command_t *command) {
if (!application_enqueue(application, command)) return LIFECYCLE_RESULT_GENERATION_FAILED;
app_command_t next = {0};
@@ -52,6 +60,16 @@ lifecycle_result_t application_submit(application_t *application, const app_comm
return game_lifecycle_rematch(&application->lifecycle, next.session_index, next.game_id);
case COMMAND_ABORT:
return game_lifecycle_abort(&application->lifecycle, next.session_index, next.game_id);
case COMMAND_LEAVE:
return application_leave(application, next.session_index, next.game_id, RECOVERY_REASON_SESSION_LEFT);
case COMMAND_PROFILE_RESET:
return application_leave(application, next.session_index, next.game_id, RECOVERY_REASON_PROFILE_RESET);
case COMMAND_RESET:
{
const lifecycle_result_t result = game_lifecycle_reset(&application->lifecycle, next.session_index, next.game_id);
if (result == LIFECYCLE_RESULT_OK) application->command_queue = (command_queue_t){0};
return result;
}
default:
return LIFECYCLE_RESULT_GENERATION_FAILED;
}
+33
View File
@@ -0,0 +1,33 @@
#include "captive_portal.h"
#include <string.h>
bool captive_portal_is_probe_path(const char *path) {
static const char *const paths[] = {"/generate_204", "/gen_204", "/hotspot-detect.html", "/library/test/success.html", "/connecttest.txt", "/ncsi.txt", "/fwlink"};
if (path == NULL) return false;
for (size_t index = 0U; index < sizeof(paths) / sizeof(paths[0]); ++index) if (strcmp(path, paths[index]) == 0) return true;
return false;
}
bool captive_portal_dns_response(const uint8_t *query, size_t query_length, const uint8_t address[4], uint8_t *response, size_t response_capacity, size_t *response_length) {
if (query == NULL || address == NULL || response == NULL || response_length == NULL || query_length < 17U || response_capacity < query_length + 16U) return false;
if (query[2] & 0x80U || query[4] != 0U || query[5] != 1U) return false;
size_t question_end = 12U;
while (question_end < query_length && query[question_end] != 0U) {
const uint8_t label_length = query[question_end++];
if (label_length == 0U || label_length > 63U || question_end + label_length > query_length) return false;
question_end += label_length;
}
if (question_end + 5U > query_length) return false;
question_end += 5U;
memcpy(response, query, question_end);
response[2] = 0x81U; response[3] = 0x80U;
response[6] = 0U; response[7] = 1U;
response[8] = 0U; response[9] = 0U; response[10] = 0U; response[11] = 0U;
uint8_t *answer = response + question_end;
const uint8_t suffix[] = {0xc0U, 0x0cU, 0x00U, 0x01U, 0x00U, 0x01U, 0x00U, 0x00U, 0x00U, 0x3cU, 0x00U, 0x04U};
memcpy(answer, suffix, sizeof(suffix));
memcpy(answer + sizeof(suffix), address, 4U);
*response_length = question_end + sizeof(suffix) + 4U;
return true;
}
+51 -5
View File
@@ -68,11 +68,24 @@ static lifecycle_result_t start_current_game(game_lifecycle_t *lifecycle) {
return LIFECYCLE_RESULT_OK;
}
static void return_to_lobby(game_lifecycle_t *lifecycle) {
const uint32_t version = lifecycle->game.state.version + 1U;
const uint32_t game_id = lifecycle->next_game_id++;
game_engine_init(&lifecycle->game);
lifecycle->game.state.game_id = game_id;
lifecycle->game.state.version = version;
lifecycle->bot_reserved = false;
lifecycle->rematch_confirmed[0] = false;
lifecycle->rematch_confirmed[1] = false;
bot_player_cancel_turn(&lifecycle->bot);
}
void game_lifecycle_init(game_lifecycle_t *lifecycle, random_source_t random) {
if (lifecycle == NULL) return;
memset(lifecycle, 0, sizeof(*lifecycle));
lifecycle->random = random;
lifecycle->next_game_id = 2U;
lifecycle->recovery_generation = 1U;
game_engine_init(&lifecycle->game);
lifecycle->game.state.game_id = 1U;
session_manager_init(&lifecycle->sessions);
@@ -86,16 +99,18 @@ void game_lifecycle_set_bot_scheduler(game_lifecycle_t *lifecycle, scheduler_t s
lifecycle_result_t game_lifecycle_join(game_lifecycle_t *lifecycle, role_t requested_role, const char *name,
uint8_t *session_index) {
if (lifecycle == NULL) return LIFECYCLE_RESULT_UNAUTHORIZED;
if ((requested_role == ROLE_PLAYER_1 || requested_role == ROLE_PLAYER_2) && lifecycle->game.state.phase != PHASE_LOBBY) {
if ((requested_role == ROLE_PLAYER_1 || requested_role == ROLE_PLAYER_2 || requested_role == ROLE_AUTO_PLAYER) && lifecycle->game.state.phase != PHASE_LOBBY) {
return LIFECYCLE_RESULT_NO_PLAYER_SLOT;
}
if (requested_role == ROLE_PLAYER_2 && !session_manager_player_present(&lifecycle->sessions, 0U)) {
return LIFECYCLE_RESULT_NO_PLAYER_SLOT;
}
if (requested_role == ROLE_PLAYER_2 && (lifecycle->game.state.mode == MODE_BOT || lifecycle->bot_reserved)) {
if ((requested_role == ROLE_PLAYER_2 || requested_role == ROLE_AUTO_PLAYER) && (lifecycle->game.state.mode == MODE_BOT || lifecycle->bot_reserved)) {
return LIFECYCLE_RESULT_NO_PLAYER_SLOT;
}
return session_result(session_manager_join(&lifecycle->sessions, requested_role, name, lifecycle->random, session_index));
const lifecycle_result_t result = session_result(session_manager_join(&lifecycle->sessions, requested_role, name, lifecycle->random, session_index));
if (result == LIFECYCLE_RESULT_OK) ++lifecycle->game.state.version;
return result;
}
lifecycle_result_t game_lifecycle_resume(game_lifecycle_t *lifecycle,
@@ -110,8 +125,39 @@ lifecycle_result_t game_lifecycle_disconnect(game_lifecycle_t *lifecycle, uint8_
}
lifecycle_result_t game_lifecycle_leave(game_lifecycle_t *lifecycle, uint8_t session_index) {
return lifecycle == NULL ? LIFECYCLE_RESULT_UNAUTHORIZED :
session_result(session_manager_leave(&lifecycle->sessions, session_index, lifecycle->game.state.phase));
if (lifecycle == NULL || session_index >= kSessionCapacity || !lifecycle->sessions.entries[session_index].occupied) return LIFECYCLE_RESULT_UNAUTHORIZED;
const bool player = session_index < kPlayerCapacity && lifecycle->sessions.entries[session_index].role == (role_t)session_index;
if (player && lifecycle->game.state.phase != PHASE_LOBBY) return_to_lobby(lifecycle);
else ++lifecycle->game.state.version;
memset(&lifecycle->sessions.entries[session_index], 0, sizeof(session_t));
++lifecycle->recovery_generation;
lifecycle->recovery_reason = RECOVERY_REASON_SESSION_LEFT;
return LIFECYCLE_RESULT_OK;
}
lifecycle_result_t game_lifecycle_reset(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id) {
uint8_t player = 0;
if (!is_player(lifecycle, session_index, &player)) return LIFECYCLE_RESULT_FORBIDDEN_ROLE;
if (!game_id_matches(lifecycle, game_id)) return LIFECYCLE_RESULT_STALE_GAME;
const uint32_t version = lifecycle->game.state.version + 1U;
const uint32_t next_game_id = lifecycle->next_game_id++;
const random_source_t random = lifecycle->random;
const scheduler_t scheduler = lifecycle->bot_scheduler;
bot_player_cancel_turn(&lifecycle->bot);
memset(lifecycle, 0, sizeof(*lifecycle));
lifecycle->random = random;
lifecycle->bot_scheduler = scheduler;
lifecycle->next_game_id = next_game_id + 1U;
lifecycle->recovery_generation = next_game_id;
lifecycle->recovery_reason = RECOVERY_REASON_GAME_RESET;
game_engine_init(&lifecycle->game);
lifecycle->game.state.game_id = next_game_id;
lifecycle->game.state.version = version;
return LIFECYCLE_RESULT_OK;
}
void game_lifecycle_set_recovery_reason(game_lifecycle_t *lifecycle, recovery_reason_t reason) {
if (lifecycle != NULL) lifecycle->recovery_reason = reason;
}
lifecycle_result_t game_lifecycle_configure(game_lifecycle_t *lifecycle, uint8_t session_index,
+42 -2
View File
@@ -118,6 +118,15 @@ static const char *phase_text(phase_t phase) {
return phase <= PHASE_REMATCH_WAIT ? values[phase] : "lobby";
}
static const char *recovery_reason_text(recovery_reason_t reason) {
switch (reason) {
case RECOVERY_REASON_SESSION_LEFT: return "session_left";
case RECOVERY_REASON_PROFILE_RESET: return "profile_reset";
case RECOVERY_REASON_GAME_RESET: return "game_reset";
default: return "session_invalidated";
}
}
static void response_write(http_api_response_t *response, uint16_t status, const char *format, ...) {
va_list arguments;
va_start(arguments, format);
@@ -153,6 +162,16 @@ static void response_lifecycle_error(http_api_response_t *response, lifecycle_re
}
}
static void response_recovery(http_api_response_t *response, const game_lifecycle_t *lifecycle) {
response_write(response, 200U, "{\"ok\":true,\"resetReason\":\"%s\",\"generation\":%" PRIu32 "}",
recovery_reason_text(lifecycle->recovery_reason), lifecycle->recovery_generation);
}
static void response_invalidated(http_api_response_t *response, const game_lifecycle_t *lifecycle) {
response_write(response, 401U, "{\"ok\":false,\"code\":\"SESSION_INVALIDATED\",\"resetReason\":\"%s\",\"generation\":%" PRIu32 "}",
recovery_reason_text(lifecycle->recovery_reason), lifecycle->recovery_generation);
}
static bool expect_post_body(const http_api_request_t *request, size_t maximum, http_api_response_t *response,
uint32_t version) {
if (request->method != HTTP_API_POST || !request->content_type_json) {
@@ -187,6 +206,7 @@ static bool parse_join(const http_api_request_t *request, char name[kDisplayName
if (strcmp(role_value, "player1") == 0) *role = ROLE_PLAYER_1;
else if (strcmp(role_value, "player2") == 0) *role = ROLE_PLAYER_2;
else if (strcmp(role_value, "spectator") == 0) *role = ROLE_SPECTATOR;
else if (strcmp(role_value, "player") == 0) *role = ROLE_AUTO_PLAYER;
else *role = (role_t)UINT8_MAX;
return true;
}
@@ -289,10 +309,14 @@ bool http_api_handle(http_api_t *api, const http_api_request_t *request, http_ap
uint8_t token[kSessionTokenBytes];
uint8_t session_index = 0;
if (request->session_token != NULL && request->session_token[0] != '\0') {
if (!parse_token(request->session_token, token) || !application_session_for_token(api->application, token, &session_index)) {
if (!parse_token(request->session_token, token)) {
response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version);
return true;
}
if (!application_session_for_token(api->application, token, &session_index)) {
response_invalidated(response, lifecycle);
return true;
}
viewer = lifecycle->sessions.entries[session_index].role;
}
if (!state_presenter_write_lifecycle(lifecycle, viewer, response->body, sizeof(response->body), &response->body_length)) {
@@ -330,7 +354,7 @@ bool http_api_handle(http_api_t *api, const http_api_request_t *request, http_ap
response_error(response, 400U, "MALFORMED_JSON", "Некорректный JSON", version);
return true;
}
if (role > ROLE_SPECTATOR) {
if (role > ROLE_AUTO_PLAYER) {
response_error(response, 400U, "INVALID_ROLE", "Некорректная роль", version);
return true;
}
@@ -374,6 +398,18 @@ bool http_api_handle(http_api_t *api, const http_api_request_t *request, http_ap
return true;
}
if (!application_session_for_token(api->application, token, &command.session_index)) {
if (request->route == HTTP_API_ROUTE_LEAVE || request->route == HTTP_API_ROUTE_PROFILE_RESET) {
response_recovery(response, lifecycle);
return true;
}
if (request->route == HTTP_API_ROUTE_RESET && lifecycle->recovery_reason == RECOVERY_REASON_GAME_RESET) {
response_recovery(response, lifecycle);
return true;
}
if (request->route == HTTP_API_ROUTE_RESET) {
response_error(response, 403U, "FORBIDDEN_ROLE", "Роль не может выполнить действие", version);
return true;
}
response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version);
return true;
}
@@ -383,11 +419,15 @@ bool http_api_handle(http_api_t *api, const http_api_request_t *request, http_ap
case HTTP_API_ROUTE_SHOT: command.type = COMMAND_SHOT; break;
case HTTP_API_ROUTE_REMATCH: command.type = COMMAND_REMATCH; break;
case HTTP_API_ROUTE_ABORT: command.type = COMMAND_ABORT; break;
case HTTP_API_ROUTE_LEAVE: command.type = COMMAND_LEAVE; break;
case HTTP_API_ROUTE_PROFILE_RESET: command.type = COMMAND_PROFILE_RESET; break;
case HTTP_API_ROUTE_RESET: command.type = COMMAND_RESET; break;
default: response_error(response, 404U, "MALFORMED_JSON", "Маршрут не найден", version); return true;
}
command.version = version;
const lifecycle_result_t result = application_submit(api->application, &command);
if (result != LIFECYCLE_RESULT_OK) response_lifecycle_error(response, result, lifecycle->game.state.version);
else if (request->route == HTTP_API_ROUTE_LEAVE || request->route == HTTP_API_ROUTE_PROFILE_RESET || request->route == HTTP_API_ROUTE_RESET) response_recovery(response, lifecycle);
else response_write(response, 200U, "{\"ok\":true,\"version\":%" PRIu32 ",\"gameId\":%" PRIu32 "}",
lifecycle->game.state.version, lifecycle->game.state.game_id);
return true;
+381 -61
View File
@@ -2,9 +2,11 @@
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include "app_config.h"
#include "application.h"
#include "captive_portal.h"
#include "esp_check.h"
#include "esp_event.h"
#include "esp_heap_caps.h"
@@ -18,37 +20,44 @@
#include "freertos/FreeRTOS.h"
#include "freertos/portmacro.h"
#include "http_api.h"
#include "network_credential_store.h"
#include "network_configuration.h"
#include "network_state.h"
#include "nvs_flash.h"
#include "sync_service.h"
#if __has_include("wifi_config.h")
#include "wifi_config.h"
#define WIFI_CONFIG_AVAILABLE 1
#else
#define WIFI_CONFIG_AVAILABLE 0
#endif
#include "lwip/sockets.h"
static const char *const kLogTag = "battleship";
static const char *const kLittlefsBasePath = "/littlefs";
static const char *const kLittlefsPartitionLabel = "littlefs";
static const size_t kFileChunkBytes = 1024U;
static const uint8_t kHttpMaxOpenSockets = 12U;
enum { kHttpMaxOpenSockets = 12U, kFallbackMaxConnections = 10U, kDnsPort = 53U, kScanResultLimit = 12U };
typedef struct { bool configured; bool connected; bool retry_scheduled; uint8_t reconnect_attempt; } wifi_state_t;
typedef struct { bool configured; bool connected; bool fallback_active; } wifi_state_t;
static portMUX_TYPE s_wifi_lock = portMUX_INITIALIZER_UNLOCKED;
static portMUX_TYPE s_network_lock = portMUX_INITIALIZER_UNLOCKED;
static portMUX_TYPE s_configuration_lock = portMUX_INITIALIZER_UNLOCKED;
static portMUX_TYPE s_diagnostics_lock = portMUX_INITIALIZER_UNLOCKED;
static wifi_state_t s_wifi_state = {0};
static bool s_littlefs_mounted;
static httpd_handle_t s_server;
static esp_netif_t *s_station_netif;
static esp_netif_t *s_access_point_netif;
static application_t s_application;
static http_api_t s_api;
static sync_service_t s_sync;
static uint16_t s_rejected_input;
static esp_timer_handle_t s_sync_timer;
static esp_timer_handle_t s_bot_timer;
#if WIFI_CONFIG_AVAILABLE
static esp_timer_handle_t s_reconnect_timer;
#endif
static esp_timer_handle_t s_network_timer;
static network_manager_t s_network_manager;
static network_configuration_t s_configuration;
static bool s_scan_pending;
static bool s_scan_ready;
static bool s_dns_running;
static bool s_validation_waiting_for_disconnect;
enum { kNetworkRequestBytes = 160U, kNetworkResponseBytes = 768U };
static uint32_t platform_random(void *unused) { (void)unused; return esp_random(); }
@@ -75,6 +84,7 @@ static wifi_state_t wifi_state_snapshot(void) {
}
static const char *wifi_state_name(const wifi_state_t *state) {
if (state->fallback_active) return "fallback";
if (!state->configured) return "not_configured";
return state->connected ? "connected" : "connecting";
}
@@ -167,11 +177,14 @@ static size_t route_body_limit(http_api_route_t route) {
switch (route) {
case HTTP_API_ROUTE_JOIN: return 192U;
case HTTP_API_ROUTE_RESUME:
case HTTP_API_ROUTE_LEAVE:
case HTTP_API_ROUTE_PROFILE_RESET:
case HTTP_API_ROUTE_CONFIG:
case HTTP_API_ROUTE_SHOT: return 96U;
case HTTP_API_ROUTE_START:
case HTTP_API_ROUTE_REMATCH:
case HTTP_API_ROUTE_ABORT: return 80U;
case HTTP_API_ROUTE_RESET: return 80U;
default: return 0U;
}
}
@@ -252,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) {
@@ -337,7 +515,7 @@ static esp_err_t send_static_file(httpd_req_t *request, const char *asset_path)
FILE *file = fopen(path_to_open, "rb");
if (file == NULL) return httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "static asset not found");
httpd_resp_set_type(request, mime_type_for_path(asset_path));
httpd_resp_set_hdr(request, "Cache-Control", strcmp(asset_path, "/index.html") == 0 ? "no-cache" : "public, max-age=86400");
httpd_resp_set_hdr(request, "Cache-Control", "no-cache");
if (gzip) { httpd_resp_set_hdr(request, "Content-Encoding", "gzip"); httpd_resp_set_hdr(request, "Vary", "Accept-Encoding"); }
char chunk[kFileChunkBytes]; size_t read = 0U; esp_err_t result = ESP_OK;
while ((read = fread(chunk, 1U, sizeof(chunk), file)) > 0U && result == ESP_OK) result = httpd_resp_send_chunk(request, chunk, read);
@@ -345,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");
@@ -356,32 +657,50 @@ 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 = 16U; 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},
{.uri = "/api/session/resume", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_RESUME},
{.uri = "/api/session/leave", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_LEAVE},
{.uri = "/api/session/profile-reset", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_PROFILE_RESET},
{.uri = "/api/game/config", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_CONFIG},
{.uri = "/api/game/start", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_START},
{.uri = "/api/game/shot", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_SHOT},
{.uri = "/api/game/rematch", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_REMATCH},
{.uri = "/api/game/abort", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_ABORT},
{.uri = "/api/game/reset", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_RESET},
{.uri = "/api/state", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_STATE},
{.uri = "/api/statistics", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_STATISTICS},
{.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;
}
@@ -393,12 +712,13 @@ static void mount_littlefs(void) {
void app_main(void) {
esp_err_t result = nvs_flash_init();
if (result == ESP_ERR_NVS_NO_FREE_PAGES || result == ESP_ERR_NVS_NEW_VERSION_FOUND) { ESP_ERROR_CHECK(nvs_flash_erase()); result = nvs_flash_init(); }
ESP_ERROR_CHECK(result); ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_event_loop_create_default());
if (result != ESP_OK) ESP_LOGE(kLogTag, "NVS unavailable (%s); preserving storage and starting fallback", esp_err_to_name(result));
ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_event_loop_create_default());
application_init(&s_application, (random_source_t){.next_u32 = platform_random, .context = NULL});
const esp_timer_create_args_t bot_timer_args = {.callback = bot_timer_callback, .name = "bot_turn"};
ESP_ERROR_CHECK(esp_timer_create(&bot_timer_args, &s_bot_timer));
application_set_bot_scheduler(&s_application, (scheduler_t){.schedule_after_ms = schedule_bot_turn, .context = NULL});
network_configuration_init(&s_configuration);
http_api_init(&s_api, &s_application);
sync_service_init(&s_sync, &s_application);
mount_littlefs();
+25
View File
@@ -0,0 +1,25 @@
#include "network_configuration.h"
#include <string.h>
void network_configuration_init(network_configuration_t *configuration) { memset(configuration, 0, sizeof(*configuration)); }
bool network_configuration_begin(network_configuration_t *configuration, network_manager_t *manager, const network_profile_t *candidate, uint32_t now_ms) {
if (configuration == NULL || manager == NULL || configuration->state == NETWORK_CONFIGURATION_VALIDATING || !network_manager_begin_validation(manager, candidate)) return false;
configuration->previous_profile = manager->profile; configuration->had_previous_profile = manager->has_profile;
configuration->state = NETWORK_CONFIGURATION_VALIDATING; configuration->validation_deadline_ms = now_ms + kNetworkValidationWindowMs;
return true;
}
bool network_configuration_validation_timed_out(const network_configuration_t *configuration, uint32_t now_ms) {
return configuration != NULL && configuration->state == NETWORK_CONFIGURATION_VALIDATING && (int32_t)(now_ms - configuration->validation_deadline_ms) >= 0;
}
network_action_t network_configuration_finish(network_configuration_t *configuration, network_manager_t *manager, bool success, uint32_t now_ms) {
if (configuration == NULL || manager == NULL || configuration->state != NETWORK_CONFIGURATION_VALIDATING) return NETWORK_ACTION_NONE;
const network_action_t action = network_manager_finish_validation(manager, success, now_ms);
configuration->state = success ? NETWORK_CONFIGURATION_SUCCESS : NETWORK_CONFIGURATION_FAILED;
if (success) configuration->success_deadline_ms = now_ms + kNetworkSuccessNoticeMs;
return action;
}
bool network_configuration_success_notice_expired(const network_configuration_t *configuration, uint32_t now_ms) {
return configuration != NULL && configuration->state == NETWORK_CONFIGURATION_SUCCESS && (int32_t)(now_ms - configuration->success_deadline_ms) >= 0;
}
bool network_configuration_busy(const network_configuration_t *configuration) { return configuration != NULL && configuration->state == NETWORK_CONFIGURATION_VALIDATING; }
+9
View File
@@ -0,0 +1,9 @@
#include "network_credential_store.h"
#include "network_credentials.h"
#include "nvs.h"
static const char *const kNamespace = "network";
static const char *const kProfileKey = "profile";
network_credential_load_result_t network_credential_store_load(network_profile_t *profile) { nvs_handle_t handle; network_credential_record_t record; size_t size = sizeof(record); const esp_err_t open_result = nvs_open(kNamespace, NVS_READONLY, &handle); if (open_result == ESP_ERR_NVS_NOT_FOUND) return NETWORK_CREDENTIAL_LOAD_ABSENT; if (open_result != ESP_OK) return NETWORK_CREDENTIAL_LOAD_INVALID; const esp_err_t result = nvs_get_blob(handle, kProfileKey, &record, &size); nvs_close(handle); if (result == ESP_ERR_NVS_NOT_FOUND) return NETWORK_CREDENTIAL_LOAD_ABSENT; return result == ESP_OK && size == sizeof(record) && network_credential_decode(&record, profile) ? NETWORK_CREDENTIAL_LOAD_VALID : NETWORK_CREDENTIAL_LOAD_INVALID; }
bool network_credential_store_replace(const network_profile_t *profile) { network_credential_record_t record; nvs_handle_t handle; if (!network_credential_encode(profile, &record) || nvs_open(kNamespace, NVS_READWRITE, &handle) != ESP_OK) return false; const esp_err_t result = nvs_set_blob(handle, kProfileKey, &record, sizeof(record)); const bool ok = result == ESP_OK && nvs_commit(handle) == ESP_OK; nvs_close(handle); return ok; }
bool network_credential_store_delete(void) { nvs_handle_t handle; if (nvs_open(kNamespace, NVS_READWRITE, &handle) != ESP_OK) return false; const esp_err_t result = nvs_erase_key(handle, kProfileKey); const bool ok = (result == ESP_OK || result == ESP_ERR_NVS_NOT_FOUND) && nvs_commit(handle) == ESP_OK; nvs_close(handle); return ok; }
+10
View File
@@ -0,0 +1,10 @@
#include "network_credentials.h"
#include <stddef.h>
#include <string.h>
enum { kCredentialMagic = 0x42534E57U, kCredentialVersion = 1U };
static uint32_t checksum(const uint8_t *bytes, size_t length) { uint32_t value = 2166136261U; for (size_t i = 0; i < length; ++i) value = (value ^ bytes[i]) * 16777619U; return value; }
bool network_credential_encode(const network_profile_t *profile, network_credential_record_t *record) { if (!network_profile_valid(profile) || record == NULL) return false; memset(record, 0, sizeof(*record)); record->magic = kCredentialMagic; record->version = kCredentialVersion; record->profile = *profile; record->checksum = checksum((const uint8_t *)record, offsetof(network_credential_record_t, checksum)); return true; }
bool network_credential_decode(const network_credential_record_t *record, network_profile_t *profile) { if (record == NULL || profile == NULL || record->magic != kCredentialMagic || record->version != kCredentialVersion || record->checksum != checksum((const uint8_t *)record, offsetof(network_credential_record_t, checksum)) || !network_profile_valid(&record->profile)) return false; *profile = record->profile; return true; }
bool network_credential_load(const network_credential_backend_t *backend, network_profile_t *profile) { network_credential_record_t record; return backend != NULL && backend->read != NULL && backend->read(backend->context, &record) && network_credential_decode(&record, profile); }
bool network_credential_replace(const network_credential_backend_t *backend, const network_profile_t *profile) { network_credential_record_t record; return backend != NULL && backend->write != NULL && network_credential_encode(profile, &record) && backend->write(backend->context, &record); }
bool network_credential_delete(const network_credential_backend_t *backend) { return backend != NULL && backend->remove != NULL && backend->remove(backend->context); }
+34
View File
@@ -0,0 +1,34 @@
#include "network_state.h"
#include <string.h>
bool network_profile_valid(const network_profile_t *profile) {
return profile != NULL && profile->ssid[0] != '\0' && memchr(profile->ssid, '\0', sizeof(profile->ssid)) != NULL &&
memchr(profile->password, '\0', sizeof(profile->password)) != NULL;
}
network_action_t network_manager_init(network_manager_t *manager, const network_profile_t *profile, uint32_t now_ms) {
memset(manager, 0, sizeof(*manager));
if (!network_profile_valid(profile)) { manager->state = NETWORK_STATE_FALLBACK; return NETWORK_ACTION_FALLBACK; }
manager->profile = *profile; manager->has_profile = true; manager->state = NETWORK_STATE_CONNECTING; manager->started_ms = now_ms;
return NETWORK_ACTION_CONNECT;
}
network_action_t network_manager_tick(network_manager_t *manager, uint32_t now_ms) {
if (manager->state == NETWORK_STATE_CONNECTING && (uint32_t)(now_ms - manager->started_ms) >= kNetworkConnectWindowMs) { manager->state = NETWORK_STATE_FALLBACK; return NETWORK_ACTION_FALLBACK; }
return NETWORK_ACTION_NONE;
}
network_action_t network_manager_connected(network_manager_t *manager) { manager->state = NETWORK_STATE_EXTERNAL; return NETWORK_ACTION_NONE; }
network_action_t network_manager_disconnected(network_manager_t *manager, uint32_t now_ms) {
if (!manager->has_profile) { manager->state = NETWORK_STATE_FALLBACK; return NETWORK_ACTION_FALLBACK; }
if (manager->state == NETWORK_STATE_CONNECTING) return NETWORK_ACTION_CONNECT;
manager->state = NETWORK_STATE_CONNECTING; manager->started_ms = now_ms; return NETWORK_ACTION_CONNECT;
}
bool network_manager_begin_validation(network_manager_t *manager, const network_profile_t *candidate) {
if (!network_profile_valid(candidate) || manager->state == NETWORK_STATE_VALIDATING) return false;
manager->candidate = *candidate; manager->state_before_validation = manager->state; manager->state = NETWORK_STATE_VALIDATING; return true;
}
network_action_t network_manager_finish_validation(network_manager_t *manager, bool success, uint32_t now_ms) {
if (manager->state != NETWORK_STATE_VALIDATING) return NETWORK_ACTION_NONE;
if (!success) { manager->state = manager->state_before_validation; return NETWORK_ACTION_NONE; }
manager->profile = manager->candidate; manager->has_profile = true; manager->state = NETWORK_STATE_CONNECTING; manager->started_ms = now_ms; return NETWORK_ACTION_CONNECT;
}
+3 -1
View File
@@ -48,11 +48,13 @@ session_result_t session_manager_join(session_manager_t *manager, role_t request
char sanitized[kDisplayNameBytes + 1] = {0};
if (!sanitize_name(name, sanitized)) return SESSION_RESULT_INVALID_NAME;
int8_t selected = -1;
if (requested_role == ROLE_PLAYER_1) selected = first_free(manager, 0, 1);
if (requested_role == ROLE_AUTO_PLAYER) selected = first_free(manager, 0, kPlayerCapacity);
else if (requested_role == ROLE_PLAYER_1) selected = first_free(manager, 0, 1);
else if (requested_role == ROLE_PLAYER_2) selected = first_free(manager, 1, 2);
else if (requested_role == ROLE_SPECTATOR) selected = first_free(manager, kPlayerCapacity, kSessionCapacity);
else return SESSION_RESULT_INVALID_ROLE;
if (selected < 0) return requested_role == ROLE_SPECTATOR ? SESSION_RESULT_NO_SPECTATOR_SLOT : SESSION_RESULT_NO_PLAYER_SLOT;
if (requested_role == ROLE_AUTO_PLAYER) requested_role = (role_t)selected;
session_t candidate = {.role = requested_role, .occupied = true, .connected = true};
memcpy(candidate.name, sanitized, sizeof(candidate.name));
for (uint8_t offset = 0; offset < kSessionTokenBytes; offset += 4) {
+17 -1
View File
@@ -21,6 +21,11 @@ static void response_error(char output[kStateMessageCapacity], size_t *output_le
code, message, version);
}
static const char *recovery_reason_text(recovery_reason_t reason) {
return reason == RECOVERY_REASON_GAME_RESET ? "game_reset" :
reason == RECOVERY_REASON_PROFILE_RESET ? "profile_reset" : "session_left";
}
static bool token_bytes(const char *text, uint8_t token[kSessionTokenBytes]) {
if (text == NULL || strlen(text) != kSessionTokenBytes * 2U) return false;
for (uint8_t index = 0; index < kSessionTokenBytes; ++index) {
@@ -211,9 +216,20 @@ void sync_service_broadcast(sync_service_t *service, sync_send_fn send, void *co
for (uint8_t index = 0; index < kSessionCapacity; ++index) {
sync_connection_t *connection = &service->connections[index];
if (!connection->active || !connection->authenticated) continue;
const session_t *session = &service->application->lifecycle.sessions.entries[connection->session_index];
if (!session->occupied) {
char reset[kStateMessageCapacity] = {0};
size_t reset_length = 0U;
response_write(reset, &reset_length, "{\"type\":\"reset\",\"reason\":\"%s\",\"generation\":%" PRIu32 "}",
recovery_reason_text(service->application->lifecycle.recovery_reason),
service->application->lifecycle.recovery_generation);
(void)send(context, connection->client_id, reset, reset_length);
*connection = (sync_connection_t){0};
continue;
}
char payload[kStateMessageCapacity];
size_t length = 0U;
const role_t role = service->application->lifecycle.sessions.entries[connection->session_index].role;
const role_t role = session->role;
if (!state_presenter_write_lifecycle(&service->application->lifecycle, role, payload, sizeof(payload), &length) ||
!send(context, connection->client_id, payload, length)) *connection = (sync_connection_t){0};
}
+14 -2
View File
@@ -1,7 +1,7 @@
CC ?= cc
CFLAGS ?= -std=c11 -Wall -Wextra -Werror -I../../include
all: test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_robustness
all: test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_robustness test_network_foundation test_captive_portal test_network_configuration
test_command_queue: test_command_queue.c ../../src/command_queue.c
$(CC) $(CFLAGS) $^ -o $@
@@ -17,6 +17,9 @@ run: all
./test_human_game_integration
./test_bot_game_integration
./test_robustness
./test_network_foundation
./test_captive_portal
./test_network_configuration
test_game_domain: test_game_domain.c ../../src/fleet_generator.c ../../src/game_engine.c
$(CC) $(CFLAGS) $^ -o $@
@@ -46,5 +49,14 @@ test_robustness: test_robustness.c ../../src/http_api.c ../../src/sync_service.c
$(CC) $(CFLAGS) $^ -o $@
test_network_foundation: test_network_foundation.c ../../src/network_state.c ../../src/network_credentials.c
$(CC) $(CFLAGS) $^ -o $@
test_captive_portal: test_captive_portal.c ../../src/captive_portal.c
$(CC) $(CFLAGS) $^ -o $@
test_network_configuration: test_network_configuration.c ../../src/network_configuration.c ../../src/network_state.c
$(CC) $(CFLAGS) $^ -o $@
clean:
rm -f test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_robustness
rm -f test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_robustness test_network_foundation test_captive_portal test_network_configuration
+15
View File
@@ -0,0 +1,15 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "captive_portal.h"
int main(void) {
const uint8_t query[] = {0x12U, 0x34U, 0x01U, 0x00U, 0x00U, 0x01U, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 3U, 'w', 'w', 'w', 7U, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 3U, 'c', 'o', 'm', 0U, 0U, 1U, 0U, 1U};
uint8_t response[80] = {0}; size_t response_length = 0U; const uint8_t address[] = {192U, 168U, 4U, 1U};
assert(captive_portal_is_probe_path("/generate_204")); assert(captive_portal_is_probe_path("/hotspot-detect.html")); assert(!captive_portal_is_probe_path("/app.js"));
assert(captive_portal_dns_response(query, sizeof(query), address, response, sizeof(response), &response_length));
assert(response_length == sizeof(query) + 16U && response[0] == 0x12U && response[1] == 0x34U && response[2] == 0x81U && response[7] == 1U);
assert(memcmp(response + response_length - 4U, address, sizeof(address)) == 0);
assert(!captive_portal_dns_response(query, 12U, address, response, sizeof(response), &response_length));
puts("captive portal tests passed"); return 0;
}
+46
View File
@@ -104,10 +104,56 @@ static void test_bot_lifecycle_and_stale_actions(void) {
assert(memcmp(&before, &lifecycle, sizeof(lifecycle)) == 0);
}
static void test_leave_and_full_reset(void) {
test_random_t random = {.value = 23U};
game_lifecycle_t lifecycle = new_lifecycle(&random);
uint8_t player_1 = 0;
uint8_t player_2 = 0;
uint8_t spectator = 0;
join_players(&lifecycle, &player_1, &player_2);
assert(game_lifecycle_join(&lifecycle, ROLE_SPECTATOR, "Watch", &spectator) == LIFECYCLE_RESULT_OK);
const uint32_t lobby_game_id = lifecycle.game.state.game_id;
assert(game_lifecycle_leave(&lifecycle, spectator) == LIFECYCLE_RESULT_OK);
assert(!lifecycle.sessions.entries[spectator].occupied && lifecycle.game.state.game_id == lobby_game_id);
assert(game_lifecycle_start(&lifecycle, player_1, lobby_game_id) == LIFECYCLE_RESULT_OK);
assert(game_lifecycle_leave(&lifecycle, player_2) == LIFECYCLE_RESULT_OK);
assert(!lifecycle.sessions.entries[player_2].occupied && lifecycle.sessions.entries[player_1].occupied);
assert(lifecycle.game.state.phase == PHASE_LOBBY && lifecycle.game.state.game_id != lobby_game_id);
const uint32_t reset_game_id = lifecycle.game.state.game_id;
lifecycle.cumulative[0].games = 3U;
assert(game_lifecycle_reset(&lifecycle, player_1, reset_game_id) == LIFECYCLE_RESULT_OK);
assert(lifecycle.game.state.phase == PHASE_LOBBY && lifecycle.game.state.game_id != reset_game_id);
assert(!lifecycle.sessions.entries[0].occupied && !lifecycle.sessions.entries[1].occupied);
assert(lifecycle.cumulative[0].games == 0U && lifecycle.recovery_reason == RECOVERY_REASON_GAME_RESET);
assert(game_lifecycle_reset(&lifecycle, player_1, reset_game_id) == LIFECYCLE_RESULT_FORBIDDEN_ROLE);
}
static void test_fifty_mixed_recovery_cycles(void) {
test_random_t random = {.value = 41U};
game_lifecycle_t lifecycle = new_lifecycle(&random);
for (uint8_t cycle = 0; cycle < 50U; ++cycle) {
uint8_t player = kSessionCapacity;
assert(game_lifecycle_join(&lifecycle, ROLE_AUTO_PLAYER, "Alice", &player) == LIFECYCLE_RESULT_OK && player == 0U);
const uint32_t game_id = lifecycle.game.state.game_id;
if (cycle % 3U == 0U) {
assert(game_lifecycle_leave(&lifecycle, player) == LIFECYCLE_RESULT_OK);
} else if (cycle % 3U == 1U) {
assert(game_lifecycle_configure(&lifecycle, player, game_id, MODE_BOT) == LIFECYCLE_RESULT_OK);
assert(game_lifecycle_start(&lifecycle, player, game_id) == LIFECYCLE_RESULT_OK);
assert(game_lifecycle_leave(&lifecycle, player) == LIFECYCLE_RESULT_OK);
} else {
assert(game_lifecycle_reset(&lifecycle, player, game_id) == LIFECYCLE_RESULT_OK);
}
assert(lifecycle.game.state.phase == PHASE_LOBBY && !lifecycle.sessions.entries[0].occupied);
}
}
int main(void) {
test_capacity_sanitize_and_resume();
test_human_lifecycle_and_guards();
test_bot_lifecycle_and_stale_actions();
test_leave_and_full_reset();
test_fifty_mixed_recovery_cycles();
puts("game lifecycle tests passed");
return 0;
}
+27
View File
@@ -186,9 +186,36 @@ static void test_sessions_commands_and_state(void) {
expect_code(&response, 503U, "SERVER_BUSY");
}
static void test_recovery_routes(void) {
application_t application;
test_random_t random = {.value = 31U};
http_api_t api = new_api(&application, &random);
http_api_response_t response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"Alice\",\"requestedRole\":\"player1\"}", NULL);
char player_token[33]; token_from_response(&response, player_token);
char command[96];
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1}", player_token);
response = call(&api, HTTP_API_ROUTE_LEAVE, HTTP_API_POST, command, NULL);
assert(response.status == 200U && strstr(response.body, "session_left") != NULL);
response = call(&api, HTTP_API_ROUTE_LEAVE, HTTP_API_POST, command, NULL);
assert(response.status == 200U && strstr(response.body, "session_left") != NULL);
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"Alice\",\"requestedRole\":\"player1\"}", NULL);
token_from_response(&response, player_token);
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1}", player_token);
response = call(&api, HTTP_API_ROUTE_RESET, HTTP_API_POST, command, NULL);
assert(response.status == 200U && strstr(response.body, "game_reset") != NULL);
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, player_token);
assert(response.status == 401U && strstr(response.body, "SESSION_INVALIDATED") != NULL && strstr(response.body, "game_reset") != NULL);
response = call(&api, HTTP_API_ROUTE_RESET, HTTP_API_POST,
"{\"token\":\"00000000000000000000000000000000\",\"gameId\":1}", NULL);
assert(response.status == 200U && strstr(response.body, "game_reset") != NULL);
}
int main(void) {
test_public_routes_and_parse_limits();
test_sessions_commands_and_state();
test_recovery_routes();
puts("http api tests passed");
return 0;
}
+86 -4
View File
@@ -96,6 +96,9 @@ static void test_human_game_journey(void) {
resume(&api, player_tokens[0], "player1");
resume(&api, player_tokens[1], "player2");
resume(&api, spectator_token, "spectator");
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, spectator_token);
assert(response.status == 200U && strstr(response.body, "\"phase\":\"lobby\"") != NULL &&
strstr(response.body, "\"viewer\":\"spectator\"") != NULL);
const uint32_t game_id = application.lifecycle.game.state.game_id;
char config[96];
snprintf(config, sizeof(config), "{\"token\":\"%s\",\"gameId\":%u,\"mode\":\"human\"}",
@@ -105,14 +108,21 @@ static void test_human_game_journey(void) {
command(&api, HTTP_API_ROUTE_START, player_tokens[0], game_id);
assert(application.lifecycle.game.state.phase == PHASE_IN_PROGRESS);
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"Новый зритель\",\"requestedRole\":\"spectator\"}", NULL);
assert(response.status == 200U && strstr(response.body, "\"role\":\"spectator\"") != NULL);
char in_progress_spectator_token[33];
token_from_response(&response, in_progress_spectator_token);
resume(&api, in_progress_spectator_token, "spectator");
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, player_tokens[0]);
assert(response.status == 200U && board_start(response.body, 0)[0] <= '3');
assert_hidden(response.body, 1U);
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, player_tokens[1]);
assert(response.status == 200U);
assert_hidden(response.body, 0U);
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, spectator_token);
assert(response.status == 200U);
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, in_progress_spectator_token);
assert(response.status == 200U && strstr(response.body, "\"viewer\":\"spectator\"") != NULL);
assert_hidden(response.body, 0U);
assert_hidden(response.body, 1U);
@@ -124,7 +134,7 @@ static void test_human_game_journey(void) {
while (application.lifecycle.game.state.phase == PHASE_IN_PROGRESS) {
const coordinate_t target = first_cell(&application.lifecycle.game.state.boards[winner ^ 1U], CELL_SHIP);
shot(&api, player_tokens[winner], game_id, target);
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, spectator_token);
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, in_progress_spectator_token);
saw_sunk = saw_sunk || strchr(board_start(response.body, winner ^ 1U), '4') != NULL;
}
assert(saw_sunk);
@@ -133,11 +143,21 @@ static void test_human_game_journey(void) {
assert(application.lifecycle.game.state.statistics[winner].hits == 20U);
assert(application.lifecycle.game.state.statistics[winner].ships_sunk == 10U);
assert(application.lifecycle.game.state.statistics[first_player].misses == 1U);
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, spectator_token);
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, in_progress_spectator_token);
assert(response.status == 200U && strstr(response.body, "\"winner\":") != NULL);
assert(strstr(response.body, "\"statistics\":[") != NULL);
assert(strchr(board_start(response.body, 0U), '1') != NULL || strchr(board_start(response.body, 1U), '1') != NULL);
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"Финишный зритель\",\"requestedRole\":\"spectator\"}", NULL);
assert(response.status == 200U && strstr(response.body, "\"role\":\"spectator\"") != NULL);
char finished_spectator_token[33];
token_from_response(&response, finished_spectator_token);
resume(&api, finished_spectator_token, "spectator");
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, finished_spectator_token);
assert(response.status == 200U && strstr(response.body, "\"phase\":\"finished\"") != NULL &&
strstr(response.body, "\"viewer\":\"spectator\"") != NULL);
resume(&api, player_tokens[0], "player1");
resume(&api, player_tokens[1], "player2");
command(&api, HTTP_API_ROUTE_REMATCH, player_tokens[0], game_id);
@@ -149,10 +169,72 @@ static void test_human_game_journey(void) {
assert(game_lifecycle_disconnect(&application.lifecycle, 1U) == LIFECYCLE_RESULT_OK);
command(&api, HTTP_API_ROUTE_ABORT, player_tokens[0], application.lifecycle.game.state.game_id);
assert(application.lifecycle.game.state.phase == PHASE_LOBBY);
char recovery[80];
snprintf(recovery, sizeof(recovery), "{\"token\":\"%s\",\"gameId\":%u}", in_progress_spectator_token,
(unsigned int)application.lifecycle.game.state.game_id);
response = call(&api, HTTP_API_ROUTE_PROFILE_RESET, HTTP_API_POST, recovery, NULL);
assert(response.status == 200U);
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"После профиля\",\"requestedRole\":\"spectator\"}", NULL);
assert(response.status == 200U && strstr(response.body, "\"role\":\"spectator\"") != NULL);
char recovery_spectator_token[33];
token_from_response(&response, recovery_spectator_token);
snprintf(recovery, sizeof(recovery), "{\"token\":\"%s\",\"gameId\":%u}", recovery_spectator_token,
(unsigned int)application.lifecycle.game.state.game_id);
response = call(&api, HTTP_API_ROUTE_LEAVE, HTTP_API_POST, recovery, NULL);
assert(response.status == 200U);
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"После выхода\",\"requestedRole\":\"spectator\"}", NULL);
assert(response.status == 200U && strstr(response.body, "\"role\":\"spectator\"") != NULL);
command(&api, HTTP_API_ROUTE_RESET, player_tokens[0], application.lifecycle.game.state.game_id);
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"После сброса\",\"requestedRole\":\"spectator\"}", NULL);
assert(response.status == 200U && strstr(response.body, "\"role\":\"spectator\"") != NULL);
}
static void test_atomic_two_player_lobby(void) {
test_random_t random = {.value = 91U};
application_t application;
application_init(&application, (random_source_t){.next_u32 = next_random, .context = &random});
http_api_t api;
http_api_init(&api, &application);
http_api_response_t response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"Алиса\",\"requestedRole\":\"player\"}", NULL);
assert(response.status == 200U && strstr(response.body, "\"role\":\"player1\"") != NULL);
char player_1[33]; token_from_response(&response, player_1);
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"Борис\",\"requestedRole\":\"player\"}", NULL);
assert(response.status == 200U && strstr(response.body, "\"role\":\"player2\"") != NULL);
char player_2[33]; token_from_response(&response, player_2);
const uint32_t game_id = application.lifecycle.game.state.game_id;
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, player_1);
assert(response.status == 200U && strstr(response.body, "\"viewer\":\"player1\"") != NULL && strstr(response.body, "Алиса") != NULL && strstr(response.body, "Борис") != NULL);
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, player_2);
assert(response.status == 200U && strstr(response.body, "\"viewer\":\"player2\"") != NULL);
char config[96];
snprintf(config, sizeof(config), "{\"token\":\"%s\",\"gameId\":%u,\"mode\":\"human\"}", player_2, (unsigned int)game_id);
response = call(&api, HTTP_API_ROUTE_CONFIG, HTTP_API_POST, config, NULL);
assert(response.status == 403U);
char start[80]; snprintf(start, sizeof(start), "{\"token\":\"%s\",\"gameId\":%u}", player_2, (unsigned int)game_id);
response = call(&api, HTTP_API_ROUTE_START, HTTP_API_POST, start, NULL); assert(response.status == 403U);
snprintf(config, sizeof(config), "{\"token\":\"%s\",\"gameId\":%u,\"mode\":\"human\"}", player_1, (unsigned int)game_id);
response = call(&api, HTTP_API_ROUTE_CONFIG, HTTP_API_POST, config, NULL); assert(response.status == 200U);
response = call(&api, HTTP_API_ROUTE_START, HTTP_API_POST, start, NULL); assert(response.status == 403U);
snprintf(start, sizeof(start), "{\"token\":\"%s\",\"gameId\":%u}", player_1, (unsigned int)game_id);
response = call(&api, HTTP_API_ROUTE_START, HTTP_API_POST, start, NULL); assert(response.status == 200U && application.lifecycle.game.state.phase == PHASE_IN_PROGRESS);
response = call(&api, HTTP_API_ROUTE_LEAVE, HTTP_API_POST, (snprintf(start, sizeof(start), "{\"token\":\"%s\",\"gameId\":%u}", player_2, (unsigned int)game_id), start), NULL);
assert(response.status == 200U && !application.lifecycle.sessions.entries[1].occupied);
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST, "{\"name\":\"Вера\",\"requestedRole\":\"player\"}", NULL);
assert(response.status == 200U && strstr(response.body, "\"role\":\"player2\"") != NULL);
resume(&api, player_1, "player1");
}
int main(void) {
test_human_game_journey();
test_atomic_two_player_lobby();
puts("human game integration tests passed");
return 0;
}
+16
View File
@@ -0,0 +1,16 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "network_configuration.h"
static network_profile_t profile(const char *ssid, const char *password) { network_profile_t value = {0}; snprintf(value.ssid, sizeof(value.ssid), "%s", ssid); snprintf(value.password, sizeof(value.password), "%s", password); return value; }
int main(void) {
network_manager_t manager; network_configuration_t configuration; network_profile_t old = profile("Old", "old-pass"); network_profile_t next = profile("New", "new-pass");
network_manager_init(&manager, &old, 0U); network_configuration_init(&configuration);
assert(network_configuration_begin(&configuration, &manager, &next, 10U)); assert(network_configuration_busy(&configuration));
assert(!network_configuration_begin(&configuration, &manager, &next, 11U)); assert(!network_configuration_validation_timed_out(&configuration, 30009U)); assert(network_configuration_validation_timed_out(&configuration, 30010U));
assert(network_configuration_finish(&configuration, &manager, false, 30010U) == NETWORK_ACTION_NONE); assert(strcmp(manager.profile.ssid, "Old") == 0);
assert(network_configuration_begin(&configuration, &manager, &next, 40000U)); assert(network_configuration_finish(&configuration, &manager, true, 40001U) == NETWORK_ACTION_CONNECT); assert(strcmp(manager.profile.ssid, "New") == 0);
assert(!network_configuration_success_notice_expired(&configuration, 55000U)); assert(network_configuration_success_notice_expired(&configuration, 55001U));
puts("network configuration tests passed"); return 0;
}
+38
View File
@@ -0,0 +1,38 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "network_credentials.h"
#include "network_state.h"
static network_profile_t profile(const char *ssid, const char *password) { network_profile_t value = {0}; snprintf(value.ssid, sizeof(value.ssid), "%s", ssid); snprintf(value.password, sizeof(value.password), "%s", password); return value; }
typedef struct { bool present; network_credential_record_t record; } memory_store_t;
static bool read_record(void *context, network_credential_record_t *record) { memory_store_t *store = context; if (!store->present) return false; *record = store->record; return true; }
static bool write_record(void *context, const network_credential_record_t *record) { memory_store_t *store = context; store->record = *record; store->present = true; return true; }
static bool delete_record(void *context) { memory_store_t *store = context; store->present = false; memset(&store->record, 0, sizeof(store->record)); return true; }
int main(void) {
network_manager_t manager; network_profile_t home = profile("Home", "secret"); network_profile_t next = profile("Next", "newsecret");
assert(network_manager_init(&manager, NULL, 10U) == NETWORK_ACTION_FALLBACK && manager.state == NETWORK_STATE_FALLBACK);
assert(network_manager_init(&manager, &home, 10U) == NETWORK_ACTION_CONNECT);
assert(network_manager_tick(&manager, 30009U) == NETWORK_ACTION_NONE);
assert(network_manager_tick(&manager, 30010U) == NETWORK_ACTION_FALLBACK);
assert(network_manager_disconnected(&manager, 400U) == NETWORK_ACTION_CONNECT);
assert(network_manager_disconnected(&manager, 1000U) == NETWORK_ACTION_CONNECT);
assert(network_manager_tick(&manager, 30400U) == NETWORK_ACTION_FALLBACK);
assert(network_manager_connected(&manager) == NETWORK_ACTION_NONE && manager.state == NETWORK_STATE_EXTERNAL);
assert(network_manager_begin_validation(&manager, &next));
assert(network_manager_finish_validation(&manager, false, 500U) == NETWORK_ACTION_NONE && strcmp(manager.profile.ssid, "Home") == 0);
assert(network_manager_begin_validation(&manager, &next));
assert(network_manager_finish_validation(&manager, true, 600U) == NETWORK_ACTION_CONNECT && strcmp(manager.profile.ssid, "Next") == 0);
network_credential_record_t record; network_profile_t restored = {0};
assert(network_credential_encode(&next, &record)); assert(network_credential_decode(&record, &restored)); assert(strcmp(restored.password, "newsecret") == 0);
record.profile.ssid[0] = 'X'; assert(!network_credential_decode(&record, &restored));
assert(network_credential_encode(&next, &record)); ++record.version; assert(!network_credential_decode(&record, &restored));
assert(network_credential_encode(&next, &record)); record.profile.password[0] = '\0'; assert(!network_credential_decode(&record, &restored));
memory_store_t store = {0}; network_credential_backend_t backend = {&store, read_record, write_record, delete_record};
assert(network_credential_replace(&backend, &home)); assert(network_credential_load(&backend, &restored)); assert(strcmp(restored.ssid, "Home") == 0);
store.record.profile.ssid[0] = 'X'; assert(!network_credential_load(&backend, &restored));
assert(network_credential_replace(&backend, &next)); assert(network_credential_load(&backend, &restored)); assert(strcmp(restored.ssid, "Next") == 0);
assert(network_credential_delete(&backend)); assert(!network_credential_load(&backend, &restored));
network_profile_t invalid = profile("", "x"); assert(!network_profile_valid(&invalid));
puts("network foundation tests passed"); return 0;
}
+18
View File
@@ -124,9 +124,27 @@ static void test_timeout_ping_and_command(void) {
assert(changed && !close && output_length == 0U && application.lifecycle.game.state.mode == MODE_BOT);
}
static void test_reset_notification_invalidates_authenticated_client(void) {
test_random_t random = {.value = 17U};
application_t application;
application_init(&application, (random_source_t){.next_u32 = next_random, .context = &random});
uint8_t player = 0;
assert(application_join(&application, ROLE_PLAYER_1, "Alice", &player) == LIFECYCLE_RESULT_OK);
sync_service_t service;
sync_service_init(&service, &application);
assert(sync_service_open(&service, 0, 0U));
hello(&service, 0, application.lifecycle.sessions.entries[player].token, 0U);
assert(game_lifecycle_reset(&application.lifecycle, player, 1U) == LIFECYCLE_RESULT_OK);
capture_t capture = {.fail_client = -1};
sync_service_broadcast(&service, capture_send, &capture);
assert(capture.sends[0] == 1U && strstr(capture.payloads[0], "\"type\":\"reset\"") != NULL);
assert(strstr(capture.payloads[0], "game_reset") != NULL && !service.connections[0].active);
}
int main(void) {
test_authentication_visibility_and_backpressure();
test_timeout_ping_and_command();
test_reset_notification_invalidates_authenticated_client();
puts("sync service tests passed");
return 0;
}
+63
View File
@@ -0,0 +1,63 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { createSoundDirector, COUNTS, CATALOG } = require('../../data/game_sounds.js');
function recorder() { const played = []; return { played, playPreset: preset => { played.push(preset); return true; } }; }
test('every required event family has its planned minimum of original synthesis presets', () => {
Object.entries(COUNTS).forEach(([family, count]) => {
assert.equal(CATALOG[family].length, count);
assert.equal(new Set(CATALOG[family].map(preset => preset.id)).size, count);
});
});
test('selection is randomized without immediate repeats and keeps bounded history', () => {
const output = recorder(); let sample = 0;
const director = createSoundDirector({ engine: output, random: () => (sample++ % 11) / 11, historyLimit: 3 });
const selected = [];
for (let index = 0; index < 30; index += 1) selected.push(director.play('shot').preset.id);
selected.slice(1).forEach((id, index) => assert.notEqual(id, selected[index]));
assert.ok(director.getHistory('shot').length <= 3);
});
test('preset parameter ranges remain safe through many randomized selections', () => {
const output = recorder(); const director = createSoundDirector({ engine: output, random: Math.random });
for (let index = 0; index < 3000; index += 1) director.play(Object.keys(COUNTS)[index % Object.keys(COUNTS).length]);
output.played.forEach(preset => {
assert.ok(preset.frequency >= 100 && preset.frequency <= 1000);
assert.ok(preset.endFrequency >= 100 && preset.endFrequency <= 1000);
assert.ok(preset.durationMs >= 100 && preset.durationMs <= 900);
assert.ok(preset.filterHz >= 900 && preset.filterHz <= 3500);
assert.ok(Math.abs(preset.pan) <= 0.4 && preset.gain <= 0.75);
});
});
test('state versions, perspectives, priority, waiting rate limit, and reconnect summaries are safe', () => {
const output = recorder(); let clock = 0;
const director = createSoundDirector({ engine: output, random: () => 0, now: () => clock, waitingIntervalMs: 12000 });
assert.ok(director.getPriority('victory') > director.getPriority('sunk'));
assert.ok(director.getPriority('sunk') > director.getPriority('hit'));
assert.ok(director.getPriority('hit') > director.getPriority('miss'));
assert.equal(director.play('watch-hit', { version: 4 }).preset.family, 'hit');
assert.equal(director.play('damage', { version: 4 }), false);
assert.equal(director.play('watch-sunk', { version: 5 }).preset.family, 'sunk');
assert.equal(director.play('waiting').preset.family, 'waiting');
assert.equal(director.play('waiting'), false);
clock = 12001;
assert.equal(director.play('waiting').preset.family, 'waiting');
assert.equal(director.play('summary', { version: 6, summary: true }).preset.family, 'victory');
});
test('combos escalate, reduced intensity suppresses extras, and recovery remains calm', () => {
const output = recorder(); const director = createSoundDirector({ engine: output, random: () => 0.3 });
assert.equal(director.play('hit', { combo: 2 }).combo, 'combo');
assert.equal(director.play('hit', { combo: 3 }).combo, 'mega');
assert.equal(director.play('sunk', { combo: 4 }).combo, 'ultra');
const count = output.played.length;
assert.equal(director.play('hit', { combo: 4, reduced: true }).combo, 'ultra');
assert.equal(output.played.length, count + 1);
assert.equal(director.play('waiting', { reduced: true }), false);
const recovery = ['recovery-open', 'recovery-cancel', 'recovery-leave', 'recovery-profile', 'recovery-game'].map(event => director.play(event).preset);
assert.equal(new Set(recovery.map(preset => preset.id)).size, 5);
recovery.forEach(preset => assert.ok(preset.family === 'recovery' && preset.gain <= 0.16 && preset.durationMs <= 300));
});
+18
View File
@@ -0,0 +1,18 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const app = fs.readFileSync(path.join(__dirname, '../../data/app.js'), 'utf8');
test('Play requests the server-authoritative next player slot', () => {
assert.match(app, /join\('player'\)/);
assert.doesNotMatch(app, /info\?\.player1Available \? 'player1'/);
});
test('lobby controls are visible only to player1 and enable when player2 is present', () => {
assert.match(app, /const playerOne = role === 'player1'/);
assert.match(app, /ui\.modeControls\.hidden = !playerOne/);
assert.match(app, /state\.players\[1\]\.trim\(\) !== '' \|\| info\?\.player2Available === false/);
assert.match(app, /const canStart = playerOne && \(state\.mode === 'bot' \|\| playerTwoReady\)/);
});
+28
View File
@@ -0,0 +1,28 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const app = fs.readFileSync(path.join(__dirname, '../../data/app.js'), 'utf8');
const page = fs.readFileSync(path.join(__dirname, '../../data/index.html'), 'utf8');
test('recovery UI is unavailable on registration and role-gates the global action', () => {
assert.match(app, /name === 'connect' \|\| !token/);
assert.match(app, /ui\.recoveryGlobal\.hidden = !isPlayer\(\)/);
assert.match(page, /id="recovery-menu-button"[^>]*hidden/);
});
test('recovery actions use distinct routes and clear the intended local scope', () => {
assert.match(app, /\/api\/session\/leave/);
assert.match(app, /\/api\/session\/profile-reset/);
assert.match(app, /\/api\/game\/reset/);
assert.match(app, /key\.startsWith\('battleship\.'\)/);
assert.match(app, /clearLocalProfile\(action !== 'leave'\)/);
});
test('global reset needs a timed hold while keyboard confirmation remains available', () => {
assert.match(app, /window\.setTimeout\(submitRecovery, 2000\)/);
assert.match(app, /pointercancel/);
assert.match(app, /event\.detail === 0/);
assert.match(app, /event\.key === 'Escape'/);
});
+12
View File
@@ -17,6 +17,7 @@ test('ship sprite contains four independently drawn class symbols', () => {
assert.equal(new Set(symbols).size, ids.length);
assert.ok(Buffer.byteLength(sprite) < 15 * 1024);
assert.ok(zlib.gzipSync(sprite).length < 5 * 1024);
assert.doesNotMatch(sprite, /stroke=/);
});
test('fleet CSS provides a shared red sunk cross overlay', () => {
@@ -24,4 +25,15 @@ test('fleet CSS provides a shared red sunk cross overlay', () => {
assert.match(css, /\.fleet-ship\.sunk::before, \.fleet-ship\.sunk::after/);
assert.match(css, /border-top: \.16rem solid #ff5f55/);
assert.match(css, /border-radius: 99rem/);
assert.match(css, /\.fleet-ship\.sunk svg \{ opacity: \.48; \}/);
});
test('fleet renderer gives every external symbol its own viewport', () => {
const app = fs.readFileSync(path.join(__dirname, '../../data/app.js'), 'utf8');
assert.match(app, /viewBox: '0 0 192 50'/);
assert.match(app, /viewBox: '0 0 144 40'/);
assert.match(app, /viewBox: '0 0 96 30'/);
assert.match(app, /viewBox: '0 0 48 26'/);
assert.match(app, /svg\.setAttribute\('viewBox', shipClass\.viewBox\)/);
assert.match(app, /preserveAspectRatio', 'xMidYMax meet'/);
});
+31
View File
@@ -0,0 +1,31 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const app = fs.readFileSync(path.join(__dirname, '../../data/app.js'), 'utf8');
const css = fs.readFileSync(path.join(__dirname, '../../data/styles.css'), 'utf8');
test('spectator state selects a public player board by default and retains only spectator board keys', () => {
assert.match(app, /const boardKeys = Object\.freeze\(\{ own: 'own', opponent: 'opponent', player1: 'player1', player2: 'player2' \}\)/);
assert.match(app, /const validBoardKeys = role === 'spectator' \? \[boardKeys\.player1, boardKeys\.player2\] : \[boardKeys\.own, boardKeys\.opponent\]/);
assert.match(app, /if \(!validBoardKeys\.includes\(activeBoard\)\) activeBoard = validBoardKeys\[0\]/);
assert.match(app, /key: boardKeys\.player1/);
assert.match(app, /key: boardKeys\.player2/);
});
test('spectator boards have working tabs and no player firing controls', () => {
assert.match(app, /tab\.addEventListener\('click', \(\) => \{ activeBoard = definition\.key; renderGame\(\); \}\)/);
assert.match(app, /ui\.shotControls\.hidden = !isPlayer\(\)/);
assert.match(app, /const watching = role === 'spectator'/);
assert.match(app, /Наблюдаем за боем · Ходит \$\{turnPlayer\}/);
assert.match(app, /watching \? 'Наблюдаем за боем' : 'Ждём соперника…'/);
assert.match(css, /\.board\[hidden\]\s*\{\s*display:\s*none;/);
assert.match(css, /\.board\[hidden\]\s*\{\s*display:\s*block;/);
});
test('WebSocket and HTTP state updates share spectator role and board-tab reconciliation', () => {
assert.match(app, /async function pollState\(\)[\s\S]*?acceptState\(payload\);/);
assert.match(app, /if \(message\.type === 'state'\) \{\s*acceptState\(message\);/);
assert.match(app, /role = payload\.viewer;\s*const validBoardKeys = role === 'spectator'/);
});
+40 -1
View File
@@ -1,6 +1,6 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { createTargetActivator } = require('../../data/target_interaction.js');
const { createTargetActivator, detectFeedback, createReactionPicker } = require('../../data/target_interaction.js');
test('a single tap selects a target without firing', () => {
const selected = [];
@@ -47,3 +47,42 @@ test('out-of-turn or already-targeted cells cannot select or fire', () => {
assert.equal(selected, 0);
assert.equal(shots, 0);
});
function gameState(version, boards, overrides = {}) {
return { version, gameId: 7, phase: 'in_progress', turn: 'player1', winner: null, boards, ...overrides };
}
test('feedback distinguishes a hit on the opponent from damage to the player', () => {
const water = '0'.repeat(100);
const opponentHit = `${water.slice(0, 12)}3${water.slice(13)}`;
assert.equal(detectFeedback(gameState(1, [water, water]), gameState(2, [water, opponentHit]), 'player1').type, 'hit');
assert.equal(detectFeedback(gameState(1, [water, water]), gameState(2, [opponentHit, water]), 'player1').type, 'damage');
});
test('sinking and victory feedback take priority over ordinary cell changes', () => {
const water = '0'.repeat(100);
const sunk = `44${water.slice(2)}`;
assert.equal(detectFeedback(gameState(1, [water, water]), gameState(2, [water, sunk]), 'player1').type, 'sunk');
const finished = gameState(3, [water, sunk], { phase: 'finished', winner: 0 });
assert.equal(detectFeedback(gameState(2, [water, water]), finished, 'player1').type, 'victory');
assert.equal(detectFeedback(gameState(2, [water, water]), finished, 'player2').type, 'defeat');
});
test('duplicate snapshots do not repeat rewards', () => {
const water = '0'.repeat(100);
assert.equal(detectFeedback(gameState(2, [water, water]), gameState(2, [water, water]), 'player1'), undefined);
});
test('reaction picker avoids an immediate repeat for the same event', () => {
const picker = createReactionPicker({ hit: ['one', 'two', 'three'] }, () => 0);
assert.equal(picker('hit'), 'one');
assert.equal(picker('hit'), 'two');
assert.equal(picker('hit'), 'one');
});
test('reaction picker handles single and missing catalogs', () => {
const picker = createReactionPicker({ miss: ['splash'] }, () => 0.9);
assert.equal(picker('miss'), 'splash');
assert.equal(picker('miss'), 'splash');
assert.equal(picker('unknown'), undefined);
});
+124
View File
@@ -0,0 +1,124 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const { createAudioEngine, MAX_VOICES, MAX_GAIN } = require('../../data/web_audio.js');
function memoryStorage() {
const values = new Map();
return { getItem: key => values.get(key) || null, setItem: (key, value) => values.set(key, String(value)) };
}
function fakeTimer() {
let next = 1;
const tasks = new Map();
return { setTimeout: callback => { const id = next++; tasks.set(id, callback); return id; }, clearTimeout: id => tasks.delete(id), runAll: () => [...tasks.values()].forEach(callback => callback()), size: () => tasks.size };
}
function fakeAudioContext() {
const contexts = [];
const parameter = () => ({ setValueAtTime() {}, exponentialRampToValueAtTime() {} });
const node = () => ({ connected: 0, disconnected: 0, connect() { this.connected += 1; }, disconnect() { this.disconnected += 1; } });
class FakeAudioContext {
constructor() { this.currentTime = 0; this.sampleRate = 1000; this.destination = node(); this.state = 'suspended'; contexts.push(this); }
createGain() { return { ...node(), gain: parameter() }; }
createDynamicsCompressor() { return { ...node(), threshold: parameter(), knee: parameter(), ratio: parameter() }; }
createBiquadFilter() { return { ...node(), frequency: parameter(), type: '' }; }
createOscillator() { return { ...node(), frequency: parameter(), start() {}, stop() { this.stopped = true; } }; }
createBufferSource() { return { ...node(), start() {}, stop() { this.stopped = true; } }; }
createBuffer(_channels, length) { const samples = new Float32Array(length); return { getChannelData: () => samples }; }
async resume() { this.state = 'running'; }
async suspend() { this.state = 'suspended'; }
close() { this.state = 'closed'; }
}
return { FakeAudioContext, contexts };
}
test('does not create an audio context before explicit enablement', () => {
const { FakeAudioContext, contexts } = fakeAudioContext();
const engine = createAudioEngine({ AudioContext: FakeAudioContext, storage: memoryStorage() });
assert.equal(contexts.length, 0);
assert.equal(engine.play('test'), false);
assert.equal(contexts.length, 0);
});
test('enables through a user gesture, persists safe preferences, and caps gain', async () => {
const { FakeAudioContext, contexts } = fakeAudioContext();
const storage = memoryStorage();
const engine = createAudioEngine({ AudioContext: FakeAudioContext, storage });
assert.equal(await engine.enable(), true);
engine.setVolume('loud'); engine.setReduced(true);
assert.equal(contexts.length, 1);
assert.equal(engine.getPreferences().enabled, true);
assert.equal(engine.getPreferences().volume, 'loud');
assert.equal(engine.getPreferences().reduced, true);
assert.equal(engine.getMaxGain(), MAX_GAIN);
assert.ok(MAX_GAIN <= 0.3);
assert.equal(storage.getItem('battleship.soundVolume'), 'loud');
});
test('uses a bounded voice pool, priority replacement, one noise buffer, and cleanup', async () => {
const { FakeAudioContext } = fakeAudioContext();
const timer = fakeTimer();
const engine = createAudioEngine({ AudioContext: FakeAudioContext, storage: memoryStorage(), timer });
await engine.enable();
for (let index = 0; index < MAX_VOICES; index += 1) assert.equal(engine.play('test'), true);
assert.equal(engine.getVoiceCount(), MAX_VOICES);
assert.equal(engine.play('test'), true);
assert.equal(engine.getVoiceCount(), MAX_VOICES);
assert.equal(engine.play('noise'), true);
const noise = engine.getNoiseBuffer();
assert.ok(noise);
engine.play('noise');
assert.equal(engine.getNoiseBuffer(), noise);
timer.runAll();
assert.equal(engine.getVoiceCount(), 0);
assert.equal(timer.size(), 0);
});
test('suppresses duplicate versions and cancels audio for mute, page hide, and reset cleanup', async () => {
const { FakeAudioContext, contexts } = fakeAudioContext();
const engine = createAudioEngine({ AudioContext: FakeAudioContext, storage: memoryStorage(), timer: fakeTimer() });
await engine.enable();
assert.equal(engine.play('test', 4), true);
assert.equal(engine.play('test', 4), false);
assert.equal(engine.getVoiceCount(), 1);
await engine.setPageHidden(true);
assert.equal(engine.getVoiceCount(), 0);
await engine.setPageHidden(false);
await engine.setEnabled(false);
assert.equal(engine.play('test'), false);
engine.cleanup();
assert.equal(contexts[0].state, 'closed');
});
test('falls back silently when Web Audio is unsupported or initialization fails', async () => {
const silent = createAudioEngine({ AudioContext: undefined, storage: memoryStorage() });
assert.equal(await silent.enable(), false);
assert.equal(silent.play('test'), false);
class FailingContext { constructor() { throw new Error('no audio'); } }
const failing = createAudioEngine({ AudioContext: FailingContext, storage: memoryStorage() });
assert.equal(await failing.enable(), false);
assert.equal(failing.play('test'), false);
});
test('the application exposes an explicit Russian sound control and cleans audio without changing transport', () => {
const app = fs.readFileSync(path.join(__dirname, '../../data/app.js'), 'utf8');
const page = fs.readFileSync(path.join(__dirname, '../../data/index.html'), 'utf8');
const firmware = fs.readFileSync(path.join(__dirname, '../../src/main.c'), 'utf8');
assert.match(page, /id="sound-toggle"[^>]*aria-pressed="false"/);
assert.match(page, /id="sound-volume"/);
assert.match(page, /id="sound-reduced"/);
assert.match(page, /id="recovery-sound-mute"/);
assert.match(page, /src="\/web_audio\.js"/);
assert.match(page, /src="\/game_sounds\.js"/);
assert.match(firmware, /strcmp\(request->uri, "\/web_audio\.js"\)/);
assert.match(firmware, /strcmp\(request->uri, "\/game_sounds\.js"\)/);
assert.match(app, /audio\.cleanup\(\)/);
assert.match(app, /globalThis\.BattleshipAudio\?\.createAudioEngine\?\.\(\) \|\| silentAudio/);
assert.match(app, /visibilitychange/);
assert.match(app, /soundKeys = new Set/);
assert.match(app, /ui\.recoverySoundMute\.hidden = !preferences\.enabled/);
assert.match(app, /\/api\/game\/shot/);
assert.match(app, /sounds\.play\('shot'/);
});