Compare commits

..

37 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
sasa 81d70a7862 feat: add Ship-Class Silhouettes and Red Sunk Markers 2026-08-30 01:24:16 +03:00
sasa dfc6b023f7 feat: add player Names Throughout the Game UI 2026-08-30 01:02:34 +03:00
sasa 4d9d0db02d feat: add active-game screen and firing interactions 2026-08-30 00:46:39 +03:00
sasa a12eac6b5b feat: refine the Lobby screen 2026-08-30 00:36:00 +03:00
sasa 5e1502a18d fix: implement the desktop/laptop connection-screen balance 2026-08-30 00:26:14 +03:00
sasa e5f9ad7b28 fix: implement responsive connection-screen updates. 2026-08-30 00:17:40 +03:00
sasa 3793b1d7a3 feat: execute final MVP acceptance and create the release baseline 2026-08-30 00:00:27 +03:00
sasa d67fd327c9 feat: harden errors, recovery, and resource usage 2026-08-29 23:40:37 +03:00
sasa c8e0c9168b feat: complete human-vs-ESP32 gameplay and cumulative statistics 2026-08-29 23:33:27 +03:00
sasa 650ade2726 feat: complete human-vs-human gameplay end to end 2026-08-29 21:31:21 +03:00
sasa e77d9d62c7 feat: build the Russian responsive web interface 2026-08-29 21:11:36 +03:00
sasa 03b283f892 feat: implement WebSocket synchronization and HTTP recovery 2026-08-28 23:33:14 +03:00
sasa 95b3450317 feat: implement the production HTTP API 2026-08-28 23:20:07 +03:00
sasa 97bebfd8fb feat: implement safe state presentation and bounded serialization 2026-08-28 23:04:04 +03:00
sasa a691783f58 feat: implement sessions, lobby, roles, and rematch lifecycle 2026-08-28 22:57:17 +03:00
sasa ec1ff20ddd feat: implement the ESP32 opponent 2026-08-28 22:46:34 +03:00
sasa 65eca822c0 feat: implement and exhaustively test the game domain core 2026-08-28 22:37:14 +03:00
sasa a957d0defc feat: establish the bounded production architecture 2026-08-28 22:28:24 +03:00
sasa 4ff1718307 feat: lock production decisions and resource budgets 2026-08-28 22:21:36 +03:00
sasa 63e33510b3 feat: prove MVP capacity and make the Go/No-Go decision 2026-08-28 21:27:55 +03:00
sasa ef141358e7 feat: prove real-time transport, fallback, and state isolation 2026-08-28 00:35:27 +03:00
sasa 0619c6be9c feat: prove the Wi-Fi, LittleFS, and HTTP vertical slice 2026-08-28 00:09:53 +03:00
89 changed files with 9424 additions and 136 deletions
+2
View File
@@ -2,3 +2,5 @@
sdkconfig.esp32-c6-devkitm-1
.pio
.vscode
include/wifi_config.h
data/*.gz
+6
View File
@@ -1,3 +1,9 @@
cmake_minimum_required(VERSION 3.16.0)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
# PlatformIO installs the pinned ESP-IDF LittleFS component per environment.
get_filename_component(platformio_environment "${CMAKE_BINARY_DIR}" NAME)
list(APPEND EXTRA_COMPONENT_DIRS
"${CMAKE_SOURCE_DIR}/.pio/libdeps/${platformio_environment}/esp_littlefs")
project(battleship)
+2093 -3
View File
File diff suppressed because it is too large Load Diff
+773
View File
@@ -0,0 +1,773 @@
(() => {
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 = {
status: el('connection-status'), notice: el('notice'), name: el('display-name'), availability: el('availability'),
lobbyDescription: el('lobby-description'), lobbyHelp: el('lobby-help'), modeControls: el('mode-controls'), modeDescription: el('mode-description'),
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'), 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;
let state;
let selectedTarget;
let cumulativeStatistics;
let statisticsGameId;
let activeBoard = 'own';
let socket;
let retryTimer;
let pollTimer;
let heartbeatTimer;
let lobbyInfoTimer;
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) {
ui.status.textContent = text;
ui.status.className = `connection-status ${status || ''}`;
}
function notify(message, error = false) {
ui.notice.hidden = !message;
ui.notice.textContent = message || '';
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 || 'Сервер временно недоступен.';
}
async function request(path, options = {}) {
const response = await fetch(path, { cache: 'no-store', ...options });
let payload;
try { payload = await response.json(); } catch (_) { throw new Error('Сервер вернул некорректный ответ.'); }
if (!response.ok || payload.ok === false) throw new Error(apiError(payload));
return payload;
}
function headers() { return { 'Content-Type': 'application/json' }; }
async function refreshInfo() {
try {
info = await request('/api/info');
const availabilityItem = (text) => {
const item = document.createElement('span');
item.className = 'availability-item';
item.textContent = text;
return item;
};
ui.availability.replaceChildren(
availabilityItem(`Игрок 1: ${info.player1Available ? 'свободен' : role === 'player1' ? 'Вы' : info.player1Name || 'занят'}`),
availabilityItem(`Игрок 2: ${info.player2Available ? 'свободен' : role === 'player2' ? 'Вы' : info.player2Name || 'занят'}`),
availabilityItem(`зрительских мест: ${info.spectatorsAvailable}`),
);
if (state?.phase === 'lobby') renderLobby();
} catch (error) {
ui.availability.textContent = error.message;
}
}
async function pollState() {
try {
const response = await fetch(`/api/state?version=${state?.version || 0}`, {
cache: 'no-store', headers: token ? { 'X-Session-Token': token } : {}
});
const payload = await response.json();
if (!response.ok || payload.ok === false) throw new Error(apiError(payload));
acceptState(payload);
} catch (error) {
if (!state) showError(error.message);
}
}
function beginPolling() {
if (!pollTimer) pollTimer = window.setInterval(pollState, 2000);
pollState();
}
function stopPolling() { if (pollTimer) window.clearInterval(pollTimer); pollTimer = undefined; }
function stopHeartbeat() { if (heartbeatTimer) window.clearInterval(heartbeatTimer); heartbeatTimer = undefined; }
function startLobbyInfoPolling() {
if (!lobbyInfoTimer) {
lobbyInfoTimer = window.setInterval(refreshInfo, 2000);
refreshInfo();
}
}
function stopLobbyInfoPolling() { if (lobbyInfoTimer) window.clearInterval(lobbyInfoTimer); lobbyInfoTimer = undefined; }
function safeState(payload) {
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 &&
entry.every(value => Number.isInteger(value) && value >= 0));
}
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'; }
function ownIndex() { return role === 'player2' ? 1 : 0; }
function opponentIndex() { return ownIndex() ^ 1; }
function canShoot() { return state?.phase === 'in_progress' && state.turn === role && isPlayer(); }
function canFireTarget(target) {
return !firePending && canShoot() && Number.isInteger(target?.x) && Number.isInteger(target?.y) &&
target.x >= 0 && target.x < 10 && target.y >= 0 && target.y < 10 &&
state.boards[opponentIndex()][target.y * 10 + target.x] === '0';
}
function playerName(index) {
const name = state?.players?.[index];
return typeof name === 'string' && name.trim() ? name : `Игрок ${index + 1}`;
}
function playerLabel(index) {
if (index === 1 && state?.mode === 'bot') return 'ESP32';
return isPlayer() && index === ownIndex() ? 'Вы' : playerName(index);
}
async function fireTarget(target) {
if (!canFireTarget(target)) return false;
firePending = true;
selectedTarget = undefined;
renderGame();
try {
await post('/api/game/shot', { token, gameId: state.gameId, ...target });
sounds.play('shot', { reduced: audio.getPreferences().reduced });
return true;
} catch (_) {
return false;
} finally {
firePending = false;
if (state?.phase === 'in_progress') renderGame();
}
}
function activateTarget(target, keyboard = false) {
if (!targetActivator) {
targetActivator = globalThis.BattleshipTargetInteraction.createTargetActivator({
canFire: canFireTarget,
select: nextTarget => { selectedTarget = nextTarget; sounds.play('select', { reduced: audio.getPreferences().reduced }); renderGame(); },
fire: fireTarget,
});
}
return keyboard ? targetActivator.keyboard(target) : targetActivator.tap(target);
}
function cellInfo(value) {
if (value === '1') return ['ship', 'Корабль'];
if (value === '2') return ['miss', 'Промах'];
if (value === '3') return ['hit', 'Попадание'];
if (value === '4') return ['sunk', 'Потопленный корабль'];
return ['', 'Вода'];
}
const fleetClasses = [
{ 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) {
const visited = new Set();
const lengths = [];
for (let start = 0; start < board.length; start += 1) {
if (board[start] !== '4' || visited.has(start)) continue;
let length = 0;
const pending = [start]; visited.add(start);
while (pending.length) {
const index = pending.pop(); length += 1;
const x = index % 10; const y = Math.floor(index / 10);
[[x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1]].forEach(([nextX, nextY]) => {
const next = nextY * 10 + nextX;
if (nextX >= 0 && nextX < 10 && nextY >= 0 && nextY < 10 && board[next] === '4' && !visited.has(next)) {
visited.add(next); pending.push(next);
}
});
}
lengths.push(length);
}
return lengths;
}
function fleetStatusElement(index, title) {
const sunkLengths = sunkShipLengths(state.boards[index]);
const fleet = document.createElement('section'); fleet.className = 'fleet-status'; fleet.setAttribute('aria-label', `Флот: ${title}`);
fleetClasses.forEach(shipClass => {
const row = document.createElement('div'); row.className = 'fleet-row';
row.append(Object.assign(document.createElement('span'), { className: 'fleet-class', textContent: shipClass.label }));
const ships = document.createElement('div'); ships.className = 'fleet-ships';
for (let number = 0; number < shipClass.count; number += 1) {
const sunkAt = sunkLengths.indexOf(shipClass.length);
const sunk = sunkAt !== -1;
if (sunk) sunkLengths.splice(sunkAt, 1);
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'); 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);
});
return fleet;
}
function boardElement(index, title, targetable, result) {
const board = document.createElement('section');
board.className = `board${targetable ? ' is-action' : ''}`;
board.dataset.board = String(index);
const heading = document.createElement('h3');
heading.textContent = title;
board.append(heading);
const grid = document.createElement('div');
grid.className = 'board-grid';
grid.setAttribute('role', 'grid');
grid.setAttribute('aria-label', title);
grid.append(Object.assign(document.createElement('span'), { className: 'axis' }));
columns.forEach(column => grid.append(Object.assign(document.createElement('span'), { className: 'axis', textContent: column })));
for (let y = 0; y < 10; y += 1) {
grid.append(Object.assign(document.createElement('span'), { className: 'axis', textContent: String(y + 1) }));
for (let x = 0; x < 10; x += 1) {
const value = state.boards[index][y * 10 + x];
const [kind, label] = cellInfo(value);
const cell = document.createElement('button');
cell.type = 'button'; cell.className = `cell ${kind}`.trim(); cell.disabled = !targetable || value !== '0' || firePending;
cell.setAttribute('aria-label', `${columns[x]}${y + 1}: ${label}`);
if (targetable && value === '0') {
cell.classList.add('target');
if (selectedTarget?.x === x && selectedTarget?.y === y) cell.classList.add('selected');
const target = { x, y };
cell.addEventListener('pointerup', event => {
if (event.button !== 0) return;
event.preventDefault();
activateTarget(target);
});
cell.addEventListener('click', event => {
if (event.detail === 0) activateTarget(target, true);
});
cell.addEventListener('dblclick', event => {
event.preventDefault();
if (targetActivator) targetActivator.doubleActivate(target);
});
} else if (value === '0') {
cell.classList.add(index === ownIndex() && isPlayer() ? 'own-water' : 'unavailable');
}
grid.append(cell);
}
}
board.append(grid);
board.append(fleetStatusElement(index, title));
if (result) board.hidden = false;
return board;
}
function boardDefinitions(result = false) {
if (role === 'spectator' || result) return [
{ 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: 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;
container.append(board);
});
if (!result) {
ui.tabs.replaceChildren();
definitions.forEach(definition => {
const tab = document.createElement('button');
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(); });
ui.tabs.append(tab);
});
}
}
function renderLobby() {
showScreen('lobby');
const playerOne = role === 'player1';
const opponent = state.mode === 'bot' ? 'ESP32' : playerName(opponentIndex());
ui.lobbyDescription.textContent = role === 'spectator' ? 'Вы наблюдаете за подготовкой партии.' : state.mode === 'human' && info?.player2Available ? 'Ожидаем соперника.' : `Ожидаем готовности ${opponent}.`;
ui.modeControls.hidden = !playerOne;
if (playerOne) startLobbyInfoPolling();
else stopLobbyInfoPolling();
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));
button.disabled = !playerOne;
});
ui.start.disabled = !canStart;
ui.modeDescription.textContent = state.mode === 'human' ? (playerTwoReady ? `${playerName(1)} готов. Можно начать партию.` : 'Ожидаем соперника.') : 'Игра против ESP32. Можно начать сразу.';
ui.lobbyHelp.textContent = playerOne ? '' : `${playerName(0)} выбирает режим и запускает партию.`;
}
function renderGame() {
if (!state) return;
stopLobbyInfoPolling();
showScreen('game');
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.querySelector('span:last-child').textContent = firePending ? 'Летит…' : 'Огонь!';
ui.cancel.disabled = !selectedTarget || firePending;
ui.abort.hidden = !(role === 'player1' && state.mode === 'human' && state.phase === 'in_progress');
}
function renderResult() {
stopLobbyInfoPolling();
showScreen('result');
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.querySelector('span:last-child').textContent = waiting ? 'Я готов!' : 'Ещё раз!';
renderBoards(ui.resultBoards, true);
}
function render() {
if (!state) return;
if (state.phase === 'lobby' || state.phase === 'preparing') renderLobby();
else if (state.phase === 'in_progress') renderGame();
else renderResult();
}
function showError(message) {
stopLobbyInfoPolling();
ui.error.textContent = message;
setConnection('Нет связи', 'offline');
showScreen('error');
}
async function post(path, body) {
try {
const response = await request(path, { method: 'POST', headers: headers(), body: JSON.stringify(body) });
await pollState();
return response;
} catch (error) { notify(error.message, true); throw error; }
}
async function refreshStatistics() {
const requestedGameId = state?.gameId;
try {
const response = await request('/api/statistics', { cache: 'no-store', headers: token ? { 'X-Session-Token': token } : {} });
if (state?.gameId === requestedGameId && response.gameId === requestedGameId && Array.isArray(response.cumulative)) {
cumulativeStatistics = response;
statisticsGameId = requestedGameId;
renderResult();
}
} catch (_) { /* The state snapshot still renders the current-match statistics. */ }
}
async function join(requestedRole) {
const name = ui.name.value.trim();
if (!name) { notify('Введите имя.', true); ui.name.focus(); return; }
try {
const joined = await request('/api/session/join', { method: 'POST', headers: headers(), body: JSON.stringify({ name, requestedRole }) });
token = joined.token; role = joined.role;
localStorage.setItem(storage.token, token); localStorage.setItem(storage.name, name);
notify(''); setConnection('Подключено', 'online');
await pollState();
connectSocket();
} catch (error) { notify(error.message, true); }
}
function connectSocket() {
if (!token || socket?.readyState === WebSocket.OPEN || retryTimer) return;
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
try { socket = new WebSocket(`${scheme}://${location.host}/ws`); } catch (_) { beginPolling(); return; }
socket.onopen = () => socket.send(JSON.stringify({ type: 'hello', token, version: state?.version || 0 }));
socket.onmessage = event => {
try {
const message = JSON.parse(event.data);
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(); }
};
socket.onerror = () => socket.close();
socket.onclose = () => {
socket = undefined; stopHeartbeat(); beginPolling(); setConnection('HTTP-обновление', 'polling');
const delays = [1000, 2000, 5000, 10000];
const delay = delays[Math.min(retryIndex++, delays.length - 1)];
retryTimer = window.setTimeout(() => { retryTimer = undefined; connectSocket(); }, delay);
};
}
el('join-form').addEventListener('submit', event => {
event.preventDefault();
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 }).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; }
try {
const resumed = await request('/api/session/resume', { method: 'POST', headers: headers(), body: JSON.stringify({ token }) });
role = resumed.role;
await pollState();
connectSocket();
} catch (_) {
localStorage.removeItem(storage.token); token = ''; showScreen('connect'); notify('Предыдущая сессия больше недоступна. Подключитесь снова.');
}
}
boot();
})();
+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;
})();
+83
View File
@@ -0,0 +1,83 @@
<!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>Морской бой — ESP32</title>
<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><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 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" 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">
<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>
<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><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" 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>
<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>
<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();
})();
+25
View File
@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg">
<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>

After

Width:  |  Height:  |  Size: 4.5 KiB

+70
View File
@@ -0,0 +1,70 @@
: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; }
button:focus-visible, input:focus-visible, .cell:focus-visible { outline: .2rem solid #ffe56a; outline-offset: .15rem; }
button:hover:not(:disabled) { filter: brightness(1.08); } button:active:not(:disabled) { filter: brightness(.94); transform: translateY(1px); } button:disabled { cursor: not-allowed; opacity: .48; }
button.secondary { background: #31536e; color: #f1f7ff; } button.text-button { min-height: 2rem; padding: .35rem 0; background: transparent; color: #bad9eb; text-decoration: underline; }
.app-shell { width: min(100%, 70rem); margin: 0 auto; padding: clamp(1rem, 3vw, 2rem); }
.app-header, .screen-heading { display: flex; flex-wrap: wrap; align-items: start; justify-content: space-between; gap: 1rem; } .app-header > div { min-width: 0; }
h1, h2, h3, p { margin-top: 0; } h1 { margin-bottom: .2rem; font-size: clamp(1.75rem, 6vw, 2.6rem); } h2 { font-size: clamp(1.35rem, 4vw, 1.8rem); } h3 { margin-bottom: .75rem; font-size: 1.05rem; }
.eyebrow, .muted { color: #b6d1e3; } .eyebrow { margin-bottom: .25rem; font-size: .8rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
.connection-status { max-width: 100%; margin: .25rem 0; padding: .4rem .6rem; border-radius: 99rem; background: #28485f; color: #d8eaff; font-size: .88rem; overflow-wrap: anywhere; } .connection-status.online { background: #145b4b; color: #cdfae6; } .connection-status.polling { background: #234c69; color: #d8eaff; } .connection-status.offline { background: #6f3c36; color: #ffddd8; }
.notice { margin: 1rem 0; padding: .75rem 1rem; border-left: .3rem solid #ffe56a; border-radius: .35rem; background: #403b22; } .notice.error { border-color: #ff8e80; background: #4d2929; }
.screen, .panel { margin-top: 1rem; padding: clamp(1rem, 3vw, 1.5rem); border: 1px solid #41708d; border-radius: 1rem; background: rgb(8 39 62 / 92%); box-shadow: 0 1rem 3rem rgb(0 0 0 / 18%); } .panel { margin-top: 1rem; background: #0b304b; }
.stack-form { display: grid; gap: .7rem; max-width: 28rem; } input { width: 100%; min-height: 2.75rem; padding: .55rem .7rem; border: 1px solid #6a94ad; border-radius: .65rem; background: #061c2d; color: #f1f7ff; } input:focus-visible { border-color: #3ec6f0; background: #082a41; box-shadow: 0 0 0 .22rem rgb(62 198 240 / 28%); }
.button-row { display: flex; flex-wrap: wrap; gap: .6rem; } .button-row > * { flex: 1 1 11rem; }
.lobby-mode-section { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid rgb(80 128 156 / 65%); } .lobby-mode-section h3 { margin-bottom: .65rem; } .mode-options { display: grid; gap: .6rem; } .mode-button { display: grid; grid-template-columns: 1.2rem minmax(0, 1fr); align-items: center; gap: .65rem; min-height: 3rem; border: 1px solid #41708d; background: #123b58; color: #f1f7ff; text-align: left; } .mode-button:hover:not(:disabled) { background: #1a4d6d; } .mode-button[aria-checked="true"] { border-color: #3ec6f0; background: #0d5276; box-shadow: 0 0 0 .12rem rgb(62 198 240 / 20%); } .mode-indicator { display: grid; place-items: center; width: 1.1rem; height: 1.1rem; border: 2px solid #8eb4cb; border-radius: 50%; } .mode-button[aria-checked="true"] .mode-indicator { border-color: #b9f0ff; background: #3ec6f0; color: #032035; } .mode-button[aria-checked="true"] .mode-indicator::before { content: "✓"; font-size: .76rem; font-weight: 900; } #mode-description { min-height: 1.4rem; margin: .7rem 0; } #start-game { width: 100%; min-height: 3rem; }
.connection-layout { display: grid; gap: 1.25rem; } .connection-form { min-width: 0; } .connection-support { padding: 1rem; border: 1px solid #315a75; border-radius: .8rem; background: rgb(10 48 75 / 68%); } .connection-support h3 { margin-bottom: .65rem; } .join-steps { display: grid; gap: .55rem; margin: 0; padding-left: 1.35rem; color: #c8e9f8; } .join-steps li { padding-left: .15rem; } .availability-panel { margin-top: 1rem; padding-top: .9rem; border-top: 1px solid rgb(80 128 156 / 65%); } .availability-panel .eyebrow { margin-bottom: .45rem; }
#availability { display: grid; grid-template-columns: minmax(0, 1fr); gap: .45rem; margin-bottom: 0; } .availability-item { display: block; padding: .45rem .6rem; border-left: 2px solid #41708d; border-radius: .35rem; background: rgb(18 59 88 / 62%); }
.turn-status { margin-bottom: 0; color: #c8e9f8; font-weight: 650; } .score { margin: .15rem 0; padding: .5rem .75rem; border-radius: .6rem; background: #123b58; font-weight: 750; white-space: nowrap; }
.board-tabs { display: flex; gap: .5rem; margin: 1rem 0; } .board-tabs button { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-height: 2.5rem; background: #31536e; color: #f1f7ff; } .board-tabs button[aria-selected="true"] { background: #3ec6f0; color: #032035; }
.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.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; } }
@media (max-width: 43.99rem) { .app-shell { padding-inline: max(1.25rem, env(safe-area-inset-left)) max(1.25rem, env(safe-area-inset-right)); } .app-header { flex-direction: column; align-items: stretch; gap: .45rem; } .connection-status { align-self: flex-start; margin: 0; } #screen-connect { padding: 1.25rem; } .button-row { flex-direction: column; flex-wrap: nowrap; } .button-row > * { flex: 0 0 auto; width: 100%; min-height: 3rem; } #availability { margin-top: 0; } .board[hidden] { display: none; } .result-boards .board[hidden] { display: block; } }
#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; } }
+92
View File
@@ -0,0 +1,92 @@
(() => {
function sameTarget(left, right) { return left?.x === right?.x && left?.y === right?.y; }
function createTargetActivator({ canFire, select, fire, thresholdMs = 360, now = () => Date.now() }) {
let lastTap;
let firing = false;
async function fireOnce(target) {
if (firing || !canFire(target)) return false;
firing = true;
lastTap = undefined;
try {
await fire(target);
return true;
} finally {
firing = false;
}
}
function tap(target) {
if (firing || !canFire(target)) return { action: 'ignored' };
const timestampMs = now();
if (lastTap && sameTarget(lastTap.target, target) && timestampMs - lastTap.timestampMs <= thresholdMs) {
return { action: 'fire', promise: fireOnce(target) };
}
select(target);
lastTap = { target, timestampMs };
return { action: 'select' };
}
function keyboard(target) {
if (firing || !canFire(target)) return { action: 'ignored' };
select(target);
lastTap = undefined;
return { action: 'select' };
}
return {
tap,
keyboard,
doubleActivate: (target) => ({ action: 'fire', promise: fireOnce(target) }),
isFiring: () => firing,
};
}
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;
})();
+10
View File
@@ -0,0 +1,10 @@
dependencies:
idf:
source:
type: idf
version: 6.0.1
direct_dependencies:
- idf
manifest_hash: 38889ab999c3c022cdccd1d77c9d34b52f3ef7d3841674a4298208c1864ba68a
target: esp32c6
version: 2.0.0
+134
View File
@@ -0,0 +1,134 @@
# API contract v1
Machine-readable HTTP documentation: [openapi.yaml](openapi.yaml).
All JSON is UTF-8 and uses `Content-Type: application/json`. API responses set
`Cache-Control: no-store`. A request exceeding its route limit is rejected
before parsing with `PAYLOAD_TOO_LARGE`; malformed JSON is `MALFORMED_JSON`.
All numeric fields are decimal JSON integers, never strings.
## Common values
| Type | Values / bound |
| ------------------- | ---------------------------------------------------------------------------------------------- |
| `role` | `player1`, `player2`, `spectator` |
| `mode` | `human`, `bot` |
| `phase` | `lobby`, `preparing`, `in_progress`, `finished`, `rematch_wait` |
| `token` | exactly 32 lowercase hexadecimal characters |
| `gameId`, `version` | unsigned 32-bit integer |
| `x`, `y` | integer 09 |
| `name` | 120 scalar values, at most 80 UTF-8 bytes |
| `board` | exactly 100 ASCII cells: `0` unknown/water, `1` revealed ship, `2` miss, `3` hit, `4` sunk hit |
Every state-changing request has `token` and `gameId`; they must precede any
mutation validation. A stale `gameId` is rejected as `STALE_GAME`.
## Response envelope
Success:
```json
{"ok":true,"version":17,"gameId":4}
```
Failure (maximum 160 encoded bytes):
```json
{"ok":false,"code":"NOT_YOUR_TURN","message":"Сейчас ход соперника","version":17}
```
`code` is one of `MALFORMED_JSON`, `PAYLOAD_TOO_LARGE`, `INVALID_NAME`,
`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.
## HTTP routes
| Route | Maximum request | Response / maximum |
| -------------------------- | --------------: | --------------------------------------------- |
| `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 |
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
maximum schema:
```json
{"type":"state","version":17,"gameId":4,"phase":"in_progress","mode":"human","viewer":"player1","turn":"player2","players":["Алиса","Борис"],"boards":["000...100 cells...","000...100 cells..."],"wins":[0,0],"winner":null,"statistics":[[3,2,1,1],[4,1,3,0]]}
```
`boards[0]` belongs to player 1 and `boards[1]` to player 2. The presenter
replaces every unauthorized unhit ship with `0`. In `finished`, both boards may
contain `1`. `winner` is `null` until `finished`, then player index `0` or `1`.
`players` contains validated display names for occupied slots (an empty string for a free slot). Each compact statistics tuple is `[shots,hits,misses,shipsSunk]`. No other event
contains a board.
## Statistics response
`GET /api/statistics` accepts the same optional `X-Session-Token` as state and
returns no board data. `match` uses `[shots,hits,misses,shipsSunk]` per side;
`cumulative` uses `[games,wins,losses,shipsSunk,shots,hits,misses]` per side.
```json
{"ok":true,"viewer":"player1","gameId":4,"match":[[3,2,1,1],[4,1,3,0]],"cumulative":[[2,1,1,10,30,15,15],[2,1,1,8,28,14,14]]}
```
## WebSocket
Endpoint: `GET /ws`; all incoming frames are text JSON, at most 192 B. The
first frame must arrive within 5 seconds:
```json
{"type":"hello","token":"32-lowercase-hex-characters","version":17}
```
It receives a `state` snapshot. Supported inbound frames are `hello` (96 B),
`ping` (16 B), `config` (96 B), `start` (80 B), `shot` (96 B), `rematch`
(80 B), and `abort` (80 B). Their fields exactly match the corresponding HTTP
commands. Outbound `state` is at most 768 B; `error` uses the common 160-byte
failure envelope; `pong` is 16 B. Commands are idempotent when the same
`gameId`, `version`, and command payload are retried: the server returns the
current state rather than applying the action twice.
+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 и громкость; не провоцируют ли звуки случайные нажатия. При замечании
укажите тип звука, а не сведения о человеке.
+11 -11
View File
@@ -33,17 +33,17 @@ carrier PCB model or its LED and pin routing.
## Current repository baseline (not hardware confirmation)
| Item | Observed value | Status |
|---|---|---|
| PlatformIO Core | 6.1.19 | Installed locally |
| PlatformIO platform | `platformio/espressif32` 7.0.1 | Installed locally; `platformio.ini` is not version-pinned |
| Framework | ESP-IDF 6.0.1 | Current project setting conflicts with the Arduino-first MVP baseline |
| Arduino Core | Not installed | Cannot be pinned and verified against this board yet |
| PlatformIO board | `esp32-c6-devkitm-1` | Unverified candidate, not evidence of the physical model |
| Board-profile flash size | 4 MB | Unverified; profile metadata only |
| Detected physical flash size | 4 MB | Confirmed by `esptool.py flash_id` |
| Generated SDK flash size | 2 MB | Conflicts with the detected hardware and board profile |
| Generated partition table | Single application, no LittleFS | Does not meet Milestone 000 |
| Item | Observed value | Status |
| ---------------------------- | ------------------------------- | --------------------------------------------------------------------- |
| PlatformIO Core | 6.1.19 | Installed locally |
| PlatformIO platform | `platformio/espressif32` 7.0.1 | Installed locally; `platformio.ini` is not version-pinned |
| Framework | ESP-IDF 6.0.1 | Current project setting conflicts with the Arduino-first MVP baseline |
| Arduino Core | Not installed | Cannot be pinned and verified against this board yet |
| PlatformIO board | `esp32-c6-devkitm-1` | Unverified candidate, not evidence of the physical model |
| Board-profile flash size | 4 MB | Unverified; profile metadata only |
| Detected physical flash size | 4 MB | Confirmed by `esptool.py flash_id` |
| Generated SDK flash size | 2 MB | Conflicts with the detected hardware and board profile |
| Generated partition table | Single application, no LittleFS | Does not meet Milestone 000 |
The current configuration is not yet a reproducible baseline: its generated
SDK configuration must be changed from 2 MB to the confirmed 4 MB and its
+82
View File
@@ -0,0 +1,82 @@
# Production decisions
This document is the binding implementation contract for the MVP. It resolves
the choices left open in `MVP.md`; future code must not widen these limits
without updating this document and `RESOURCE_BUDGET.md`.
## Platform and ownership
- Target: ESP32-C6FH4 (4 MB flash, no PSRAM assumed), custom 2 MiB app and
1,984 KiB LittleFS partitions.
- Stack: PlatformIO Core 6.1.19, `espressif32` 7.0.1, ESP-IDF 6.0.1,
built-in `esp_http_server` WebSocket support, and `esp_littlefs` 1.20.4.
- The ESP32 is authoritative. HTTP and WebSocket callbacks only validate and
enqueue commands; one application task owns game, session, and statistics
mutation.
- Internal coordinates are unsigned `x` and `y` in `[0, 9]`. The browser
renders Russian column labels; it never sends them.
## Fixed game rules
- Board size is 10 by 10. Each side has ships of lengths `4, 3, 3, 2, 2, 2,
1, 1, 1, 1`, placed randomly by the server without touching, including
diagonally.
- A hit retains the turn. A miss changes it. Sinking a ship marks its
surrounding cells as misses. A repeated shot is rejected without state
change. First turn and each fleet are independently random.
- Modes are `HUMAN_VS_HUMAN` and `HUMAN_VS_BOT`. The bot is `ESP32`; it uses
only previously visible shot results and acts after a bounded 500900 ms
server timer.
- Phases are `LOBBY`, `PREPARING`, `IN_PROGRESS`, `FINISHED`, and
`REMATCH_WAIT`. Every accepted state change increments `version`.
## Sessions and capacity
- Roles are `PLAYER_1`, `PLAYER_2`, and `SPECTATOR`; capacity is two players
and eight spectators. New clients become spectators when player slots are
occupied, subject to the spectator limit.
- Display names are 120 Unicode scalar values after removing controls and
markup. The server stores a bounded UTF-8 encoding of at most 80 bytes and
escapes it before HTML rendering.
- A session token is 16 cryptographically random bytes, transported as 32
lowercase hexadecimal characters. It is opaque, never logged, and remains
valid only until board reboot or explicit slot release.
- Active player sessions survive disconnects. Spectator disconnects free their
slot immediately. A player may resume only with the same token; a resumed
session receives a complete role-safe snapshot.
## Lifecycle
1. `join` assigns player 1 if vacant and requested, then player 2 only in the
two-player mode; otherwise it assigns spectator. `resume` never changes a
role.
2. Player 1 may configure a mode only in `LOBBY`. Switching to bot reserves
player 2 as `ESP32`; switching back requires no human player 2 conflict.
3. Player 1 may start only when player 2 is present in human mode, or bot mode
is selected. `PREPARING` is internal and advances atomically to
`IN_PROGRESS` after both fleets validate.
4. A player disconnect does not abort a game. The game waits for that player;
the bot continues only when it is the bot's turn. Player 1 may issue the
explicit `abort` command while a human opponent is disconnected, returning
to `LOBBY` and incrementing `version`.
5. Destroying all ten opponent ships transitions to `FINISHED`, records match
and cumulative statistics, reveals both boards, and rejects shots.
6. In `FINISHED`, a player may confirm `rematch`. Human mode requires both
player confirmations; bot mode requires player 1 only. The server enters
`REMATCH_WAIT` until the required confirmations exist, then creates a new
game ID and returns to `PREPARING`. Names, roles, and cumulative statistics
remain; per-match statistics reset.
7. Board reboot clears sessions, game, and all statistics. Wi-Fi loss does not
mutate them; clients use HTTP polling while WebSocket reconnects.
## Visibility and transport
- Player 1 receives its complete board and only known opponent shot results;
player 2 is symmetric. Spectators see known shot results from both boards.
Unhit ships are replaced by `0` in every unauthorized view.
- `FINISHED` is the sole phase that exposes full boards to every role.
- HTTP commands include the token in the JSON body. WebSocket authentication
is the first `hello` message; the token is never placed in a URL or log.
- WebSocket reconnect delay is 1, 2, 5, then 10 seconds. HTTP polls a complete
snapshot every two seconds only while WebSocket is unavailable. Any skipped
version triggers a full snapshot request.
+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, число
клиентов и причина перезагрузки не должны показывать утечку, зависание или
перезагрузку.
+152
View File
@@ -0,0 +1,152 @@
# MVP release and acceptance guide
This guide is the reproducible handoff for the ESP32-C6 Battleship MVP. It
does not replace the locked rules in [MVP.md](MVP.md), the HTTP contract in
[API_CONTRACT.md](API_CONTRACT.md), or the resource limits in
[RESOURCE_BUDGET.md](RESOURCE_BUDGET.md).
## Release prerequisites
- Target environment: `esp32-c6-devkitm-1` from `platformio.ini`.
- ESP32-C6FH4, revision v0.2, 4 MB flash; no PSRAM is assumed.
- PlatformIO Core 6.1.19, `espressif32` 7.0.1, ESP-IDF 6.0.1, and
`esp_littlefs` 1.20.4.
- USB serial device: normally `/dev/ttyACM0`; confirm with `pio device list`
before an upload.
- 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
From the repository root, run the automated release checks:
```sh
make -C test/host run
pio run -e esp32-c6-devkitm-1 -t buildfs
pio run -e esp32-c6-devkitm-1
```
Confirm the build remains within the limits in `RESOURCE_BUDGET.md`, then
connect only the intended board and upload the browser assets before firmware:
```sh
pio run -e esp32-c6-devkitm-1 -t uploadfs --upload-port /dev/ttyACM0
pio run -e esp32-c6-devkitm-1 -t upload --upload-port /dev/ttyACM0
```
Replace `/dev/ttyACM0` when `pio device list` identifies another device. Do
not use erase targets for this release procedure.
After boot, obtain the DHCP address from the router or serial log, then open
`http://<device-address>/`. The web application and API must be used only on
the local network. The API health check is:
```sh
curl http://<device-address>/api/health
```
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
deviation for every item. A `PASS` requires all thirteen checks.
| MVP criterion | Required physical check |
| --- | --- |
| 1 | Cold boot, Wi-Fi join, and Russian interface load from LittleFS. |
| 2 | Two phones complete a human-versus-human game. |
| 3 | One phone completes a human-versus-ESP32 game, including delayed bot turns. |
| 46 | Verify fleet rules, rejected duplicate/out-of-turn shots, and role-safe views. |
| 7 | Connect at least two spectators during a game; confirm controls stay unavailable. Repeat to the eight-spectator target. |
| 8 | Interrupt WebSocket connectivity; confirm two-second HTTP fallback and WebSocket recovery. |
| 9 | Refresh each player and a spectator; confirm token-based role/state recovery. |
| 1011 | Finish, inspect winner and statistics, rematch, then confirm fresh fleets and preserved cumulative totals. |
| 12 | Check narrow-phone and tablet portrait/landscape views, including tabs and side-by-side boards. |
| 13 | Complete 20 representative consecutive games with no reset, hang, or material heap decline. |
For the final load run, keep two players and eight spectators connected and
sample `/api/health` before, during, and after the run. The final report must
compare minimum free heap, largest free block, reset reason, and observed
state/delivery latency with the hard limits in `RESOURCE_BUDGET.md`.
## Recovery and incident handling
- A player refreshes the page to resume with the token stored in browser
`localStorage`; a reboot intentionally invalidates all tokens and resets
game/statistics state.
- If WebSocket is unavailable, leave the page open: the browser uses HTTP
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:
```text
Source revision:
PlatformIO / ESP-IDF / esp_littlefs:
Firmware bytes / LittleFS bytes:
RAM bytes / minimum heap / largest free block:
Largest state payload / maximum observed delivery latency:
Reset reason before and after run:
20-game result:
2-player + 8-spectator result:
MVP criteria 113: PASS / FAIL with evidence:
Release decision: PASS
```
Post-MVP work begins only after this record is complete and Milestone 017 is
marked `DONE`.
+56
View File
@@ -0,0 +1,56 @@
# Resource budget
## Measured feasibility baseline
Milestone 004's accepted ESP32-C6 run completed 20 games of 200 updates with
two players and eight spectators. Its measured values were 1,000,496 B flash,
38,204 B RAM, 249,616 B minimum free heap, 320 B largest state message, 154 us
maximum generation time, and 5,283 us maximum asynchronous delivery-enqueue
time. The application partition is 2,097,152 B and LittleFS is 2,031,616 B.
## Hard production limits
| Resource | Limit | Basis |
| ---------------------------- | ----------: | -------------------------------------------------------------------------- |
| Firmware image | 1,500,000 B | M004 fixed threshold, leaving 597,152 B app-partition reserve |
| LittleFS image | 250,000 B | M004 fixed threshold, leaving 1,781,616 B filesystem reserve |
| Minimum free heap | 96,000 B | M004 fixed threshold; observed minimum was 249,616 B |
| Largest role-safe state JSON | 768 B | Two bounded 80-byte display names plus role-safe game state |
| HTTP JSON request body | 192 B | Largest defined command (`join`) fits within this bound |
| WebSocket incoming frame | 192 B | `hello` is the largest defined incoming frame |
| HTTP request target/query | 128 B | `/api/state?version=4294967295` is below this bound |
| State generation | 100,000 us | M004 fixed threshold; observed maximum was 154 us |
| State delivery enqueue | 100,000 us | M004 fixed threshold; observed maximum was 5,283 us |
| Pending application commands | 16 | Fixed bounded queue; excess requests return `SERVER_BUSY` |
| Sessions | 10 | Two players and eight spectators |
| Open HTTP sockets | 12 | Ten clients plus two polling/transport headroom; LWIP is configured for 16 |
No handler may allocate a per-client complete JSON state. It serializes one
bounded snapshot at a time into the 768-byte transport buffer.
## Per-milestone budget gates
| Milestone | Firmware ceiling | LittleFS ceiling | Heap floor | Required check |
| ---------------- | ---------------: | ---------------: | ---------: | ---------------------------------- |
| 006 architecture | 1,080,000 B | 250,000 B | 220,000 B | clean build and host tests |
| 007 game core | 1,180,000 B | 250,000 B | 190,000 B | fleet and rules tests |
| 008 sessions/API | 1,300,000 B | 250,000 B | 150,000 B | role filtering and malformed input |
| 009 transport/UI | 1,420,000 B | 250,000 B | 115,000 B | two players/eight spectators |
| MVP completion | 1,500,000 B | 250,000 B | 96,000 B | repeated on-board game test |
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.
+452
View File
@@ -0,0 +1,452 @@
openapi: 3.0.3
info:
title: Battleship ESP32 API
version: 1.0.0
description: |
Local HTTP API for the ESP32-C6 Battleship MVP. All responses are JSON,
UTF-8, and carry `Cache-Control: no-store`. The ESP32 is authoritative.
WebSocket synchronization at `/ws` is documented in API_CONTRACT.md;
it is not an HTTP request/response operation and is therefore outside
this OpenAPI document.
servers:
- url: http://{device-address}
variables:
device-address:
default: 192.168.1.50
description: DHCP address of the ESP32 on the local network.
paths:
/api/info:
get:
summary: Read public device and slot availability
responses:
'200':
description: Public game metadata.
content:
application/json:
schema:
$ref: '#/components/schemas/Info'
/api/health:
get:
summary: Read device diagnostics without secrets
responses:
'200':
description: Current resource and network diagnostics.
content:
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
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/JoinRequest'
responses:
'200':
description: Session created.
content:
application/json:
schema:
$ref: '#/components/schemas/SessionCreated'
'400': { $ref: '#/components/responses/BadRequest' }
'409': { $ref: '#/components/responses/Conflict' }
'413': { $ref: '#/components/responses/PayloadTooLarge' }
/api/session/resume:
post:
summary: Resume a role using its opaque token
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TokenRequest'
responses:
'200':
description: Session resumed.
content:
application/json:
schema:
$ref: '#/components/schemas/SessionResumed'
'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)
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ConfigRequest'
responses: &commandResponses
'200': { $ref: '#/components/responses/CommandAccepted' }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'409': { $ref: '#/components/responses/Conflict' }
'413': { $ref: '#/components/responses/PayloadTooLarge' }
'503': { $ref: '#/components/responses/ServerBusy' }
/api/game/start:
post:
summary: Start a configured game (Player 1)
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/GameRequest'
responses: *commandResponses
/api/game/shot:
post:
summary: Fire at an opponent cell
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ShotRequest'
responses: *commandResponses
/api/game/rematch:
post:
summary: Confirm a rematch after a finished game
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/GameRequest'
responses: *commandResponses
/api/game/abort:
post:
summary: Abort a human game with a disconnected opponent
description: Available only to Player 1 in the locked human-versus-human case.
requestBody:
required: true
content:
application/json:
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
parameters:
- $ref: '#/components/parameters/Version'
- $ref: '#/components/parameters/SessionToken'
responses:
'200':
description: State view for the token role; without a token, a spectator-safe view.
content:
application/json:
schema:
$ref: '#/components/schemas/State'
'401': { $ref: '#/components/responses/Unauthorized' }
'413': { $ref: '#/components/responses/PayloadTooLarge' }
/api/statistics:
get:
summary: Get match and reboot-scoped cumulative statistics
parameters:
- $ref: '#/components/parameters/SessionToken'
responses:
'200':
description: Statistics only; no board cells are returned.
content:
application/json:
schema:
$ref: '#/components/schemas/Statistics'
'401': { $ref: '#/components/responses/Unauthorized' }
'413': { $ref: '#/components/responses/PayloadTooLarge' }
components:
parameters:
Version:
name: version
in: query
required: false
schema: { type: integer, minimum: 0, maximum: 4294967295 }
description: Last applied state version; a complete snapshot is always safe to consume.
SessionToken:
name: X-Session-Token
in: header
required: false
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:
application/json:
schema: { $ref: '#/components/schemas/CommandAccepted' }
BadRequest:
description: Malformed JSON or invalid input.
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
Unauthorized:
description: Invalid or expired token.
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
Forbidden:
description: The role may not perform this command.
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
Conflict:
description: Stale game, wrong phase, unavailable slot, or invalid turn/cell state.
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
PayloadTooLarge:
description: Request body or target exceeded its route bound.
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
ServerBusy:
description: Bounded command queue or serializer is unavailable.
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
schemas:
Token:
type: string
pattern: '^[0-9a-f]{32}$'
description: Opaque, reboot-scoped session token.
Role:
type: string
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]
Phase:
type: string
enum: [lobby, preparing, in_progress, finished, rematch_wait]
GameId:
type: integer
minimum: 0
maximum: 4294967295
JoinRequest:
type: object
required: [name, requestedRole]
additionalProperties: false
properties:
name: { type: string, minLength: 1, maxLength: 80, description: 120 Unicode scalar values after server validation. }
requestedRole: { $ref: '#/components/schemas/Role' }
TokenRequest:
type: object
required: [token]
additionalProperties: false
properties: { token: { $ref: '#/components/schemas/Token' } }
GameRequest:
type: object
required: [token, gameId]
properties:
token: { $ref: '#/components/schemas/Token' }
gameId: { $ref: '#/components/schemas/GameId' }
ConfigRequest:
allOf:
- $ref: '#/components/schemas/GameRequest'
- type: object
required: [mode]
properties: { mode: { $ref: '#/components/schemas/Mode' } }
ShotRequest:
allOf:
- $ref: '#/components/schemas/GameRequest'
- type: object
required: [x, y]
properties:
x: { type: integer, minimum: 0, maximum: 9 }
y: { type: integer, minimum: 0, maximum: 9 }
CommandAccepted:
type: object
required: [ok, version, gameId]
properties:
ok: { type: boolean, enum: [true] }
version: { $ref: '#/components/schemas/GameId' }
gameId: { $ref: '#/components/schemas/GameId' }
SessionCreated:
allOf:
- $ref: '#/components/schemas/CommandAccepted'
- type: object
required: [token, role]
properties:
token: { $ref: '#/components/schemas/Token' }
role: { $ref: '#/components/schemas/Role' }
SessionResumed:
allOf:
- $ref: '#/components/schemas/CommandAccepted'
- 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]
properties:
ok: { type: boolean, enum: [true] }
phase: { $ref: '#/components/schemas/Phase' }
gameId: { $ref: '#/components/schemas/GameId' }
version: { $ref: '#/components/schemas/GameId' }
player1Available: { type: boolean }
player2Available: { type: boolean }
player1Name: { type: string, maxLength: 20 }
player2Name: { type: string, maxLength: 20 }
spectatorsAvailable: { type: integer, minimum: 0, maximum: 8 }
Health:
type: object
required: [ok, uptimeMs, wifiState, freeHeapBytes, minimumFreeHeapBytes, largestFreeBlockBytes, connectedClients, rejectedInput, resetReason]
properties:
ok: { type: boolean, enum: [true] }
uptimeMs: { $ref: '#/components/schemas/GameId' }
wifiState: { type: string, enum: [not_configured, connecting, connected, fallback] }
freeHeapBytes: { $ref: '#/components/schemas/GameId' }
minimumFreeHeapBytes: { $ref: '#/components/schemas/GameId' }
largestFreeBlockBytes: { $ref: '#/components/schemas/GameId' }
connectedClients: { type: integer, minimum: 0, maximum: 12 }
rejectedInput: { type: integer, minimum: 0, maximum: 65535 }
resetReason: { type: integer }
Board:
type: string
pattern: '^[01234]{100}$'
description: 0 unknown/water, 1 revealed ship, 2 miss, 3 hit, 4 sunk hit.
MatchStatistics:
type: array
minItems: 4
maxItems: 4
items: { type: integer, minimum: 0 }
description: '[shots, hits, misses, shipsSunk]'
CumulativeStatistics:
type: array
minItems: 7
maxItems: 7
items: { type: integer, minimum: 0 }
description: '[games, wins, losses, shipsSunk, shots, hits, misses]'
State:
type: object
required: [type, version, gameId, phase, mode, viewer, turn, players, boards, wins, winner, statistics]
properties:
type: { type: string, enum: [state] }
version: { $ref: '#/components/schemas/GameId' }
gameId: { $ref: '#/components/schemas/GameId' }
phase: { $ref: '#/components/schemas/Phase' }
mode: { $ref: '#/components/schemas/Mode' }
viewer: { $ref: '#/components/schemas/Role' }
turn: { type: string, enum: [player1, player2] }
players:
type: array
minItems: 2
maxItems: 2
items: { type: string, maxLength: 20 }
boards:
type: array
minItems: 2
maxItems: 2
items: { $ref: '#/components/schemas/Board' }
wins:
type: array
minItems: 2
maxItems: 2
items: { type: integer, minimum: 0 }
winner:
nullable: true
type: integer
enum: [0, 1]
statistics:
type: array
minItems: 2
maxItems: 2
items: { $ref: '#/components/schemas/MatchStatistics' }
Statistics:
type: object
required: [ok, viewer, gameId, match, cumulative]
properties:
ok: { type: boolean, enum: [true] }
viewer: { $ref: '#/components/schemas/Role' }
gameId: { $ref: '#/components/schemas/GameId' }
match:
type: array
minItems: 2
maxItems: 2
items: { $ref: '#/components/schemas/MatchStatistics' }
cumulative:
type: array
minItems: 2
maxItems: 2
items: { $ref: '#/components/schemas/CumulativeStatistics' }
Error:
type: object
required: [ok, code, message, version]
properties:
ok: { type: boolean, enum: [false] }
code:
type: string
enum: [MALFORMED_JSON, PAYLOAD_TOO_LARGE, INVALID_NAME, INVALID_ROLE, INVALID_MODE, INVALID_COORDINATE, UNAUTHORIZED, NO_PLAYER_SLOT, NO_SPECTATOR_SLOT, FORBIDDEN_ROLE, WRONG_PHASE, NOT_YOUR_TURN, CELL_ALREADY_SHOT, STALE_GAME, SERVER_BUSY]
message: { type: string, maxLength: 80, description: Russian user-facing text. }
version: { $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>
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include <stdint.h>
enum {
kBoardWidth = 10,
kBoardHeight = 10,
kBoardCellCount = kBoardWidth * kBoardHeight,
kFleetShipCount = 10,
kPlayerCapacity = 2,
kSpectatorCapacity = 8,
kSessionCapacity = kPlayerCapacity + kSpectatorCapacity,
kCommandQueueCapacity = 16,
kBotKnowledgeCellCount = kBoardCellCount,
kStateMessageCapacity = 768,
kRequestBodyCapacity = 192,
kSessionTokenBytes = 16,
kDisplayNameBytes = 80,
};
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
typedef struct { uint64_t (*now_ms)(void *context); void *context; } app_clock_t;
typedef struct { uint32_t (*next_u32)(void *context); void *context; } random_source_t;
typedef struct { bool (*schedule_after_ms)(void *context, uint32_t delay_ms); void *context; } scheduler_t;
typedef struct { bool (*send)(void *context, int client_id, const char *data, size_t length); void *context; } transport_t;
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <stdbool.h>
#include "command_queue.h"
#include "game_lifecycle.h"
/* The application task is the sole owner of lifecycle and game mutations. */
typedef struct { command_queue_t command_queue; game_lifecycle_t lifecycle; } application_t;
void application_init(application_t *application, random_source_t random);
void application_set_bot_scheduler(application_t *application, scheduler_t scheduler);
bool application_enqueue(application_t *application, const app_command_t *command);
bool application_take_next_command(application_t *application, app_command_t *command);
lifecycle_result_t application_join(application_t *application, role_t requested_role, const char *name,
uint8_t *session_index);
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);
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include <stdbool.h>
#include "app_interfaces.h"
#include "game_types.h"
typedef uint8_t bot_cell_t;
enum { BOT_CELL_UNKNOWN, BOT_CELL_MISS, BOT_CELL_HIT, BOT_CELL_BLOCKED };
typedef struct { bool hit; bool sunk; } bot_shot_result_t;
typedef struct {
random_source_t random;
scheduler_t scheduler;
bot_cell_t knowledge[kBotKnowledgeCellCount];
bool turn_pending;
} bot_player_t;
void bot_player_init(bot_player_t *bot, random_source_t random, scheduler_t scheduler);
bool bot_player_next_shot(bot_player_t *bot, coordinate_t *coordinate);
void bot_player_record_result(bot_player_t *bot, coordinate_t coordinate, const bot_shot_result_t *result);
bool bot_player_schedule_turn(bot_player_t *bot);
void bot_player_cancel_turn(bot_player_t *bot);
_Static_assert(sizeof(bot_player_t) <= 160, "bot state grew beyond fixed budget");
+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
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include "app_config.h"
#include "game_types.h"
typedef uint8_t command_type_t;
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;
bool command_queue_push(command_queue_t *queue, const app_command_t *command);
bool command_queue_pop(command_queue_t *queue, app_command_t *command);
_Static_assert(sizeof(command_queue_t) <= 320, "command queue grew beyond fixed budget");
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include <stdint.h>
typedef struct {
uint64_t uptime_ms;
uint32_t free_heap_bytes;
uint32_t minimum_free_heap_bytes;
uint32_t largest_free_block_bytes;
uint16_t connected_clients;
uint16_t rejected_oversized_input;
int reset_reason;
} diagnostics_t;
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include "app_interfaces.h"
#include "game_types.h"
enum {
kFleetPlacementAttempts = 128,
kFleetGenerationRestarts = 64,
};
typedef struct { random_source_t random; } fleet_generator_t;
bool fleet_generator_generate(const fleet_generator_t *generator, board_t *board);
bool fleet_generator_validate(const board_t *board);
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include <stdbool.h>
#include "app_interfaces.h"
#include "fleet_generator.h"
#include "game_types.h"
typedef struct { game_state_t state; } game_engine_t;
typedef uint8_t game_result_t;
enum {
GAME_RESULT_OK,
GAME_RESULT_INVALID_COORDINATE,
GAME_RESULT_WRONG_PHASE,
GAME_RESULT_NOT_YOUR_TURN,
GAME_RESULT_CELL_ALREADY_SHOT,
GAME_RESULT_GENERATION_FAILED,
};
typedef struct { bool hit; bool sunk; bool finished; } shot_result_t;
void game_engine_init(game_engine_t *engine);
game_result_t game_engine_start(game_engine_t *engine, uint32_t game_id, game_mode_t mode,
const fleet_generator_t *generator);
game_result_t game_engine_shot(game_engine_t *engine, uint8_t player, coordinate_t coordinate,
shot_result_t *result);
+63
View File
@@ -0,0 +1,63 @@
#pragma once
#include "fleet_generator.h"
#include "bot_player.h"
#include "game_engine.h"
#include "session_manager.h"
#include "statistics.h"
typedef uint8_t lifecycle_result_t;
enum {
LIFECYCLE_RESULT_OK,
LIFECYCLE_RESULT_INVALID_NAME,
LIFECYCLE_RESULT_INVALID_ROLE,
LIFECYCLE_RESULT_INVALID_MODE,
LIFECYCLE_RESULT_UNAUTHORIZED,
LIFECYCLE_RESULT_NO_PLAYER_SLOT,
LIFECYCLE_RESULT_NO_SPECTATOR_SLOT,
LIFECYCLE_RESULT_FORBIDDEN_ROLE,
LIFECYCLE_RESULT_WRONG_PHASE,
LIFECYCLE_RESULT_NOT_YOUR_TURN,
LIFECYCLE_RESULT_CELL_ALREADY_SHOT,
LIFECYCLE_RESULT_INVALID_COORDINATE,
LIFECYCLE_RESULT_STALE_GAME,
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;
cumulative_statistics_t cumulative[kPlayerCapacity];
random_source_t random;
bot_player_t bot;
scheduler_t bot_scheduler;
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);
void game_lifecycle_set_bot_scheduler(game_lifecycle_t *lifecycle, scheduler_t scheduler);
lifecycle_result_t game_lifecycle_join(game_lifecycle_t *lifecycle, role_t requested_role, const char *name,
uint8_t *session_index);
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);
lifecycle_result_t game_lifecycle_shot(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id,
coordinate_t coordinate, shot_result_t *result);
lifecycle_result_t game_lifecycle_rematch(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id);
lifecycle_result_t game_lifecycle_abort(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id);
bool game_lifecycle_bot_take_turn(game_lifecycle_t *lifecycle);
_Static_assert(sizeof(game_lifecycle_t) <= 1600, "lifecycle state grew beyond fixed budget");
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include "app_config.h"
typedef uint8_t cell_t;
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, ROLE_AUTO_PLAYER };
typedef uint8_t game_mode_t;
enum { MODE_HUMAN, MODE_BOT };
typedef struct { uint8_t x; uint8_t y; } coordinate_t;
typedef struct { uint8_t x; uint8_t y; uint8_t length; uint8_t hits; bool horizontal; } ship_t;
typedef struct { cell_t cells[kBoardCellCount]; ship_t ships[kFleetShipCount]; uint8_t ships_alive; } board_t;
typedef struct { uint16_t shots; uint16_t hits; uint16_t misses; uint8_t ships_sunk; } match_statistics_t;
typedef struct {
uint32_t game_id;
uint32_t version;
phase_t phase;
game_mode_t mode;
uint8_t current_player;
uint8_t winner;
board_t boards[kPlayerCapacity];
match_statistics_t statistics[kPlayerCapacity];
} game_state_t;
_Static_assert(kBoardCellCount == 100, "board contract changed");
_Static_assert(kFleetShipCount == 10, "fleet contract changed");
_Static_assert(kSessionCapacity == 10, "session contract changed");
_Static_assert(sizeof(coordinate_t) == 2, "coordinate must remain compact");
_Static_assert(sizeof(ship_t) <= 6, "ship grew beyond fixed budget");
_Static_assert(sizeof(board_t) <= 164, "board grew beyond fixed budget");
_Static_assert(sizeof(match_statistics_t) <= 8, "statistics grew beyond fixed budget");
_Static_assert(sizeof(game_state_t) <= 360, "game state grew beyond fixed budget");
+59
View File
@@ -0,0 +1,59 @@
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "application.h"
#include "app_config.h"
typedef enum { HTTP_API_GET, HTTP_API_POST } http_api_method_t;
typedef enum {
HTTP_API_ROUTE_INFO,
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;
typedef struct {
uint32_t uptime_ms;
uint32_t free_heap_bytes;
uint32_t minimum_free_heap_bytes;
uint32_t largest_free_block_bytes;
uint8_t connected_clients;
uint16_t rejected_input;
int8_t reset_reason;
const char *wifi_state;
} http_api_health_t;
typedef struct {
http_api_method_t method;
http_api_route_t route;
bool content_type_json;
bool target_too_large;
const char *body;
size_t body_length;
const char *session_token;
} http_api_request_t;
typedef struct {
uint16_t status;
char body[kStateMessageCapacity];
size_t body_length;
} http_api_response_t;
typedef struct { application_t *application; http_api_health_t health; } http_api_t;
void http_api_init(http_api_t *api, application_t *application);
void http_api_set_health(http_api_t *api, const http_api_health_t *health);
bool http_api_handle(http_api_t *api, const http_api_request_t *request, http_api_response_t *response);
+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
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include "app_interfaces.h"
#include "app_config.h"
#include "game_types.h"
typedef uint8_t session_result_t;
enum {
SESSION_RESULT_OK,
SESSION_RESULT_INVALID_NAME,
SESSION_RESULT_INVALID_ROLE,
SESSION_RESULT_NO_PLAYER_SLOT,
SESSION_RESULT_NO_SPECTATOR_SLOT,
SESSION_RESULT_UNAUTHORIZED,
SESSION_RESULT_WRONG_PHASE,
};
typedef struct {
role_t role;
bool occupied;
bool connected;
uint8_t token[kSessionTokenBytes];
char name[kDisplayNameBytes + 1];
} session_t;
typedef struct { session_t entries[kSessionCapacity]; } session_manager_t;
void session_manager_init(session_manager_t *manager);
session_result_t session_manager_join(session_manager_t *manager, role_t requested_role, const char *name,
random_source_t random, uint8_t *session_index);
session_result_t session_manager_resume(session_manager_t *manager, const uint8_t token[kSessionTokenBytes],
uint8_t *session_index);
bool session_manager_find(const session_manager_t *manager, const uint8_t token[kSessionTokenBytes],
uint8_t *session_index);
bool session_manager_disconnect(session_manager_t *manager, uint8_t session_index);
session_result_t session_manager_leave(session_manager_t *manager, uint8_t session_index, phase_t phase);
bool session_manager_player_present(const session_manager_t *manager, uint8_t player_index);
_Static_assert(sizeof(session_t) <= 104, "session grew beyond fixed budget");
_Static_assert(sizeof(session_manager_t) <= 1040, "session table grew beyond fixed budget");
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include <stddef.h>
#include "game_lifecycle.h"
#include "game_types.h"
bool state_presenter_write(const game_state_t *state, role_t viewer, char *output, size_t output_size, size_t *written);
bool state_presenter_write_lifecycle(const game_lifecycle_t *lifecycle, role_t viewer,
char *output, size_t output_size, size_t *written);
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include <stdint.h>
#include "game_types.h"
typedef struct {
uint16_t games;
uint16_t wins;
uint16_t losses;
uint16_t ships_sunk;
uint32_t shots;
uint32_t hits;
uint32_t misses;
} cumulative_statistics_t;
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "application.h"
enum {
kWebSocketFrameCapacity = 192,
kWebSocketHelloTimeoutMs = 5000,
};
typedef struct {
int client_id;
uint8_t session_index;
bool active;
bool authenticated;
uint64_t opened_ms;
} sync_connection_t;
typedef struct { application_t *application; sync_connection_t connections[kSessionCapacity]; } sync_service_t;
typedef bool (*sync_send_fn)(void *context, int client_id, const char *payload, size_t length);
void sync_service_init(sync_service_t *service, application_t *application);
bool sync_service_open(sync_service_t *service, int client_id, uint64_t now_ms);
void sync_service_close(sync_service_t *service, int client_id);
bool sync_service_receive(sync_service_t *service, int client_id, const char *frame, size_t frame_length,
uint64_t now_ms, char output[kStateMessageCapacity], size_t *output_length,
bool *state_changed, bool *close_client);
void sync_service_expire(sync_service_t *service, uint64_t now_ms, int closed_clients[kSessionCapacity],
size_t *closed_count);
void sync_service_broadcast(sync_service_t *service, sync_send_fn send, void *context);
+5
View File
@@ -0,0 +1,5 @@
#pragma once
#include "app_interfaces.h"
typedef struct { transport_t transport; } transport_service_t;
+6
View File
@@ -0,0 +1,6 @@
#pragma once
// Copy this file to include/wifi_config.h and set the local home-network
// credentials. The copied file is ignored by Git and must never be committed.
#define WIFI_CONFIG_SSID "replace-with-network-name"
#define WIFI_CONFIG_PASSWORD "replace-with-network-password"
+4
View File
@@ -13,5 +13,9 @@ platform = espressif32 @ 7.0.1
board = esp32-c6-devkitm-1
framework = espidf
board_build.partitions = partitions.csv
board_build.filesystem = littlefs
board_build.sdkconfig_defaults = sdkconfig.defaults
lib_deps = https://github.com/joltwallet/esp_littlefs.git#v1.20.4
extra_scripts = pre:scripts/compress_web_assets.py
monitor_port = /dev/ttyACM0
monitor_speed = 115200
+66
View File
@@ -0,0 +1,66 @@
Import("env")
import gzip
import re
from pathlib import Path
ROOT = Path(env.subst("$PROJECT_DIR"))
DATA = ROOT / "data"
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):
return re.sub(r">\s+<", "><", source).strip()
def minify_css(source):
source = re.sub(r"/\*.*?\*/", "", source, flags=re.S)
return re.sub(r"\s*([{}:;,>])\s*", r"\1", source).strip()
def minify_js(source):
output = []
quote = ""
escaped = False
pending_space = False
for character in source:
if quote:
output.append(character)
if escaped:
escaped = False
elif character == "\\":
escaped = True
elif character == quote:
quote = ""
continue
if character in ("'", '"', "`"):
if pending_space and output and (output[-1].isalnum() or output[-1] in "_$"):
output.append(" ")
pending_space = False
quote = character
output.append(character)
elif character.isspace():
pending_space = True
else:
if pending_space and output and (output[-1].isalnum() or output[-1] in "_$") and (character.isalnum() or character in "_$"):
output.append(" ")
pending_space = False
output.append(character)
return "".join(output)
def compress_assets():
for asset in ASSETS:
path = DATA / asset
text = path.read_text(encoding="utf-8")
if asset.endswith(".html"):
text = minify_html(text)
elif asset.endswith(".css"):
text = minify_css(text)
elif asset.endswith(".js"):
text = minify_js(text)
(DATA / f"{asset}.gz").write_bytes(gzip.compress(text.encode("utf-8"), mtime=0))
compress_assets()
+1
View File
@@ -0,0 +1 @@
CONFIG_LWIP_MAX_SOCKETS=16
+5 -3
View File
@@ -1,6 +1,8 @@
# This file was automatically generated for projects
# without default 'CMakeLists.txt' file.
FILE(GLOB_RECURSE app_sources ${CMAKE_SOURCE_DIR}/src/*.*)
idf_component_register(SRCS ${app_sources})
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" "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
)
+80
View File
@@ -0,0 +1,80 @@
#include "application.h"
#include <stddef.h>
void application_init(application_t *application, random_source_t random) {
if (application == NULL) return;
application->command_queue = (command_queue_t){0};
game_lifecycle_init(&application->lifecycle, random);
}
void application_set_bot_scheduler(application_t *application, scheduler_t scheduler) {
if (application != NULL) game_lifecycle_set_bot_scheduler(&application->lifecycle, scheduler);
}
bool application_enqueue(application_t *application, const app_command_t *command) {
return application != NULL && command_queue_push(&application->command_queue, command);
}
bool application_take_next_command(application_t *application, app_command_t *command) {
return application != NULL && command_queue_pop(&application->command_queue, command);
}
lifecycle_result_t application_join(application_t *application, role_t requested_role, const char *name,
uint8_t *session_index) {
return application == NULL ? LIFECYCLE_RESULT_UNAUTHORIZED :
game_lifecycle_join(&application->lifecycle, requested_role, name, session_index);
}
lifecycle_result_t application_resume(application_t *application,
const uint8_t token[kSessionTokenBytes], uint8_t *session_index) {
return application == NULL ? LIFECYCLE_RESULT_UNAUTHORIZED :
game_lifecycle_resume(&application->lifecycle, token, session_index);
}
bool application_session_for_token(const application_t *application,
const uint8_t token[kSessionTokenBytes], uint8_t *session_index) {
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};
if (!application_take_next_command(application, &next)) return LIFECYCLE_RESULT_GENERATION_FAILED;
switch (next.type) {
case COMMAND_CONFIG:
return game_lifecycle_configure(&application->lifecycle, next.session_index, next.game_id, next.mode);
case COMMAND_START:
return game_lifecycle_start(&application->lifecycle, next.session_index, next.game_id);
case COMMAND_SHOT:
return game_lifecycle_shot(&application->lifecycle, next.session_index, next.game_id, next.coordinate, NULL);
case COMMAND_REMATCH:
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;
}
}
bool application_bot_take_turn(application_t *application) {
return application != NULL && game_lifecycle_bot_take_turn(&application->lifecycle);
}
+182
View File
@@ -0,0 +1,182 @@
#include "bot_player.h"
#include <stddef.h>
#include <string.h>
static uint8_t cell_index(uint8_t x, uint8_t y) {
return (uint8_t)(y * kBoardWidth + x);
}
static bool coordinate_is_valid(coordinate_t coordinate) {
return coordinate.x < kBoardWidth && coordinate.y < kBoardHeight;
}
static uint32_t random_u32(bot_player_t *bot) {
if (bot->random.next_u32 == NULL) return 0U;
return bot->random.next_u32(bot->random.context);
}
static bool choose_unknown(const bot_player_t *bot, uint8_t start, bool checkerboard,
coordinate_t *coordinate) {
for (uint8_t offset = 0; offset < kBotKnowledgeCellCount; ++offset) {
const uint8_t index = (uint8_t)((start + offset) % kBotKnowledgeCellCount);
const uint8_t x = (uint8_t)(index % kBoardWidth);
const uint8_t y = (uint8_t)(index / kBoardWidth);
if (bot->knowledge[index] != BOT_CELL_UNKNOWN) continue;
if (checkerboard && ((x + y) & 1U) != 0U) continue;
*coordinate = (coordinate_t){x, y};
return true;
}
return false;
}
static uint8_t collect_hit_group(const bot_player_t *bot, uint8_t first, uint8_t *group,
uint8_t *min_x, uint8_t *max_x, uint8_t *min_y, uint8_t *max_y) {
uint8_t queue[kBotKnowledgeCellCount] = {0};
uint8_t count = 0;
uint8_t read = 0;
queue[count++] = first;
group[0] = first;
*min_x = *max_x = (uint8_t)(first % kBoardWidth);
*min_y = *max_y = (uint8_t)(first / kBoardWidth);
while (read < count) {
const uint8_t index = queue[read++];
const uint8_t x = (uint8_t)(index % kBoardWidth);
const uint8_t y = (uint8_t)(index / kBoardWidth);
const int8_t directions[4][2] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
for (uint8_t direction = 0; direction < 4; ++direction) {
const int16_t neighbour_x = (int16_t)x + directions[direction][0];
const int16_t neighbour_y = (int16_t)y + directions[direction][1];
if (neighbour_x < 0 || neighbour_y < 0 || neighbour_x >= kBoardWidth || neighbour_y >= kBoardHeight) continue;
const uint8_t neighbour = cell_index((uint8_t)neighbour_x, (uint8_t)neighbour_y);
if (bot->knowledge[neighbour] != BOT_CELL_HIT) continue;
bool already_present = false;
for (uint8_t item = 0; item < count; ++item) {
if (queue[item] == neighbour) {
already_present = true;
break;
}
}
if (already_present) continue;
queue[count] = neighbour;
group[count++] = neighbour;
if ((uint8_t)neighbour_x < *min_x) *min_x = (uint8_t)neighbour_x;
if ((uint8_t)neighbour_x > *max_x) *max_x = (uint8_t)neighbour_x;
if ((uint8_t)neighbour_y < *min_y) *min_y = (uint8_t)neighbour_y;
if ((uint8_t)neighbour_y > *max_y) *max_y = (uint8_t)neighbour_y;
}
}
return count;
}
static bool choose_target(bot_player_t *bot, coordinate_t *coordinate) {
uint8_t first = kBotKnowledgeCellCount;
for (uint8_t index = 0; index < kBotKnowledgeCellCount; ++index) {
if (bot->knowledge[index] == BOT_CELL_HIT) {
first = index;
break;
}
}
if (first == kBotKnowledgeCellCount) return false;
uint8_t group[kBotKnowledgeCellCount] = {0};
uint8_t min_x = 0;
uint8_t max_x = 0;
uint8_t min_y = 0;
uint8_t max_y = 0;
const uint8_t count = collect_hit_group(bot, first, group, &min_x, &max_x, &min_y, &max_y);
if (count >= 2U && min_y == max_y) {
const coordinate_t ends[2] = {{min_x == 0 ? kBoardWidth : (uint8_t)(min_x - 1U), min_y},
{max_x + 1U, min_y}};
const uint8_t first_end = (uint8_t)(random_u32(bot) & 1U);
for (uint8_t offset = 0; offset < 2; ++offset) {
const coordinate_t candidate = ends[(first_end + offset) & 1U];
if (coordinate_is_valid(candidate) && bot->knowledge[cell_index(candidate.x, candidate.y)] == BOT_CELL_UNKNOWN) {
*coordinate = candidate;
return true;
}
}
return false;
}
if (count >= 2U && min_x == max_x) {
const coordinate_t ends[2] = {{min_x, min_y == 0 ? kBoardHeight : (uint8_t)(min_y - 1U)},
{min_x, max_y + 1U}};
const uint8_t first_end = (uint8_t)(random_u32(bot) & 1U);
for (uint8_t offset = 0; offset < 2; ++offset) {
const coordinate_t candidate = ends[(first_end + offset) & 1U];
if (coordinate_is_valid(candidate) && bot->knowledge[cell_index(candidate.x, candidate.y)] == BOT_CELL_UNKNOWN) {
*coordinate = candidate;
return true;
}
}
return false;
}
const uint8_t hit_x = (uint8_t)(first % kBoardWidth);
const uint8_t hit_y = (uint8_t)(first / kBoardWidth);
const int8_t directions[4][2] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
const uint8_t first_direction = (uint8_t)(random_u32(bot) % 4U);
for (uint8_t offset = 0; offset < 4; ++offset) {
const int8_t *direction = directions[(first_direction + offset) % 4U];
const int16_t x = (int16_t)hit_x + direction[0];
const int16_t y = (int16_t)hit_y + direction[1];
if (x < 0 || y < 0 || x >= kBoardWidth || y >= kBoardHeight) continue;
if (bot->knowledge[cell_index((uint8_t)x, (uint8_t)y)] == BOT_CELL_UNKNOWN) {
*coordinate = (coordinate_t){(uint8_t)x, (uint8_t)y};
return true;
}
}
return false;
}
void bot_player_init(bot_player_t *bot, random_source_t random, scheduler_t scheduler) {
if (bot == NULL) return;
memset(bot, 0, sizeof(*bot));
bot->random = random;
bot->scheduler = scheduler;
}
bool bot_player_next_shot(bot_player_t *bot, coordinate_t *coordinate) {
if (bot == NULL || coordinate == NULL) return false;
bot->turn_pending = false;
if (choose_target(bot, coordinate)) return true;
const uint8_t start = (uint8_t)(random_u32(bot) % kBotKnowledgeCellCount);
return choose_unknown(bot, start, true, coordinate) || choose_unknown(bot, start, false, coordinate);
}
void bot_player_record_result(bot_player_t *bot, coordinate_t coordinate, const bot_shot_result_t *result) {
if (bot == NULL || result == NULL || !coordinate_is_valid(coordinate)) return;
const uint8_t index = cell_index(coordinate.x, coordinate.y);
bot->knowledge[index] = result->hit ? BOT_CELL_HIT : BOT_CELL_MISS;
if (!result->sunk || !result->hit) return;
uint8_t group[kBotKnowledgeCellCount] = {0};
uint8_t min_x = 0;
uint8_t max_x = 0;
uint8_t min_y = 0;
uint8_t max_y = 0;
const uint8_t count = collect_hit_group(bot, index, group, &min_x, &max_x, &min_y, &max_y);
for (uint8_t item = 0; item < count; ++item) bot->knowledge[group[item]] = BOT_CELL_BLOCKED;
const uint8_t from_x = min_x == 0 ? 0 : (uint8_t)(min_x - 1U);
const uint8_t from_y = min_y == 0 ? 0 : (uint8_t)(min_y - 1U);
const uint8_t to_x = max_x + 1U >= kBoardWidth ? (uint8_t)(kBoardWidth - 1U) : (uint8_t)(max_x + 1U);
const uint8_t to_y = max_y + 1U >= kBoardHeight ? (uint8_t)(kBoardHeight - 1U) : (uint8_t)(max_y + 1U);
for (uint8_t y = from_y; y <= to_y; ++y) {
for (uint8_t x = from_x; x <= to_x; ++x) {
const uint8_t neighbour = cell_index(x, y);
if (bot->knowledge[neighbour] == BOT_CELL_UNKNOWN) bot->knowledge[neighbour] = BOT_CELL_BLOCKED;
}
}
}
bool bot_player_schedule_turn(bot_player_t *bot) {
if (bot == NULL || bot->turn_pending || bot->scheduler.schedule_after_ms == NULL) return false;
const uint32_t delay_ms = 500U + (random_u32(bot) % 401U);
if (!bot->scheduler.schedule_after_ms(bot->scheduler.context, delay_ms)) return false;
bot->turn_pending = true;
return true;
}
void bot_player_cancel_turn(bot_player_t *bot) {
if (bot != NULL) bot->turn_pending = false;
}
+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;
}
+19
View File
@@ -0,0 +1,19 @@
#include "command_queue.h"
#include <stddef.h>
bool command_queue_push(command_queue_t *queue, const app_command_t *command) {
if (queue == NULL || command == NULL || queue->count == kCommandQueueCapacity) return false;
queue->entries[queue->tail] = *command;
queue->tail = (uint8_t)((queue->tail + 1U) % kCommandQueueCapacity);
++queue->count;
return true;
}
bool command_queue_pop(command_queue_t *queue, app_command_t *command) {
if (queue == NULL || command == NULL || queue->count == 0) return false;
*command = queue->entries[queue->head];
queue->head = (uint8_t)((queue->head + 1U) % kCommandQueueCapacity);
--queue->count;
return true;
}
+119
View File
@@ -0,0 +1,119 @@
#include "fleet_generator.h"
#include <stddef.h>
#include <string.h>
static const uint8_t kFleetLengths[kFleetShipCount] = {4, 3, 3, 2, 2, 2, 1, 1, 1, 1};
static bool coordinate_is_valid(uint8_t x, uint8_t y) {
return x < kBoardWidth && y < kBoardHeight;
}
static uint8_t cell_index(uint8_t x, uint8_t y) {
return (uint8_t)(y * kBoardWidth + x);
}
static bool random_u32(const fleet_generator_t *generator, uint32_t *value) {
if (generator == NULL || generator->random.next_u32 == NULL || value == NULL) return false;
*value = generator->random.next_u32(generator->random.context);
return true;
}
static bool placement_fits(const board_t *board, uint8_t x, uint8_t y, uint8_t length,
bool horizontal) {
const uint8_t end_x = horizontal ? (uint8_t)(x + length - 1U) : x;
const uint8_t end_y = horizontal ? y : (uint8_t)(y + length - 1U);
if (!coordinate_is_valid(end_x, end_y)) return false;
const uint8_t min_x = x == 0 ? 0 : (uint8_t)(x - 1U);
const uint8_t min_y = y == 0 ? 0 : (uint8_t)(y - 1U);
const uint8_t max_x = end_x + 1U >= kBoardWidth ? (uint8_t)(kBoardWidth - 1U) : (uint8_t)(end_x + 1U);
const uint8_t max_y = end_y + 1U >= kBoardHeight ? (uint8_t)(kBoardHeight - 1U) : (uint8_t)(end_y + 1U);
for (uint8_t check_y = min_y; check_y <= max_y; ++check_y) {
for (uint8_t check_x = min_x; check_x <= max_x; ++check_x) {
if (board->cells[cell_index(check_x, check_y)] == CELL_SHIP) return false;
}
}
return true;
}
static void place_ship(board_t *board, uint8_t ship_index, uint8_t x, uint8_t y, uint8_t length,
bool horizontal) {
ship_t *ship = &board->ships[ship_index];
*ship = (ship_t){.x = x, .y = y, .length = length, .hits = 0, .horizontal = horizontal};
for (uint8_t offset = 0; offset < length; ++offset) {
const uint8_t cell_x = horizontal ? (uint8_t)(x + offset) : x;
const uint8_t cell_y = horizontal ? y : (uint8_t)(y + offset);
board->cells[cell_index(cell_x, cell_y)] = CELL_SHIP;
}
}
bool fleet_generator_generate(const fleet_generator_t *generator, board_t *board) {
if (board == NULL) return false;
for (uint8_t restart = 0; restart < kFleetGenerationRestarts; ++restart) {
board_t candidate = {0};
memset(candidate.cells, CELL_WATER, sizeof(candidate.cells));
bool complete = true;
for (uint8_t ship_index = 0; ship_index < kFleetShipCount; ++ship_index) {
bool placed = false;
for (uint16_t attempt = 0; attempt < kFleetPlacementAttempts; ++attempt) {
uint32_t value = 0;
if (!random_u32(generator, &value)) return false;
const bool horizontal = (value & 1U) != 0U;
const uint8_t x = (uint8_t)((value >> 1U) % kBoardWidth);
const uint8_t y = (uint8_t)((value >> 5U) % kBoardHeight);
const uint8_t length = kFleetLengths[ship_index];
if (!placement_fits(&candidate, x, y, length, horizontal)) continue;
place_ship(&candidate, ship_index, x, y, length, horizontal);
placed = true;
break;
}
if (!placed) {
complete = false;
break;
}
}
candidate.ships_alive = complete ? kFleetShipCount : 0U;
if (complete && fleet_generator_validate(&candidate)) {
*board = candidate;
return true;
}
}
return false;
}
bool fleet_generator_validate(const board_t *board) {
if (board == NULL || board->ships_alive != kFleetShipCount) return false;
uint8_t expected_cells[kBoardCellCount] = {0};
for (uint8_t ship_index = 0; ship_index < kFleetShipCount; ++ship_index) {
const ship_t *ship = &board->ships[ship_index];
if (ship->length != kFleetLengths[ship_index] || ship->hits > ship->length) return false;
const uint8_t end_x = ship->horizontal ? (uint8_t)(ship->x + ship->length - 1U) : ship->x;
const uint8_t end_y = ship->horizontal ? ship->y : (uint8_t)(ship->y + ship->length - 1U);
if (!coordinate_is_valid(ship->x, ship->y) || !coordinate_is_valid(end_x, end_y)) return false;
for (uint8_t offset = 0; offset < ship->length; ++offset) {
const uint8_t x = ship->horizontal ? (uint8_t)(ship->x + offset) : ship->x;
const uint8_t y = ship->horizontal ? ship->y : (uint8_t)(ship->y + offset);
const uint8_t index = cell_index(x, y);
if (expected_cells[index] != 0U || board->cells[index] != CELL_SHIP) return false;
expected_cells[index] = (uint8_t)(ship_index + 1U);
}
}
for (uint8_t y = 0; y < kBoardHeight; ++y) {
for (uint8_t x = 0; x < kBoardWidth; ++x) {
const uint8_t index = cell_index(x, y);
if ((board->cells[index] == CELL_SHIP) != (expected_cells[index] != 0U)) return false;
if (board->cells[index] != CELL_SHIP) continue;
for (int8_t delta_y = -1; delta_y <= 1; ++delta_y) {
for (int8_t delta_x = -1; delta_x <= 1; ++delta_x) {
const int16_t neighbour_x = (int16_t)x + delta_x;
const int16_t neighbour_y = (int16_t)y + delta_y;
if (neighbour_x < 0 || neighbour_y < 0 || neighbour_x >= kBoardWidth || neighbour_y >= kBoardHeight) continue;
const uint8_t neighbour = cell_index((uint8_t)neighbour_x, (uint8_t)neighbour_y);
if (board->cells[neighbour] == CELL_SHIP && expected_cells[index] != expected_cells[neighbour]) return false;
}
}
}
}
return true;
}
+111
View File
@@ -0,0 +1,111 @@
#include "game_engine.h"
#include <stddef.h>
#include <string.h>
static uint8_t cell_index(coordinate_t coordinate) {
return (uint8_t)(coordinate.y * kBoardWidth + coordinate.x);
}
static bool coordinate_is_valid(coordinate_t coordinate) {
return coordinate.x < kBoardWidth && coordinate.y < kBoardHeight;
}
static ship_t *ship_at(board_t *board, coordinate_t coordinate) {
for (uint8_t index = 0; index < kFleetShipCount; ++index) {
ship_t *ship = &board->ships[index];
for (uint8_t offset = 0; offset < ship->length; ++offset) {
const uint8_t x = ship->horizontal ? (uint8_t)(ship->x + offset) : ship->x;
const uint8_t y = ship->horizontal ? ship->y : (uint8_t)(ship->y + offset);
if (x == coordinate.x && y == coordinate.y) return ship;
}
}
return NULL;
}
static void mark_sunk_ship_border(board_t *board, const ship_t *ship) {
const uint8_t end_x = ship->horizontal ? (uint8_t)(ship->x + ship->length - 1U) : ship->x;
const uint8_t end_y = ship->horizontal ? ship->y : (uint8_t)(ship->y + ship->length - 1U);
const uint8_t min_x = ship->x == 0 ? 0 : (uint8_t)(ship->x - 1U);
const uint8_t min_y = ship->y == 0 ? 0 : (uint8_t)(ship->y - 1U);
const uint8_t max_x = end_x + 1U >= kBoardWidth ? (uint8_t)(kBoardWidth - 1U) : (uint8_t)(end_x + 1U);
const uint8_t max_y = end_y + 1U >= kBoardHeight ? (uint8_t)(kBoardHeight - 1U) : (uint8_t)(end_y + 1U);
for (uint8_t y = min_y; y <= max_y; ++y) {
for (uint8_t x = min_x; x <= max_x; ++x) {
const uint8_t index = (uint8_t)(y * kBoardWidth + x);
if (board->cells[index] == CELL_WATER) board->cells[index] = CELL_MISS;
}
}
}
void game_engine_init(game_engine_t *engine) {
if (engine == NULL) return;
memset(engine, 0, sizeof(*engine));
engine->state.phase = PHASE_LOBBY;
engine->state.winner = kPlayerCapacity;
}
game_result_t game_engine_start(game_engine_t *engine, uint32_t game_id, game_mode_t mode,
const fleet_generator_t *generator) {
if (engine == NULL || generator == NULL) return GAME_RESULT_GENERATION_FAILED;
if (engine->state.phase != PHASE_LOBBY) return GAME_RESULT_WRONG_PHASE;
if (mode != MODE_HUMAN && mode != MODE_BOT) return GAME_RESULT_WRONG_PHASE;
board_t generated[kPlayerCapacity] = {0};
for (uint8_t player = 0; player < kPlayerCapacity; ++player) {
if (!fleet_generator_generate(generator, &generated[player])) return GAME_RESULT_GENERATION_FAILED;
}
if (generator->random.next_u32 == NULL) return GAME_RESULT_GENERATION_FAILED;
const uint8_t first_player = (uint8_t)(generator->random.next_u32(generator->random.context) % kPlayerCapacity);
const uint32_t previous_version = engine->state.version;
memset(&engine->state, 0, sizeof(engine->state));
engine->state.game_id = game_id;
engine->state.version = previous_version + 1U;
engine->state.phase = PHASE_IN_PROGRESS;
engine->state.mode = mode;
engine->state.current_player = first_player;
engine->state.winner = kPlayerCapacity;
memcpy(engine->state.boards, generated, sizeof(generated));
return GAME_RESULT_OK;
}
game_result_t game_engine_shot(game_engine_t *engine, uint8_t player, coordinate_t coordinate,
shot_result_t *result) {
if (engine == NULL || !coordinate_is_valid(coordinate)) return GAME_RESULT_INVALID_COORDINATE;
if (engine->state.phase != PHASE_IN_PROGRESS) return GAME_RESULT_WRONG_PHASE;
if (player >= kPlayerCapacity || player != engine->state.current_player) return GAME_RESULT_NOT_YOUR_TURN;
board_t *target = &engine->state.boards[player ^ 1U];
cell_t *cell = &target->cells[cell_index(coordinate)];
if (*cell == CELL_MISS || *cell == CELL_HIT) return GAME_RESULT_CELL_ALREADY_SHOT;
shot_result_t accepted = {0};
match_statistics_t *statistics = &engine->state.statistics[player];
++statistics->shots;
if (*cell == CELL_SHIP) {
*cell = CELL_HIT;
++statistics->hits;
accepted.hit = true;
ship_t *ship = ship_at(target, coordinate);
if (ship != NULL) {
++ship->hits;
if (ship->hits == ship->length) {
--target->ships_alive;
++statistics->ships_sunk;
accepted.sunk = true;
mark_sunk_ship_border(target, ship);
}
}
if (target->ships_alive == 0U) {
engine->state.phase = PHASE_FINISHED;
engine->state.winner = player;
accepted.finished = true;
}
} else {
*cell = CELL_MISS;
++statistics->misses;
engine->state.current_player ^= 1U;
}
++engine->state.version;
if (result != NULL) *result = accepted;
return GAME_RESULT_OK;
}
+249
View File
@@ -0,0 +1,249 @@
#include "game_lifecycle.h"
#include <stddef.h>
#include <string.h>
static lifecycle_result_t session_result(session_result_t result) {
switch (result) {
case SESSION_RESULT_OK: return LIFECYCLE_RESULT_OK;
case SESSION_RESULT_INVALID_NAME: return LIFECYCLE_RESULT_INVALID_NAME;
case SESSION_RESULT_INVALID_ROLE: return LIFECYCLE_RESULT_INVALID_ROLE;
case SESSION_RESULT_NO_PLAYER_SLOT: return LIFECYCLE_RESULT_NO_PLAYER_SLOT;
case SESSION_RESULT_NO_SPECTATOR_SLOT: return LIFECYCLE_RESULT_NO_SPECTATOR_SLOT;
case SESSION_RESULT_WRONG_PHASE: return LIFECYCLE_RESULT_WRONG_PHASE;
default: return LIFECYCLE_RESULT_UNAUTHORIZED;
}
}
static lifecycle_result_t game_result(game_result_t result) {
switch (result) {
case GAME_RESULT_OK: return LIFECYCLE_RESULT_OK;
case GAME_RESULT_INVALID_COORDINATE: return LIFECYCLE_RESULT_INVALID_COORDINATE;
case GAME_RESULT_WRONG_PHASE: return LIFECYCLE_RESULT_WRONG_PHASE;
case GAME_RESULT_NOT_YOUR_TURN: return LIFECYCLE_RESULT_NOT_YOUR_TURN;
case GAME_RESULT_CELL_ALREADY_SHOT: return LIFECYCLE_RESULT_CELL_ALREADY_SHOT;
default: return LIFECYCLE_RESULT_GENERATION_FAILED;
}
}
static bool is_player(const game_lifecycle_t *lifecycle, uint8_t session_index, uint8_t *player) {
if (lifecycle == NULL || session_index >= kPlayerCapacity || !lifecycle->sessions.entries[session_index].occupied) return false;
if (lifecycle->sessions.entries[session_index].role != session_index) return false;
if (player != NULL) *player = session_index;
return true;
}
static bool game_id_matches(const game_lifecycle_t *lifecycle, uint32_t game_id) {
return lifecycle != NULL && lifecycle->game.state.game_id == game_id;
}
static void record_cumulative(game_lifecycle_t *lifecycle) {
for (uint8_t player = 0; player < kPlayerCapacity; ++player) {
cumulative_statistics_t *total = &lifecycle->cumulative[player];
const match_statistics_t *match = &lifecycle->game.state.statistics[player];
++total->games;
total->shots += match->shots;
total->hits += match->hits;
total->misses += match->misses;
total->ships_sunk += match->ships_sunk;
if (lifecycle->game.state.winner == player) ++total->wins;
else ++total->losses;
}
}
static lifecycle_result_t start_current_game(game_lifecycle_t *lifecycle) {
const uint32_t game_id = lifecycle->game.state.game_id;
const game_mode_t mode = lifecycle->game.state.mode;
const uint32_t version = lifecycle->game.state.version;
game_engine_init(&lifecycle->game);
lifecycle->game.state.game_id = game_id;
lifecycle->game.state.version = version;
const lifecycle_result_t result = game_result(game_engine_start(&lifecycle->game, game_id, mode,
&(fleet_generator_t){.random = lifecycle->random}));
if (result != LIFECYCLE_RESULT_OK || mode != MODE_BOT) return result;
bot_player_init(&lifecycle->bot, lifecycle->random, lifecycle->bot_scheduler);
if (lifecycle->game.state.current_player == 1U && lifecycle->bot_scheduler.schedule_after_ms != NULL) {
(void)bot_player_schedule_turn(&lifecycle->bot);
}
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);
}
void game_lifecycle_set_bot_scheduler(game_lifecycle_t *lifecycle, scheduler_t scheduler) {
if (lifecycle == NULL) return;
lifecycle->bot_scheduler = scheduler;
}
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 || 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 || requested_role == ROLE_AUTO_PLAYER) && (lifecycle->game.state.mode == MODE_BOT || lifecycle->bot_reserved)) {
return LIFECYCLE_RESULT_NO_PLAYER_SLOT;
}
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,
const uint8_t token[kSessionTokenBytes], uint8_t *session_index) {
return lifecycle == NULL ? LIFECYCLE_RESULT_UNAUTHORIZED :
session_result(session_manager_resume(&lifecycle->sessions, token, session_index));
}
lifecycle_result_t game_lifecycle_disconnect(game_lifecycle_t *lifecycle, uint8_t session_index) {
return lifecycle != NULL && session_manager_disconnect(&lifecycle->sessions, session_index) ?
LIFECYCLE_RESULT_OK : LIFECYCLE_RESULT_UNAUTHORIZED;
}
lifecycle_result_t game_lifecycle_leave(game_lifecycle_t *lifecycle, uint8_t session_index) {
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,
uint32_t game_id, game_mode_t mode) {
uint8_t player = 0;
if (!is_player(lifecycle, session_index, &player)) return LIFECYCLE_RESULT_FORBIDDEN_ROLE;
if (player != 0U) return LIFECYCLE_RESULT_FORBIDDEN_ROLE;
if (!game_id_matches(lifecycle, game_id)) return LIFECYCLE_RESULT_STALE_GAME;
if (lifecycle->game.state.phase != PHASE_LOBBY) return LIFECYCLE_RESULT_WRONG_PHASE;
if (mode != MODE_HUMAN && mode != MODE_BOT) return LIFECYCLE_RESULT_INVALID_MODE;
if (mode == MODE_BOT && session_manager_player_present(&lifecycle->sessions, 1U)) return LIFECYCLE_RESULT_NO_PLAYER_SLOT;
lifecycle->game.state.mode = mode;
lifecycle->bot_reserved = mode == MODE_BOT;
++lifecycle->game.state.version;
return LIFECYCLE_RESULT_OK;
}
lifecycle_result_t game_lifecycle_start(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 (player != 0U) return LIFECYCLE_RESULT_FORBIDDEN_ROLE;
if (!game_id_matches(lifecycle, game_id)) return LIFECYCLE_RESULT_STALE_GAME;
if (lifecycle->game.state.phase != PHASE_LOBBY) return LIFECYCLE_RESULT_WRONG_PHASE;
if (lifecycle->game.state.mode == MODE_HUMAN && !session_manager_player_present(&lifecycle->sessions, 1U)) return LIFECYCLE_RESULT_WRONG_PHASE;
if (lifecycle->game.state.mode == MODE_BOT && !lifecycle->bot_reserved) return LIFECYCLE_RESULT_WRONG_PHASE;
return start_current_game(lifecycle);
}
lifecycle_result_t game_lifecycle_shot(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id,
coordinate_t coordinate, shot_result_t *result) {
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 lifecycle_result_t outcome = game_result(game_engine_shot(&lifecycle->game, player, coordinate, result));
if (outcome == LIFECYCLE_RESULT_OK && lifecycle->game.state.phase == PHASE_FINISHED) record_cumulative(lifecycle);
else if (outcome == LIFECYCLE_RESULT_OK && lifecycle->game.state.mode == MODE_BOT && lifecycle->game.state.current_player == 1U &&
lifecycle->bot_scheduler.schedule_after_ms != NULL) (void)bot_player_schedule_turn(&lifecycle->bot);
return outcome;
}
lifecycle_result_t game_lifecycle_rematch(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;
if (lifecycle->game.state.phase != PHASE_FINISHED && lifecycle->game.state.phase != PHASE_REMATCH_WAIT) return LIFECYCLE_RESULT_WRONG_PHASE;
lifecycle->rematch_confirmed[player] = true;
const bool ready = lifecycle->game.state.mode == MODE_BOT ? lifecycle->rematch_confirmed[0] :
lifecycle->rematch_confirmed[0] && lifecycle->rematch_confirmed[1];
if (!ready) {
if (lifecycle->game.state.phase == PHASE_FINISHED) {
lifecycle->game.state.phase = PHASE_REMATCH_WAIT;
++lifecycle->game.state.version;
}
return LIFECYCLE_RESULT_OK;
}
lifecycle->rematch_confirmed[0] = false;
lifecycle->rematch_confirmed[1] = false;
lifecycle->game.state.game_id = lifecycle->next_game_id++;
return start_current_game(lifecycle);
}
lifecycle_result_t game_lifecycle_abort(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id) {
uint8_t player = 0;
if (!is_player(lifecycle, session_index, &player) || player != 0U) return LIFECYCLE_RESULT_FORBIDDEN_ROLE;
if (!game_id_matches(lifecycle, game_id)) return LIFECYCLE_RESULT_STALE_GAME;
if (lifecycle->game.state.phase != PHASE_IN_PROGRESS || lifecycle->game.state.mode != MODE_HUMAN ||
lifecycle->sessions.entries[1].connected) return LIFECYCLE_RESULT_WRONG_PHASE;
const uint32_t version = lifecycle->game.state.version + 1U;
const uint32_t next_game_id = lifecycle->next_game_id++;
game_engine_init(&lifecycle->game);
lifecycle->game.state.game_id = next_game_id;
lifecycle->game.state.version = version;
lifecycle->bot_reserved = false;
bot_player_cancel_turn(&lifecycle->bot);
return LIFECYCLE_RESULT_OK;
}
bool game_lifecycle_bot_take_turn(game_lifecycle_t *lifecycle) {
if (lifecycle == NULL || lifecycle->game.state.phase != PHASE_IN_PROGRESS || lifecycle->game.state.mode != MODE_BOT ||
lifecycle->game.state.current_player != 1U) return false;
coordinate_t coordinate = {0};
shot_result_t result = {0};
if (!bot_player_next_shot(&lifecycle->bot, &coordinate) ||
game_engine_shot(&lifecycle->game, 1U, coordinate, &result) != GAME_RESULT_OK) return false;
bot_player_record_result(&lifecycle->bot, coordinate, &(bot_shot_result_t){.hit = result.hit, .sunk = result.sunk});
if (lifecycle->game.state.phase == PHASE_FINISHED) record_cumulative(lifecycle);
else if (lifecycle->game.state.current_player == 1U) (void)bot_player_schedule_turn(&lifecycle->bot);
return true;
}
+434
View File
@@ -0,0 +1,434 @@
#include "http_api.h"
#include <inttypes.h>
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
#include "state_presenter.h"
enum { kErrorMessageBytes = 80, kErrorResponseBytes = 160 };
typedef struct { const char *text; size_t length; } json_reader_t;
static void skip_space(json_reader_t *reader) {
while (reader->length > 0U && (*reader->text == ' ' || *reader->text == '\n' ||
*reader->text == '\r' || *reader->text == '\t')) {
++reader->text;
--reader->length;
}
}
static bool take_character(json_reader_t *reader, char expected) {
skip_space(reader);
if (reader->length == 0U || *reader->text != expected) return false;
++reader->text;
--reader->length;
return true;
}
static bool parse_string(json_reader_t *reader, char *output, size_t output_size) {
if (!take_character(reader, '"') || output_size == 0U) return false;
size_t written = 0;
while (reader->length > 0U && *reader->text != '"') {
unsigned char character = (unsigned char)*reader->text++;
--reader->length;
if (character < 0x20U) return false;
if (character == '\\') {
if (reader->length == 0U) return false;
const char escaped = *reader->text++;
--reader->length;
if (escaped == '"' || escaped == '\\' || escaped == '/') character = (unsigned char)escaped;
else if (escaped == 'b') character = '\b';
else if (escaped == 'f') character = '\f';
else if (escaped == 'n') character = '\n';
else if (escaped == 'r') character = '\r';
else if (escaped == 't') character = '\t';
else return false;
}
if (written + 1U >= output_size) return false;
output[written++] = (char)character;
}
if (reader->length == 0U) return false;
++reader->text;
--reader->length;
output[written] = '\0';
return true;
}
static bool parse_u32(json_reader_t *reader, uint32_t *value) {
skip_space(reader);
if (reader->length == 0U || *reader->text < '0' || *reader->text > '9') return false;
uint32_t parsed = 0;
do {
const uint8_t digit = (uint8_t)(*reader->text - '0');
if (parsed > (UINT32_MAX - digit) / 10U) return false;
parsed = parsed * 10U + digit;
++reader->text;
--reader->length;
} while (reader->length > 0U && *reader->text >= '0' && *reader->text <= '9');
*value = parsed;
return true;
}
static bool parse_object_start(json_reader_t *reader) { return take_character(reader, '{'); }
static bool parse_next_key(json_reader_t *reader, bool *first, char *key, size_t key_size) {
skip_space(reader);
if (!*first && !take_character(reader, ',')) return false;
*first = false;
if (!parse_string(reader, key, key_size)) return false;
return take_character(reader, ':');
}
static bool parse_object_end(json_reader_t *reader) {
if (!take_character(reader, '}')) return false;
skip_space(reader);
return reader->length == 0U;
}
static bool parse_token(const char *text, uint8_t token[kSessionTokenBytes]) {
if (text == NULL || strlen(text) != kSessionTokenBytes * 2U) return false;
for (uint8_t index = 0; index < kSessionTokenBytes; ++index) {
const char high = text[index * 2U];
const char low = text[index * 2U + 1U];
if (high < '0' || (high > '9' && high < 'a') || high > 'f' ||
low < '0' || (low > '9' && low < 'a') || low > 'f') return false;
const uint8_t high_value = (uint8_t)(high <= '9' ? high - '0' : high - 'a' + 10);
const uint8_t low_value = (uint8_t)(low <= '9' ? low - '0' : low - 'a' + 10);
token[index] = (uint8_t)((high_value << 4U) | low_value);
}
return true;
}
static void token_text(const uint8_t token[kSessionTokenBytes], char output[kSessionTokenBytes * 2U + 1U]) {
static const char hex[] = "0123456789abcdef";
for (uint8_t index = 0; index < kSessionTokenBytes; ++index) {
output[index * 2U] = hex[token[index] >> 4U];
output[index * 2U + 1U] = hex[token[index] & 0x0fU];
}
output[kSessionTokenBytes * 2U] = '\0';
}
static const char *role_text(role_t role) {
return role == ROLE_PLAYER_1 ? "player1" : role == ROLE_PLAYER_2 ? "player2" : "spectator";
}
static const char *phase_text(phase_t phase) {
static const char *const values[] = {"lobby", "preparing", "in_progress", "finished", "rematch_wait"};
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);
const int length = vsnprintf(response->body, sizeof(response->body), format, arguments);
va_end(arguments);
response->status = length >= 0 && (size_t)length < sizeof(response->body) ? status : 500U;
response->body_length = response->status == status ? (size_t)length : 0U;
if (response->status != status) response->body[0] = '\0';
}
static void response_error(http_api_response_t *response, uint16_t status, const char *code, const char *message,
uint32_t version) {
response_write(response, status, "{\"ok\":false,\"code\":\"%s\",\"message\":\"%s\",\"version\":%" PRIu32 "}",
code, message, version);
if (response->body_length >= kErrorResponseBytes) response_write(response, 500U, "{\"ok\":false,\"code\":\"SERVER_BUSY\",\"message\":\"Ошибка сервера\",\"version\":0}");
}
static void response_lifecycle_error(http_api_response_t *response, lifecycle_result_t result, uint32_t version) {
switch (result) {
case LIFECYCLE_RESULT_INVALID_NAME: response_error(response, 400U, "INVALID_NAME", "Некорректное имя", version); break;
case LIFECYCLE_RESULT_INVALID_ROLE: response_error(response, 400U, "INVALID_ROLE", "Некорректная роль", version); break;
case LIFECYCLE_RESULT_INVALID_MODE: response_error(response, 400U, "INVALID_MODE", "Некорректный режим", version); break;
case LIFECYCLE_RESULT_INVALID_COORDINATE: response_error(response, 400U, "INVALID_COORDINATE", "Некорректные координаты", version); break;
case LIFECYCLE_RESULT_NO_PLAYER_SLOT: response_error(response, 409U, "NO_PLAYER_SLOT", "Нет места игрока", version); break;
case LIFECYCLE_RESULT_NO_SPECTATOR_SLOT: response_error(response, 409U, "NO_SPECTATOR_SLOT", "Нет места зрителя", version); break;
case LIFECYCLE_RESULT_FORBIDDEN_ROLE: response_error(response, 403U, "FORBIDDEN_ROLE", "Роль не может выполнить действие", version); break;
case LIFECYCLE_RESULT_WRONG_PHASE: response_error(response, 409U, "WRONG_PHASE", "Действие недоступно сейчас", version); break;
case LIFECYCLE_RESULT_NOT_YOUR_TURN: response_error(response, 409U, "NOT_YOUR_TURN", "Сейчас ход соперника", version); break;
case LIFECYCLE_RESULT_CELL_ALREADY_SHOT: response_error(response, 409U, "CELL_ALREADY_SHOT", "Клетка уже обстреляна", version); break;
case LIFECYCLE_RESULT_STALE_GAME: response_error(response, 409U, "STALE_GAME", "Партия уже изменилась", version); break;
case LIFECYCLE_RESULT_UNAUTHORIZED: response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version); break;
default: response_error(response, 503U, "SERVER_BUSY", "Сервер занят", version); break;
}
}
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) {
response_error(response, 400U, "MALFORMED_JSON", "Ожидается JSON запрос", version);
return false;
}
if (request->body == NULL || request->body_length > maximum) {
response_error(response, 413U, "PAYLOAD_TOO_LARGE", "Слишком большой запрос", version);
return false;
}
return true;
}
static bool parse_join(const http_api_request_t *request, char name[kDisplayNameBytes + 1U], role_t *role) {
json_reader_t reader = {.text = request->body, .length = request->body_length};
char key[16];
char role_value[16];
bool first = true;
uint8_t fields = 0;
if (!parse_object_start(&reader)) return false;
while (reader.length > 0U && *reader.text != '}') {
if (!parse_next_key(&reader, &first, key, sizeof(key))) return false;
if (strcmp(key, "name") == 0 && (fields & 1U) == 0U) {
if (!parse_string(&reader, name, kDisplayNameBytes + 1U)) return false;
fields |= 1U;
} else if (strcmp(key, "requestedRole") == 0 && (fields & 2U) == 0U) {
if (!parse_string(&reader, role_value, sizeof(role_value))) return false;
fields |= 2U;
} else return false;
}
if (!parse_object_end(&reader) || fields != 3U) return false;
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;
}
static bool parse_token_only(const http_api_request_t *request, char token[kSessionTokenBytes * 2U + 1U]) {
json_reader_t reader = {.text = request->body, .length = request->body_length};
char key[16];
bool first = true;
if (!parse_object_start(&reader) || !parse_next_key(&reader, &first, key, sizeof(key)) || strcmp(key, "token") != 0 ||
!parse_string(&reader, token, kSessionTokenBytes * 2U + 1U)) return false;
return parse_object_end(&reader);
}
static bool parse_command_with_token(const http_api_request_t *request, app_command_t *command, bool needs_mode,
bool needs_coordinate, uint8_t token[kSessionTokenBytes]) {
char body_token[kSessionTokenBytes * 2U + 1U] = {0};
/* Parse into a temporary command first; token bytes never occupy command storage. */
json_reader_t reader = {.text = request->body, .length = request->body_length};
char key[16];
char mode[8] = {0};
bool first = true;
uint8_t fields = 0;
uint32_t x = 0;
uint32_t y = 0;
if (!parse_object_start(&reader)) return false;
while (reader.length > 0U && *reader.text != '}') {
if (!parse_next_key(&reader, &first, key, sizeof(key))) return false;
if (strcmp(key, "token") == 0 && (fields & 1U) == 0U) {
if (!parse_string(&reader, body_token, sizeof(body_token))) return false;
fields |= 1U;
} else if (strcmp(key, "gameId") == 0 && (fields & 2U) == 0U) {
if (!parse_u32(&reader, &command->game_id)) return false;
fields |= 2U;
} else if (needs_mode && strcmp(key, "mode") == 0 && (fields & 4U) == 0U) {
if (!parse_string(&reader, mode, sizeof(mode))) return false;
fields |= 4U;
} else if (needs_coordinate && strcmp(key, "x") == 0 && (fields & 4U) == 0U) {
if (!parse_u32(&reader, &x)) return false;
fields |= 4U;
} else if (needs_coordinate && strcmp(key, "y") == 0 && (fields & 8U) == 0U) {
if (!parse_u32(&reader, &y)) return false;
fields |= 8U;
} else return false;
}
const uint8_t expected = needs_coordinate ? 15U : needs_mode ? 7U : 3U;
if (!parse_object_end(&reader) || fields != expected || !parse_token(body_token, token)) return false;
if (needs_mode) {
if (strcmp(mode, "human") == 0) command->mode = MODE_HUMAN;
else if (strcmp(mode, "bot") == 0) command->mode = MODE_BOT;
else command->mode = (game_mode_t)UINT8_MAX;
}
if (needs_coordinate) {
command->coordinate = x >= kBoardWidth || y >= kBoardHeight ?
(coordinate_t){.x = kBoardWidth, .y = 0U} : (coordinate_t){.x = (uint8_t)x, .y = (uint8_t)y};
}
return true;
}
void http_api_init(http_api_t *api, application_t *application) {
if (api == NULL) return;
*api = (http_api_t){.application = application, .health = {.wifi_state = "connecting"}};
}
void http_api_set_health(http_api_t *api, const http_api_health_t *health) {
if (api != NULL && health != NULL) api->health = *health;
}
bool http_api_handle(http_api_t *api, const http_api_request_t *request, http_api_response_t *response) {
if (api == NULL || api->application == NULL || request == NULL || response == NULL) return false;
*response = (http_api_response_t){0};
game_lifecycle_t *lifecycle = &api->application->lifecycle;
const uint32_t version = lifecycle->game.state.version;
if (request->target_too_large) {
response_error(response, 413U, "PAYLOAD_TOO_LARGE", "Слишком большой запрос", version);
return true;
}
if (request->route == HTTP_API_ROUTE_INFO && request->method == HTTP_API_GET) {
const session_manager_t *sessions = &lifecycle->sessions;
uint8_t spectators = 0;
for (uint8_t index = kPlayerCapacity; index < kSessionCapacity; ++index) spectators += sessions->entries[index].occupied;
response_write(response, 200U, "{\"ok\":true,\"phase\":\"%s\",\"gameId\":%" PRIu32 ",\"version\":%" PRIu32
",\"player1Available\":%s,\"player2Available\":%s,\"player1Name\":\"%s\",\"player2Name\":\"%s\",\"spectatorsAvailable\":%u}",
phase_text(lifecycle->game.state.phase), lifecycle->game.state.game_id, version,
sessions->entries[0].occupied ? "false" : "true", sessions->entries[1].occupied ? "false" : "true",
sessions->entries[0].occupied ? sessions->entries[0].name : "", sessions->entries[1].occupied ? sessions->entries[1].name : "",
(unsigned int)(kSpectatorCapacity - spectators));
return response->body_length <= 384U;
}
if (request->route == HTTP_API_ROUTE_HEALTH && request->method == HTTP_API_GET) {
response_write(response, 200U, "{\"ok\":true,\"uptimeMs\":%" PRIu32 ",\"wifiState\":\"%s\",\"freeHeapBytes\":%" PRIu32
",\"minimumFreeHeapBytes\":%" PRIu32 ",\"largestFreeBlockBytes\":%" PRIu32
",\"connectedClients\":%u,\"rejectedInput\":%u,\"resetReason\":%d}", api->health.uptime_ms,
api->health.wifi_state == NULL ? "unknown" : api->health.wifi_state, api->health.free_heap_bytes,
api->health.minimum_free_heap_bytes, api->health.largest_free_block_bytes,
api->health.connected_clients, api->health.rejected_input, api->health.reset_reason);
return response->body_length <= 320U;
}
if (request->route == HTTP_API_ROUTE_STATE && request->method == HTTP_API_GET) {
role_t viewer = ROLE_SPECTATOR;
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)) {
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)) {
response_error(response, 503U, "SERVER_BUSY", "Сервер занят", version);
} else response->status = 200U;
return true;
}
if (request->route == HTTP_API_ROUTE_STATISTICS && request->method == HTTP_API_GET) {
role_t viewer = ROLE_SPECTATOR;
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)) {
response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version);
return true;
}
viewer = lifecycle->sessions.entries[session_index].role;
}
const match_statistics_t *match = lifecycle->game.state.statistics;
const cumulative_statistics_t *total = lifecycle->cumulative;
response_write(response, 200U, "{\"ok\":true,\"viewer\":\"%s\",\"gameId\":%" PRIu32
",\"match\":[[%u,%u,%u,%u],[%u,%u,%u,%u]],\"cumulative\":[[%u,%u,%u,%u,%" PRIu32 ",%" PRIu32 ",%" PRIu32
"],[%u,%u,%u,%u,%" PRIu32 ",%" PRIu32 ",%" PRIu32 "]]}", role_text(viewer), lifecycle->game.state.game_id,
match[0].shots, match[0].hits, match[0].misses, match[0].ships_sunk,
match[1].shots, match[1].hits, match[1].misses, match[1].ships_sunk,
total[0].games, total[0].wins, total[0].losses, total[0].ships_sunk, total[0].shots, total[0].hits, total[0].misses,
total[1].games, total[1].wins, total[1].losses, total[1].ships_sunk, total[1].shots, total[1].hits, total[1].misses);
return response->body_length < sizeof(response->body);
}
if (request->route == HTTP_API_ROUTE_JOIN) {
char name[kDisplayNameBytes + 1U] = {0};
role_t role = ROLE_SPECTATOR;
if (!expect_post_body(request, 192U, response, version)) return true;
if (!parse_join(request, name, &role)) {
response_error(response, 400U, "MALFORMED_JSON", "Некорректный JSON", version);
return true;
}
if (role > ROLE_AUTO_PLAYER) {
response_error(response, 400U, "INVALID_ROLE", "Некорректная роль", version);
return true;
}
uint8_t session_index = 0;
const lifecycle_result_t result = application_join(api->application, role, name, &session_index);
if (result != LIFECYCLE_RESULT_OK) response_lifecycle_error(response, result, lifecycle->game.state.version);
else {
char token[kSessionTokenBytes * 2U + 1U];
token_text(lifecycle->sessions.entries[session_index].token, token);
response_write(response, 200U, "{\"ok\":true,\"token\":\"%s\",\"role\":\"%s\",\"version\":%" PRIu32 ",\"gameId\":%" PRIu32 "}",
token, role_text(lifecycle->sessions.entries[session_index].role), lifecycle->game.state.version,
lifecycle->game.state.game_id);
}
return true;
}
if (request->route == HTTP_API_ROUTE_RESUME) {
char token_text_value[kSessionTokenBytes * 2U + 1U] = {0};
uint8_t token[kSessionTokenBytes];
if (!expect_post_body(request, 96U, response, version)) return true;
if (!parse_token_only(request, token_text_value) || !parse_token(token_text_value, token)) {
response_error(response, 400U, "MALFORMED_JSON", "Некорректный JSON", version);
return true;
}
uint8_t session_index = 0;
const lifecycle_result_t result = application_resume(api->application, token, &session_index);
if (result != LIFECYCLE_RESULT_OK) response_lifecycle_error(response, result, lifecycle->game.state.version);
else response_write(response, 200U, "{\"ok\":true,\"role\":\"%s\",\"version\":%" PRIu32 ",\"gameId\":%" PRIu32 "}",
role_text(lifecycle->sessions.entries[session_index].role), lifecycle->game.state.version,
lifecycle->game.state.game_id);
return true;
}
app_command_t command = {0};
uint8_t token[kSessionTokenBytes];
bool needs_mode = request->route == HTTP_API_ROUTE_CONFIG;
bool needs_coordinate = request->route == HTTP_API_ROUTE_SHOT;
size_t maximum = needs_coordinate || needs_mode ? 96U : 80U;
if (!expect_post_body(request, maximum, response, version)) return true;
if (!parse_command_with_token(request, &command, needs_mode, needs_coordinate, token)) {
response_error(response, 400U, "MALFORMED_JSON", "Некорректный JSON", version);
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;
}
switch (request->route) {
case HTTP_API_ROUTE_CONFIG: command.type = COMMAND_CONFIG; break;
case HTTP_API_ROUTE_START: command.type = COMMAND_START; break;
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;
}
+707 -117
View File
@@ -1,141 +1,731 @@
#include <inttypes.h>
#include <stddef.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include "driver/gpio.h"
#include "esp_chip_info.h"
#include "esp_err.h"
#include "esp_flash.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"
#include "esp_http_server.h"
#include "esp_littlefs.h"
#include "esp_log.h"
#include "esp_system.h"
#include "esp_netif.h"
#include "esp_random.h"
#include "esp_timer.h"
#include "esp_wifi.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.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"
#include "lwip/sockets.h"
static const char *const kLogTag = "bringup";
static const gpio_num_t kCandidateLedGpio = GPIO_NUM_8;
static const uint32_t kLedProbeCount = 10;
static const uint32_t kLedProbeIntervalMs = 500;
static const uint32_t kHealthIntervalMs = 30000;
static const uint32_t kStartupAttachmentDelayMs = 10000;
static const char *const kLogTag = "battleship";
static const char *const kLittlefsBasePath = "/littlefs";
static const char *const kLittlefsPartitionLabel = "littlefs";
static const size_t kFileChunkBytes = 1024U;
enum { kHttpMaxOpenSockets = 12U, kFallbackMaxConnections = 10U, kDnsPort = 53U, kScanResultLimit = 12U };
static const char *reset_reason_name(esp_reset_reason_t reason) {
switch (reason) {
case ESP_RST_UNKNOWN:
return "unknown";
case ESP_RST_POWERON:
return "power_on";
case ESP_RST_EXT:
return "external";
case ESP_RST_SW:
return "software";
case ESP_RST_PANIC:
return "panic";
case ESP_RST_INT_WDT:
return "interrupt_watchdog";
case ESP_RST_TASK_WDT:
return "task_watchdog";
case ESP_RST_WDT:
return "other_watchdog";
case ESP_RST_DEEPSLEEP:
return "deep_sleep";
case ESP_RST_BROWNOUT:
return "brownout";
case ESP_RST_SDIO:
return "sdio";
case ESP_RST_USB:
return "usb";
case ESP_RST_JTAG:
return "jtag";
case ESP_RST_EFUSE:
return "efuse";
case ESP_RST_PWR_GLITCH:
return "power_glitch";
case ESP_RST_CPU_LOCKUP:
return "cpu_lockup";
}
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;
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;
return "unrecognized";
enum { kNetworkRequestBytes = 160U, kNetworkResponseBytes = 768U };
static uint32_t platform_random(void *unused) { (void)unused; return esp_random(); }
static void record_rejected_input(void) {
portENTER_CRITICAL(&s_diagnostics_lock);
if (s_rejected_input < UINT16_MAX) ++s_rejected_input;
portEXIT_CRITICAL(&s_diagnostics_lock);
}
static void log_heap(uint64_t uptime_ms) {
const size_t free_heap_bytes = esp_get_free_heap_size();
const size_t minimum_free_heap_bytes = esp_get_minimum_free_heap_size();
uint32_t flash_size_bytes = 0;
const esp_err_t flash_result = esp_flash_get_size(NULL, &flash_size_bytes);
static uint16_t rejected_input_snapshot(void) {
uint16_t rejected = 0;
portENTER_CRITICAL(&s_diagnostics_lock);
rejected = s_rejected_input;
portEXIT_CRITICAL(&s_diagnostics_lock);
return rejected;
}
if (flash_result == ESP_OK) {
ESP_LOGI(kLogTag,
"health uptime_ms=%" PRIu64 " reset_reason=%s flash_bytes=%" PRIu32
" free_heap_bytes=%u min_free_heap_bytes=%u",
uptime_ms,
reset_reason_name(esp_reset_reason()),
flash_size_bytes,
(unsigned int)free_heap_bytes,
(unsigned int)minimum_free_heap_bytes);
} else {
ESP_LOGE(kLogTag, "health flash_size_failed=%s", esp_err_to_name(flash_result));
static wifi_state_t wifi_state_snapshot(void) {
wifi_state_t snapshot;
portENTER_CRITICAL(&s_wifi_lock);
snapshot = s_wifi_state;
portEXIT_CRITICAL(&s_wifi_lock);
return snapshot;
}
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";
}
static void refresh_health(void) {
const wifi_state_t wifi_state = wifi_state_snapshot();
size_t clients = kHttpMaxOpenSockets;
int client_fds[kHttpMaxOpenSockets];
if (s_server == NULL || httpd_get_client_list(s_server, &clients, client_fds) != ESP_OK) clients = 0U;
http_api_set_health(&s_api, &(http_api_health_t){
.uptime_ms = (uint32_t)(esp_timer_get_time() / 1000U),
.free_heap_bytes = esp_get_free_heap_size(),
.minimum_free_heap_bytes = esp_get_minimum_free_heap_size(),
.largest_free_block_bytes = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT),
.connected_clients = (uint8_t)clients,
.rejected_input = rejected_input_snapshot(),
.reset_reason = (int8_t)esp_reset_reason(),
.wifi_state = wifi_state_name(&wifi_state),
});
}
static void set_status(httpd_req_t *request, uint16_t status) {
const char *const values[] = {"200 OK", "400 Bad Request", "401 Unauthorized", "403 Forbidden", "404 Not Found", "409 Conflict", "413 Payload Too Large", "503 Service Unavailable"};
const uint16_t codes[] = {200U, 400U, 401U, 403U, 404U, 409U, 413U, 503U};
for (size_t index = 0; index < sizeof(codes) / sizeof(codes[0]); ++index) if (status == codes[index]) { httpd_resp_set_status(request, values[index]); return; }
httpd_resp_set_status(request, "503 Service Unavailable");
}
static esp_err_t send_api_response(httpd_req_t *request, const http_api_response_t *response) {
set_status(request, response->status);
httpd_resp_set_type(request, "application/json");
httpd_resp_set_hdr(request, "Cache-Control", "no-store");
return httpd_resp_send(request, response->body, response->body_length);
}
static bool websocket_send(void *unused, int client_id, const char *payload, size_t length) {
(void)unused;
httpd_ws_frame_t frame = {.final = true, .type = HTTPD_WS_TYPE_TEXT, .payload = (uint8_t *)payload, .len = length};
const esp_err_t result = httpd_ws_send_frame_async(s_server, client_id, &frame);
if (result != ESP_OK) httpd_sess_trigger_close(s_server, client_id);
return result == ESP_OK;
}
static void sync_broadcast_work(void *unused) {
(void)unused;
sync_service_broadcast(&s_sync, websocket_send, NULL);
}
static void queue_state_broadcast(void) {
if (s_server != NULL && httpd_queue_work(s_server, sync_broadcast_work, NULL) != ESP_OK) record_rejected_input();
}
static bool schedule_bot_turn(void *unused, uint32_t delay_ms) {
(void)unused;
return s_bot_timer != NULL && esp_timer_start_once(s_bot_timer, (uint64_t)delay_ms * 1000U) == ESP_OK;
}
static void bot_turn_work(void *unused) {
(void)unused;
const uint32_t version_before = s_application.lifecycle.game.state.version;
if (application_bot_take_turn(&s_application) && s_application.lifecycle.game.state.version != version_before) queue_state_broadcast();
}
static void bot_timer_callback(void *unused) {
(void)unused;
if (s_server != NULL && httpd_queue_work(s_server, bot_turn_work, NULL) != ESP_OK) record_rejected_input();
}
static void sync_expire_work(void *unused) {
(void)unused;
int closed[kSessionCapacity] = {0};
size_t closed_count = 0U;
sync_service_expire(&s_sync, (uint64_t)(esp_timer_get_time() / 1000U), closed, &closed_count);
for (size_t index = 0; index < closed_count; ++index) httpd_sess_trigger_close(s_server, closed[index]);
}
static void sync_timer_callback(void *unused) {
(void)unused;
if (s_server != NULL) httpd_queue_work(s_server, sync_expire_work, NULL);
}
static bool request_has_json_content_type(httpd_req_t *request) {
const size_t length = httpd_req_get_hdr_value_len(request, "Content-Type");
if (length == 0U || length >= 64U) return false;
char value[64];
return httpd_req_get_hdr_value_str(request, "Content-Type", value, sizeof(value)) == ESP_OK && strncmp(value, "application/json", 16U) == 0;
}
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;
}
}
static void probe_candidate_led(void) {
ESP_LOGI(kLogTag,
"led_probe gpio=%d transitions=%" PRIu32 " interval_ms=%" PRIu32 "; visually confirm the LED",
(int)kCandidateLedGpio,
kLedProbeCount,
kLedProbeIntervalMs);
esp_err_t result = gpio_reset_pin(kCandidateLedGpio);
if (result != ESP_OK) {
ESP_LOGW(kLogTag, "led_probe gpio_reset_pin failed: %s", esp_err_to_name(result));
return;
static esp_err_t api_handler(httpd_req_t *request) {
const http_api_route_t route = (http_api_route_t)(uintptr_t)request->user_ctx;
char body[kRequestBodyCapacity + 1U] = {0};
char token[kSessionTokenBytes * 2U + 1U] = {0};
const size_t maximum = route_body_limit(route);
size_t body_length = 0U;
if (request->method == HTTP_POST) {
if ((size_t)request->content_len > maximum) { body_length = maximum + 1U; record_rejected_input(); }
else {
while (body_length < (size_t)request->content_len) {
const int received = httpd_req_recv(request, body + body_length, request->content_len - body_length);
if (received <= 0) { record_rejected_input(); body_length = maximum + 1U; break; }
body_length += (size_t)received;
}
body[body_length <= kRequestBodyCapacity ? body_length : 0U] = '\0';
}
}
result = gpio_set_direction(kCandidateLedGpio, GPIO_MODE_OUTPUT);
if (result != ESP_OK) {
ESP_LOGW(kLogTag, "led_probe gpio_set_direction failed: %s", esp_err_to_name(result));
return;
const bool target_too_large = request->method == HTTP_GET && httpd_req_get_url_query_len(request) > 128U;
if (target_too_large) record_rejected_input();
if (route == HTTP_API_ROUTE_STATE || route == HTTP_API_ROUTE_STATISTICS) {
const size_t token_length = httpd_req_get_hdr_value_len(request, "X-Session-Token");
if (token_length > 0U && token_length < sizeof(token) && httpd_req_get_hdr_value_str(request, "X-Session-Token", token, sizeof(token)) != ESP_OK) token[0] = '\0';
else if (token_length >= sizeof(token)) snprintf(token, sizeof(token), "%s", "invalid");
}
for (uint32_t transition = 0; transition < kLedProbeCount; ++transition) {
gpio_set_level(kCandidateLedGpio, transition % 2U);
vTaskDelay(pdMS_TO_TICKS(kLedProbeIntervalMs));
}
gpio_set_level(kCandidateLedGpio, 0);
ESP_LOGI(kLogTag, "led_probe complete; USB serial remained available during probe");
refresh_health();
http_api_response_t response;
const http_api_request_t api_request = {.method = request->method == HTTP_GET ? HTTP_API_GET : HTTP_API_POST, .route = route,
.content_type_json = request_has_json_content_type(request), .body = body, .body_length = body_length,
.session_token = token[0] == '\0' ? NULL : token, .target_too_large = target_too_large};
const uint32_t version_before = s_application.lifecycle.game.state.version;
if (!http_api_handle(&s_api, &api_request, &response)) return ESP_FAIL;
if (s_application.lifecycle.game.state.version != version_before) queue_state_broadcast();
return send_api_response(request, &response);
}
static void log_startup(void) {
esp_chip_info_t chip_info = {0};
uint32_t flash_size_bytes = 0;
const esp_err_t flash_result = esp_flash_get_size(NULL, &flash_size_bytes);
esp_chip_info(&chip_info);
ESP_LOGI(kLogTag,
"startup reset_reason=%s chip_model=%d chip_revision=%d cores=%d features=0x%08" PRIx32,
reset_reason_name(esp_reset_reason()),
(int)chip_info.model,
chip_info.revision,
chip_info.cores,
chip_info.features);
if (flash_result == ESP_OK) {
ESP_LOGI(kLogTag, "startup flash_bytes=%" PRIu32, flash_size_bytes);
} else {
ESP_LOGE(kLogTag, "startup flash_size_failed=%s", esp_err_to_name(flash_result));
static esp_err_t websocket_handler(httpd_req_t *request) {
const int client_id = httpd_req_to_sockfd(request);
if (request->method == HTTP_GET) {
if (sync_service_open(&s_sync, client_id, (uint64_t)(esp_timer_get_time() / 1000U))) return ESP_OK;
httpd_sess_trigger_close(s_server, client_id);
return ESP_FAIL;
}
httpd_ws_frame_t frame = {0};
if (httpd_ws_recv_frame(request, &frame, 0U) != ESP_OK || frame.type != HTTPD_WS_TYPE_TEXT ||
frame.len > kWebSocketFrameCapacity) {
record_rejected_input();
sync_service_close(&s_sync, client_id);
httpd_sess_trigger_close(s_server, client_id);
return ESP_OK;
}
char input[kWebSocketFrameCapacity + 1U] = {0};
frame.payload = (uint8_t *)input;
if (httpd_ws_recv_frame(request, &frame, sizeof(input) - 1U) != ESP_OK) {
sync_service_close(&s_sync, client_id);
httpd_sess_trigger_close(s_server, client_id);
return ESP_OK;
}
char output[kStateMessageCapacity] = {0};
size_t output_length = 0U;
bool state_changed = false;
bool close_client = false;
if (!sync_service_receive(&s_sync, client_id, input, frame.len, (uint64_t)(esp_timer_get_time() / 1000U),
output, &output_length, &state_changed, &close_client)) return ESP_FAIL;
if (output_length > 0U) {
httpd_ws_frame_t response = {.final = true, .type = HTTPD_WS_TYPE_TEXT,
.payload = (uint8_t *)output, .len = output_length};
if (httpd_ws_send_frame(request, &response) != ESP_OK) close_client = true;
}
if (state_changed) queue_state_broadcast();
if (close_client) {
sync_service_close(&s_sync, client_id);
httpd_sess_trigger_close(s_server, client_id);
}
return ESP_OK;
}
log_heap(0);
static bool fallback_active(void) {
const wifi_state_t state = wifi_state_snapshot();
return state.fallback_active;
}
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;
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) {
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);
}
static esp_err_t start_wifi(void) {
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 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");
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 || 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 || strcmp(path, "/setup.css") == 0 ? "text/css; charset=utf-8" : "application/javascript; charset=utf-8";
}
static bool request_accepts_gzip(httpd_req_t *request) {
const size_t length = httpd_req_get_hdr_value_len(request, "Accept-Encoding");
if (length == 0U || length >= 96U) return false;
char value[96];
return httpd_req_get_hdr_value_str(request, "Accept-Encoding", value, sizeof(value)) == ESP_OK &&
strstr(value, "gzip") != NULL;
}
static esp_err_t send_static_file(httpd_req_t *request, const char *asset_path) {
char path[80];
char gzip_path[84];
snprintf(path, sizeof(path), "%s%s", kLittlefsBasePath, asset_path);
const char *path_to_open = path;
bool gzip = false;
if (request_accepts_gzip(request)) {
snprintf(gzip_path, sizeof(gzip_path), "%s.gz", path);
FILE *compressed = fopen(gzip_path, "rb");
if (compressed != NULL) { fclose(compressed); path_to_open = gzip_path; gzip = true; }
}
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", "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);
fclose(file);
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");
return httpd_resp_send(request, "LittleFS is unavailable", HTTPD_RESP_USE_STRLEN);
}
static esp_err_t static_file_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);
}
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, "/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 = 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) {
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;
}
static void mount_littlefs(void) {
const esp_vfs_littlefs_conf_t config = {.base_path = kLittlefsBasePath, .partition_label = kLittlefsPartitionLabel, .format_if_mount_failed = false, .dont_mount = false, .grow_on_mount = false};
if (esp_vfs_littlefs_register(&config) != ESP_OK) { ESP_LOGE(kLogTag, "littlefs mount failed"); return; }
s_littlefs_mounted = true;
}
void app_main(void) {
vTaskDelay(pdMS_TO_TICKS(kStartupAttachmentDelayMs));
log_startup();
probe_candidate_led();
while (true) {
log_heap((uint64_t)(esp_timer_get_time() / 1000));
vTaskDelay(pdMS_TO_TICKS(kHealthIntervalMs));
}
esp_err_t result = nvs_flash_init();
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();
ESP_ERROR_CHECK(start_http_server());
const esp_timer_create_args_t sync_timer_args = {.callback = sync_timer_callback, .name = "sync_expire"};
ESP_ERROR_CHECK(esp_timer_create(&sync_timer_args, &s_sync_timer));
ESP_ERROR_CHECK(esp_timer_start_periodic(s_sync_timer, 1000000U));
result = start_wifi();
if (result != ESP_OK) ESP_LOGW(kLogTag, "wifi unavailable: %s", esp_err_to_name(result));
}
+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;
}
+111
View File
@@ -0,0 +1,111 @@
#include "session_manager.h"
#include <stddef.h>
#include <string.h>
static bool valid_utf8_scalar(const unsigned char *text, size_t remaining, size_t *bytes) {
if (text[0] < 0x80U) { *bytes = 1; return text[0] >= 0x20U && text[0] != '<' && text[0] != '>'; }
if (text[0] >= 0xC2U && text[0] <= 0xDFU && remaining >= 2 && (text[1] & 0xC0U) == 0x80U) { *bytes = 2; return true; }
if (text[0] >= 0xE0U && text[0] <= 0xEFU && remaining >= 3 && (text[1] & 0xC0U) == 0x80U && (text[2] & 0xC0U) == 0x80U) { *bytes = 3; return true; }
if (text[0] >= 0xF0U && text[0] <= 0xF4U && remaining >= 4 && (text[1] & 0xC0U) == 0x80U && (text[2] & 0xC0U) == 0x80U && (text[3] & 0xC0U) == 0x80U) { *bytes = 4; return true; }
return false;
}
static bool sanitize_name(const char *name, char output[kDisplayNameBytes + 1]) {
if (name == NULL) return false;
size_t input = 0;
while (name[input] == ' ') ++input;
const size_t start = input;
size_t end = start;
while (name[end] != '\0') ++end;
while (end > start && name[end - 1U] == ' ') --end;
size_t written = 0;
uint8_t scalars = 0;
while (input < end) {
size_t bytes = 0;
if (!valid_utf8_scalar((const unsigned char *)&name[input], end - input, &bytes) || name[input] == '"' || name[input] == '\\') return false;
if (written + bytes > kDisplayNameBytes || ++scalars > 20U) return false;
memcpy(&output[written], &name[input], bytes);
input += bytes;
written += bytes;
}
output[written] = '\0';
return scalars != 0U;
}
static int8_t first_free(const session_manager_t *manager, uint8_t start, uint8_t end) {
for (uint8_t index = start; index < end; ++index) if (!manager->entries[index].occupied) return (int8_t)index;
return -1;
}
void session_manager_init(session_manager_t *manager) {
if (manager != NULL) memset(manager, 0, sizeof(*manager));
}
session_result_t session_manager_join(session_manager_t *manager, role_t requested_role, const char *name,
random_source_t random, uint8_t *session_index) {
if (manager == NULL || session_index == NULL || random.next_u32 == NULL) return SESSION_RESULT_UNAUTHORIZED;
char sanitized[kDisplayNameBytes + 1] = {0};
if (!sanitize_name(name, sanitized)) return SESSION_RESULT_INVALID_NAME;
int8_t selected = -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) {
const uint32_t value = random.next_u32(random.context);
for (uint8_t byte = 0; byte < 4; ++byte) candidate.token[offset + byte] = (uint8_t)(value >> (byte * 8U));
}
manager->entries[selected] = candidate;
*session_index = (uint8_t)selected;
return SESSION_RESULT_OK;
}
session_result_t session_manager_resume(session_manager_t *manager, const uint8_t token[kSessionTokenBytes], uint8_t *session_index) {
if (manager == NULL || token == NULL || session_index == NULL) return SESSION_RESULT_UNAUTHORIZED;
for (uint8_t index = 0; index < kSessionCapacity; ++index) {
session_t *session = &manager->entries[index];
if (session->occupied && memcmp(session->token, token, kSessionTokenBytes) == 0) {
session->connected = true;
*session_index = index;
return SESSION_RESULT_OK;
}
}
return SESSION_RESULT_UNAUTHORIZED;
}
bool session_manager_find(const session_manager_t *manager, const uint8_t token[kSessionTokenBytes],
uint8_t *session_index) {
if (manager == NULL || token == NULL || session_index == NULL) return false;
for (uint8_t index = 0; index < kSessionCapacity; ++index) {
const session_t *session = &manager->entries[index];
if (session->occupied && memcmp(session->token, token, kSessionTokenBytes) == 0) {
*session_index = index;
return true;
}
}
return false;
}
bool session_manager_disconnect(session_manager_t *manager, uint8_t session_index) {
if (manager == NULL || session_index >= kSessionCapacity || !manager->entries[session_index].occupied) return false;
if (manager->entries[session_index].role == ROLE_SPECTATOR) memset(&manager->entries[session_index], 0, sizeof(session_t));
else manager->entries[session_index].connected = false;
return true;
}
session_result_t session_manager_leave(session_manager_t *manager, uint8_t session_index, phase_t phase) {
if (manager == NULL || session_index >= kSessionCapacity || !manager->entries[session_index].occupied) return SESSION_RESULT_UNAUTHORIZED;
if (manager->entries[session_index].role != ROLE_SPECTATOR && phase != PHASE_LOBBY) return SESSION_RESULT_WRONG_PHASE;
memset(&manager->entries[session_index], 0, sizeof(session_t));
return SESSION_RESULT_OK;
}
bool session_manager_player_present(const session_manager_t *manager, uint8_t player_index) {
return manager != NULL && player_index < kPlayerCapacity && manager->entries[player_index].occupied;
}
+126
View File
@@ -0,0 +1,126 @@
#include "state_presenter.h"
#include <stdint.h>
#include <string.h>
typedef struct { char data[kStateMessageCapacity]; size_t length; } state_writer_t;
static bool append_character(state_writer_t *writer, char character) {
if (writer->length + 1U >= sizeof(writer->data)) return false;
writer->data[writer->length++] = character;
return true;
}
static bool append_text(state_writer_t *writer, const char *text) {
while (*text != '\0') if (!append_character(writer, *text++)) return false;
return true;
}
static bool append_player_names(state_writer_t *writer, const session_manager_t *sessions) {
const char *first = sessions != NULL && sessions->entries[0].occupied ? sessions->entries[0].name : "";
const char *second = sessions != NULL && sessions->entries[1].occupied ? sessions->entries[1].name : "";
return append_text(writer, ",\"players\":[\"") && append_text(writer, first) &&
append_text(writer, "\",\"") && append_text(writer, second) && append_text(writer, "\"]");
}
static bool append_u32(state_writer_t *writer, uint32_t value) {
char digits[10];
uint8_t count = 0;
do { digits[count++] = (char)('0' + value % 10U); value /= 10U; } while (value != 0U);
while (count > 0U) if (!append_character(writer, digits[--count])) return false;
return true;
}
static const char *phase_name(phase_t phase) {
static const char *const names[] = {"lobby", "preparing", "in_progress", "finished", "rematch_wait"};
return phase <= PHASE_REMATCH_WAIT ? names[phase] : NULL;
}
static const char *mode_name(game_mode_t mode) {
static const char *const names[] = {"human", "bot"};
return mode <= MODE_BOT ? names[mode] : NULL;
}
static const char *role_name(role_t role) {
static const char *const names[] = {"player1", "player2", "spectator"};
return role <= ROLE_SPECTATOR ? names[role] : NULL;
}
static char present_cell(cell_t cell, bool reveal_ships) {
if (cell == CELL_MISS) return '2';
if (cell == CELL_HIT) return '3';
return cell == CELL_SHIP && reveal_ships ? '1' : '0';
}
static bool cell_is_sunk(const board_t *board, uint8_t cell_index) {
const uint8_t x = (uint8_t)(cell_index % kBoardWidth);
const uint8_t y = (uint8_t)(cell_index / kBoardWidth);
for (uint8_t index = 0; index < kFleetShipCount; ++index) {
const ship_t *ship = &board->ships[index];
for (uint8_t offset = 0; offset < ship->length; ++offset) {
const uint8_t ship_x = ship->horizontal ? (uint8_t)(ship->x + offset) : ship->x;
const uint8_t ship_y = ship->horizontal ? ship->y : (uint8_t)(ship->y + offset);
if (ship_x == x && ship_y == y) return ship->hits == ship->length;
}
}
return false;
}
static bool append_board(state_writer_t *writer, const board_t *board, bool reveal_ships) {
for (uint8_t index = 0; index < kBoardCellCount; ++index) {
const char cell = board->cells[index] == CELL_HIT && cell_is_sunk(board, index) ? '4' :
present_cell(board->cells[index], reveal_ships);
if (!append_character(writer, cell)) return false;
}
return true;
}
static bool append_statistics(state_writer_t *writer, const match_statistics_t *statistics) {
return append_character(writer, '[') && append_u32(writer, statistics->shots) &&
append_character(writer, ',') && append_u32(writer, statistics->hits) &&
append_character(writer, ',') && append_u32(writer, statistics->misses) &&
append_character(writer, ',') && append_u32(writer, statistics->ships_sunk) && append_character(writer, ']');
}
static bool write_state(const game_state_t *state, const session_manager_t *sessions, role_t viewer, const cumulative_statistics_t *cumulative,
char *output, size_t output_size, size_t *written) {
if (written != NULL) *written = 0;
if (state == NULL || output == NULL || output_size == 0U || phase_name(state->phase) == NULL ||
mode_name(state->mode) == NULL || role_name(viewer) == NULL || state->current_player >= kPlayerCapacity) return false;
state_writer_t writer = {0};
const bool finished = state->phase == PHASE_FINISHED;
const uint16_t wins_0 = cumulative == NULL ? 0U : cumulative[0].wins;
const uint16_t wins_1 = cumulative == NULL ? 0U : cumulative[1].wins;
const bool reveal[2] = {finished || viewer == ROLE_PLAYER_1, finished || viewer == ROLE_PLAYER_2};
if (!append_text(&writer, "{\"type\":\"state\",\"version\":" ) || !append_u32(&writer, state->version) ||
!append_text(&writer, ",\"gameId\":") || !append_u32(&writer, state->game_id) ||
!append_text(&writer, ",\"phase\":\"") || !append_text(&writer, phase_name(state->phase)) ||
!append_text(&writer, "\",\"mode\":\"") || !append_text(&writer, mode_name(state->mode)) ||
!append_text(&writer, "\",\"viewer\":\"") || !append_text(&writer, role_name(viewer)) ||
!append_text(&writer, "\",\"turn\":\"") || !append_text(&writer, role_name((role_t)state->current_player)) ||
!append_text(&writer, "\"") || !append_player_names(&writer, sessions) ||
!append_text(&writer, ",\"boards\":[\"") || !append_board(&writer, &state->boards[0], reveal[0]) ||
!append_text(&writer, "\",\"") || !append_board(&writer, &state->boards[1], reveal[1]) ||
!append_text(&writer, "\"],\"wins\":[") || !append_u32(&writer, wins_0) ||
!append_character(&writer, ',') || !append_u32(&writer, wins_1) ||
!append_text(&writer, "],\"winner\":") ||
!(state->winner < kPlayerCapacity ? append_u32(&writer, state->winner) : append_text(&writer, "null")) ||
!append_text(&writer, ",\"statistics\":[") || !append_statistics(&writer, &state->statistics[0]) ||
!append_character(&writer, ',') || !append_statistics(&writer, &state->statistics[1]) ||
!append_text(&writer, "]}")) return false;
if (writer.length + 1U > output_size) return false;
writer.data[writer.length] = '\0';
memcpy(output, writer.data, writer.length + 1U);
if (written != NULL) *written = writer.length;
return true;
}
bool state_presenter_write(const game_state_t *state, role_t viewer, char *output, size_t output_size, size_t *written) {
return write_state(state, NULL, viewer, NULL, output, output_size, written);
}
bool state_presenter_write_lifecycle(const game_lifecycle_t *lifecycle, role_t viewer,
char *output, size_t output_size, size_t *written) {
return lifecycle != NULL && write_state(&lifecycle->game.state, &lifecycle->sessions, viewer, lifecycle->cumulative,
output, output_size, written);
}
+236
View File
@@ -0,0 +1,236 @@
#include "sync_service.h"
#include <inttypes.h>
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
#include "state_presenter.h"
static void response_write(char output[kStateMessageCapacity], size_t *output_length, const char *format, ...) {
va_list arguments;
va_start(arguments, format);
const int length = vsnprintf(output, kStateMessageCapacity, format, arguments);
va_end(arguments);
*output_length = length >= 0 && (size_t)length < kStateMessageCapacity ? (size_t)length : 0U;
}
static void response_error(char output[kStateMessageCapacity], size_t *output_length, const char *code,
const char *message, uint32_t version) {
response_write(output, output_length, "{\"ok\":false,\"code\":\"%s\",\"message\":\"%s\",\"version\":%" PRIu32 "}",
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) {
const char high = text[index * 2U];
const char low = text[index * 2U + 1U];
if (high < '0' || (high > '9' && high < 'a') || high > 'f' ||
low < '0' || (low > '9' && low < 'a') || low > 'f') return false;
const uint8_t high_value = (uint8_t)(high <= '9' ? high - '0' : high - 'a' + 10);
const uint8_t low_value = (uint8_t)(low <= '9' ? low - '0' : low - 'a' + 10);
token[index] = (uint8_t)((high_value << 4U) | low_value);
}
return true;
}
static bool compact_json(const char *frame, size_t frame_length, char output[kWebSocketFrameCapacity + 1U]) {
if (frame == NULL || frame_length == 0U || frame_length > kWebSocketFrameCapacity) return false;
bool in_string = false;
bool escaped = false;
size_t written = 0;
for (size_t index = 0; index < frame_length; ++index) {
const unsigned char character = (unsigned char)frame[index];
if (character < 0x20U && character != ' ' && character != '\n' && character != '\r' && character != '\t') return false;
if (!in_string && (character == ' ' || character == '\n' || character == '\r' || character == '\t')) continue;
if (written >= kWebSocketFrameCapacity) return false;
output[written++] = (char)character;
if (in_string && escaped) escaped = false;
else if (in_string && character == '\\') escaped = true;
else if (character == '"') in_string = !in_string;
}
output[written] = '\0';
return !in_string && !escaped;
}
static sync_connection_t *connection_for(sync_service_t *service, int client_id) {
if (service == NULL) return NULL;
for (uint8_t index = 0; index < kSessionCapacity; ++index) {
if (service->connections[index].active && service->connections[index].client_id == client_id) return &service->connections[index];
}
return NULL;
}
static void lifecycle_error(char output[kStateMessageCapacity], size_t *output_length, lifecycle_result_t result,
uint32_t version) {
switch (result) {
case LIFECYCLE_RESULT_INVALID_MODE: response_error(output, output_length, "INVALID_MODE", "Некорректный режим", version); break;
case LIFECYCLE_RESULT_INVALID_COORDINATE: response_error(output, output_length, "INVALID_COORDINATE", "Некорректные координаты", version); break;
case LIFECYCLE_RESULT_FORBIDDEN_ROLE: response_error(output, output_length, "FORBIDDEN_ROLE", "Роль не может выполнить действие", version); break;
case LIFECYCLE_RESULT_WRONG_PHASE: response_error(output, output_length, "WRONG_PHASE", "Действие недоступно сейчас", version); break;
case LIFECYCLE_RESULT_NOT_YOUR_TURN: response_error(output, output_length, "NOT_YOUR_TURN", "Сейчас ход соперника", version); break;
case LIFECYCLE_RESULT_CELL_ALREADY_SHOT: response_error(output, output_length, "CELL_ALREADY_SHOT", "Клетка уже обстреляна", version); break;
case LIFECYCLE_RESULT_STALE_GAME: response_error(output, output_length, "STALE_GAME", "Партия уже изменилась", version); break;
default: response_error(output, output_length, "SERVER_BUSY", "Сервер занят", version); break;
}
}
static bool parse_command(const char *json, app_command_t *command, uint8_t token_bytes_out[kSessionTokenBytes]) {
char token[kSessionTokenBytes * 2U + 1U] = {0};
char mode[8] = {0};
uint32_t game_id = 0;
uint32_t x = 0;
uint32_t y = 0;
int consumed = 0;
if (sscanf(json, "{\"type\":\"config\",\"token\":\"%32[0-9a-f]\",\"gameId\":%" SCNu32 ",\"mode\":\"%7[a-z]\"}%n", token, &game_id, mode, &consumed) == 3 && json[consumed] == '\0') {
command->type = COMMAND_CONFIG;
command->mode = strcmp(mode, "human") == 0 ? MODE_HUMAN : strcmp(mode, "bot") == 0 ? MODE_BOT : (game_mode_t)UINT8_MAX;
} else if (sscanf(json, "{\"type\":\"shot\",\"token\":\"%32[0-9a-f]\",\"gameId\":%" SCNu32 ",\"x\":%" SCNu32 ",\"y\":%" SCNu32 "}%n", token, &game_id, &x, &y, &consumed) == 4 && json[consumed] == '\0') {
command->type = COMMAND_SHOT;
command->coordinate = x < kBoardWidth && y < kBoardHeight ?
(coordinate_t){.x = (uint8_t)x, .y = (uint8_t)y} : (coordinate_t){.x = kBoardWidth, .y = 0U};
} else if (sscanf(json, "{\"type\":\"start\",\"token\":\"%32[0-9a-f]\",\"gameId\":%" SCNu32 "}%n", token, &game_id, &consumed) == 2 && json[consumed] == '\0') {
command->type = COMMAND_START;
} else if (sscanf(json, "{\"type\":\"rematch\",\"token\":\"%32[0-9a-f]\",\"gameId\":%" SCNu32 "}%n", token, &game_id, &consumed) == 2 && json[consumed] == '\0') {
command->type = COMMAND_REMATCH;
} else if (sscanf(json, "{\"type\":\"abort\",\"token\":\"%32[0-9a-f]\",\"gameId\":%" SCNu32 "}%n", token, &game_id, &consumed) == 2 && json[consumed] == '\0') {
command->type = COMMAND_ABORT;
} else return false;
if (!token_bytes(token, token_bytes_out)) return false;
command->game_id = game_id;
return true;
}
void sync_service_init(sync_service_t *service, application_t *application) {
if (service != NULL) *service = (sync_service_t){.application = application};
}
bool sync_service_open(sync_service_t *service, int client_id, uint64_t now_ms) {
if (service == NULL || service->application == NULL || connection_for(service, client_id) != NULL) return false;
for (uint8_t index = 0; index < kSessionCapacity; ++index) {
if (!service->connections[index].active) {
service->connections[index] = (sync_connection_t){.client_id = client_id, .active = true, .opened_ms = now_ms};
return true;
}
}
return false;
}
void sync_service_close(sync_service_t *service, int client_id) {
sync_connection_t *connection = connection_for(service, client_id);
if (connection != NULL) *connection = (sync_connection_t){0};
}
bool sync_service_receive(sync_service_t *service, int client_id, const char *frame, size_t frame_length,
uint64_t now_ms, char output[kStateMessageCapacity], size_t *output_length,
bool *state_changed, bool *close_client) {
if (output_length != NULL) *output_length = 0U;
if (state_changed != NULL) *state_changed = false;
if (close_client != NULL) *close_client = false;
sync_connection_t *connection = connection_for(service, client_id);
if (service == NULL || service->application == NULL || connection == NULL || output == NULL || output_length == NULL) return false;
const uint32_t version = service->application->lifecycle.game.state.version;
if (now_ms - connection->opened_ms > kWebSocketHelloTimeoutMs && !connection->authenticated) {
response_error(output, output_length, "UNAUTHORIZED", "Сессия не найдена", version);
if (close_client != NULL) *close_client = true;
return true;
}
char json[kWebSocketFrameCapacity + 1U];
if (!compact_json(frame, frame_length, json)) {
response_error(output, output_length, "MALFORMED_JSON", "Некорректный JSON", version);
return true;
}
if (!connection->authenticated) {
char token_text[kSessionTokenBytes * 2U + 1U] = {0};
uint32_t client_version = 0;
int consumed = 0;
if (sscanf(json, "{\"type\":\"hello\",\"token\":\"%32[0-9a-f]\",\"version\":%" SCNu32 "}%n", token_text, &client_version, &consumed) != 2 || json[consumed] != '\0') {
response_error(output, output_length, "UNAUTHORIZED", "Сессия не найдена", version);
if (close_client != NULL) *close_client = true;
return true;
}
uint8_t token[kSessionTokenBytes];
uint8_t session_index = 0;
if (!token_bytes(token_text, token) || !application_session_for_token(service->application, token, &session_index)) {
response_error(output, output_length, "UNAUTHORIZED", "Сессия не найдена", version);
if (close_client != NULL) *close_client = true;
return true;
}
for (uint8_t index = 0; index < kSessionCapacity; ++index) {
if (service->connections[index].active && service->connections[index].authenticated &&
service->connections[index].session_index == session_index) service->connections[index].active = false;
}
connection->authenticated = true;
connection->session_index = session_index;
(void)client_version;
if (!state_presenter_write_lifecycle(&service->application->lifecycle,
service->application->lifecycle.sessions.entries[session_index].role,
output, kStateMessageCapacity, output_length)) {
response_error(output, output_length, "SERVER_BUSY", "Сервер занят", version);
}
return true;
}
if (strcmp(json, "{\"type\":\"ping\"}") == 0) {
response_write(output, output_length, "{\"type\":\"pong\"}");
return true;
}
app_command_t command = {0};
uint8_t token[kSessionTokenBytes];
if (!parse_command(json, &command, token) ||
!application_session_for_token(service->application, token, &command.session_index) ||
command.session_index != connection->session_index) {
response_error(output, output_length, "UNAUTHORIZED", "Сессия не найдена", version);
return true;
}
command.version = version;
const lifecycle_result_t result = application_submit(service->application, &command);
if (result != LIFECYCLE_RESULT_OK) {
lifecycle_error(output, output_length, result, service->application->lifecycle.game.state.version);
} else if (state_changed != NULL) {
*state_changed = true;
}
return true;
}
void sync_service_expire(sync_service_t *service, uint64_t now_ms, int closed_clients[kSessionCapacity],
size_t *closed_count) {
if (closed_count != NULL) *closed_count = 0U;
if (service == NULL || closed_clients == NULL || closed_count == NULL) return;
for (uint8_t index = 0; index < kSessionCapacity; ++index) {
sync_connection_t *connection = &service->connections[index];
if (connection->active && !connection->authenticated && now_ms - connection->opened_ms > kWebSocketHelloTimeoutMs) {
closed_clients[(*closed_count)++] = connection->client_id;
*connection = (sync_connection_t){0};
}
}
}
void sync_service_broadcast(sync_service_t *service, sync_send_fn send, void *context) {
if (service == NULL || service->application == NULL || send == NULL) return;
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 = 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};
}
}
+62
View File
@@ -0,0 +1,62 @@
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 test_network_foundation test_captive_portal test_network_configuration
test_command_queue: test_command_queue.c ../../src/command_queue.c
$(CC) $(CFLAGS) $^ -o $@
run: 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_game_domain: test_game_domain.c ../../src/fleet_generator.c ../../src/game_engine.c
$(CC) $(CFLAGS) $^ -o $@
test_bot_player: test_bot_player.c ../../src/bot_player.c ../../src/fleet_generator.c ../../src/game_engine.c
$(CC) $(CFLAGS) $^ -o $@
test_game_lifecycle: test_game_lifecycle.c ../../src/session_manager.c ../../src/game_lifecycle.c ../../src/bot_player.c ../../src/fleet_generator.c ../../src/game_engine.c
$(CC) $(CFLAGS) $^ -o $@
test_state_presenter: test_state_presenter.c ../../src/state_presenter.c
$(CC) $(CFLAGS) $^ -o $@
test_http_api: test_http_api.c ../../src/http_api.c ../../src/application.c ../../src/command_queue.c ../../src/session_manager.c ../../src/game_lifecycle.c ../../src/bot_player.c ../../src/fleet_generator.c ../../src/game_engine.c ../../src/state_presenter.c
$(CC) $(CFLAGS) $^ -o $@
test_sync_service: test_sync_service.c ../../src/sync_service.c ../../src/application.c ../../src/command_queue.c ../../src/session_manager.c ../../src/game_lifecycle.c ../../src/bot_player.c ../../src/fleet_generator.c ../../src/game_engine.c ../../src/state_presenter.c
$(CC) $(CFLAGS) $^ -o $@
test_human_game_integration: test_human_game_integration.c ../../src/http_api.c ../../src/application.c ../../src/command_queue.c ../../src/session_manager.c ../../src/game_lifecycle.c ../../src/bot_player.c ../../src/fleet_generator.c ../../src/game_engine.c ../../src/state_presenter.c
$(CC) $(CFLAGS) $^ -o $@
test_bot_game_integration: test_bot_game_integration.c ../../src/session_manager.c ../../src/game_lifecycle.c ../../src/bot_player.c ../../src/fleet_generator.c ../../src/game_engine.c
$(CC) $(CFLAGS) $^ -o $@
test_robustness: test_robustness.c ../../src/http_api.c ../../src/sync_service.c ../../src/application.c ../../src/command_queue.c ../../src/session_manager.c ../../src/game_lifecycle.c ../../src/bot_player.c ../../src/fleet_generator.c ../../src/game_engine.c ../../src/state_presenter.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 test_network_foundation test_captive_portal test_network_configuration
+107
View File
@@ -0,0 +1,107 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "game_lifecycle.h"
typedef struct { uint32_t value; } test_random_t;
typedef struct { uint32_t delay_ms; uint8_t calls; } test_scheduler_t;
static uint32_t next_random(void *context) {
test_random_t *random = context;
random->value = random->value * 1664525U + 1013904223U;
return random->value;
}
static bool schedule_after(void *context, uint32_t delay_ms) {
test_scheduler_t *scheduler = context;
scheduler->delay_ms = delay_ms;
++scheduler->calls;
return true;
}
static coordinate_t first_cell(const board_t *board, cell_t cell) {
for (uint8_t index = 0; index < kBoardCellCount; ++index) {
if (board->cells[index] == cell) return (coordinate_t){.x = (uint8_t)(index % kBoardWidth), .y = (uint8_t)(index / kBoardWidth)};
}
assert(false);
return (coordinate_t){0};
}
static void test_bot_game_lifecycle(void) {
test_random_t random = {.value = 101U};
test_scheduler_t scheduler = {0};
game_lifecycle_t lifecycle;
game_lifecycle_init(&lifecycle, (random_source_t){.next_u32 = next_random, .context = &random});
game_lifecycle_set_bot_scheduler(&lifecycle, (scheduler_t){.schedule_after_ms = schedule_after, .context = &scheduler});
uint8_t player = 0;
assert(game_lifecycle_join(&lifecycle, ROLE_PLAYER_1, "Alice", &player) == LIFECYCLE_RESULT_OK);
const uint32_t game_id = lifecycle.game.state.game_id;
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(lifecycle.game.state.phase == PHASE_IN_PROGRESS && lifecycle.game.state.mode == MODE_BOT);
if (lifecycle.game.state.current_player == 0U) {
assert(game_lifecycle_shot(&lifecycle, player, game_id, first_cell(&lifecycle.game.state.boards[1], CELL_WATER), NULL) == LIFECYCLE_RESULT_OK);
}
assert(lifecycle.game.state.current_player == 1U);
assert(lifecycle.bot.turn_pending && scheduler.calls == 1U);
assert(scheduler.delay_ms >= 500U && scheduler.delay_ms <= 900U);
assert(game_lifecycle_bot_take_turn(&lifecycle));
assert(lifecycle.game.state.statistics[1].shots == 1U);
assert(lifecycle.game.state.statistics[1].shots == lifecycle.game.state.statistics[1].hits + lifecycle.game.state.statistics[1].misses);
for (uint16_t attempts = 0; attempts < kBoardCellCount && lifecycle.game.state.current_player == 1U &&
lifecycle.game.state.phase == PHASE_IN_PROGRESS; ++attempts) {
assert(game_lifecycle_bot_take_turn(&lifecycle));
}
assert(lifecycle.game.state.phase == PHASE_IN_PROGRESS && lifecycle.game.state.current_player == 0U);
while (lifecycle.game.state.phase == PHASE_IN_PROGRESS) {
assert(game_lifecycle_shot(&lifecycle, player, game_id, first_cell(&lifecycle.game.state.boards[1], CELL_SHIP), NULL) == LIFECYCLE_RESULT_OK);
}
assert(lifecycle.game.state.winner == 0U);
assert(lifecycle.game.state.statistics[0].ships_sunk == kFleetShipCount);
for (uint8_t side = 0; side < kPlayerCapacity; ++side) {
const match_statistics_t *match = &lifecycle.game.state.statistics[side];
const cumulative_statistics_t *total = &lifecycle.cumulative[side];
assert(total->games == 1U && total->shots == match->shots && total->hits == match->hits &&
total->misses == match->misses && total->ships_sunk == match->ships_sunk);
}
assert(lifecycle.cumulative[0].wins == 1U && lifecycle.cumulative[1].losses == 1U);
assert(game_lifecycle_rematch(&lifecycle, player, game_id) == LIFECYCLE_RESULT_OK);
assert(lifecycle.game.state.phase == PHASE_IN_PROGRESS && lifecycle.game.state.game_id != game_id);
assert(lifecycle.game.state.statistics[0].shots == 0U && lifecycle.game.state.statistics[1].shots == 0U);
assert(lifecycle.cumulative[0].games == 1U && lifecycle.cumulative[0].wins == 1U);
game_lifecycle_init(&lifecycle, (random_source_t){.next_u32 = next_random, .context = &random});
assert(lifecycle.game.state.phase == PHASE_LOBBY && lifecycle.game.state.game_id == 1U);
assert(lifecycle.cumulative[0].games == 0U && lifecycle.cumulative[1].games == 0U);
assert(!lifecycle.sessions.entries[0].occupied && !lifecycle.sessions.entries[1].occupied);
}
static void test_bot_can_receive_the_first_turn(void) {
for (uint32_t seed = 1U; seed < 100U; ++seed) {
test_random_t random = {.value = seed};
test_scheduler_t scheduler = {0};
game_lifecycle_t lifecycle;
game_lifecycle_init(&lifecycle, (random_source_t){.next_u32 = next_random, .context = &random});
game_lifecycle_set_bot_scheduler(&lifecycle, (scheduler_t){.schedule_after_ms = schedule_after, .context = &scheduler});
uint8_t player = 0;
assert(game_lifecycle_join(&lifecycle, ROLE_PLAYER_1, "Alice", &player) == LIFECYCLE_RESULT_OK);
assert(game_lifecycle_configure(&lifecycle, player, lifecycle.game.state.game_id, MODE_BOT) == LIFECYCLE_RESULT_OK);
assert(game_lifecycle_start(&lifecycle, player, lifecycle.game.state.game_id) == LIFECYCLE_RESULT_OK);
if (lifecycle.game.state.current_player != 1U) continue;
assert(lifecycle.bot.turn_pending && scheduler.calls == 1U);
assert(scheduler.delay_ms >= 500U && scheduler.delay_ms <= 900U);
return;
}
assert(false);
}
int main(void) {
test_bot_game_lifecycle();
test_bot_can_receive_the_first_turn();
puts("bot game integration tests passed");
return 0;
}
+160
View File
@@ -0,0 +1,160 @@
#include <assert.h>
#include <stdio.h>
#include "bot_player.h"
#include "fleet_generator.h"
#include "game_engine.h"
typedef struct { uint32_t value; } test_random_t;
typedef struct { uint32_t delay_ms; uint8_t calls; bool accepted; } test_scheduler_t;
static uint32_t next_random(void *context) {
test_random_t *random = context;
random->value = random->value * 1664525U + 1013904223U;
return random->value;
}
static bool schedule_after(void *context, uint32_t delay_ms) {
test_scheduler_t *scheduler = context;
scheduler->delay_ms = delay_ms;
++scheduler->calls;
return scheduler->accepted;
}
static bot_player_t new_bot(test_random_t *random, test_scheduler_t *scheduler) {
bot_player_t bot;
bot_player_init(&bot, (random_source_t){.next_u32 = next_random, .context = random},
(scheduler_t){.schedule_after_ms = schedule_after, .context = scheduler});
return bot;
}
static void record(bot_player_t *bot, uint8_t x, uint8_t y, bool hit, bool sunk) {
const bot_shot_result_t result = {.hit = hit, .sunk = sunk};
bot_player_record_result(bot, (coordinate_t){x, y}, &result);
}
static bool is_adjacent(coordinate_t source, coordinate_t candidate) {
const int16_t dx = (int16_t)source.x - candidate.x;
const int16_t dy = (int16_t)source.y - candidate.y;
return (dx == 0 && (dy == 1 || dy == -1)) || (dy == 0 && (dx == 1 || dx == -1));
}
static void test_targeting_and_cleanup(void) {
test_random_t random = {.value = 3};
test_scheduler_t scheduler = {.accepted = true};
bot_player_t bot = new_bot(&random, &scheduler);
coordinate_t next = {0};
record(&bot, 0, 0, true, false);
assert(bot_player_next_shot(&bot, &next));
assert(is_adjacent((coordinate_t){0, 0}, next));
bot_player_init(&bot, (random_source_t){.next_u32 = next_random, .context = &random},
(scheduler_t){.schedule_after_ms = schedule_after, .context = &scheduler});
record(&bot, 4, 4, true, false);
record(&bot, 5, 4, true, false);
record(&bot, 6, 4, false, false);
assert(bot_player_next_shot(&bot, &next));
assert(next.x == 3U && next.y == 4U);
bot_player_init(&bot, (random_source_t){.next_u32 = next_random, .context = &random},
(scheduler_t){.schedule_after_ms = schedule_after, .context = &scheduler});
record(&bot, 0, 0, true, true);
assert(bot.knowledge[0] == BOT_CELL_BLOCKED);
assert(bot.knowledge[1] == BOT_CELL_BLOCKED);
assert(bot.knowledge[kBoardWidth] == BOT_CELL_BLOCKED);
assert(bot_player_next_shot(&bot, &next));
assert(next.x > 1U || next.y > 1U);
}
static void test_nonblocking_schedule_and_cancel(void) {
test_random_t random = {.value = 9};
test_scheduler_t scheduler = {.accepted = true};
bot_player_t bot = new_bot(&random, &scheduler);
assert(bot_player_schedule_turn(&bot));
assert(bot.turn_pending && scheduler.calls == 1U);
assert(scheduler.delay_ms >= 500U && scheduler.delay_ms <= 900U);
assert(!bot_player_schedule_turn(&bot));
bot_player_cancel_turn(&bot);
assert(!bot.turn_pending);
scheduler.accepted = false;
assert(!bot_player_schedule_turn(&bot));
assert(!bot.turn_pending);
}
static void test_final_bot_shot_uses_only_result(void) {
test_random_t random = {.value = 17};
test_scheduler_t scheduler = {.accepted = true};
bot_player_t bot = new_bot(&random, &scheduler);
for (uint8_t index = 1; index < kBoardCellCount; ++index) bot.knowledge[index] = BOT_CELL_BLOCKED;
game_engine_t engine;
game_engine_init(&engine);
engine.state.phase = PHASE_IN_PROGRESS;
engine.state.current_player = 1U;
engine.state.boards[0].ships_alive = 1U;
engine.state.boards[0].ships[0] = (ship_t){.x = 0, .y = 0, .length = 1, .horizontal = true};
engine.state.boards[0].cells[0] = CELL_SHIP;
coordinate_t target = {0, 0};
shot_result_t result = {0};
assert(bot_player_next_shot(&bot, &target));
/* The strategy sees this result, never engine.state.boards[0]. */
assert(game_engine_shot(&engine, 1U, target, &result) == GAME_RESULT_OK);
bot_player_record_result(&bot, target, &(bot_shot_result_t){.hit = result.hit, .sunk = result.sunk});
assert(result.hit && result.sunk && result.finished);
assert(engine.state.phase == PHASE_FINISHED && engine.state.winner == 1U);
}
static fleet_generator_t generator_for(test_random_t *random) {
return (fleet_generator_t){.random = {.next_u32 = next_random, .context = random}};
}
static coordinate_t first_available_target(const board_t *board) {
for (uint8_t index = 0; index < kBoardCellCount; ++index) {
if (board->cells[index] == CELL_WATER || board->cells[index] == CELL_SHIP) {
return (coordinate_t){(uint8_t)(index % kBoardWidth), (uint8_t)(index / kBoardWidth)};
}
}
assert(false);
return (coordinate_t){0, 0};
}
static void test_ten_thousand_games_without_hidden_board_access(void) {
for (uint32_t seed = 0; seed < 10000U; ++seed) {
test_random_t random = {.value = seed + 1000U};
test_scheduler_t scheduler = {.accepted = true};
const fleet_generator_t generator = generator_for(&random);
game_engine_t engine;
game_engine_init(&engine);
assert(game_engine_start(&engine, seed + 1U, MODE_BOT, &generator) == GAME_RESULT_OK);
bot_player_t bot = new_bot(&random, &scheduler);
bool bot_shot[kBoardCellCount] = {0};
for (uint16_t step = 0; step < 200U && engine.state.phase == PHASE_IN_PROGRESS; ++step) {
const uint8_t player = engine.state.current_player;
coordinate_t coordinate;
shot_result_t result = {0};
if (player == 1U) {
assert(bot_player_next_shot(&bot, &coordinate));
const uint8_t index = (uint8_t)(coordinate.y * kBoardWidth + coordinate.x);
assert(!bot_shot[index]);
bot_shot[index] = true;
assert(game_engine_shot(&engine, player, coordinate, &result) == GAME_RESULT_OK);
bot_player_record_result(&bot, coordinate, &(bot_shot_result_t){.hit = result.hit, .sunk = result.sunk});
} else {
coordinate = first_available_target(&engine.state.boards[1]);
assert(game_engine_shot(&engine, player, coordinate, &result) == GAME_RESULT_OK);
}
}
assert(engine.state.phase == PHASE_FINISHED);
assert(engine.state.winner < kPlayerCapacity);
}
}
int main(void) {
test_targeting_and_cleanup();
test_nonblocking_schedule_and_cancel();
test_final_bot_shot_uses_only_result();
test_ten_thousand_games_without_hidden_board_access();
puts("bot player tests passed");
return 0;
}
+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;
}
BIN
View File
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "command_queue.h"
int main(void) {
command_queue_t queue = {0};
app_command_t input = {.type = COMMAND_SHOT, .session_index = 1, .game_id = 7, .version = 3, .coordinate = {4, 5}};
app_command_t output = {0};
assert(!command_queue_pop(&queue, &output));
for (int index = 0; index < kCommandQueueCapacity; ++index) {
input.coordinate.x = (uint8_t)index;
assert(command_queue_push(&queue, &input));
}
assert(!command_queue_push(&queue, &input));
for (int index = 0; index < kCommandQueueCapacity; ++index) {
assert(command_queue_pop(&queue, &output));
assert(output.coordinate.x == (uint8_t)index);
}
assert(!command_queue_pop(&queue, &output));
puts("command queue tests passed");
return 0;
}
+162
View File
@@ -0,0 +1,162 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "fleet_generator.h"
#include "game_engine.h"
typedef struct { uint32_t value; } test_random_t;
static uint32_t next_random(void *context) {
test_random_t *random = context;
random->value = random->value * 1664525U + 1013904223U;
return random->value;
}
static fleet_generator_t generator_for(test_random_t *random) {
return (fleet_generator_t){.random = {.next_u32 = next_random, .context = random}};
}
static void start_game(game_engine_t *engine, test_random_t *random) {
const fleet_generator_t generator = generator_for(random);
game_engine_init(engine);
assert(game_engine_start(engine, 1, MODE_HUMAN, &generator) == GAME_RESULT_OK);
assert(engine->state.phase == PHASE_IN_PROGRESS);
assert(engine->state.version == 1U);
assert(fleet_generator_validate(&engine->state.boards[0]));
assert(fleet_generator_validate(&engine->state.boards[1]));
}
static coordinate_t first_cell(const board_t *board, cell_t wanted) {
for (uint8_t y = 0; y < kBoardHeight; ++y) {
for (uint8_t x = 0; x < kBoardWidth; ++x) {
if (board->cells[y * kBoardWidth + x] == wanted) return (coordinate_t){x, y};
}
}
assert(false);
return (coordinate_t){0, 0};
}
static void assert_rejected_unchanged(game_engine_t *engine, uint8_t player, coordinate_t coordinate,
game_result_t expected) {
game_engine_t before = *engine;
assert(game_engine_shot(engine, player, coordinate, NULL) == expected);
assert(memcmp(&before, engine, sizeof(before)) == 0);
}
static void test_many_fleets(void) {
for (uint32_t seed = 0; seed < 10000U; ++seed) {
test_random_t random = {.value = seed};
const fleet_generator_t generator = generator_for(&random);
board_t board = {0};
assert(fleet_generator_generate(&generator, &board));
assert(fleet_generator_validate(&board));
}
}
static void test_rules_and_rejection(void) {
test_random_t random = {.value = 7};
game_engine_t engine;
start_game(&engine, &random);
const uint8_t player = engine.state.current_player;
game_engine_t before_start = engine;
const fleet_generator_t generator = generator_for(&random);
assert(game_engine_start(&engine, 2, MODE_HUMAN, &generator) == GAME_RESULT_WRONG_PHASE);
assert(memcmp(&before_start, &engine, sizeof(engine)) == 0);
assert_rejected_unchanged(&engine, player, (coordinate_t){10, 0}, GAME_RESULT_INVALID_COORDINATE);
assert_rejected_unchanged(&engine, player ^ 1U, (coordinate_t){0, 0}, GAME_RESULT_NOT_YOUR_TURN);
const coordinate_t water = first_cell(&engine.state.boards[player ^ 1U], CELL_WATER);
shot_result_t result = {0};
const uint32_t version = engine.state.version;
assert(game_engine_shot(&engine, player, water, &result) == GAME_RESULT_OK);
assert(!result.hit && !result.sunk && !result.finished);
assert(engine.state.current_player == (uint8_t)(player ^ 1U));
assert(engine.state.version == version + 1U);
assert(engine.state.statistics[player].shots == 1U && engine.state.statistics[player].misses == 1U);
assert_rejected_unchanged(&engine, player, water, GAME_RESULT_NOT_YOUR_TURN);
const uint8_t hitter = engine.state.current_player;
const coordinate_t ship_cell = first_cell(&engine.state.boards[hitter ^ 1U], CELL_SHIP);
assert(game_engine_shot(&engine, hitter, ship_cell, &result) == GAME_RESULT_OK);
assert(result.hit && !result.finished);
assert(engine.state.current_player == hitter);
assert(engine.state.statistics[hitter].hits == 1U);
assert_rejected_unchanged(&engine, hitter, ship_cell, GAME_RESULT_CELL_ALREADY_SHOT);
}
static void test_sinking_and_victory(void) {
test_random_t random = {.value = 11};
game_engine_t engine;
start_game(&engine, &random);
const uint8_t player = engine.state.current_player;
board_t *target = &engine.state.boards[player ^ 1U];
const ship_t ship = target->ships[9];
assert(ship.length == 1U);
const coordinate_t coordinate = {ship.x, ship.y};
shot_result_t result = {0};
assert(game_engine_shot(&engine, player, coordinate, &result) == GAME_RESULT_OK);
assert(result.hit && result.sunk && !result.finished);
assert(target->ships_alive == kFleetShipCount - 1U);
assert(engine.state.statistics[player].ships_sunk == 1U);
for (int8_t y = -1; y <= 1; ++y) {
for (int8_t x = -1; x <= 1; ++x) {
const int16_t neighbour_x = (int16_t)coordinate.x + x;
const int16_t neighbour_y = (int16_t)coordinate.y + y;
if (neighbour_x < 0 || neighbour_y < 0 || neighbour_x >= kBoardWidth || neighbour_y >= kBoardHeight) continue;
const uint8_t index = (uint8_t)(neighbour_y * kBoardWidth + neighbour_x);
if (x != 0 || y != 0) assert(target->cells[index] == CELL_MISS);
}
}
for (uint8_t ship_index = 0; ship_index < kFleetShipCount; ++ship_index) {
const ship_t remaining = target->ships[ship_index];
for (uint8_t offset = 0; offset < remaining.length; ++offset) {
const coordinate_t hit = {remaining.horizontal ? (uint8_t)(remaining.x + offset) : remaining.x,
remaining.horizontal ? remaining.y : (uint8_t)(remaining.y + offset)};
if (target->cells[hit.y * kBoardWidth + hit.x] == CELL_SHIP) assert(game_engine_shot(&engine, player, hit, &result) == GAME_RESULT_OK);
}
}
assert(result.finished);
assert(engine.state.phase == PHASE_FINISHED && engine.state.winner == player);
assert_rejected_unchanged(&engine, player, (coordinate_t){0, 0}, GAME_RESULT_WRONG_PHASE);
}
static void test_simulated_games(void) {
for (uint32_t seed = 100; seed < 10100U; ++seed) {
test_random_t random = {.value = seed};
game_engine_t engine;
start_game(&engine, &random);
for (uint16_t step = 0; step < 200U && engine.state.phase == PHASE_IN_PROGRESS; ++step) {
const uint8_t player = engine.state.current_player;
const board_t *target = &engine.state.boards[player ^ 1U];
coordinate_t choice = {0, 0};
bool found = false;
for (uint8_t index = 0; index < kBoardCellCount; ++index) {
if (target->cells[index] == CELL_WATER || target->cells[index] == CELL_SHIP) {
choice = (coordinate_t){(uint8_t)(index % kBoardWidth), (uint8_t)(index / kBoardWidth)};
found = true;
break;
}
}
assert(found);
assert(game_engine_shot(&engine, player, choice, NULL) == GAME_RESULT_OK);
}
assert(engine.state.phase == PHASE_FINISHED);
assert(engine.state.winner < kPlayerCapacity);
for (uint8_t player = 0; player < kPlayerCapacity; ++player) {
const match_statistics_t *statistics = &engine.state.statistics[player];
assert(statistics->shots == statistics->hits + statistics->misses);
assert(statistics->ships_sunk <= kFleetShipCount);
}
}
}
int main(void) {
test_many_fleets();
test_rules_and_rejection();
test_sinking_and_victory();
test_simulated_games();
puts("game domain tests passed");
return 0;
}
+159
View File
@@ -0,0 +1,159 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "game_lifecycle.h"
typedef struct { uint32_t value; } test_random_t;
static uint32_t next_random(void *context) {
test_random_t *random = context;
random->value = random->value * 1664525U + 1013904223U;
return random->value;
}
static game_lifecycle_t new_lifecycle(test_random_t *random) {
game_lifecycle_t lifecycle;
game_lifecycle_init(&lifecycle, (random_source_t){.next_u32 = next_random, .context = random});
return lifecycle;
}
static void join_players(game_lifecycle_t *lifecycle, uint8_t *player_1, uint8_t *player_2) {
assert(game_lifecycle_join(lifecycle, ROLE_PLAYER_1, "Alice", player_1) == LIFECYCLE_RESULT_OK);
assert(game_lifecycle_join(lifecycle, ROLE_PLAYER_2, "Bob", player_2) == LIFECYCLE_RESULT_OK);
assert(*player_1 == 0U && *player_2 == 1U);
}
static void finish_for_player_1(game_lifecycle_t *lifecycle, uint8_t player_1) {
game_state_t *state = &lifecycle->game.state;
state->current_player = 0U;
memset(&state->boards[1], 0, sizeof(state->boards[1]));
state->boards[1].ships_alive = 1U;
state->boards[1].ships[0] = (ship_t){.x = 0, .y = 0, .length = 1, .horizontal = true};
state->boards[1].cells[0] = CELL_SHIP;
shot_result_t result = {0};
assert(game_lifecycle_shot(lifecycle, player_1, state->game_id, (coordinate_t){0, 0}, &result) == LIFECYCLE_RESULT_OK);
assert(result.finished && state->phase == PHASE_FINISHED && state->winner == 0U);
}
static void test_capacity_sanitize_and_resume(void) {
test_random_t random = {.value = 1};
game_lifecycle_t lifecycle = new_lifecycle(&random);
uint8_t player_1 = 0;
uint8_t player_2 = 0;
assert(game_lifecycle_join(&lifecycle, ROLE_PLAYER_2, "Bob", &player_2) == LIFECYCLE_RESULT_NO_PLAYER_SLOT);
join_players(&lifecycle, &player_1, &player_2);
assert(game_lifecycle_join(&lifecycle, ROLE_PLAYER_1, "Other", &player_1) == LIFECYCLE_RESULT_NO_PLAYER_SLOT);
assert(game_lifecycle_join(&lifecycle, ROLE_SPECTATOR, "<bad>", &player_1) == LIFECYCLE_RESULT_INVALID_NAME);
for (uint8_t index = 0; index < kSpectatorCapacity; ++index) {
assert(game_lifecycle_join(&lifecycle, ROLE_SPECTATOR, "Watch", &player_1) == LIFECYCLE_RESULT_OK);
}
assert(game_lifecycle_join(&lifecycle, ROLE_SPECTATOR, "Extra", &player_1) == LIFECYCLE_RESULT_NO_SPECTATOR_SLOT);
const uint8_t token[kSessionTokenBytes] = {0};
uint8_t saved_token[kSessionTokenBytes] = {0};
memcpy(saved_token, lifecycle.sessions.entries[player_2].token, sizeof(saved_token));
assert(memcmp(token, saved_token, sizeof(token)) != 0);
assert(game_lifecycle_disconnect(&lifecycle, player_2) == LIFECYCLE_RESULT_OK);
assert(!lifecycle.sessions.entries[player_2].connected);
uint8_t resumed = kSessionCapacity;
assert(game_lifecycle_resume(&lifecycle, saved_token, &resumed) == LIFECYCLE_RESULT_OK);
assert(resumed == player_2 && lifecycle.sessions.entries[resumed].role == ROLE_PLAYER_2);
}
static void test_human_lifecycle_and_guards(void) {
test_random_t random = {.value = 9};
game_lifecycle_t lifecycle = new_lifecycle(&random);
uint8_t player_1 = 0;
uint8_t player_2 = 0;
join_players(&lifecycle, &player_1, &player_2);
const uint32_t game_id = lifecycle.game.state.game_id;
game_lifecycle_t before = lifecycle;
assert(game_lifecycle_configure(&lifecycle, player_2, game_id, MODE_BOT) == LIFECYCLE_RESULT_FORBIDDEN_ROLE);
assert(memcmp(&before, &lifecycle, sizeof(lifecycle)) == 0);
assert(game_lifecycle_start(&lifecycle, player_1, game_id + 1U) == LIFECYCLE_RESULT_STALE_GAME);
assert(game_lifecycle_start(&lifecycle, player_1, game_id) == LIFECYCLE_RESULT_OK);
assert(lifecycle.game.state.phase == PHASE_IN_PROGRESS);
finish_for_player_1(&lifecycle, player_1);
assert(lifecycle.cumulative[0].games == 1U && lifecycle.cumulative[0].wins == 1U);
assert(lifecycle.cumulative[1].games == 1U && lifecycle.cumulative[1].losses == 1U);
assert(game_lifecycle_rematch(&lifecycle, player_1, game_id) == LIFECYCLE_RESULT_OK);
assert(lifecycle.game.state.phase == PHASE_REMATCH_WAIT);
assert(game_lifecycle_rematch(&lifecycle, player_2, game_id) == LIFECYCLE_RESULT_OK);
assert(lifecycle.game.state.phase == PHASE_IN_PROGRESS && lifecycle.game.state.game_id != game_id);
assert(lifecycle.cumulative[0].wins == 1U);
assert(game_lifecycle_disconnect(&lifecycle, player_2) == LIFECYCLE_RESULT_OK);
assert(game_lifecycle_abort(&lifecycle, player_1, lifecycle.game.state.game_id) == LIFECYCLE_RESULT_OK);
assert(lifecycle.game.state.phase == PHASE_LOBBY);
}
static void test_bot_lifecycle_and_stale_actions(void) {
test_random_t random = {.value = 17};
game_lifecycle_t lifecycle = new_lifecycle(&random);
uint8_t player_1 = 0;
assert(game_lifecycle_join(&lifecycle, ROLE_PLAYER_1, "Alice", &player_1) == LIFECYCLE_RESULT_OK);
const uint32_t game_id = lifecycle.game.state.game_id;
assert(game_lifecycle_configure(&lifecycle, player_1, game_id, MODE_BOT) == LIFECYCLE_RESULT_OK);
assert(lifecycle.bot_reserved);
assert(game_lifecycle_join(&lifecycle, ROLE_PLAYER_2, "Bob", &player_1) == LIFECYCLE_RESULT_NO_PLAYER_SLOT);
assert(game_lifecycle_start(&lifecycle, player_1, game_id) == LIFECYCLE_RESULT_OK);
finish_for_player_1(&lifecycle, player_1);
assert(game_lifecycle_rematch(&lifecycle, player_1, game_id) == LIFECYCLE_RESULT_OK);
assert(lifecycle.game.state.phase == PHASE_IN_PROGRESS && lifecycle.game.state.game_id != game_id);
const game_lifecycle_t before = lifecycle;
assert(game_lifecycle_shot(&lifecycle, player_1, game_id, (coordinate_t){0, 0}, NULL) == LIFECYCLE_RESULT_STALE_GAME);
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;
}
+221
View File
@@ -0,0 +1,221 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "http_api.h"
typedef struct { uint32_t value; } test_random_t;
static uint32_t next_random(void *context) {
test_random_t *random = context;
random->value = random->value * 1664525U + 1013904223U;
return random->value;
}
static http_api_t new_api(application_t *application, test_random_t *random) {
application_init(application, (random_source_t){.next_u32 = next_random, .context = random});
http_api_t api;
http_api_init(&api, application);
http_api_set_health(&api, &(http_api_health_t){.uptime_ms = 12U, .free_heap_bytes = 200000U,
.minimum_free_heap_bytes = 190000U, .largest_free_block_bytes = 180000U,
.connected_clients = 2U, .rejected_input = 3U, .reset_reason = 11, .wifi_state = "connected"});
return api;
}
static http_api_response_t call(http_api_t *api, http_api_route_t route, http_api_method_t method,
const char *body, const char *token) {
http_api_response_t response;
const http_api_request_t request = {.method = method, .route = route, .content_type_json = true,
.body = body, .body_length = body == NULL ? 0U : strlen(body), .session_token = token};
assert(http_api_handle(api, &request, &response));
assert(response.body_length == strlen(response.body));
return response;
}
static void expect_code(const http_api_response_t *response, uint16_t status, const char *code) {
char expected[64];
snprintf(expected, sizeof(expected), "\"code\":\"%s\"", code);
assert(response->status == status);
assert(strstr(response->body, expected) != NULL);
assert(response->body_length < 160U);
}
static void token_from_response(const http_api_response_t *response, char token[33]) {
const char *start = strstr(response->body, "\"token\":\"");
assert(start != NULL);
start += strlen("\"token\":\"");
memcpy(token, start, 32U);
token[32] = '\0';
}
static void test_public_routes_and_parse_limits(void) {
application_t application;
test_random_t random = {.value = 1U};
http_api_t api = new_api(&application, &random);
http_api_response_t response = call(&api, HTTP_API_ROUTE_INFO, HTTP_API_GET, NULL, NULL);
assert(response.status == 200U && response.body_length <= 192U && strstr(response.body, "player1Available") != NULL);
response = call(&api, HTTP_API_ROUTE_HEALTH, HTTP_API_GET, NULL, NULL);
assert(response.status == 200U && response.body_length <= 320U && strstr(response.body, "connected") != NULL &&
strstr(response.body, "\"resetReason\":11") != NULL);
const http_api_request_t long_target = {.method = HTTP_API_GET, .route = HTTP_API_ROUTE_INFO, .target_too_large = true};
assert(http_api_handle(&api, &long_target, &response));
expect_code(&response, 413U, "PAYLOAD_TOO_LARGE");
const char oversized[194] = {[0 ... 192] = 'a', [193] = '\0'};
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST, oversized, NULL);
expect_code(&response, 413U, "PAYLOAD_TOO_LARGE");
const http_api_request_t non_json = {.method = HTTP_API_POST, .route = HTTP_API_ROUTE_JOIN,
.content_type_json = false, .body = "{}", .body_length = 2U};
assert(http_api_handle(&api, &non_json, &response));
expect_code(&response, 400U, "MALFORMED_JSON");
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST, "{\"name\":\"A\",\"requestedRole\":\"invalid\"}", NULL);
expect_code(&response, 400U, "INVALID_ROLE");
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST, "{\"name\":\"<bad>\",\"requestedRole\":\"player1\"}", NULL);
expect_code(&response, 400U, "INVALID_NAME");
}
static void test_sessions_commands_and_state(void) {
application_t application;
test_random_t random = {.value = 9U};
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);
assert(response.status == 200U);
char player_1_token[33];
token_from_response(&response, player_1_token);
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"Other\",\"requestedRole\":\"player1\"}", NULL);
expect_code(&response, 409U, "NO_PLAYER_SLOT");
response = call(&api, HTTP_API_ROUTE_RESUME, HTTP_API_POST, "{\"token\":\"00000000000000000000000000000000\"}", NULL);
expect_code(&response, 401U, "UNAUTHORIZED");
char resume_body[48];
snprintf(resume_body, sizeof(resume_body), "{\"token\":\"%s\"}", player_1_token);
response = call(&api, HTTP_API_ROUTE_RESUME, HTTP_API_POST, resume_body, NULL);
assert(response.status == 200U && strstr(response.body, "player1") != NULL);
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"Bob\",\"requestedRole\":\"player2\"}", NULL);
assert(response.status == 200U);
char player_2_token[33];
token_from_response(&response, player_2_token);
response = call(&api, HTTP_API_ROUTE_INFO, HTTP_API_GET, NULL, NULL);
assert(response.status == 200U && strstr(response.body, "\"player1Name\":\"Alice\"") != NULL &&
strstr(response.body, "\"player2Name\":\"Bob\"") != NULL);
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"Watch\",\"requestedRole\":\"spectator\"}", NULL);
assert(response.status == 200U);
char spectator_token[33];
token_from_response(&response, spectator_token);
for (uint8_t index = 1U; index < kSpectatorCapacity; ++index) {
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"Watch\",\"requestedRole\":\"spectator\"}", NULL);
assert(response.status == 200U);
}
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"Extra\",\"requestedRole\":\"spectator\"}", NULL);
expect_code(&response, 409U, "NO_SPECTATOR_SLOT");
response = call(&api, HTTP_API_ROUTE_CONFIG, HTTP_API_POST,
"{\"token\":\"00000000000000000000000000000000\",\"gameId\":1,\"mode\":\"human\"}", NULL);
expect_code(&response, 401U, "UNAUTHORIZED");
char command[128];
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":2,\"mode\":\"human\"}", player_1_token);
response = call(&api, HTTP_API_ROUTE_CONFIG, HTTP_API_POST, command, NULL);
expect_code(&response, 409U, "STALE_GAME");
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1,\"mode\":\"invalid\"}", player_1_token);
response = call(&api, HTTP_API_ROUTE_CONFIG, HTTP_API_POST, command, NULL);
expect_code(&response, 400U, "INVALID_MODE");
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1,\"mode\":\"human\"}", player_2_token);
response = call(&api, HTTP_API_ROUTE_CONFIG, HTTP_API_POST, command, NULL);
expect_code(&response, 403U, "FORBIDDEN_ROLE");
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1,\"mode\":\"human\"}", player_1_token);
response = call(&api, HTTP_API_ROUTE_CONFIG, HTTP_API_POST, command, NULL);
assert(response.status == 200U);
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1}", player_1_token);
response = call(&api, HTTP_API_ROUTE_START, HTTP_API_POST, command, NULL);
assert(response.status == 200U);
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1,\"mode\":\"human\"}", player_1_token);
response = call(&api, HTTP_API_ROUTE_CONFIG, HTTP_API_POST, command, NULL);
expect_code(&response, 409U, "WRONG_PHASE");
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, NULL);
assert(response.status == 200U && strstr(response.body, "\"viewer\":\"spectator\"") != NULL);
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, player_1_token);
assert(response.status == 200U && strstr(response.body, "\"viewer\":\"player1\"") != NULL);
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, "bad");
expect_code(&response, 401U, "UNAUTHORIZED");
response = call(&api, HTTP_API_ROUTE_STATISTICS, HTTP_API_GET, NULL, player_1_token);
assert(response.status == 200U && strstr(response.body, "\"viewer\":\"player1\"") != NULL &&
strstr(response.body, "\"match\":[[") != NULL && strstr(response.body, "\"cumulative\":[[") != NULL);
response = call(&api, HTTP_API_ROUTE_STATISTICS, HTTP_API_GET, NULL, "bad");
expect_code(&response, 401U, "UNAUTHORIZED");
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1,\"x\":10,\"y\":0}", player_1_token);
response = call(&api, HTTP_API_ROUTE_SHOT, HTTP_API_POST, command, NULL);
expect_code(&response, 400U, "INVALID_COORDINATE");
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1,\"x\":0,\"y\":0}", spectator_token);
response = call(&api, HTTP_API_ROUTE_SHOT, HTTP_API_POST, command, NULL);
expect_code(&response, 403U, "FORBIDDEN_ROLE");
application.lifecycle.game.state.current_player = 0U;
application.lifecycle.game.state.boards[1].cells[0] = CELL_SHIP;
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1,\"x\":0,\"y\":0}", player_2_token);
response = call(&api, HTTP_API_ROUTE_SHOT, HTTP_API_POST, command, NULL);
expect_code(&response, 409U, "NOT_YOUR_TURN");
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1,\"x\":0,\"y\":0}", player_1_token);
response = call(&api, HTTP_API_ROUTE_SHOT, HTTP_API_POST, command, NULL);
assert(response.status == 200U);
response = call(&api, HTTP_API_ROUTE_SHOT, HTTP_API_POST, command, NULL);
expect_code(&response, 409U, "CELL_ALREADY_SHOT");
application.lifecycle.game.state.phase = PHASE_FINISHED;
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1}", player_1_token);
response = call(&api, HTTP_API_ROUTE_REMATCH, HTTP_API_POST, command, NULL);
assert(response.status == 200U && application.lifecycle.game.state.phase == PHASE_REMATCH_WAIT);
application.lifecycle.game.state.phase = PHASE_IN_PROGRESS;
application.lifecycle.game.state.mode = MODE_HUMAN;
application.lifecycle.sessions.entries[1].connected = false;
response = call(&api, HTTP_API_ROUTE_ABORT, HTTP_API_POST, command, NULL);
assert(response.status == 200U && application.lifecycle.game.state.phase == PHASE_LOBBY);
for (uint8_t index = 0; index < kCommandQueueCapacity; ++index) {
assert(application_enqueue(&application, &(app_command_t){.type = COMMAND_START}));
}
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1}", player_1_token);
response = call(&api, HTTP_API_ROUTE_REMATCH, HTTP_API_POST, command, NULL);
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;
}
+240
View File
@@ -0,0 +1,240 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "http_api.h"
typedef struct { uint32_t value; } test_random_t;
static uint32_t next_random(void *context) {
test_random_t *random = context;
random->value = random->value * 1664525U + 1013904223U;
return random->value;
}
static http_api_response_t call(http_api_t *api, http_api_route_t route, http_api_method_t method,
const char *body, const char *token) {
http_api_response_t response;
const http_api_request_t request = {.method = method, .route = route, .content_type_json = true,
.body = body, .body_length = body == NULL ? 0U : strlen(body), .session_token = token};
assert(http_api_handle(api, &request, &response));
assert(response.body_length == strlen(response.body));
return response;
}
static void token_from_response(const http_api_response_t *response, char token[33]) {
const char *start = strstr(response->body, "\"token\":\"");
assert(start != NULL);
memcpy(token, start + strlen("\"token\":\""), 32U);
token[32] = '\0';
}
static const char *board_start(const char *json, uint8_t board) {
const char *first = strstr(json, "\"boards\":[\"");
assert(first != NULL);
first += strlen("\"boards\":[\"");
return board == 0U ? first : first + kBoardCellCount + 3U;
}
static void assert_hidden(const char *json, uint8_t board) {
const char *cells = board_start(json, board);
for (uint8_t index = 0; index < kBoardCellCount; ++index) assert(cells[index] != '1');
}
static void resume(http_api_t *api, const char token[33], const char *role) {
char body[48];
snprintf(body, sizeof(body), "{\"token\":\"%s\"}", token);
const http_api_response_t response = call(api, HTTP_API_ROUTE_RESUME, HTTP_API_POST, body, NULL);
assert(response.status == 200U && strstr(response.body, role) != NULL);
}
static void command(http_api_t *api, http_api_route_t route, const char token[33], uint32_t game_id) {
char body[80];
snprintf(body, sizeof(body), "{\"token\":\"%s\",\"gameId\":%u}", token, (unsigned int)game_id);
const http_api_response_t response = call(api, route, HTTP_API_POST, body, NULL);
assert(response.status == 200U);
}
static void shot(http_api_t *api, const char token[33], uint32_t game_id, coordinate_t coordinate) {
char body[96];
snprintf(body, sizeof(body), "{\"token\":\"%s\",\"gameId\":%u,\"x\":%u,\"y\":%u}", token,
(unsigned int)game_id, coordinate.x, coordinate.y);
const http_api_response_t response = call(api, HTTP_API_ROUTE_SHOT, HTTP_API_POST, body, NULL);
assert(response.status == 200U);
}
static coordinate_t first_cell(const board_t *board, cell_t cell) {
for (uint8_t index = 0; index < kBoardCellCount; ++index) {
if (board->cells[index] == cell) return (coordinate_t){.x = (uint8_t)(index % kBoardWidth), .y = (uint8_t)(index / kBoardWidth)};
}
assert(false);
return (coordinate_t){0};
}
static void test_human_game_journey(void) {
test_random_t random = {.value = 57U};
application_t application;
application_init(&application, (random_source_t){.next_u32 = next_random, .context = &random});
http_api_t api;
http_api_init(&api, &application);
char player_tokens[2][33];
http_api_response_t response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"Алиса\",\"requestedRole\":\"player1\"}", NULL);
assert(response.status == 200U);
token_from_response(&response, player_tokens[0]);
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"Борис\",\"requestedRole\":\"player2\"}", NULL);
assert(response.status == 200U);
token_from_response(&response, player_tokens[1]);
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
"{\"name\":\"Зритель\",\"requestedRole\":\"spectator\"}", NULL);
assert(response.status == 200U);
char spectator_token[33];
token_from_response(&response, spectator_token);
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\"}",
player_tokens[0], (unsigned int)game_id);
response = call(&api, HTTP_API_ROUTE_CONFIG, HTTP_API_POST, config, NULL);
assert(response.status == 200U);
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, in_progress_spectator_token);
assert(response.status == 200U && strstr(response.body, "\"viewer\":\"spectator\"") != NULL);
assert_hidden(response.body, 0U);
assert_hidden(response.body, 1U);
uint8_t first_player = application.lifecycle.game.state.current_player;
shot(&api, player_tokens[first_player], game_id, first_cell(&application.lifecycle.game.state.boards[first_player ^ 1U], CELL_WATER));
const uint8_t winner = application.lifecycle.game.state.current_player;
assert(winner == (first_player ^ 1U));
bool saw_sunk = false;
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, in_progress_spectator_token);
saw_sunk = saw_sunk || strchr(board_start(response.body, winner ^ 1U), '4') != NULL;
}
assert(saw_sunk);
assert(application.lifecycle.game.state.winner == winner);
assert(application.lifecycle.game.state.statistics[winner].shots == 20U);
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, 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);
assert(application.lifecycle.game.state.phase == PHASE_REMATCH_WAIT);
resume(&api, spectator_token, "spectator");
command(&api, HTTP_API_ROUTE_REMATCH, player_tokens[1], game_id);
assert(application.lifecycle.game.state.phase == PHASE_IN_PROGRESS && application.lifecycle.game.state.game_id != game_id);
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;
}
+89
View File
@@ -0,0 +1,89 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "http_api.h"
#include "sync_service.h"
typedef struct { uint32_t value; } test_random_t;
static uint32_t next_random(void *context) {
test_random_t *random = context;
random->value = random->value * 1664525U + 1013904223U;
return random->value;
}
static application_t new_application(test_random_t *random) {
application_t application;
application_init(&application, (random_source_t){.next_u32 = next_random, .context = random});
return application;
}
static void test_http_malformed_inputs_do_not_mutate(void) {
test_random_t random = {.value = 7U};
application_t application = new_application(&random);
http_api_t api;
http_api_init(&api, &application);
const char *const corpus[] = {"", "{", "[]", "{\"token\":", "{\"token\":null}",
"{\"name\":\"A\"}", "{\"token\":\"00000000000000000000000000000000\",\"gameId\":-1}",
"{\"type\":\"shot\"}", "{\"name\":\"A\",\"requestedRole\":\"player1\",\"extra\":1}"};
const http_api_route_t routes[] = {HTTP_API_ROUTE_JOIN, HTTP_API_ROUTE_RESUME, HTTP_API_ROUTE_CONFIG,
HTTP_API_ROUTE_START, HTTP_API_ROUTE_SHOT, HTTP_API_ROUTE_REMATCH, HTTP_API_ROUTE_ABORT};
for (size_t index = 0; index < 400U; ++index) {
const char *body = corpus[index % (sizeof(corpus) / sizeof(corpus[0]))];
const game_lifecycle_t before = application.lifecycle;
http_api_response_t response;
const http_api_request_t request = {.method = HTTP_API_POST, .route = routes[index % (sizeof(routes) / sizeof(routes[0]))],
.content_type_json = true, .body = body, .body_length = strlen(body)};
assert(http_api_handle(&api, &request, &response));
assert(response.status >= 400U && response.status < 600U && response.body_length < sizeof(response.body));
assert(memcmp(&before, &application.lifecycle, sizeof(before)) == 0);
}
char oversized[kRequestBodyCapacity + 2U];
memset(oversized, 'x', sizeof(oversized) - 1U);
oversized[sizeof(oversized) - 1U] = '\0';
http_api_response_t response;
const http_api_request_t request = {.method = HTTP_API_POST, .route = HTTP_API_ROUTE_JOIN, .content_type_json = true,
.body = oversized, .body_length = strlen(oversized)};
assert(http_api_handle(&api, &request, &response));
assert(response.status == 413U);
}
static void test_websocket_fuzz_and_connection_churn(void) {
test_random_t random = {.value = 19U};
application_t application = new_application(&random);
sync_service_t service;
sync_service_init(&service, &application);
for (uint16_t attempt = 0; attempt < 600U; ++attempt) {
char frame[kWebSocketFrameCapacity + 1U];
const size_t length = (size_t)(next_random(&random) % (kWebSocketFrameCapacity + 1U));
for (size_t index = 0; index < length; ++index) frame[index] = (char)(next_random(&random) & 0x7fU);
frame[length] = '\0';
const game_lifecycle_t before = application.lifecycle;
char output[kStateMessageCapacity] = {0};
size_t output_length = 0U;
bool changed = true;
bool close = false;
assert(sync_service_open(&service, 1, attempt));
assert(sync_service_receive(&service, 1, frame, length, attempt, output, &output_length, &changed, &close));
assert(!changed && output_length < sizeof(output));
assert(memcmp(&before, &application.lifecycle, sizeof(before)) == 0);
sync_service_close(&service, 1);
}
char too_large[kWebSocketFrameCapacity + 1U] = {0};
char output[kStateMessageCapacity] = {0};
size_t output_length = 0U;
bool changed = false;
bool close = false;
assert(sync_service_open(&service, 1, 0U));
assert(sync_service_receive(&service, 1, too_large, sizeof(too_large), 0U, output, &output_length, &changed, &close));
assert(!changed && strstr(output, "MALFORMED_JSON") != NULL);
sync_service_close(&service, 1);
}
int main(void) {
test_http_malformed_inputs_do_not_mutate();
test_websocket_fuzz_and_connection_churn();
puts("robustness tests passed");
return 0;
}
+118
View File
@@ -0,0 +1,118 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "state_presenter.h"
static void set_board(board_t *board) {
memset(board, 0, sizeof(*board));
board->cells[0] = CELL_SHIP;
board->cells[1] = CELL_MISS;
board->cells[2] = CELL_HIT;
board->cells[99] = CELL_SHIP;
}
static const char *board_start(const char *json, uint8_t board) {
const char *first = strstr(json, "\"boards\":[\"");
assert(first != NULL);
first += strlen("\"boards\":[\"");
return board == 0U ? first : first + kBoardCellCount + 3U;
}
static void assert_hidden(const char *json, uint8_t board) {
const char *cells = board_start(json, board);
for (uint8_t index = 0; index < kBoardCellCount; ++index) assert(cells[index] != '1');
}
static void test_role_views_and_golden_schema(void) {
game_state_t state = {0};
state.game_id = 42U;
state.version = 9U;
state.phase = PHASE_IN_PROGRESS;
state.mode = MODE_HUMAN;
state.current_player = 1U;
state.winner = kPlayerCapacity;
set_board(&state.boards[0]);
set_board(&state.boards[1]);
char output[kStateMessageCapacity] = {0};
size_t written = 0;
assert(state_presenter_write(&state, ROLE_PLAYER_1, output, sizeof(output), &written));
assert(written == strlen(output));
assert(strstr(output, "{\"type\":\"state\",\"version\":9,\"gameId\":42,\"phase\":\"in_progress\",\"mode\":\"human\",\"viewer\":\"player1\",\"turn\":\"player2\",\"players\":[\"\",\"\"],\"boards\":[\"") == output);
assert(board_start(output, 0)[0] == '1' && board_start(output, 0)[1] == '2' && board_start(output, 0)[2] == '3');
assert_hidden(output, 1);
assert(strstr(output, "\"players\":[\"\",\"\"]") != NULL);
assert(state_presenter_write(&state, ROLE_PLAYER_2, output, sizeof(output), &written));
assert_hidden(output, 0);
assert(board_start(output, 1)[0] == '1');
assert(state_presenter_write(&state, ROLE_SPECTATOR, output, sizeof(output), &written));
assert_hidden(output, 0);
assert_hidden(output, 1);
assert(board_start(output, 0)[1] == '2' && board_start(output, 1)[2] == '3');
assert(strstr(output, "\"winner\":null,\"statistics\":[[0,0,0,0],[0,0,0,0]]") != NULL);
}
static void test_finished_and_failure_are_safe(void) {
game_state_t state = {0};
state.game_id = UINT32_MAX;
state.version = UINT32_MAX;
state.phase = PHASE_FINISHED;
state.mode = MODE_BOT;
state.current_player = 0U;
set_board(&state.boards[0]);
set_board(&state.boards[1]);
char output[kStateMessageCapacity] = {0};
size_t written = 0;
assert(state_presenter_write(&state, ROLE_SPECTATOR, output, sizeof(output), &written));
assert(written < sizeof(output));
assert(board_start(output, 0)[0] == '1' && board_start(output, 1)[99] == '1');
game_lifecycle_t lifecycle = {0};
lifecycle.game.state = state;
lifecycle.game.state.phase = PHASE_REMATCH_WAIT;
lifecycle.game.state.mode = MODE_HUMAN;
lifecycle.game.state.winner = 1U;
lifecycle.game.state.statistics[0] = (match_statistics_t){.shots = 9U, .hits = 4U, .misses = 5U, .ships_sunk = 2U};
lifecycle.game.state.statistics[1] = (match_statistics_t){.shots = 8U, .hits = 5U, .misses = 3U, .ships_sunk = 3U};
lifecycle.sessions.entries[0] = (session_t){.occupied = true, .role = ROLE_PLAYER_1, .name = "Алиса"};
lifecycle.sessions.entries[1] = (session_t){.occupied = true, .role = ROLE_PLAYER_2, .name = "Борис"};
lifecycle.cumulative[0].wins = UINT16_MAX;
lifecycle.cumulative[1].wins = UINT16_MAX;
assert(state_presenter_write_lifecycle(&lifecycle, ROLE_SPECTATOR, output, sizeof(output), &written));
assert(written < sizeof(output));
assert(strstr(output, "\"winner\":1,\"statistics\":[[9,4,5,2],[8,5,3,3]]") != NULL);
assert(strstr(output, "\"players\":[\"Алиса\",\"Борис\"]") != NULL);
memset(lifecycle.sessions.entries[0].name, 'A', kDisplayNameBytes);
memset(lifecycle.sessions.entries[1].name, 'B', kDisplayNameBytes);
lifecycle.sessions.entries[0].name[kDisplayNameBytes] = '\0';
lifecycle.sessions.entries[1].name[kDisplayNameBytes] = '\0';
assert(state_presenter_write_lifecycle(&lifecycle, ROLE_SPECTATOR, output, sizeof(output), &written));
assert(written < sizeof(output));
lifecycle.game.state.statistics[0] = (match_statistics_t){UINT16_MAX, UINT16_MAX, UINT16_MAX, UINT8_MAX};
lifecycle.game.state.statistics[1] = (match_statistics_t){UINT16_MAX, UINT16_MAX, UINT16_MAX, UINT8_MAX};
assert(state_presenter_write_lifecycle(&lifecycle, ROLE_SPECTATOR, output, sizeof(output), &written));
assert(written < sizeof(output));
state.boards[0].ships[0] = (ship_t){.x = 2U, .y = 0U, .length = 1U, .hits = 1U, .horizontal = true};
assert(state_presenter_write(&state, ROLE_SPECTATOR, output, sizeof(output), &written));
assert(board_start(output, 0)[2] == '4');
char unchanged[16];
memset(unchanged, 'X', sizeof(unchanged));
size_t failure_written = 99U;
assert(!state_presenter_write(&state, ROLE_PLAYER_1, unchanged, sizeof(unchanged), &failure_written));
assert(failure_written == 0U);
for (size_t index = 0; index < sizeof(unchanged); ++index) assert(unchanged[index] == 'X');
}
int main(void) {
test_role_views_and_golden_schema();
test_finished_and_failure_are_safe();
puts("state presenter tests passed");
return 0;
}
+150
View File
@@ -0,0 +1,150 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "sync_service.h"
typedef struct { uint32_t value; } test_random_t;
typedef struct { int fail_client; uint8_t sends[kSessionCapacity]; char payloads[kSessionCapacity][kStateMessageCapacity]; } capture_t;
static uint32_t next_random(void *context) {
test_random_t *random = context;
random->value = random->value * 1664525U + 1013904223U;
return random->value;
}
static void token_text(const uint8_t token[kSessionTokenBytes], char output[33]) {
static const char hex[] = "0123456789abcdef";
for (uint8_t index = 0; index < kSessionTokenBytes; ++index) {
output[index * 2U] = hex[token[index] >> 4U];
output[index * 2U + 1U] = hex[token[index] & 15U];
}
output[32] = '\0';
}
static bool capture_send(void *context, int client_id, const char *payload, size_t length) {
capture_t *capture = context;
if (client_id == capture->fail_client) return false;
assert(client_id >= 0 && client_id < kSessionCapacity);
assert(length < kStateMessageCapacity);
++capture->sends[client_id];
memcpy(capture->payloads[client_id], payload, length);
capture->payloads[client_id][length] = '\0';
return true;
}
static const char *board_start(const char *json, uint8_t board) {
const char *start = strstr(json, "\"boards\":[\"");
assert(start != NULL);
start += strlen("\"boards\":[\"");
return board == 0U ? start : start + kBoardCellCount + 3U;
}
static void hello(sync_service_t *service, int client_id, const uint8_t token[kSessionTokenBytes], uint32_t version) {
char token_value[33];
char frame[96];
char output[kStateMessageCapacity];
size_t output_length = 0U;
bool changed = false;
bool close = false;
token_text(token, token_value);
snprintf(frame, sizeof(frame), "{\"type\":\"hello\",\"token\":\"%s\",\"version\":%u}", token_value, version);
assert(sync_service_receive(service, client_id, frame, strlen(frame), 10U, output, &output_length, &changed, &close));
assert(!changed && !close && output_length > 0U && strstr(output, "\"type\":\"state\"") != NULL);
}
static void test_authentication_visibility_and_backpressure(void) {
test_random_t random = {.value = 1U};
application_t application;
application_init(&application, (random_source_t){.next_u32 = next_random, .context = &random});
uint8_t player_1 = 0;
uint8_t player_2 = 0;
uint8_t spectator = 0;
assert(application_join(&application, ROLE_PLAYER_1, "Alice", &player_1) == LIFECYCLE_RESULT_OK);
assert(application_join(&application, ROLE_PLAYER_2, "Bob", &player_2) == LIFECYCLE_RESULT_OK);
assert(application_join(&application, ROLE_SPECTATOR, "Watch", &spectator) == LIFECYCLE_RESULT_OK);
sync_service_t service;
sync_service_init(&service, &application);
assert(sync_service_open(&service, 0, 0U));
assert(sync_service_open(&service, 1, 0U));
assert(sync_service_open(&service, 2, 0U));
for (int client_id = 3; client_id < kSessionCapacity; ++client_id) assert(sync_service_open(&service, client_id, 0U));
assert(!sync_service_open(&service, kSessionCapacity, 0U));
hello(&service, 0, application.lifecycle.sessions.entries[player_1].token, 0U);
hello(&service, 1, application.lifecycle.sessions.entries[player_2].token, 999U);
hello(&service, 2, application.lifecycle.sessions.entries[spectator].token, 0U);
application.lifecycle.game.state.phase = PHASE_IN_PROGRESS;
application.lifecycle.game.state.boards[0].cells[0] = CELL_SHIP;
application.lifecycle.game.state.boards[1].cells[0] = CELL_SHIP;
application.lifecycle.game.state.boards[0].cells[1] = CELL_HIT;
application.lifecycle.game.state.boards[1].cells[1] = CELL_MISS;
capture_t capture = {.fail_client = -1};
sync_service_broadcast(&service, capture_send, &capture);
assert(board_start(capture.payloads[0], 0)[0] == '1' && board_start(capture.payloads[0], 1)[0] == '0');
assert(board_start(capture.payloads[1], 0)[0] == '0' && board_start(capture.payloads[1], 1)[0] == '1');
assert(board_start(capture.payloads[2], 0)[0] == '0' && board_start(capture.payloads[2], 1)[0] == '0');
assert(board_start(capture.payloads[2], 0)[1] == '3' && board_start(capture.payloads[2], 1)[1] == '2');
capture.fail_client = 2;
const uint8_t previous_sends = capture.sends[2];
sync_service_broadcast(&service, capture_send, &capture);
sync_service_broadcast(&service, capture_send, &capture);
assert(capture.sends[2] == previous_sends);
}
static void test_timeout_ping_and_command(void) {
test_random_t random = {.value = 9U};
application_t application;
application_init(&application, (random_source_t){.next_u32 = next_random, .context = &random});
uint8_t player_1 = 0;
assert(application_join(&application, ROLE_PLAYER_1, "Alice", &player_1) == LIFECYCLE_RESULT_OK);
sync_service_t service;
sync_service_init(&service, &application);
assert(sync_service_open(&service, 3, 0U));
int expired[kSessionCapacity] = {0};
size_t expired_count = 0U;
sync_service_expire(&service, kWebSocketHelloTimeoutMs + 1U, expired, &expired_count);
assert(expired_count == 1U && expired[0] == 3);
assert(sync_service_open(&service, 0, 0U));
hello(&service, 0, application.lifecycle.sessions.entries[player_1].token, 0U);
char output[kStateMessageCapacity];
size_t output_length = 0U;
bool changed = false;
bool close = false;
assert(sync_service_receive(&service, 0, "{\"type\":\"ping\"}", 15U, 20U, output, &output_length, &changed, &close));
assert(!changed && !close && strcmp(output, "{\"type\":\"pong\"}") == 0);
char token[33];
char frame[128];
token_text(application.lifecycle.sessions.entries[player_1].token, token);
snprintf(frame, sizeof(frame), "{\"type\":\"config\",\"token\":\"%s\",\"gameId\":1,\"mode\":\"bot\"}", token);
assert(sync_service_receive(&service, 0, frame, strlen(frame), 30U, output, &output_length, &changed, &close));
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'/);
});
+39
View File
@@ -0,0 +1,39 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const zlib = require('node:zlib');
const sprite = fs.readFileSync(path.join(__dirname, '../../data/ship-sprite.svg'), 'utf8');
test('ship sprite contains four independently drawn class symbols', () => {
const ids = ['ship-1-cutter', 'ship-2-destroyer', 'ship-3-cruiser', 'ship-4-battleship'];
const symbols = ids.map(id => {
const match = sprite.match(new RegExp(`<symbol id="${id}" viewBox="([^"]+)">([\\s\\S]*?)</symbol>`));
assert.ok(match, `${id} is present`);
assert.match(match[1], /0 0 \d+ \d+/);
return match[2];
});
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', () => {
const css = fs.readFileSync(path.join(__dirname, '../../data/styles.css'), 'utf8');
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'/);
});
+88
View File
@@ -0,0 +1,88 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { createTargetActivator, detectFeedback, createReactionPicker } = require('../../data/target_interaction.js');
test('a single tap selects a target without firing', () => {
const selected = [];
let shots = 0;
const activator = createTargetActivator({ canFire: () => true, select: target => selected.push(target), fire: async () => { shots += 1; } });
assert.equal(activator.tap({ x: 1, y: 6 }).action, 'select');
assert.deepEqual(selected, [{ x: 1, y: 6 }]);
assert.equal(shots, 0);
});
test('a second tap on the same cell fires once while a different cell selects', async () => {
let timeMs = 0;
const selected = [];
let shots = 0;
const activator = createTargetActivator({ canFire: () => true, select: target => selected.push(target), fire: async () => { shots += 1; }, now: () => timeMs });
activator.tap({ x: 1, y: 6 });
timeMs = 200;
const sameCell = activator.tap({ x: 1, y: 6 });
await sameCell.promise;
timeMs = 300;
assert.equal(activator.tap({ x: 2, y: 6 }).action, 'select');
assert.equal(shots, 1);
assert.deepEqual(selected, [{ x: 1, y: 6 }, { x: 2, y: 6 }]);
});
test('double activation and explicit fire are deduplicated while a shot is pending', async () => {
let resolveShot;
let shots = 0;
const activator = createTargetActivator({ canFire: () => true, select: () => {}, fire: () => new Promise(resolve => { shots += 1; resolveShot = resolve; }) });
const first = activator.doubleActivate({ x: 1, y: 6 });
const duplicate = activator.doubleActivate({ x: 1, y: 6 });
assert.equal(await duplicate.promise, false);
assert.equal(shots, 1);
resolveShot();
await first.promise;
});
test('out-of-turn or already-targeted cells cannot select or fire', () => {
let selected = 0;
let shots = 0;
const activator = createTargetActivator({ canFire: () => false, select: () => { selected += 1; }, fire: async () => { shots += 1; } });
assert.equal(activator.tap({ x: 1, y: 6 }).action, 'ignored');
assert.equal(activator.keyboard({ x: 1, y: 6 }).action, 'ignored');
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'/);
});